JeonghyeokDo commited on
Commit
6aedfc0
·
verified ·
1 Parent(s): 6a1845f

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ license_name: cc-by-nc-4.0
4
+ pipeline_tag: image-to-image
5
+ library_name: diffusers
6
+ tags:
7
+ - sar-to-eo
8
+ - remote-sensing
9
+ - flow-matching
10
+ - image-to-image
11
+ - synthetic-aperture-radar
12
+ base_model: black-forest-labs/FLUX.2-klein-base-4B
13
+ ---
14
+
15
+ # ReFlowSET
16
+
17
+ SAR-to-EO image translation with a conditional flow-matching transformer trained
18
+ from scratch inside a frozen high-fidelity autoencoder latent space.
19
+
20
+ - **Paper:** https://arxiv.org/abs/{{ARXIV_ID}}
21
+ - **Code:** https://github.com/KAIST-VICLab/ReFlowSET
22
+ - **Project page:** https://kaist-viclab.github.io/ReFlowSET_site/
23
+ - **Comparison-method weights:** [`JeonghyeokDo/ReFlowSET-baselines`](https://huggingface.co/JeonghyeokDo/ReFlowSET-baselines)
24
+
25
+ ## Checkpoints
26
+
27
+ | Subfolder | Dataset | Resolution | Training | Deployed parameters |
28
+ |---|---|---|---|---|
29
+ | `qxs-saropt` | QXS-SAROPT | 256×256 | 40,000 steps × global batch 64 (2.56 M samples) | 509,324,417 + 84,046,115 frozen autoencoder |
30
+ | `sar2opt` | SAR2Opt | 512×512 | 20,000 steps × global batch 32 (640 k samples) | same |
31
+
32
+ Each subfolder is a complete `diffusers` pipeline: `transformer/`, `vae/`,
33
+ `scheduler/` and `model_index.json`. The two arms share the same architecture and
34
+ the same frozen autoencoder; they differ only in resolution, batch size and step
35
+ count. SAR2Opt stops at 20,000 steps to hold a comparable sample budget on a
36
+ 1,450-image training set.
37
+
38
+ The published weights are the **EMA** parameters. The training-only REPA projector
39
+ is not included.
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ import torch
45
+ from PIL import Image
46
+ from diffusers import DiffusionPipeline
47
+ from huggingface_hub import snapshot_download
48
+
49
+ # Both arms live in this one repository, one per subfolder. `DiffusionPipeline`
50
+ # has no `subfolder` argument, so fetch the arm and load it as a local pipeline.
51
+ ARM = "qxs-saropt" # or "sar2opt"
52
+ root = snapshot_download("JeonghyeokDo/ReFlowSET", allow_patterns=[f"{ARM}/*"])
53
+ pipe = DiffusionPipeline.from_pretrained(
54
+ f"{root}/{ARM}", custom_pipeline=f"{root}/{ARM}", torch_dtype=torch.float32,
55
+ ).to("cuda")
56
+
57
+ sar = Image.open("sar.png") # 1-channel SAR, 8-bit PNG
58
+ eo = pipe(sar, num_inference_steps=50, guidance_scale=1.5,
59
+ generator=torch.Generator("cuda").manual_seed(2024)).images[0]
60
+ eo.save("eo.png")
61
+ ```
62
+
63
+ `custom_pipeline` points at the same directory because the pipeline, transformer,
64
+ autoencoder and scheduler classes ship with the checkpoint rather than living in
65
+ `diffusers`. The classes are also on GitHub under `src/reflowset/`.
66
+
67
+ **Sampling settings are part of the reported result, not free knobs.** The paper's
68
+ main table is NFE 50 with guidance scale 1.5. NFE 4 samples **11× faster at 256²**
69
+ (163 ms vs 1824 ms) and **13× faster at 512²** (371 ms vs 4807 ms), batch 1 on one
70
+ B200, and trades distribution metrics against pixel metrics; do not mix the two in
71
+ one comparison.
72
+
73
+ The SAR input is read without a colour conversion, collapsed to one channel,
74
+ center-cropped (never resized), scaled by `x / 127.5 − 1`, replicated to three
75
+ channels, and encoded by the same frozen autoencoder that defines the EO latent
76
+ space. The pipeline does all of this; feed it the raw PNG.
77
+
78
+ ## Results
79
+
80
+ Scored on the same test items as fifteen prior methods that we retrained under one
81
+ protocol, by a single evaluator.
82
+
83
+ | Dataset | n | FID↓ | DISTS↓ | LPIPS↓ | SSIM↑ | PSNR↑ |
84
+ |---|---|---|---|---|---|---|
85
+ | QXS-SAROPT @256 | 3,999 | 19.1 | **0.2310** | 0.5344 | 0.3554 | 16.09 |
86
+ | SAR2Opt @512 | 627 | **66.3** | **0.1847** | **0.5217** | 0.2871 | 16.06 |
87
+
88
+ Bold marks the best value among all sixteen methods in the paper's main table.
89
+ The full table, with every comparison method's weights and licence, is in
90
+ [`MODEL_ZOO.md`](https://github.com/KAIST-VICLab/ReFlowSET/blob/main/MODEL_ZOO.md).
91
+
92
+ > **These numbers are not comparable with the ones printed in the source papers.**
93
+ > Splits, resolutions and evaluator conventions differ. In particular **LPIPS has
94
+ > two conventions in this literature that differ by ~0.05**: we feed `x*2−1` to
95
+ > the LPIPS network, while several released evaluators feed `[0,1]` with
96
+ > `normalize=False` and obtain a systematically lower number.
97
+
98
+ ## Architecture
99
+
100
+ A DiT with hidden size 1024 and depth 24 — eight double-stream blocks that give
101
+ the EO and SAR streams their own projections and joint attention, then sixteen
102
+ single-stream blocks over the concatenated token sequence — with 16 heads of
103
+ dimension 64 and 2-D RoPE over axes (32, 32).
104
+
105
+ There is **no separate SAR encoder**: the SAR image goes through the same frozen
106
+ autoencoder as the EO image. Training defines a linear bridge
107
+ `z_t = (1−t)·ε + t·z_e` and regresses the velocity `u* = z_e − ε` conditioned on
108
+ the SAR latent; sampling starts from `N(0, I)` and integrates `t: 0 → 1` with an
109
+ explicit Euler step. Classifier-free guidance was trained by zeroing the SAR
110
+ condition on 10 % of rows.
111
+
112
+ ## Training data
113
+
114
+ QXS-SAROPT and SAR2Opt only, one dataset per arm, trained from scratch. No
115
+ pretraining corpus. Neither dataset is redistributed. QXS-SAROPT requires citing
116
+ arXiv:2103.08259 for research use.
117
+
118
+ ## Licence and provenance
119
+
120
+ **Weights: CC BY-NC 4.0. Code: Apache-2.0.**
121
+
122
+ The frozen autoencoder bundled in `vae/` is the **Apache-2.0** autoencoder from
123
+ [`black-forest-labs/FLUX.2-klein-base-4B`](https://huggingface.co/black-forest-labs/FLUX.2-klein-base-4B),
124
+ re-serialised to the upstream layout and cast to bfloat16 — tensors paired by
125
+ value, not by an assumed rename table, and bit-identical to that source through a
126
+ full encode/decode.
127
+
128
+ **It is a substitution, and here is exactly what was substituted.** Both arms were
129
+ *trained and evaluated* with the `FLUX.2-dev` serialisation of the same network;
130
+ that file is under the FLUX Non-Commercial License, whose §4(a)(iii) forbids
131
+ "research and development related to surveillance" and whose §1(a) makes the
132
+ restriction inherit permanently. The two serialisations are the same autoencoder
133
+ — 250 of 251 tensors pair by value, worst absolute deviation 7.8e-03 (bfloat16
134
+ rounding) — and swapping the Apache file into the released checkpoints changes
135
+ QXS-SAROPT PSNR by **less than 0.004 dB in absolute value**. Four independent
136
+ measurements at different guidance scales and sample sets land between −0.004 and
137
+ +0.002 dB, so the sign is not resolved and only the magnitude is meaningful.
138
+ Changing only the evaluation seed moves the same number by +0.395 dB. Nothing
139
+ reported here changes.
140
+
141
+ Apache-2.0 §6 withholds trademark rights. This model is named ReFlowSET; it is not
142
+ a FLUX product and is not endorsed by Black Forest Labs.
143
+
144
+ **Why this autoencoder.** A latent generator cannot beat its codec's round trip,
145
+ so the codec is a ceiling on every row of a latent-model comparison.
146
+ [`vae_audit/`](https://github.com/KAIST-VICLab/ReFlowSET/tree/main/vae_audit)
147
+ measures that ceiling for six autoencoders — SD2.1, SDXL, SD3.0, SD3.5, FLUX.1
148
+ and FLUX.2 — on four SAR/EO benchmarks, EO and SAR scored separately. It ships as
149
+ code with download links and licence terms; no imagery and no third-party
150
+ autoencoder weights are redistributed.
151
+
152
+ Training used a frozen **DINOv3** ViT-L/16 (LVD-1689M) as a representation-
153
+ alignment teacher, acknowledged here as the DINOv3 License §1(b)(ii) requires. The
154
+ teacher is not loaded at inference and **no DINOv3 weights are redistributed**;
155
+ obtain them from [Meta's release](https://github.com/facebookresearch/dinov3)
156
+ under its own terms if you intend to retrain.
157
+
158
+ See [`LICENSE-WEIGHTS.md`](https://github.com/KAIST-VICLab/ReFlowSET/blob/main/LICENSE-WEIGHTS.md)
159
+ for the full reasoning, including one open question about the datasets' optical
160
+ imagery that we flag rather than resolve.
161
+
162
+ ## Citation
163
+
164
+ ```bibtex
165
+ @article{do2026reflowset,
166
+ title = {ReFlowSET: Representation-Aligned Latent Flow Matching for SAR-to-EO Image Translation},
167
+ author = {Do, Jeonghyeok and Lee, Seungchul and Kim, Munchurl},
168
+ journal = {arXiv preprint arXiv:{{ARXIV_ID}}},
169
+ year = {2026}
170
+ }
171
+ ```
autoencoder_flux2.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen FLUX.2 autoencoder — the latent endpoint of ReFlowSET.
2
+
3
+ ReFlowSET never fine-tunes this module: it is loaded once, frozen, and used to
4
+ encode the SAR condition and to decode the sampled EO latent. The released
5
+ weights are the **Apache-2.0** FLUX.2-klein-base-4B copy of the autoencoder,
6
+ re-keyed to the layout below (see ``scripts/convert_flux2_ae.py``).
7
+
8
+ Three details of the checkpoint are non-standard for `diffusers` and are
9
+ preserved exactly, because the file must load with ``strict=True``:
10
+
11
+ * ``quant_conv`` lives **inside** ``encoder.*`` and is the last op of the
12
+ encoder forward; ``post_quant_conv`` lives **inside** ``decoder.*`` and is the
13
+ first op of the decoder forward. `diffusers`' ``AutoencoderKL`` makes both
14
+ siblings of the encoder/decoder.
15
+ * The latent normaliser is a real ``BatchNorm2d(128, affine=False)`` whose
16
+ running statistics ship in the checkpoint under ``bn.*`` — a per-channel mean
17
+ **and** variance, not a scalar ``scaling_factor``/``shift_factor``. Its
18
+ epsilon is ``1e-4``, not torch's ``1e-5``.
19
+ * ``encode`` returns the posterior **mean**; the log-variance chunk of the
20
+ encoder's moments is discarded, so encoding is deterministic and there is no
21
+ ``DiagonalGaussianDistribution`` and no ``.sample()``.
22
+
23
+ The public latent is ``[B, 128, H/16, W/16]``: an 8x convolutional stride
24
+ followed by a 2x2 space-to-depth pack that is part of the *autoencoder*, not of
25
+ the transformer.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+
32
+ import torch
33
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
34
+ from diffusers.models.modeling_utils import ModelMixin
35
+ from torch import Tensor, nn
36
+ from torch.nn import functional as F
37
+
38
+
39
+ def swish(x: Tensor) -> Tensor:
40
+ """``x * sigmoid(x)`` — the activation used throughout the FLUX.2 AE."""
41
+ return x * torch.sigmoid(x)
42
+
43
+
44
+ class AttnBlock(nn.Module):
45
+ """Single-head self-attention over the spatial grid (head dim == channels)."""
46
+
47
+ def __init__(self, in_channels: int) -> None:
48
+ super().__init__()
49
+ self.in_channels = in_channels
50
+ self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
51
+ self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1)
52
+ self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1)
53
+ self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1)
54
+ self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1)
55
+
56
+ def attention(self, h_: Tensor) -> Tensor:
57
+ h_ = self.norm(h_)
58
+ q, k, v = self.q(h_), self.k(h_), self.v(h_)
59
+ b, c, h, w = q.shape
60
+ # "b c h w -> b 1 (h w) c": ONE head whose head-dim is the full channel
61
+ # count (flux2_ae.py:70-73).
62
+ q = q.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
63
+ k = k.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
64
+ v = v.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
65
+ h_ = F.scaled_dot_product_attention(q, k, v)
66
+ return h_.squeeze(1).transpose(1, 2).reshape(b, c, h, w)
67
+
68
+ def forward(self, x: Tensor) -> Tensor:
69
+ return x + self.proj_out(self.attention(x))
70
+
71
+
72
+ class ResnetBlock(nn.Module):
73
+ def __init__(self, in_channels: int, out_channels: int) -> None:
74
+ super().__init__()
75
+ self.in_channels = in_channels
76
+ self.out_channels = out_channels
77
+ self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
78
+ self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
79
+ self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True)
80
+ self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
81
+ if in_channels != out_channels:
82
+ self.nin_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0)
83
+
84
+ def forward(self, x: Tensor) -> Tensor:
85
+ h = self.conv1(swish(self.norm1(x)))
86
+ h = self.conv2(swish(self.norm2(h)))
87
+ if self.in_channels != self.out_channels:
88
+ x = self.nin_shortcut(x)
89
+ return x + h
90
+
91
+
92
+ class Downsample(nn.Module):
93
+ """Stride-2 conv with FLUX's asymmetric ``(0, 1, 0, 1)`` pad (flux2_ae.py:111-121)."""
94
+
95
+ def __init__(self, in_channels: int) -> None:
96
+ super().__init__()
97
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=2, padding=0)
98
+
99
+ def forward(self, x: Tensor) -> Tensor:
100
+ return self.conv(F.pad(x, (0, 1, 0, 1), mode="constant", value=0))
101
+
102
+
103
+ class Upsample(nn.Module):
104
+ """Nearest-neighbour 2x followed by a 3x3 conv (flux2_ae.py:124-132)."""
105
+
106
+ def __init__(self, in_channels: int) -> None:
107
+ super().__init__()
108
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=1, padding=1)
109
+
110
+ def forward(self, x: Tensor) -> Tensor:
111
+ return self.conv(F.interpolate(x, scale_factor=2.0, mode="nearest"))
112
+
113
+
114
+ class Encoder(nn.Module):
115
+ """FLUX.2 encoder. Emits ``2 * z_channels`` moments; ``quant_conv`` is internal."""
116
+
117
+ def __init__(
118
+ self,
119
+ resolution: int,
120
+ in_channels: int,
121
+ ch: int,
122
+ ch_mult: list[int],
123
+ num_res_blocks: int,
124
+ z_channels: int,
125
+ ) -> None:
126
+ super().__init__()
127
+ # Declared first so the checkpoint key is `encoder.quant_conv.*`
128
+ # (flux2_ae.py:146) — diffusers keeps quant_conv outside the encoder.
129
+ self.quant_conv = nn.Conv2d(2 * z_channels, 2 * z_channels, 1)
130
+ self.ch = ch
131
+ self.num_resolutions = len(ch_mult)
132
+ self.num_res_blocks = num_res_blocks
133
+ self.resolution = resolution
134
+ self.in_channels = in_channels
135
+
136
+ self.conv_in = nn.Conv2d(in_channels, ch, kernel_size=3, stride=1, padding=1)
137
+
138
+ in_ch_mult = (1,) + tuple(ch_mult)
139
+ self.down = nn.ModuleList()
140
+ block_in = ch
141
+ for i_level in range(self.num_resolutions):
142
+ block = nn.ModuleList()
143
+ block_in = ch * in_ch_mult[i_level]
144
+ block_out = ch * ch_mult[i_level]
145
+ for _ in range(num_res_blocks):
146
+ block.append(ResnetBlock(block_in, block_out))
147
+ block_in = block_out
148
+ down = nn.Module()
149
+ down.block = block
150
+ # Empty at every level in this checkpoint: attention exists only in
151
+ # `mid` (flux2_ae.py:162). Kept so the forward guard is meaningful.
152
+ down.attn = nn.ModuleList()
153
+ if i_level != self.num_resolutions - 1:
154
+ down.downsample = Downsample(block_in)
155
+ self.down.append(down)
156
+
157
+ self.mid = nn.Module()
158
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
159
+ self.mid.attn_1 = AttnBlock(block_in)
160
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
161
+
162
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
163
+ self.conv_out = nn.Conv2d(block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1)
164
+
165
+ def forward(self, x: Tensor) -> Tensor:
166
+ hs = [self.conv_in(x)]
167
+ for i_level in range(self.num_resolutions):
168
+ for i_block in range(self.num_res_blocks):
169
+ h = self.down[i_level].block[i_block](hs[-1])
170
+ if len(self.down[i_level].attn) > 0:
171
+ h = self.down[i_level].attn[i_block](h)
172
+ hs.append(h)
173
+ if i_level != self.num_resolutions - 1:
174
+ hs.append(self.down[i_level].downsample(hs[-1]))
175
+
176
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(hs[-1])))
177
+ h = self.conv_out(swish(self.norm_out(h)))
178
+ return self.quant_conv(h) # last op of the encoder (flux2_ae.py:207)
179
+
180
+
181
+ class Decoder(nn.Module):
182
+ """FLUX.2 decoder. ``post_quant_conv`` is internal and runs first."""
183
+
184
+ def __init__(
185
+ self,
186
+ ch: int,
187
+ out_ch: int,
188
+ ch_mult: list[int],
189
+ num_res_blocks: int,
190
+ in_channels: int,
191
+ resolution: int,
192
+ z_channels: int,
193
+ ) -> None:
194
+ super().__init__()
195
+ # Checkpoint key `decoder.post_quant_conv.*` (flux2_ae.py:223).
196
+ self.post_quant_conv = nn.Conv2d(z_channels, z_channels, 1)
197
+ self.ch = ch
198
+ self.num_resolutions = len(ch_mult)
199
+ self.num_res_blocks = num_res_blocks
200
+ self.resolution = resolution
201
+ self.in_channels = in_channels
202
+
203
+ block_in = ch * ch_mult[self.num_resolutions - 1]
204
+ self.conv_in = nn.Conv2d(z_channels, block_in, kernel_size=3, stride=1, padding=1)
205
+
206
+ self.mid = nn.Module()
207
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
208
+ self.mid.attn_1 = AttnBlock(block_in)
209
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
210
+
211
+ self.up = nn.ModuleList()
212
+ for i_level in reversed(range(self.num_resolutions)):
213
+ block = nn.ModuleList()
214
+ block_out = ch * ch_mult[i_level]
215
+ for _ in range(num_res_blocks + 1):
216
+ block.append(ResnetBlock(block_in, block_out))
217
+ block_in = block_out
218
+ up = nn.Module()
219
+ up.block = block
220
+ up.attn = nn.ModuleList() # empty in this checkpoint (flux2_ae.py:249)
221
+ if i_level != 0:
222
+ up.upsample = Upsample(block_in)
223
+ self.up.insert(0, up) # prepend so `up.<i>` indexes by resolution level
224
+
225
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
226
+ self.conv_out = nn.Conv2d(block_in, out_ch, kernel_size=3, stride=1, padding=1)
227
+
228
+ def forward(self, z: Tensor) -> Tensor:
229
+ z = self.post_quant_conv(z) # first op of the decoder (flux2_ae.py:267)
230
+ upscale_dtype = next(self.up.parameters()).dtype
231
+
232
+ h = self.conv_in(z)
233
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(h)))
234
+ h = h.to(upscale_dtype)
235
+
236
+ for i_level in reversed(range(self.num_resolutions)):
237
+ for i_block in range(self.num_res_blocks + 1):
238
+ h = self.up[i_level].block[i_block](h)
239
+ if len(self.up[i_level].attn) > 0:
240
+ h = self.up[i_level].attn[i_block](h)
241
+ if i_level != 0:
242
+ h = self.up[i_level].upsample(h)
243
+
244
+ return self.conv_out(swish(self.norm_out(h)))
245
+
246
+
247
+ class AutoencoderFlux2(ModelMixin, ConfigMixin):
248
+ """Frozen FLUX.2 autoencoder with ReFlowSET's packed, BN-normalised latent.
249
+
250
+ ``encode`` maps ``[B, 3, H, W]`` in ``[-1, 1]`` to ``[B, 128, H/16, W/16]``
251
+ and ``decode`` inverts it. The module is frozen: ``train()`` is a no-op that
252
+ always selects eval mode, and the latent BatchNorm is additionally forced to
253
+ eval on every call so no batch statistic can ever leak into the latent.
254
+
255
+ Args:
256
+ resolution: Nominal training resolution of the original autoencoder.
257
+ Only used to size bookkeeping attributes; any ``H``, ``W`` divisible
258
+ by 16 may be encoded.
259
+ in_channels: Input image channels (3).
260
+ ch: Base width.
261
+ out_ch: Output image channels (3).
262
+ ch_mult: Per-level width multipliers; ``len(ch_mult) - 1`` downsamples.
263
+ num_res_blocks: Residual blocks per level.
264
+ z_channels: Pre-pack latent channels (32).
265
+ patch_size: Space-to-depth factor applied after the encoder (2), which
266
+ takes the latent from 32 channels at ``H/8`` to 128 at ``H/16``.
267
+ bn_eps: Epsilon of the latent BatchNorm. **1e-4**, not torch's 1e-5
268
+ (flux2_ae.py:331); using 1e-5 shifts the latent by up to 2.6e-5.
269
+ """
270
+
271
+ _supports_gradient_checkpointing = False
272
+
273
+ @register_to_config
274
+ def __init__(
275
+ self,
276
+ resolution: int = 256,
277
+ in_channels: int = 3,
278
+ ch: int = 128,
279
+ out_ch: int = 3,
280
+ ch_mult: tuple[int, ...] = (1, 2, 4, 4),
281
+ num_res_blocks: int = 2,
282
+ z_channels: int = 32,
283
+ patch_size: int = 2,
284
+ bn_eps: float = 1e-4,
285
+ ) -> None:
286
+ super().__init__()
287
+ ch_mult = list(ch_mult)
288
+ self.encoder = Encoder(
289
+ resolution=resolution,
290
+ in_channels=in_channels,
291
+ ch=ch,
292
+ ch_mult=ch_mult,
293
+ num_res_blocks=num_res_blocks,
294
+ z_channels=z_channels,
295
+ )
296
+ self.decoder = Decoder(
297
+ ch=ch,
298
+ out_ch=out_ch,
299
+ ch_mult=ch_mult,
300
+ num_res_blocks=num_res_blocks,
301
+ in_channels=in_channels,
302
+ resolution=resolution,
303
+ z_channels=z_channels,
304
+ )
305
+ # Per-channel latent normaliser with the checkpoint's running statistics.
306
+ # affine=False, so there is no weight/bias to load (flux2_ae.py:334-340).
307
+ self.bn = nn.BatchNorm2d(
308
+ patch_size * patch_size * z_channels,
309
+ eps=bn_eps,
310
+ momentum=0.1,
311
+ affine=False,
312
+ track_running_stats=True,
313
+ )
314
+
315
+ @property
316
+ def latent_channels(self) -> int:
317
+ """Channels of the public latent: ``patch_size**2 * z_channels`` = 128."""
318
+ return self.config.patch_size**2 * self.config.z_channels
319
+
320
+ @property
321
+ def spatial_factor(self) -> int:
322
+ """Total stride: 8x convolutional times ``patch_size`` packing = 16."""
323
+ return 2 ** (len(self.config.ch_mult) - 1) * self.config.patch_size
324
+
325
+ # ---- 2x2 space-to-depth pack / unpack -----------------------------------
326
+
327
+ def pack(self, z: Tensor) -> Tensor:
328
+ """``[B, C, H, W] -> [B, C*p*p, H/p, W/p]``, channel-major.
329
+
330
+ Bit-identical to the reference ``rearrange("... c (i pi) (j pj) -> ...
331
+ (c pi pj) i j")`` (flux2_ae.py:349-357). Note this is **not** diffusers'
332
+ ``_pack_latents``, whose channel grouping is transposed.
333
+ """
334
+ return F.pixel_unshuffle(z, self.config.patch_size)
335
+
336
+ def unpack(self, z: Tensor) -> Tensor:
337
+ """Exact inverse of :meth:`pack` (flux2_ae.py:359-367)."""
338
+ return F.pixel_shuffle(z, self.config.patch_size)
339
+
340
+ # ---- latent normalisation ----------------------------------------------
341
+
342
+ def normalize(self, z: Tensor) -> Tensor:
343
+ """``(z - running_mean) / sqrt(running_var + bn_eps)``, per channel."""
344
+ self.bn.eval() # forced every call (flux2_ae.py:372); train mode shifts z by ~1.67
345
+ return self.bn(z)
346
+
347
+ def inv_normalize(self, z: Tensor) -> Tensor:
348
+ """Exact inverse of :meth:`normalize` — same ``bn_eps`` (flux2_ae.py:375-379)."""
349
+ self.bn.eval()
350
+ s = torch.sqrt(self.bn.running_var.view(1, -1, 1, 1) + self.config.bn_eps)
351
+ m = self.bn.running_mean.view(1, -1, 1, 1)
352
+ return z * s + m
353
+
354
+ # ---- public API ---------------------------------------------------------
355
+
356
+ @torch.no_grad()
357
+ def encode(self, x: Tensor) -> Tensor:
358
+ """Encode an image to the packed, normalised latent.
359
+
360
+ Args:
361
+ x: ``[B, 3, H, W]`` in ``[-1, 1]``; ``H`` and ``W`` divisible by 16.
362
+
363
+ Returns:
364
+ ``[B, 128, H/16, W/16]`` — the posterior **mean**, packed and
365
+ BN-normalised. The encoder's log-variance chunk is discarded
366
+ (flux2_ae.py:396), so this is deterministic: there is no posterior
367
+ distribution object and nothing to sample.
368
+ """
369
+ if x.ndim != 4 or x.shape[1] != self.config.in_channels:
370
+ raise ValueError(
371
+ f"encode expects [B, {self.config.in_channels}, H, W], got {tuple(x.shape)}"
372
+ )
373
+ h, w = x.shape[-2:]
374
+ if h % self.spatial_factor or w % self.spatial_factor:
375
+ raise ValueError(
376
+ f"encode requires H and W divisible by {self.spatial_factor}, got {h}x{w}"
377
+ )
378
+ moments = self.encoder(x)
379
+ mean = torch.chunk(moments, 2, dim=1)[0]
380
+ return self.normalize(self.pack(mean))
381
+
382
+ @torch.no_grad()
383
+ def decode(self, z: Tensor) -> Tensor:
384
+ """Decode a packed, normalised latent ``[B, 128, h, w]`` to ``[B, 3, 16h, 16w]``.
385
+
386
+ The output is approximately ``[-1, 1]`` and is **not** clamped here; the
387
+ pipeline applies ``(x * 0.5 + 0.5).clamp(0, 1)``.
388
+ """
389
+ if z.ndim != 4 or z.shape[1] != self.latent_channels:
390
+ raise ValueError(
391
+ f"decode expects [B, {self.latent_channels}, h, w], got {tuple(z.shape)}"
392
+ )
393
+ return self.decoder(self.unpack(self.inv_normalize(z)))
394
+
395
+ # ---- construction / freezing -------------------------------------------
396
+
397
+ @classmethod
398
+ def from_single_file(
399
+ cls,
400
+ path: str | os.PathLike,
401
+ torch_dtype: torch.dtype = torch.float32,
402
+ ) -> "AutoencoderFlux2":
403
+ """Load the single-file ``ae.safetensors`` (BFL key names) with ``strict=True``.
404
+
405
+ The released file is the Apache-2.0 FLUX.2-klein-base-4B autoencoder
406
+ re-keyed to this layout; it is stored in bfloat16 and is upcast to
407
+ ``torch_dtype``. ReFlowSET runs the autoencoder in float32.
408
+ """
409
+ from safetensors.torch import load_file
410
+
411
+ path = os.fspath(path)
412
+ if not os.path.isfile(path):
413
+ raise FileNotFoundError(
414
+ f"FLUX.2 autoencoder weights not found at: {path}. Expected the "
415
+ "single-file 'ae.safetensors' shipped with ReFlowSET."
416
+ )
417
+ model = cls()
418
+ model.load_state_dict(load_file(path, device="cpu"), strict=True)
419
+ model.to(dtype=torch_dtype)
420
+ model.eval()
421
+ model.requires_grad_(False)
422
+ return model
423
+
424
+ def train(self, mode: bool = True) -> "AutoencoderFlux2":
425
+ """The autoencoder is frozen: never leave eval mode (flux2_ae.py:437-439)."""
426
+ return super().train(False)
pipeline.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET SAR -> EO translation pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Optional, Union
7
+
8
+ import numpy as np
9
+ import torch
10
+ from diffusers.pipelines.pipeline_utils import DiffusionPipeline, ImagePipelineOutput
11
+ from diffusers.utils.torch_utils import randn_tensor
12
+ from PIL import Image
13
+
14
+ from .autoencoder_flux2 import AutoencoderFlux2
15
+ from .scheduler_flow_bridge import FlowBridgeScheduler
16
+ from .transformer_reflowset import ReFlowSETTransformer2DModel
17
+
18
+ #: PIL modes the SAR loader accepts. The reference loader calls ``np.array(im)``
19
+ #: with no ``convert()`` (datasets.py:454, 574), so a 16-bit (``I;16``) or
20
+ #: palette (``P``) raster would flow straight into ``x / 127.5 - 1`` and be
21
+ #: badly out of range. That is an unguarded trap upstream; it is guarded here.
22
+ _ACCEPTED_SAR_MODES = ("L", "RGB", "RGBA")
23
+
24
+
25
+ class ReFlowSETPipeline(DiffusionPipeline):
26
+ """Generate an EO image from a SAR image with ReFlowSET's flow bridge.
27
+
28
+ Args:
29
+ transformer: The velocity transformer.
30
+ vae: The frozen FLUX.2 autoencoder that defines the latent space.
31
+ scheduler: The Design-B flow-bridge Euler solver.
32
+
33
+ To reproduce the paper's numbers, sample at ``num_inference_steps=50``,
34
+ ``guidance_scale=1.5``, float32, one image per call, with a generator freshly
35
+ seeded to 2024 on the compute device before each call — every test image in
36
+ the reported evaluation starts from the same seeded noise draw, and CPU-drawn
37
+ noise does not reproduce a CUDA draw.
38
+ """
39
+
40
+ model_cpu_offload_seq = "transformer->vae"
41
+
42
+ def __init__(
43
+ self,
44
+ transformer: ReFlowSETTransformer2DModel,
45
+ vae: AutoencoderFlux2,
46
+ scheduler: FlowBridgeScheduler,
47
+ ) -> None:
48
+ super().__init__()
49
+ self.register_modules(transformer=transformer, vae=vae, scheduler=scheduler)
50
+
51
+ # ---- preprocessing (datasets.py:124-205, 452-474, 572-589) --------------
52
+
53
+ @staticmethod
54
+ def _sar_hwc(raster: Union[Image.Image, np.ndarray]) -> np.ndarray:
55
+ """SAR raster -> ``[H, W, C]`` float32 in ``[0, 255]``, collapsed to 1 channel."""
56
+ if isinstance(raster, Image.Image):
57
+ if raster.mode not in _ACCEPTED_SAR_MODES:
58
+ raise ValueError(
59
+ f"SAR image mode {raster.mode!r} is not an 8-bit display raster; expected "
60
+ f"one of {_ACCEPTED_SAR_MODES}. ReFlowSET was trained on 8-bit display "
61
+ "quicklooks (sar_value_domain='display_png'); convert with .convert('L') "
62
+ "and be aware that the contrast stretch you choose is part of the input."
63
+ )
64
+ # No .convert() on the SAR side, matching datasets.py:454, 574.
65
+ arr = np.array(raster)
66
+ else:
67
+ arr = np.asarray(raster)
68
+ if arr.ndim == 2: # PIL mode "L" (datasets.py:171-172)
69
+ arr = arr[:, :, None]
70
+ if arr.shape[-1] == 4: # drop a container alpha channel (datasets.py:173-174)
71
+ arr = arr[..., :3]
72
+ arr = arr.astype(np.float32)
73
+ if arr.shape[-1] > 1:
74
+ # Exact-equality test, tol=0.0 (datasets.py:100-111): a display RGB
75
+ # quicklook collapses to its single amplitude channel.
76
+ if np.abs(arr - arr[..., :1]).max() == 0.0:
77
+ arr = arr[..., :1]
78
+ else:
79
+ warnings.warn(
80
+ "SAR raster has non-identical colour channels; feeding all 3 to the "
81
+ "frozen encoder. The released arms were trained on single-channel "
82
+ "amplitude quicklooks, so this is an undeclared input.",
83
+ RuntimeWarning,
84
+ stacklevel=3,
85
+ )
86
+ return arr
87
+
88
+ @staticmethod
89
+ def _center_crop(arr: np.ndarray, crop: int) -> np.ndarray:
90
+ """Center-crop ``[H, W, C]`` to ``crop`` — **never** resize (datasets.py:145-165).
91
+
92
+ The offsets are albumentations' ``CenterCrop`` arithmetic ``(n - c) // 2``:
93
+ the SAR2Opt protocol takes the central 512 of 600 at offset 44.
94
+ """
95
+ h, w = arr.shape[:2]
96
+ if h < crop or w < crop:
97
+ raise ValueError(
98
+ f"image {h}x{w} is smaller than the requested crop {crop}; ReFlowSET never "
99
+ "upscales an input"
100
+ )
101
+ top, left = (h - crop) // 2, (w - crop) // 2
102
+ return arr[top : top + crop, left : left + crop]
103
+
104
+ def preprocess(
105
+ self,
106
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
107
+ crop: Optional[int] = None,
108
+ ) -> torch.Tensor:
109
+ """Build the model-boundary SAR tensor ``[B, 3, H, W]`` in ``[-1, 1]``.
110
+
111
+ Args:
112
+ sar: A PIL image, a list of PIL images, an ``[H, W]`` / ``[H, W, C]``
113
+ uint8 array, or a float tensor already in ``[-1, 1]`` shaped
114
+ ``[H, W]``, ``[C, H, W]`` or ``[B, C, H, W]``.
115
+ crop: Center-crop size applied before normalisation. ``None``
116
+ center-crops to the arm's own training resolution when the
117
+ raster is larger and not already a multiple of the latent
118
+ stride -- which is exactly the SAR2Opt 600 -> 512 protocol the
119
+ reported numbers use. Pass an explicit size to override, or
120
+ ``0`` to keep the native raster and fail loudly if it does not
121
+ fit.
122
+
123
+ Images are read as 8-bit display rasters and mapped to ``[-1, 1]`` by
124
+ ``x / 127.5 - 1`` (datasets.py:124-126) with no per-image statistics, no
125
+ percentile stretch and no resize. The single SAR channel is then
126
+ replicated to 3 at the model boundary (evaluate.py:566-570), because the
127
+ frozen FLUX.2 encoder is the same one that encodes EO — ReFlowSET has no
128
+ separate SAR encoder.
129
+ """
130
+ if crop == 0:
131
+ crop = None
132
+ elif crop is None:
133
+ # Fall back to the resolution this arm was trained at. Cropping is
134
+ # the protocol (train.py random-crops, evaluate.py center-crops);
135
+ # ReFlowSET never resizes, so an un-croppable raster is an error
136
+ # rather than something to silently rescale.
137
+ crop = self.transformer.config.sample_size
138
+
139
+ if isinstance(sar, torch.Tensor):
140
+ x = sar.float()
141
+ if x.ndim == 2:
142
+ x = x[None, None]
143
+ elif x.ndim == 3:
144
+ x = x[None]
145
+ elif x.ndim != 4:
146
+ raise ValueError(f"SAR tensor must have 2, 3 or 4 dims, got {tuple(sar.shape)}")
147
+ h, w = x.shape[-2:]
148
+ if crop is not None and (h, w) != (crop, crop):
149
+ if h < crop or w < crop:
150
+ raise ValueError(f"tensor {h}x{w} is smaller than the requested crop {crop}")
151
+ top, left = (h - crop) // 2, (w - crop) // 2
152
+ x = x[..., top : top + crop, left : left + crop]
153
+ else:
154
+ images = sar if isinstance(sar, list) else [sar]
155
+ arrays = []
156
+ for item in images:
157
+ if not isinstance(item, (Image.Image, np.ndarray)):
158
+ raise TypeError(f"unsupported SAR input type {type(item)!r}")
159
+ arr = self._sar_hwc(item)
160
+ if crop is not None and arr.shape[:2] != (crop, crop):
161
+ arr = self._center_crop(arr, crop)
162
+ arrays.append(np.ascontiguousarray(arr.transpose(2, 0, 1)))
163
+ x = torch.from_numpy(np.stack(arrays)) / 127.5 - 1.0
164
+
165
+ # Train-side clamp (train.py:770); a no-op on 8-bit input, which maps
166
+ # exactly onto [-1, 1].
167
+ x = x.clamp(-1.0, 1.0)
168
+ if x.shape[1] == 1:
169
+ x = x.repeat(1, 3, 1, 1)
170
+ elif x.shape[1] != 3:
171
+ raise ValueError(
172
+ f"the frozen FLUX.2 encoder takes 1 or 3 SAR channels, got {x.shape[1]}"
173
+ )
174
+ factor = self.vae.spatial_factor
175
+ if x.shape[-2] % factor or x.shape[-1] % factor:
176
+ raise ValueError(
177
+ f"SAR size {x.shape[-2]}x{x.shape[-1]} must be divisible by {factor}; pass "
178
+ "crop= to center-crop (ReFlowSET never resizes)"
179
+ )
180
+ return x
181
+
182
+ # ---- postprocessing -----------------------------------------------------
183
+
184
+ @staticmethod
185
+ def _to_pil(images: torch.Tensor) -> list[Image.Image]:
186
+ """``[B, 3, H, W]`` in ``[0, 1]`` -> PIL, quantised round-half-up.
187
+
188
+ ``255 * x + 0.5`` truncated is what ``torchvision.utils.save_image``
189
+ does and is therefore what the released PNGs contain; numpy's
190
+ ``round()`` is banker's rounding and would differ on exact halves.
191
+ """
192
+ arr = (images * 255 + 0.5).clamp(0, 255).to(torch.uint8)
193
+ arr = arr.permute(0, 2, 3, 1).cpu().numpy()
194
+ return [Image.fromarray(a) for a in arr]
195
+
196
+ @torch.no_grad()
197
+ def __call__(
198
+ self,
199
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
200
+ num_inference_steps: int = 50,
201
+ guidance_scale: float = 1.5,
202
+ generator: Optional[Union[torch.Generator, list[torch.Generator]]] = None,
203
+ output_type: str = "pil",
204
+ crop: Optional[int] = None,
205
+ return_dict: bool = True,
206
+ ) -> Union[ImagePipelineOutput, tuple[list]]:
207
+ """Translate a SAR image into an EO image.
208
+
209
+ Args:
210
+ sar: SAR input; see :meth:`preprocess`.
211
+ num_inference_steps: NFE, the number of velocity evaluations. The
212
+ paper's main results are NFE 50; NFE 4 is the efficiency
213
+ operating point and trades FID for PSNR/SSIM, so the two must
214
+ not be mixed in one comparison.
215
+ guidance_scale: Classifier-free guidance scale. 1.5 is the published
216
+ setting; 1.0 disables guidance and halves the cost.
217
+ generator: Generator for the initial noise. Create it on the compute
218
+ device — CPU-drawn noise does not reproduce a CUDA draw.
219
+ output_type: ``"pil"``, ``"np"`` or ``"pt"``.
220
+ crop: Center-crop size applied to the SAR input before encoding.
221
+ return_dict: Return an ``ImagePipelineOutput`` instead of a tuple.
222
+
223
+ Returns:
224
+ The generated EO image(s) in ``[0, 1]`` (or as PIL).
225
+ """
226
+ if output_type not in ("pil", "np", "pt"):
227
+ raise ValueError(f"output_type must be 'pil', 'np' or 'pt', got {output_type!r}")
228
+
229
+ device = self._execution_device
230
+ dtype = self.transformer.dtype
231
+
232
+ sar_pm1 = self.preprocess(sar, crop=crop).to(device=device, dtype=self.vae.dtype)
233
+ # The SAR condition is encoded by the SAME frozen autoencoder that
234
+ # defines the EO latent space (evaluate.py:553-577).
235
+ z_s = self.vae.encode(sar_pm1).to(dtype)
236
+
237
+ self.scheduler.set_timesteps(num_inference_steps, device=device)
238
+ # Design B: the bridge starts at t = 0 from pure Gaussian noise
239
+ # (bridge.py:409-433), NOT from the SAR latent.
240
+ latents = randn_tensor(z_s.shape, generator=generator, device=device, dtype=z_s.dtype)
241
+
242
+ for t in self.progress_bar(self.scheduler.timesteps):
243
+ timestep = t.expand(latents.shape[0])
244
+ velocity = self.transformer(latents, timestep, z_s, return_dict=False)[0]
245
+ if guidance_scale != 1.0:
246
+ # Two passes; the null branch is cond=None, which the transformer
247
+ # turns into an all-zero conditioning latent (bridge.py:530-535).
248
+ uncond = self.transformer(latents, timestep, None, return_dict=False)[0]
249
+ velocity = uncond + guidance_scale * (velocity - uncond)
250
+ latents = self.scheduler.step(velocity, t, latents, return_dict=False)[0]
251
+
252
+ image = self.vae.decode(latents.to(self.vae.dtype))
253
+ # `--denorm standard` (evaluate.py:292-295). The `legacy` C-DiffSET
254
+ # convention `(x + 0.5).clamp(0, 1)` is a 2x contrast stretch and must
255
+ # not be used with these numbers.
256
+ image = (image * 0.5 + 0.5).clamp(0.0, 1.0)
257
+
258
+ self.maybe_free_model_hooks()
259
+
260
+ if output_type == "pil":
261
+ image = self._to_pil(image)
262
+ elif output_type == "np":
263
+ image = image.permute(0, 2, 3, 1).float().cpu().numpy()
264
+
265
+ if not return_dict:
266
+ return (image,)
267
+ return ImagePipelineOutput(images=image)
pipeline_reflowset.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET SAR -> EO translation pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Optional, Union
7
+
8
+ import numpy as np
9
+ import torch
10
+ from diffusers.pipelines.pipeline_utils import DiffusionPipeline, ImagePipelineOutput
11
+ from diffusers.utils.torch_utils import randn_tensor
12
+ from PIL import Image
13
+
14
+ from .autoencoder_flux2 import AutoencoderFlux2
15
+ from .scheduler_flow_bridge import FlowBridgeScheduler
16
+ from .transformer_reflowset import ReFlowSETTransformer2DModel
17
+
18
+ #: PIL modes the SAR loader accepts. The reference loader calls ``np.array(im)``
19
+ #: with no ``convert()`` (datasets.py:454, 574), so a 16-bit (``I;16``) or
20
+ #: palette (``P``) raster would flow straight into ``x / 127.5 - 1`` and be
21
+ #: badly out of range. That is an unguarded trap upstream; it is guarded here.
22
+ _ACCEPTED_SAR_MODES = ("L", "RGB", "RGBA")
23
+
24
+
25
+ class ReFlowSETPipeline(DiffusionPipeline):
26
+ """Generate an EO image from a SAR image with ReFlowSET's flow bridge.
27
+
28
+ Args:
29
+ transformer: The velocity transformer.
30
+ vae: The frozen FLUX.2 autoencoder that defines the latent space.
31
+ scheduler: The Design-B flow-bridge Euler solver.
32
+
33
+ To reproduce the paper's numbers, sample at ``num_inference_steps=50``,
34
+ ``guidance_scale=1.5``, float32, one image per call, with a generator freshly
35
+ seeded to 2024 on the compute device before each call — every test image in
36
+ the reported evaluation starts from the same seeded noise draw, and CPU-drawn
37
+ noise does not reproduce a CUDA draw.
38
+ """
39
+
40
+ model_cpu_offload_seq = "transformer->vae"
41
+
42
+ def __init__(
43
+ self,
44
+ transformer: ReFlowSETTransformer2DModel,
45
+ vae: AutoencoderFlux2,
46
+ scheduler: FlowBridgeScheduler,
47
+ ) -> None:
48
+ super().__init__()
49
+ self.register_modules(transformer=transformer, vae=vae, scheduler=scheduler)
50
+
51
+ # ---- preprocessing (datasets.py:124-205, 452-474, 572-589) --------------
52
+
53
+ @staticmethod
54
+ def _sar_hwc(raster: Union[Image.Image, np.ndarray]) -> np.ndarray:
55
+ """SAR raster -> ``[H, W, C]`` float32 in ``[0, 255]``, collapsed to 1 channel."""
56
+ if isinstance(raster, Image.Image):
57
+ if raster.mode not in _ACCEPTED_SAR_MODES:
58
+ raise ValueError(
59
+ f"SAR image mode {raster.mode!r} is not an 8-bit display raster; expected "
60
+ f"one of {_ACCEPTED_SAR_MODES}. ReFlowSET was trained on 8-bit display "
61
+ "quicklooks (sar_value_domain='display_png'); convert with .convert('L') "
62
+ "and be aware that the contrast stretch you choose is part of the input."
63
+ )
64
+ # No .convert() on the SAR side, matching datasets.py:454, 574.
65
+ arr = np.array(raster)
66
+ else:
67
+ arr = np.asarray(raster)
68
+ if arr.ndim == 2: # PIL mode "L" (datasets.py:171-172)
69
+ arr = arr[:, :, None]
70
+ if arr.shape[-1] == 4: # drop a container alpha channel (datasets.py:173-174)
71
+ arr = arr[..., :3]
72
+ arr = arr.astype(np.float32)
73
+ if arr.shape[-1] > 1:
74
+ # Exact-equality test, tol=0.0 (datasets.py:100-111): a display RGB
75
+ # quicklook collapses to its single amplitude channel.
76
+ if np.abs(arr - arr[..., :1]).max() == 0.0:
77
+ arr = arr[..., :1]
78
+ else:
79
+ warnings.warn(
80
+ "SAR raster has non-identical colour channels; feeding all 3 to the "
81
+ "frozen encoder. The released arms were trained on single-channel "
82
+ "amplitude quicklooks, so this is an undeclared input.",
83
+ RuntimeWarning,
84
+ stacklevel=3,
85
+ )
86
+ return arr
87
+
88
+ @staticmethod
89
+ def _center_crop(arr: np.ndarray, crop: int) -> np.ndarray:
90
+ """Center-crop ``[H, W, C]`` to ``crop`` — **never** resize (datasets.py:145-165).
91
+
92
+ The offsets are albumentations' ``CenterCrop`` arithmetic ``(n - c) // 2``:
93
+ the SAR2Opt protocol takes the central 512 of 600 at offset 44.
94
+ """
95
+ h, w = arr.shape[:2]
96
+ if h < crop or w < crop:
97
+ raise ValueError(
98
+ f"image {h}x{w} is smaller than the requested crop {crop}; ReFlowSET never "
99
+ "upscales an input"
100
+ )
101
+ top, left = (h - crop) // 2, (w - crop) // 2
102
+ return arr[top : top + crop, left : left + crop]
103
+
104
+ def preprocess(
105
+ self,
106
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
107
+ crop: Optional[int] = None,
108
+ ) -> torch.Tensor:
109
+ """Build the model-boundary SAR tensor ``[B, 3, H, W]`` in ``[-1, 1]``.
110
+
111
+ Args:
112
+ sar: A PIL image, a list of PIL images, an ``[H, W]`` / ``[H, W, C]``
113
+ uint8 array, or a float tensor already in ``[-1, 1]`` shaped
114
+ ``[H, W]``, ``[C, H, W]`` or ``[B, C, H, W]``.
115
+ crop: Center-crop size applied before normalisation. ``None``
116
+ center-crops to the arm's own training resolution when the
117
+ raster is larger and not already a multiple of the latent
118
+ stride -- which is exactly the SAR2Opt 600 -> 512 protocol the
119
+ reported numbers use. Pass an explicit size to override, or
120
+ ``0`` to keep the native raster and fail loudly if it does not
121
+ fit.
122
+
123
+ Images are read as 8-bit display rasters and mapped to ``[-1, 1]`` by
124
+ ``x / 127.5 - 1`` (datasets.py:124-126) with no per-image statistics, no
125
+ percentile stretch and no resize. The single SAR channel is then
126
+ replicated to 3 at the model boundary (evaluate.py:566-570), because the
127
+ frozen FLUX.2 encoder is the same one that encodes EO — ReFlowSET has no
128
+ separate SAR encoder.
129
+ """
130
+ if crop == 0:
131
+ crop = None
132
+ elif crop is None:
133
+ # Fall back to the resolution this arm was trained at. Cropping is
134
+ # the protocol (train.py random-crops, evaluate.py center-crops);
135
+ # ReFlowSET never resizes, so an un-croppable raster is an error
136
+ # rather than something to silently rescale.
137
+ crop = self.transformer.config.sample_size
138
+
139
+ if isinstance(sar, torch.Tensor):
140
+ x = sar.float()
141
+ if x.ndim == 2:
142
+ x = x[None, None]
143
+ elif x.ndim == 3:
144
+ x = x[None]
145
+ elif x.ndim != 4:
146
+ raise ValueError(f"SAR tensor must have 2, 3 or 4 dims, got {tuple(sar.shape)}")
147
+ h, w = x.shape[-2:]
148
+ if crop is not None and (h, w) != (crop, crop):
149
+ if h < crop or w < crop:
150
+ raise ValueError(f"tensor {h}x{w} is smaller than the requested crop {crop}")
151
+ top, left = (h - crop) // 2, (w - crop) // 2
152
+ x = x[..., top : top + crop, left : left + crop]
153
+ else:
154
+ images = sar if isinstance(sar, list) else [sar]
155
+ arrays = []
156
+ for item in images:
157
+ if not isinstance(item, (Image.Image, np.ndarray)):
158
+ raise TypeError(f"unsupported SAR input type {type(item)!r}")
159
+ arr = self._sar_hwc(item)
160
+ if crop is not None and arr.shape[:2] != (crop, crop):
161
+ arr = self._center_crop(arr, crop)
162
+ arrays.append(np.ascontiguousarray(arr.transpose(2, 0, 1)))
163
+ x = torch.from_numpy(np.stack(arrays)) / 127.5 - 1.0
164
+
165
+ # Train-side clamp (train.py:770); a no-op on 8-bit input, which maps
166
+ # exactly onto [-1, 1].
167
+ x = x.clamp(-1.0, 1.0)
168
+ if x.shape[1] == 1:
169
+ x = x.repeat(1, 3, 1, 1)
170
+ elif x.shape[1] != 3:
171
+ raise ValueError(
172
+ f"the frozen FLUX.2 encoder takes 1 or 3 SAR channels, got {x.shape[1]}"
173
+ )
174
+ factor = self.vae.spatial_factor
175
+ if x.shape[-2] % factor or x.shape[-1] % factor:
176
+ raise ValueError(
177
+ f"SAR size {x.shape[-2]}x{x.shape[-1]} must be divisible by {factor}; pass "
178
+ "crop= to center-crop (ReFlowSET never resizes)"
179
+ )
180
+ return x
181
+
182
+ # ---- postprocessing -----------------------------------------------------
183
+
184
+ @staticmethod
185
+ def _to_pil(images: torch.Tensor) -> list[Image.Image]:
186
+ """``[B, 3, H, W]`` in ``[0, 1]`` -> PIL, quantised round-half-up.
187
+
188
+ ``255 * x + 0.5`` truncated is what ``torchvision.utils.save_image``
189
+ does and is therefore what the released PNGs contain; numpy's
190
+ ``round()`` is banker's rounding and would differ on exact halves.
191
+ """
192
+ arr = (images * 255 + 0.5).clamp(0, 255).to(torch.uint8)
193
+ arr = arr.permute(0, 2, 3, 1).cpu().numpy()
194
+ return [Image.fromarray(a) for a in arr]
195
+
196
+ @torch.no_grad()
197
+ def __call__(
198
+ self,
199
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
200
+ num_inference_steps: int = 50,
201
+ guidance_scale: float = 1.5,
202
+ generator: Optional[Union[torch.Generator, list[torch.Generator]]] = None,
203
+ output_type: str = "pil",
204
+ crop: Optional[int] = None,
205
+ return_dict: bool = True,
206
+ ) -> Union[ImagePipelineOutput, tuple[list]]:
207
+ """Translate a SAR image into an EO image.
208
+
209
+ Args:
210
+ sar: SAR input; see :meth:`preprocess`.
211
+ num_inference_steps: NFE, the number of velocity evaluations. The
212
+ paper's main results are NFE 50; NFE 4 is the efficiency
213
+ operating point and trades FID for PSNR/SSIM, so the two must
214
+ not be mixed in one comparison.
215
+ guidance_scale: Classifier-free guidance scale. 1.5 is the published
216
+ setting; 1.0 disables guidance and halves the cost.
217
+ generator: Generator for the initial noise. Create it on the compute
218
+ device — CPU-drawn noise does not reproduce a CUDA draw.
219
+ output_type: ``"pil"``, ``"np"`` or ``"pt"``.
220
+ crop: Center-crop size applied to the SAR input before encoding.
221
+ return_dict: Return an ``ImagePipelineOutput`` instead of a tuple.
222
+
223
+ Returns:
224
+ The generated EO image(s) in ``[0, 1]`` (or as PIL).
225
+ """
226
+ if output_type not in ("pil", "np", "pt"):
227
+ raise ValueError(f"output_type must be 'pil', 'np' or 'pt', got {output_type!r}")
228
+
229
+ device = self._execution_device
230
+ dtype = self.transformer.dtype
231
+
232
+ sar_pm1 = self.preprocess(sar, crop=crop).to(device=device, dtype=self.vae.dtype)
233
+ # The SAR condition is encoded by the SAME frozen autoencoder that
234
+ # defines the EO latent space (evaluate.py:553-577).
235
+ z_s = self.vae.encode(sar_pm1).to(dtype)
236
+
237
+ self.scheduler.set_timesteps(num_inference_steps, device=device)
238
+ # Design B: the bridge starts at t = 0 from pure Gaussian noise
239
+ # (bridge.py:409-433), NOT from the SAR latent.
240
+ latents = randn_tensor(z_s.shape, generator=generator, device=device, dtype=z_s.dtype)
241
+
242
+ for t in self.progress_bar(self.scheduler.timesteps):
243
+ timestep = t.expand(latents.shape[0])
244
+ velocity = self.transformer(latents, timestep, z_s, return_dict=False)[0]
245
+ if guidance_scale != 1.0:
246
+ # Two passes; the null branch is cond=None, which the transformer
247
+ # turns into an all-zero conditioning latent (bridge.py:530-535).
248
+ uncond = self.transformer(latents, timestep, None, return_dict=False)[0]
249
+ velocity = uncond + guidance_scale * (velocity - uncond)
250
+ latents = self.scheduler.step(velocity, t, latents, return_dict=False)[0]
251
+
252
+ image = self.vae.decode(latents.to(self.vae.dtype))
253
+ # `--denorm standard` (evaluate.py:292-295). The `legacy` C-DiffSET
254
+ # convention `(x + 0.5).clamp(0, 1)` is a 2x contrast stretch and must
255
+ # not be used with these numbers.
256
+ image = (image * 0.5 + 0.5).clamp(0.0, 1.0)
257
+
258
+ self.maybe_free_model_hooks()
259
+
260
+ if output_type == "pil":
261
+ image = self._to_pil(image)
262
+ elif output_type == "np":
263
+ image = image.permute(0, 2, 3, 1).float().cpu().numpy()
264
+
265
+ if not return_dict:
266
+ return (image,)
267
+ return ImagePipelineOutput(images=image)
qxs-saropt/autoencoder_flux2.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen FLUX.2 autoencoder — the latent endpoint of ReFlowSET.
2
+
3
+ ReFlowSET never fine-tunes this module: it is loaded once, frozen, and used to
4
+ encode the SAR condition and to decode the sampled EO latent. The released
5
+ weights are the **Apache-2.0** FLUX.2-klein-base-4B copy of the autoencoder,
6
+ re-keyed to the layout below (see ``scripts/convert_flux2_ae.py``).
7
+
8
+ Three details of the checkpoint are non-standard for `diffusers` and are
9
+ preserved exactly, because the file must load with ``strict=True``:
10
+
11
+ * ``quant_conv`` lives **inside** ``encoder.*`` and is the last op of the
12
+ encoder forward; ``post_quant_conv`` lives **inside** ``decoder.*`` and is the
13
+ first op of the decoder forward. `diffusers`' ``AutoencoderKL`` makes both
14
+ siblings of the encoder/decoder.
15
+ * The latent normaliser is a real ``BatchNorm2d(128, affine=False)`` whose
16
+ running statistics ship in the checkpoint under ``bn.*`` — a per-channel mean
17
+ **and** variance, not a scalar ``scaling_factor``/``shift_factor``. Its
18
+ epsilon is ``1e-4``, not torch's ``1e-5``.
19
+ * ``encode`` returns the posterior **mean**; the log-variance chunk of the
20
+ encoder's moments is discarded, so encoding is deterministic and there is no
21
+ ``DiagonalGaussianDistribution`` and no ``.sample()``.
22
+
23
+ The public latent is ``[B, 128, H/16, W/16]``: an 8x convolutional stride
24
+ followed by a 2x2 space-to-depth pack that is part of the *autoencoder*, not of
25
+ the transformer.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+
32
+ import torch
33
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
34
+ from diffusers.models.modeling_utils import ModelMixin
35
+ from torch import Tensor, nn
36
+ from torch.nn import functional as F
37
+
38
+
39
+ def swish(x: Tensor) -> Tensor:
40
+ """``x * sigmoid(x)`` — the activation used throughout the FLUX.2 AE."""
41
+ return x * torch.sigmoid(x)
42
+
43
+
44
+ class AttnBlock(nn.Module):
45
+ """Single-head self-attention over the spatial grid (head dim == channels)."""
46
+
47
+ def __init__(self, in_channels: int) -> None:
48
+ super().__init__()
49
+ self.in_channels = in_channels
50
+ self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
51
+ self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1)
52
+ self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1)
53
+ self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1)
54
+ self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1)
55
+
56
+ def attention(self, h_: Tensor) -> Tensor:
57
+ h_ = self.norm(h_)
58
+ q, k, v = self.q(h_), self.k(h_), self.v(h_)
59
+ b, c, h, w = q.shape
60
+ # "b c h w -> b 1 (h w) c": ONE head whose head-dim is the full channel
61
+ # count (flux2_ae.py:70-73).
62
+ q = q.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
63
+ k = k.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
64
+ v = v.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
65
+ h_ = F.scaled_dot_product_attention(q, k, v)
66
+ return h_.squeeze(1).transpose(1, 2).reshape(b, c, h, w)
67
+
68
+ def forward(self, x: Tensor) -> Tensor:
69
+ return x + self.proj_out(self.attention(x))
70
+
71
+
72
+ class ResnetBlock(nn.Module):
73
+ def __init__(self, in_channels: int, out_channels: int) -> None:
74
+ super().__init__()
75
+ self.in_channels = in_channels
76
+ self.out_channels = out_channels
77
+ self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
78
+ self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
79
+ self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True)
80
+ self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
81
+ if in_channels != out_channels:
82
+ self.nin_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0)
83
+
84
+ def forward(self, x: Tensor) -> Tensor:
85
+ h = self.conv1(swish(self.norm1(x)))
86
+ h = self.conv2(swish(self.norm2(h)))
87
+ if self.in_channels != self.out_channels:
88
+ x = self.nin_shortcut(x)
89
+ return x + h
90
+
91
+
92
+ class Downsample(nn.Module):
93
+ """Stride-2 conv with FLUX's asymmetric ``(0, 1, 0, 1)`` pad (flux2_ae.py:111-121)."""
94
+
95
+ def __init__(self, in_channels: int) -> None:
96
+ super().__init__()
97
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=2, padding=0)
98
+
99
+ def forward(self, x: Tensor) -> Tensor:
100
+ return self.conv(F.pad(x, (0, 1, 0, 1), mode="constant", value=0))
101
+
102
+
103
+ class Upsample(nn.Module):
104
+ """Nearest-neighbour 2x followed by a 3x3 conv (flux2_ae.py:124-132)."""
105
+
106
+ def __init__(self, in_channels: int) -> None:
107
+ super().__init__()
108
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=1, padding=1)
109
+
110
+ def forward(self, x: Tensor) -> Tensor:
111
+ return self.conv(F.interpolate(x, scale_factor=2.0, mode="nearest"))
112
+
113
+
114
+ class Encoder(nn.Module):
115
+ """FLUX.2 encoder. Emits ``2 * z_channels`` moments; ``quant_conv`` is internal."""
116
+
117
+ def __init__(
118
+ self,
119
+ resolution: int,
120
+ in_channels: int,
121
+ ch: int,
122
+ ch_mult: list[int],
123
+ num_res_blocks: int,
124
+ z_channels: int,
125
+ ) -> None:
126
+ super().__init__()
127
+ # Declared first so the checkpoint key is `encoder.quant_conv.*`
128
+ # (flux2_ae.py:146) — diffusers keeps quant_conv outside the encoder.
129
+ self.quant_conv = nn.Conv2d(2 * z_channels, 2 * z_channels, 1)
130
+ self.ch = ch
131
+ self.num_resolutions = len(ch_mult)
132
+ self.num_res_blocks = num_res_blocks
133
+ self.resolution = resolution
134
+ self.in_channels = in_channels
135
+
136
+ self.conv_in = nn.Conv2d(in_channels, ch, kernel_size=3, stride=1, padding=1)
137
+
138
+ in_ch_mult = (1,) + tuple(ch_mult)
139
+ self.down = nn.ModuleList()
140
+ block_in = ch
141
+ for i_level in range(self.num_resolutions):
142
+ block = nn.ModuleList()
143
+ block_in = ch * in_ch_mult[i_level]
144
+ block_out = ch * ch_mult[i_level]
145
+ for _ in range(num_res_blocks):
146
+ block.append(ResnetBlock(block_in, block_out))
147
+ block_in = block_out
148
+ down = nn.Module()
149
+ down.block = block
150
+ # Empty at every level in this checkpoint: attention exists only in
151
+ # `mid` (flux2_ae.py:162). Kept so the forward guard is meaningful.
152
+ down.attn = nn.ModuleList()
153
+ if i_level != self.num_resolutions - 1:
154
+ down.downsample = Downsample(block_in)
155
+ self.down.append(down)
156
+
157
+ self.mid = nn.Module()
158
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
159
+ self.mid.attn_1 = AttnBlock(block_in)
160
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
161
+
162
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
163
+ self.conv_out = nn.Conv2d(block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1)
164
+
165
+ def forward(self, x: Tensor) -> Tensor:
166
+ hs = [self.conv_in(x)]
167
+ for i_level in range(self.num_resolutions):
168
+ for i_block in range(self.num_res_blocks):
169
+ h = self.down[i_level].block[i_block](hs[-1])
170
+ if len(self.down[i_level].attn) > 0:
171
+ h = self.down[i_level].attn[i_block](h)
172
+ hs.append(h)
173
+ if i_level != self.num_resolutions - 1:
174
+ hs.append(self.down[i_level].downsample(hs[-1]))
175
+
176
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(hs[-1])))
177
+ h = self.conv_out(swish(self.norm_out(h)))
178
+ return self.quant_conv(h) # last op of the encoder (flux2_ae.py:207)
179
+
180
+
181
+ class Decoder(nn.Module):
182
+ """FLUX.2 decoder. ``post_quant_conv`` is internal and runs first."""
183
+
184
+ def __init__(
185
+ self,
186
+ ch: int,
187
+ out_ch: int,
188
+ ch_mult: list[int],
189
+ num_res_blocks: int,
190
+ in_channels: int,
191
+ resolution: int,
192
+ z_channels: int,
193
+ ) -> None:
194
+ super().__init__()
195
+ # Checkpoint key `decoder.post_quant_conv.*` (flux2_ae.py:223).
196
+ self.post_quant_conv = nn.Conv2d(z_channels, z_channels, 1)
197
+ self.ch = ch
198
+ self.num_resolutions = len(ch_mult)
199
+ self.num_res_blocks = num_res_blocks
200
+ self.resolution = resolution
201
+ self.in_channels = in_channels
202
+
203
+ block_in = ch * ch_mult[self.num_resolutions - 1]
204
+ self.conv_in = nn.Conv2d(z_channels, block_in, kernel_size=3, stride=1, padding=1)
205
+
206
+ self.mid = nn.Module()
207
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
208
+ self.mid.attn_1 = AttnBlock(block_in)
209
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
210
+
211
+ self.up = nn.ModuleList()
212
+ for i_level in reversed(range(self.num_resolutions)):
213
+ block = nn.ModuleList()
214
+ block_out = ch * ch_mult[i_level]
215
+ for _ in range(num_res_blocks + 1):
216
+ block.append(ResnetBlock(block_in, block_out))
217
+ block_in = block_out
218
+ up = nn.Module()
219
+ up.block = block
220
+ up.attn = nn.ModuleList() # empty in this checkpoint (flux2_ae.py:249)
221
+ if i_level != 0:
222
+ up.upsample = Upsample(block_in)
223
+ self.up.insert(0, up) # prepend so `up.<i>` indexes by resolution level
224
+
225
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
226
+ self.conv_out = nn.Conv2d(block_in, out_ch, kernel_size=3, stride=1, padding=1)
227
+
228
+ def forward(self, z: Tensor) -> Tensor:
229
+ z = self.post_quant_conv(z) # first op of the decoder (flux2_ae.py:267)
230
+ upscale_dtype = next(self.up.parameters()).dtype
231
+
232
+ h = self.conv_in(z)
233
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(h)))
234
+ h = h.to(upscale_dtype)
235
+
236
+ for i_level in reversed(range(self.num_resolutions)):
237
+ for i_block in range(self.num_res_blocks + 1):
238
+ h = self.up[i_level].block[i_block](h)
239
+ if len(self.up[i_level].attn) > 0:
240
+ h = self.up[i_level].attn[i_block](h)
241
+ if i_level != 0:
242
+ h = self.up[i_level].upsample(h)
243
+
244
+ return self.conv_out(swish(self.norm_out(h)))
245
+
246
+
247
+ class AutoencoderFlux2(ModelMixin, ConfigMixin):
248
+ """Frozen FLUX.2 autoencoder with ReFlowSET's packed, BN-normalised latent.
249
+
250
+ ``encode`` maps ``[B, 3, H, W]`` in ``[-1, 1]`` to ``[B, 128, H/16, W/16]``
251
+ and ``decode`` inverts it. The module is frozen: ``train()`` is a no-op that
252
+ always selects eval mode, and the latent BatchNorm is additionally forced to
253
+ eval on every call so no batch statistic can ever leak into the latent.
254
+
255
+ Args:
256
+ resolution: Nominal training resolution of the original autoencoder.
257
+ Only used to size bookkeeping attributes; any ``H``, ``W`` divisible
258
+ by 16 may be encoded.
259
+ in_channels: Input image channels (3).
260
+ ch: Base width.
261
+ out_ch: Output image channels (3).
262
+ ch_mult: Per-level width multipliers; ``len(ch_mult) - 1`` downsamples.
263
+ num_res_blocks: Residual blocks per level.
264
+ z_channels: Pre-pack latent channels (32).
265
+ patch_size: Space-to-depth factor applied after the encoder (2), which
266
+ takes the latent from 32 channels at ``H/8`` to 128 at ``H/16``.
267
+ bn_eps: Epsilon of the latent BatchNorm. **1e-4**, not torch's 1e-5
268
+ (flux2_ae.py:331); using 1e-5 shifts the latent by up to 2.6e-5.
269
+ """
270
+
271
+ _supports_gradient_checkpointing = False
272
+
273
+ @register_to_config
274
+ def __init__(
275
+ self,
276
+ resolution: int = 256,
277
+ in_channels: int = 3,
278
+ ch: int = 128,
279
+ out_ch: int = 3,
280
+ ch_mult: tuple[int, ...] = (1, 2, 4, 4),
281
+ num_res_blocks: int = 2,
282
+ z_channels: int = 32,
283
+ patch_size: int = 2,
284
+ bn_eps: float = 1e-4,
285
+ ) -> None:
286
+ super().__init__()
287
+ ch_mult = list(ch_mult)
288
+ self.encoder = Encoder(
289
+ resolution=resolution,
290
+ in_channels=in_channels,
291
+ ch=ch,
292
+ ch_mult=ch_mult,
293
+ num_res_blocks=num_res_blocks,
294
+ z_channels=z_channels,
295
+ )
296
+ self.decoder = Decoder(
297
+ ch=ch,
298
+ out_ch=out_ch,
299
+ ch_mult=ch_mult,
300
+ num_res_blocks=num_res_blocks,
301
+ in_channels=in_channels,
302
+ resolution=resolution,
303
+ z_channels=z_channels,
304
+ )
305
+ # Per-channel latent normaliser with the checkpoint's running statistics.
306
+ # affine=False, so there is no weight/bias to load (flux2_ae.py:334-340).
307
+ self.bn = nn.BatchNorm2d(
308
+ patch_size * patch_size * z_channels,
309
+ eps=bn_eps,
310
+ momentum=0.1,
311
+ affine=False,
312
+ track_running_stats=True,
313
+ )
314
+
315
+ @property
316
+ def latent_channels(self) -> int:
317
+ """Channels of the public latent: ``patch_size**2 * z_channels`` = 128."""
318
+ return self.config.patch_size**2 * self.config.z_channels
319
+
320
+ @property
321
+ def spatial_factor(self) -> int:
322
+ """Total stride: 8x convolutional times ``patch_size`` packing = 16."""
323
+ return 2 ** (len(self.config.ch_mult) - 1) * self.config.patch_size
324
+
325
+ # ---- 2x2 space-to-depth pack / unpack -----------------------------------
326
+
327
+ def pack(self, z: Tensor) -> Tensor:
328
+ """``[B, C, H, W] -> [B, C*p*p, H/p, W/p]``, channel-major.
329
+
330
+ Bit-identical to the reference ``rearrange("... c (i pi) (j pj) -> ...
331
+ (c pi pj) i j")`` (flux2_ae.py:349-357). Note this is **not** diffusers'
332
+ ``_pack_latents``, whose channel grouping is transposed.
333
+ """
334
+ return F.pixel_unshuffle(z, self.config.patch_size)
335
+
336
+ def unpack(self, z: Tensor) -> Tensor:
337
+ """Exact inverse of :meth:`pack` (flux2_ae.py:359-367)."""
338
+ return F.pixel_shuffle(z, self.config.patch_size)
339
+
340
+ # ---- latent normalisation ----------------------------------------------
341
+
342
+ def normalize(self, z: Tensor) -> Tensor:
343
+ """``(z - running_mean) / sqrt(running_var + bn_eps)``, per channel."""
344
+ self.bn.eval() # forced every call (flux2_ae.py:372); train mode shifts z by ~1.67
345
+ return self.bn(z)
346
+
347
+ def inv_normalize(self, z: Tensor) -> Tensor:
348
+ """Exact inverse of :meth:`normalize` — same ``bn_eps`` (flux2_ae.py:375-379)."""
349
+ self.bn.eval()
350
+ s = torch.sqrt(self.bn.running_var.view(1, -1, 1, 1) + self.config.bn_eps)
351
+ m = self.bn.running_mean.view(1, -1, 1, 1)
352
+ return z * s + m
353
+
354
+ # ---- public API ---------------------------------------------------------
355
+
356
+ @torch.no_grad()
357
+ def encode(self, x: Tensor) -> Tensor:
358
+ """Encode an image to the packed, normalised latent.
359
+
360
+ Args:
361
+ x: ``[B, 3, H, W]`` in ``[-1, 1]``; ``H`` and ``W`` divisible by 16.
362
+
363
+ Returns:
364
+ ``[B, 128, H/16, W/16]`` — the posterior **mean**, packed and
365
+ BN-normalised. The encoder's log-variance chunk is discarded
366
+ (flux2_ae.py:396), so this is deterministic: there is no posterior
367
+ distribution object and nothing to sample.
368
+ """
369
+ if x.ndim != 4 or x.shape[1] != self.config.in_channels:
370
+ raise ValueError(
371
+ f"encode expects [B, {self.config.in_channels}, H, W], got {tuple(x.shape)}"
372
+ )
373
+ h, w = x.shape[-2:]
374
+ if h % self.spatial_factor or w % self.spatial_factor:
375
+ raise ValueError(
376
+ f"encode requires H and W divisible by {self.spatial_factor}, got {h}x{w}"
377
+ )
378
+ moments = self.encoder(x)
379
+ mean = torch.chunk(moments, 2, dim=1)[0]
380
+ return self.normalize(self.pack(mean))
381
+
382
+ @torch.no_grad()
383
+ def decode(self, z: Tensor) -> Tensor:
384
+ """Decode a packed, normalised latent ``[B, 128, h, w]`` to ``[B, 3, 16h, 16w]``.
385
+
386
+ The output is approximately ``[-1, 1]`` and is **not** clamped here; the
387
+ pipeline applies ``(x * 0.5 + 0.5).clamp(0, 1)``.
388
+ """
389
+ if z.ndim != 4 or z.shape[1] != self.latent_channels:
390
+ raise ValueError(
391
+ f"decode expects [B, {self.latent_channels}, h, w], got {tuple(z.shape)}"
392
+ )
393
+ return self.decoder(self.unpack(self.inv_normalize(z)))
394
+
395
+ # ---- construction / freezing -------------------------------------------
396
+
397
+ @classmethod
398
+ def from_single_file(
399
+ cls,
400
+ path: str | os.PathLike,
401
+ torch_dtype: torch.dtype = torch.float32,
402
+ ) -> "AutoencoderFlux2":
403
+ """Load the single-file ``ae.safetensors`` (BFL key names) with ``strict=True``.
404
+
405
+ The released file is the Apache-2.0 FLUX.2-klein-base-4B autoencoder
406
+ re-keyed to this layout; it is stored in bfloat16 and is upcast to
407
+ ``torch_dtype``. ReFlowSET runs the autoencoder in float32.
408
+ """
409
+ from safetensors.torch import load_file
410
+
411
+ path = os.fspath(path)
412
+ if not os.path.isfile(path):
413
+ raise FileNotFoundError(
414
+ f"FLUX.2 autoencoder weights not found at: {path}. Expected the "
415
+ "single-file 'ae.safetensors' shipped with ReFlowSET."
416
+ )
417
+ model = cls()
418
+ model.load_state_dict(load_file(path, device="cpu"), strict=True)
419
+ model.to(dtype=torch_dtype)
420
+ model.eval()
421
+ model.requires_grad_(False)
422
+ return model
423
+
424
+ def train(self, mode: bool = True) -> "AutoencoderFlux2":
425
+ """The autoencoder is frozen: never leave eval mode (flux2_ae.py:437-439)."""
426
+ return super().train(False)
qxs-saropt/model_index.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "ReFlowSETPipeline",
3
+ "_diffusers_version": "0.37.1",
4
+ "transformer": [
5
+ "transformer_reflowset",
6
+ "ReFlowSETTransformer2DModel"
7
+ ],
8
+ "vae": [
9
+ "autoencoder_flux2",
10
+ "AutoencoderFlux2"
11
+ ],
12
+ "scheduler": [
13
+ "scheduler_flow_bridge",
14
+ "FlowBridgeScheduler"
15
+ ]
16
+ }
qxs-saropt/pipeline.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET SAR -> EO translation pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Optional, Union
7
+
8
+ import numpy as np
9
+ import torch
10
+ from diffusers.pipelines.pipeline_utils import DiffusionPipeline, ImagePipelineOutput
11
+ from diffusers.utils.torch_utils import randn_tensor
12
+ from PIL import Image
13
+
14
+ from .autoencoder_flux2 import AutoencoderFlux2
15
+ from .scheduler_flow_bridge import FlowBridgeScheduler
16
+ from .transformer_reflowset import ReFlowSETTransformer2DModel
17
+
18
+ #: PIL modes the SAR loader accepts. The reference loader calls ``np.array(im)``
19
+ #: with no ``convert()`` (datasets.py:454, 574), so a 16-bit (``I;16``) or
20
+ #: palette (``P``) raster would flow straight into ``x / 127.5 - 1`` and be
21
+ #: badly out of range. That is an unguarded trap upstream; it is guarded here.
22
+ _ACCEPTED_SAR_MODES = ("L", "RGB", "RGBA")
23
+
24
+
25
+ class ReFlowSETPipeline(DiffusionPipeline):
26
+ """Generate an EO image from a SAR image with ReFlowSET's flow bridge.
27
+
28
+ Args:
29
+ transformer: The velocity transformer.
30
+ vae: The frozen FLUX.2 autoencoder that defines the latent space.
31
+ scheduler: The Design-B flow-bridge Euler solver.
32
+
33
+ To reproduce the paper's numbers, sample at ``num_inference_steps=50``,
34
+ ``guidance_scale=1.5``, float32, one image per call, with a generator freshly
35
+ seeded to 2024 on the compute device before each call — every test image in
36
+ the reported evaluation starts from the same seeded noise draw, and CPU-drawn
37
+ noise does not reproduce a CUDA draw.
38
+ """
39
+
40
+ model_cpu_offload_seq = "transformer->vae"
41
+
42
+ def __init__(
43
+ self,
44
+ transformer: ReFlowSETTransformer2DModel,
45
+ vae: AutoencoderFlux2,
46
+ scheduler: FlowBridgeScheduler,
47
+ ) -> None:
48
+ super().__init__()
49
+ self.register_modules(transformer=transformer, vae=vae, scheduler=scheduler)
50
+
51
+ # ---- preprocessing (datasets.py:124-205, 452-474, 572-589) --------------
52
+
53
+ @staticmethod
54
+ def _sar_hwc(raster: Union[Image.Image, np.ndarray]) -> np.ndarray:
55
+ """SAR raster -> ``[H, W, C]`` float32 in ``[0, 255]``, collapsed to 1 channel."""
56
+ if isinstance(raster, Image.Image):
57
+ if raster.mode not in _ACCEPTED_SAR_MODES:
58
+ raise ValueError(
59
+ f"SAR image mode {raster.mode!r} is not an 8-bit display raster; expected "
60
+ f"one of {_ACCEPTED_SAR_MODES}. ReFlowSET was trained on 8-bit display "
61
+ "quicklooks (sar_value_domain='display_png'); convert with .convert('L') "
62
+ "and be aware that the contrast stretch you choose is part of the input."
63
+ )
64
+ # No .convert() on the SAR side, matching datasets.py:454, 574.
65
+ arr = np.array(raster)
66
+ else:
67
+ arr = np.asarray(raster)
68
+ if arr.ndim == 2: # PIL mode "L" (datasets.py:171-172)
69
+ arr = arr[:, :, None]
70
+ if arr.shape[-1] == 4: # drop a container alpha channel (datasets.py:173-174)
71
+ arr = arr[..., :3]
72
+ arr = arr.astype(np.float32)
73
+ if arr.shape[-1] > 1:
74
+ # Exact-equality test, tol=0.0 (datasets.py:100-111): a display RGB
75
+ # quicklook collapses to its single amplitude channel.
76
+ if np.abs(arr - arr[..., :1]).max() == 0.0:
77
+ arr = arr[..., :1]
78
+ else:
79
+ warnings.warn(
80
+ "SAR raster has non-identical colour channels; feeding all 3 to the "
81
+ "frozen encoder. The released arms were trained on single-channel "
82
+ "amplitude quicklooks, so this is an undeclared input.",
83
+ RuntimeWarning,
84
+ stacklevel=3,
85
+ )
86
+ return arr
87
+
88
+ @staticmethod
89
+ def _center_crop(arr: np.ndarray, crop: int) -> np.ndarray:
90
+ """Center-crop ``[H, W, C]`` to ``crop`` — **never** resize (datasets.py:145-165).
91
+
92
+ The offsets are albumentations' ``CenterCrop`` arithmetic ``(n - c) // 2``:
93
+ the SAR2Opt protocol takes the central 512 of 600 at offset 44.
94
+ """
95
+ h, w = arr.shape[:2]
96
+ if h < crop or w < crop:
97
+ raise ValueError(
98
+ f"image {h}x{w} is smaller than the requested crop {crop}; ReFlowSET never "
99
+ "upscales an input"
100
+ )
101
+ top, left = (h - crop) // 2, (w - crop) // 2
102
+ return arr[top : top + crop, left : left + crop]
103
+
104
+ def preprocess(
105
+ self,
106
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
107
+ crop: Optional[int] = None,
108
+ ) -> torch.Tensor:
109
+ """Build the model-boundary SAR tensor ``[B, 3, H, W]`` in ``[-1, 1]``.
110
+
111
+ Args:
112
+ sar: A PIL image, a list of PIL images, an ``[H, W]`` / ``[H, W, C]``
113
+ uint8 array, or a float tensor already in ``[-1, 1]`` shaped
114
+ ``[H, W]``, ``[C, H, W]`` or ``[B, C, H, W]``.
115
+ crop: Center-crop size applied before normalisation. ``None``
116
+ center-crops to the arm's own training resolution when the
117
+ raster is larger and not already a multiple of the latent
118
+ stride -- which is exactly the SAR2Opt 600 -> 512 protocol the
119
+ reported numbers use. Pass an explicit size to override, or
120
+ ``0`` to keep the native raster and fail loudly if it does not
121
+ fit.
122
+
123
+ Images are read as 8-bit display rasters and mapped to ``[-1, 1]`` by
124
+ ``x / 127.5 - 1`` (datasets.py:124-126) with no per-image statistics, no
125
+ percentile stretch and no resize. The single SAR channel is then
126
+ replicated to 3 at the model boundary (evaluate.py:566-570), because the
127
+ frozen FLUX.2 encoder is the same one that encodes EO — ReFlowSET has no
128
+ separate SAR encoder.
129
+ """
130
+ if crop == 0:
131
+ crop = None
132
+ elif crop is None:
133
+ # Fall back to the resolution this arm was trained at. Cropping is
134
+ # the protocol (train.py random-crops, evaluate.py center-crops);
135
+ # ReFlowSET never resizes, so an un-croppable raster is an error
136
+ # rather than something to silently rescale.
137
+ crop = self.transformer.config.sample_size
138
+
139
+ if isinstance(sar, torch.Tensor):
140
+ x = sar.float()
141
+ if x.ndim == 2:
142
+ x = x[None, None]
143
+ elif x.ndim == 3:
144
+ x = x[None]
145
+ elif x.ndim != 4:
146
+ raise ValueError(f"SAR tensor must have 2, 3 or 4 dims, got {tuple(sar.shape)}")
147
+ h, w = x.shape[-2:]
148
+ if crop is not None and (h, w) != (crop, crop):
149
+ if h < crop or w < crop:
150
+ raise ValueError(f"tensor {h}x{w} is smaller than the requested crop {crop}")
151
+ top, left = (h - crop) // 2, (w - crop) // 2
152
+ x = x[..., top : top + crop, left : left + crop]
153
+ else:
154
+ images = sar if isinstance(sar, list) else [sar]
155
+ arrays = []
156
+ for item in images:
157
+ if not isinstance(item, (Image.Image, np.ndarray)):
158
+ raise TypeError(f"unsupported SAR input type {type(item)!r}")
159
+ arr = self._sar_hwc(item)
160
+ if crop is not None and arr.shape[:2] != (crop, crop):
161
+ arr = self._center_crop(arr, crop)
162
+ arrays.append(np.ascontiguousarray(arr.transpose(2, 0, 1)))
163
+ x = torch.from_numpy(np.stack(arrays)) / 127.5 - 1.0
164
+
165
+ # Train-side clamp (train.py:770); a no-op on 8-bit input, which maps
166
+ # exactly onto [-1, 1].
167
+ x = x.clamp(-1.0, 1.0)
168
+ if x.shape[1] == 1:
169
+ x = x.repeat(1, 3, 1, 1)
170
+ elif x.shape[1] != 3:
171
+ raise ValueError(
172
+ f"the frozen FLUX.2 encoder takes 1 or 3 SAR channels, got {x.shape[1]}"
173
+ )
174
+ factor = self.vae.spatial_factor
175
+ if x.shape[-2] % factor or x.shape[-1] % factor:
176
+ raise ValueError(
177
+ f"SAR size {x.shape[-2]}x{x.shape[-1]} must be divisible by {factor}; pass "
178
+ "crop= to center-crop (ReFlowSET never resizes)"
179
+ )
180
+ return x
181
+
182
+ # ---- postprocessing -----------------------------------------------------
183
+
184
+ @staticmethod
185
+ def _to_pil(images: torch.Tensor) -> list[Image.Image]:
186
+ """``[B, 3, H, W]`` in ``[0, 1]`` -> PIL, quantised round-half-up.
187
+
188
+ ``255 * x + 0.5`` truncated is what ``torchvision.utils.save_image``
189
+ does and is therefore what the released PNGs contain; numpy's
190
+ ``round()`` is banker's rounding and would differ on exact halves.
191
+ """
192
+ arr = (images * 255 + 0.5).clamp(0, 255).to(torch.uint8)
193
+ arr = arr.permute(0, 2, 3, 1).cpu().numpy()
194
+ return [Image.fromarray(a) for a in arr]
195
+
196
+ @torch.no_grad()
197
+ def __call__(
198
+ self,
199
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
200
+ num_inference_steps: int = 50,
201
+ guidance_scale: float = 1.5,
202
+ generator: Optional[Union[torch.Generator, list[torch.Generator]]] = None,
203
+ output_type: str = "pil",
204
+ crop: Optional[int] = None,
205
+ return_dict: bool = True,
206
+ ) -> Union[ImagePipelineOutput, tuple[list]]:
207
+ """Translate a SAR image into an EO image.
208
+
209
+ Args:
210
+ sar: SAR input; see :meth:`preprocess`.
211
+ num_inference_steps: NFE, the number of velocity evaluations. The
212
+ paper's main results are NFE 50; NFE 4 is the efficiency
213
+ operating point and trades FID for PSNR/SSIM, so the two must
214
+ not be mixed in one comparison.
215
+ guidance_scale: Classifier-free guidance scale. 1.5 is the published
216
+ setting; 1.0 disables guidance and halves the cost.
217
+ generator: Generator for the initial noise. Create it on the compute
218
+ device — CPU-drawn noise does not reproduce a CUDA draw.
219
+ output_type: ``"pil"``, ``"np"`` or ``"pt"``.
220
+ crop: Center-crop size applied to the SAR input before encoding.
221
+ return_dict: Return an ``ImagePipelineOutput`` instead of a tuple.
222
+
223
+ Returns:
224
+ The generated EO image(s) in ``[0, 1]`` (or as PIL).
225
+ """
226
+ if output_type not in ("pil", "np", "pt"):
227
+ raise ValueError(f"output_type must be 'pil', 'np' or 'pt', got {output_type!r}")
228
+
229
+ device = self._execution_device
230
+ dtype = self.transformer.dtype
231
+
232
+ sar_pm1 = self.preprocess(sar, crop=crop).to(device=device, dtype=self.vae.dtype)
233
+ # The SAR condition is encoded by the SAME frozen autoencoder that
234
+ # defines the EO latent space (evaluate.py:553-577).
235
+ z_s = self.vae.encode(sar_pm1).to(dtype)
236
+
237
+ self.scheduler.set_timesteps(num_inference_steps, device=device)
238
+ # Design B: the bridge starts at t = 0 from pure Gaussian noise
239
+ # (bridge.py:409-433), NOT from the SAR latent.
240
+ latents = randn_tensor(z_s.shape, generator=generator, device=device, dtype=z_s.dtype)
241
+
242
+ for t in self.progress_bar(self.scheduler.timesteps):
243
+ timestep = t.expand(latents.shape[0])
244
+ velocity = self.transformer(latents, timestep, z_s, return_dict=False)[0]
245
+ if guidance_scale != 1.0:
246
+ # Two passes; the null branch is cond=None, which the transformer
247
+ # turns into an all-zero conditioning latent (bridge.py:530-535).
248
+ uncond = self.transformer(latents, timestep, None, return_dict=False)[0]
249
+ velocity = uncond + guidance_scale * (velocity - uncond)
250
+ latents = self.scheduler.step(velocity, t, latents, return_dict=False)[0]
251
+
252
+ image = self.vae.decode(latents.to(self.vae.dtype))
253
+ # `--denorm standard` (evaluate.py:292-295). The `legacy` C-DiffSET
254
+ # convention `(x + 0.5).clamp(0, 1)` is a 2x contrast stretch and must
255
+ # not be used with these numbers.
256
+ image = (image * 0.5 + 0.5).clamp(0.0, 1.0)
257
+
258
+ self.maybe_free_model_hooks()
259
+
260
+ if output_type == "pil":
261
+ image = self._to_pil(image)
262
+ elif output_type == "np":
263
+ image = image.permute(0, 2, 3, 1).float().cpu().numpy()
264
+
265
+ if not return_dict:
266
+ return (image,)
267
+ return ImagePipelineOutput(images=image)
qxs-saropt/pipeline_reflowset.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET SAR -> EO translation pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Optional, Union
7
+
8
+ import numpy as np
9
+ import torch
10
+ from diffusers.pipelines.pipeline_utils import DiffusionPipeline, ImagePipelineOutput
11
+ from diffusers.utils.torch_utils import randn_tensor
12
+ from PIL import Image
13
+
14
+ from .autoencoder_flux2 import AutoencoderFlux2
15
+ from .scheduler_flow_bridge import FlowBridgeScheduler
16
+ from .transformer_reflowset import ReFlowSETTransformer2DModel
17
+
18
+ #: PIL modes the SAR loader accepts. The reference loader calls ``np.array(im)``
19
+ #: with no ``convert()`` (datasets.py:454, 574), so a 16-bit (``I;16``) or
20
+ #: palette (``P``) raster would flow straight into ``x / 127.5 - 1`` and be
21
+ #: badly out of range. That is an unguarded trap upstream; it is guarded here.
22
+ _ACCEPTED_SAR_MODES = ("L", "RGB", "RGBA")
23
+
24
+
25
+ class ReFlowSETPipeline(DiffusionPipeline):
26
+ """Generate an EO image from a SAR image with ReFlowSET's flow bridge.
27
+
28
+ Args:
29
+ transformer: The velocity transformer.
30
+ vae: The frozen FLUX.2 autoencoder that defines the latent space.
31
+ scheduler: The Design-B flow-bridge Euler solver.
32
+
33
+ To reproduce the paper's numbers, sample at ``num_inference_steps=50``,
34
+ ``guidance_scale=1.5``, float32, one image per call, with a generator freshly
35
+ seeded to 2024 on the compute device before each call — every test image in
36
+ the reported evaluation starts from the same seeded noise draw, and CPU-drawn
37
+ noise does not reproduce a CUDA draw.
38
+ """
39
+
40
+ model_cpu_offload_seq = "transformer->vae"
41
+
42
+ def __init__(
43
+ self,
44
+ transformer: ReFlowSETTransformer2DModel,
45
+ vae: AutoencoderFlux2,
46
+ scheduler: FlowBridgeScheduler,
47
+ ) -> None:
48
+ super().__init__()
49
+ self.register_modules(transformer=transformer, vae=vae, scheduler=scheduler)
50
+
51
+ # ---- preprocessing (datasets.py:124-205, 452-474, 572-589) --------------
52
+
53
+ @staticmethod
54
+ def _sar_hwc(raster: Union[Image.Image, np.ndarray]) -> np.ndarray:
55
+ """SAR raster -> ``[H, W, C]`` float32 in ``[0, 255]``, collapsed to 1 channel."""
56
+ if isinstance(raster, Image.Image):
57
+ if raster.mode not in _ACCEPTED_SAR_MODES:
58
+ raise ValueError(
59
+ f"SAR image mode {raster.mode!r} is not an 8-bit display raster; expected "
60
+ f"one of {_ACCEPTED_SAR_MODES}. ReFlowSET was trained on 8-bit display "
61
+ "quicklooks (sar_value_domain='display_png'); convert with .convert('L') "
62
+ "and be aware that the contrast stretch you choose is part of the input."
63
+ )
64
+ # No .convert() on the SAR side, matching datasets.py:454, 574.
65
+ arr = np.array(raster)
66
+ else:
67
+ arr = np.asarray(raster)
68
+ if arr.ndim == 2: # PIL mode "L" (datasets.py:171-172)
69
+ arr = arr[:, :, None]
70
+ if arr.shape[-1] == 4: # drop a container alpha channel (datasets.py:173-174)
71
+ arr = arr[..., :3]
72
+ arr = arr.astype(np.float32)
73
+ if arr.shape[-1] > 1:
74
+ # Exact-equality test, tol=0.0 (datasets.py:100-111): a display RGB
75
+ # quicklook collapses to its single amplitude channel.
76
+ if np.abs(arr - arr[..., :1]).max() == 0.0:
77
+ arr = arr[..., :1]
78
+ else:
79
+ warnings.warn(
80
+ "SAR raster has non-identical colour channels; feeding all 3 to the "
81
+ "frozen encoder. The released arms were trained on single-channel "
82
+ "amplitude quicklooks, so this is an undeclared input.",
83
+ RuntimeWarning,
84
+ stacklevel=3,
85
+ )
86
+ return arr
87
+
88
+ @staticmethod
89
+ def _center_crop(arr: np.ndarray, crop: int) -> np.ndarray:
90
+ """Center-crop ``[H, W, C]`` to ``crop`` — **never** resize (datasets.py:145-165).
91
+
92
+ The offsets are albumentations' ``CenterCrop`` arithmetic ``(n - c) // 2``:
93
+ the SAR2Opt protocol takes the central 512 of 600 at offset 44.
94
+ """
95
+ h, w = arr.shape[:2]
96
+ if h < crop or w < crop:
97
+ raise ValueError(
98
+ f"image {h}x{w} is smaller than the requested crop {crop}; ReFlowSET never "
99
+ "upscales an input"
100
+ )
101
+ top, left = (h - crop) // 2, (w - crop) // 2
102
+ return arr[top : top + crop, left : left + crop]
103
+
104
+ def preprocess(
105
+ self,
106
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
107
+ crop: Optional[int] = None,
108
+ ) -> torch.Tensor:
109
+ """Build the model-boundary SAR tensor ``[B, 3, H, W]`` in ``[-1, 1]``.
110
+
111
+ Args:
112
+ sar: A PIL image, a list of PIL images, an ``[H, W]`` / ``[H, W, C]``
113
+ uint8 array, or a float tensor already in ``[-1, 1]`` shaped
114
+ ``[H, W]``, ``[C, H, W]`` or ``[B, C, H, W]``.
115
+ crop: Center-crop size applied before normalisation. ``None``
116
+ center-crops to the arm's own training resolution when the
117
+ raster is larger and not already a multiple of the latent
118
+ stride -- which is exactly the SAR2Opt 600 -> 512 protocol the
119
+ reported numbers use. Pass an explicit size to override, or
120
+ ``0`` to keep the native raster and fail loudly if it does not
121
+ fit.
122
+
123
+ Images are read as 8-bit display rasters and mapped to ``[-1, 1]`` by
124
+ ``x / 127.5 - 1`` (datasets.py:124-126) with no per-image statistics, no
125
+ percentile stretch and no resize. The single SAR channel is then
126
+ replicated to 3 at the model boundary (evaluate.py:566-570), because the
127
+ frozen FLUX.2 encoder is the same one that encodes EO — ReFlowSET has no
128
+ separate SAR encoder.
129
+ """
130
+ if crop == 0:
131
+ crop = None
132
+ elif crop is None:
133
+ # Fall back to the resolution this arm was trained at. Cropping is
134
+ # the protocol (train.py random-crops, evaluate.py center-crops);
135
+ # ReFlowSET never resizes, so an un-croppable raster is an error
136
+ # rather than something to silently rescale.
137
+ crop = self.transformer.config.sample_size
138
+
139
+ if isinstance(sar, torch.Tensor):
140
+ x = sar.float()
141
+ if x.ndim == 2:
142
+ x = x[None, None]
143
+ elif x.ndim == 3:
144
+ x = x[None]
145
+ elif x.ndim != 4:
146
+ raise ValueError(f"SAR tensor must have 2, 3 or 4 dims, got {tuple(sar.shape)}")
147
+ h, w = x.shape[-2:]
148
+ if crop is not None and (h, w) != (crop, crop):
149
+ if h < crop or w < crop:
150
+ raise ValueError(f"tensor {h}x{w} is smaller than the requested crop {crop}")
151
+ top, left = (h - crop) // 2, (w - crop) // 2
152
+ x = x[..., top : top + crop, left : left + crop]
153
+ else:
154
+ images = sar if isinstance(sar, list) else [sar]
155
+ arrays = []
156
+ for item in images:
157
+ if not isinstance(item, (Image.Image, np.ndarray)):
158
+ raise TypeError(f"unsupported SAR input type {type(item)!r}")
159
+ arr = self._sar_hwc(item)
160
+ if crop is not None and arr.shape[:2] != (crop, crop):
161
+ arr = self._center_crop(arr, crop)
162
+ arrays.append(np.ascontiguousarray(arr.transpose(2, 0, 1)))
163
+ x = torch.from_numpy(np.stack(arrays)) / 127.5 - 1.0
164
+
165
+ # Train-side clamp (train.py:770); a no-op on 8-bit input, which maps
166
+ # exactly onto [-1, 1].
167
+ x = x.clamp(-1.0, 1.0)
168
+ if x.shape[1] == 1:
169
+ x = x.repeat(1, 3, 1, 1)
170
+ elif x.shape[1] != 3:
171
+ raise ValueError(
172
+ f"the frozen FLUX.2 encoder takes 1 or 3 SAR channels, got {x.shape[1]}"
173
+ )
174
+ factor = self.vae.spatial_factor
175
+ if x.shape[-2] % factor or x.shape[-1] % factor:
176
+ raise ValueError(
177
+ f"SAR size {x.shape[-2]}x{x.shape[-1]} must be divisible by {factor}; pass "
178
+ "crop= to center-crop (ReFlowSET never resizes)"
179
+ )
180
+ return x
181
+
182
+ # ---- postprocessing -----------------------------------------------------
183
+
184
+ @staticmethod
185
+ def _to_pil(images: torch.Tensor) -> list[Image.Image]:
186
+ """``[B, 3, H, W]`` in ``[0, 1]`` -> PIL, quantised round-half-up.
187
+
188
+ ``255 * x + 0.5`` truncated is what ``torchvision.utils.save_image``
189
+ does and is therefore what the released PNGs contain; numpy's
190
+ ``round()`` is banker's rounding and would differ on exact halves.
191
+ """
192
+ arr = (images * 255 + 0.5).clamp(0, 255).to(torch.uint8)
193
+ arr = arr.permute(0, 2, 3, 1).cpu().numpy()
194
+ return [Image.fromarray(a) for a in arr]
195
+
196
+ @torch.no_grad()
197
+ def __call__(
198
+ self,
199
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
200
+ num_inference_steps: int = 50,
201
+ guidance_scale: float = 1.5,
202
+ generator: Optional[Union[torch.Generator, list[torch.Generator]]] = None,
203
+ output_type: str = "pil",
204
+ crop: Optional[int] = None,
205
+ return_dict: bool = True,
206
+ ) -> Union[ImagePipelineOutput, tuple[list]]:
207
+ """Translate a SAR image into an EO image.
208
+
209
+ Args:
210
+ sar: SAR input; see :meth:`preprocess`.
211
+ num_inference_steps: NFE, the number of velocity evaluations. The
212
+ paper's main results are NFE 50; NFE 4 is the efficiency
213
+ operating point and trades FID for PSNR/SSIM, so the two must
214
+ not be mixed in one comparison.
215
+ guidance_scale: Classifier-free guidance scale. 1.5 is the published
216
+ setting; 1.0 disables guidance and halves the cost.
217
+ generator: Generator for the initial noise. Create it on the compute
218
+ device — CPU-drawn noise does not reproduce a CUDA draw.
219
+ output_type: ``"pil"``, ``"np"`` or ``"pt"``.
220
+ crop: Center-crop size applied to the SAR input before encoding.
221
+ return_dict: Return an ``ImagePipelineOutput`` instead of a tuple.
222
+
223
+ Returns:
224
+ The generated EO image(s) in ``[0, 1]`` (or as PIL).
225
+ """
226
+ if output_type not in ("pil", "np", "pt"):
227
+ raise ValueError(f"output_type must be 'pil', 'np' or 'pt', got {output_type!r}")
228
+
229
+ device = self._execution_device
230
+ dtype = self.transformer.dtype
231
+
232
+ sar_pm1 = self.preprocess(sar, crop=crop).to(device=device, dtype=self.vae.dtype)
233
+ # The SAR condition is encoded by the SAME frozen autoencoder that
234
+ # defines the EO latent space (evaluate.py:553-577).
235
+ z_s = self.vae.encode(sar_pm1).to(dtype)
236
+
237
+ self.scheduler.set_timesteps(num_inference_steps, device=device)
238
+ # Design B: the bridge starts at t = 0 from pure Gaussian noise
239
+ # (bridge.py:409-433), NOT from the SAR latent.
240
+ latents = randn_tensor(z_s.shape, generator=generator, device=device, dtype=z_s.dtype)
241
+
242
+ for t in self.progress_bar(self.scheduler.timesteps):
243
+ timestep = t.expand(latents.shape[0])
244
+ velocity = self.transformer(latents, timestep, z_s, return_dict=False)[0]
245
+ if guidance_scale != 1.0:
246
+ # Two passes; the null branch is cond=None, which the transformer
247
+ # turns into an all-zero conditioning latent (bridge.py:530-535).
248
+ uncond = self.transformer(latents, timestep, None, return_dict=False)[0]
249
+ velocity = uncond + guidance_scale * (velocity - uncond)
250
+ latents = self.scheduler.step(velocity, t, latents, return_dict=False)[0]
251
+
252
+ image = self.vae.decode(latents.to(self.vae.dtype))
253
+ # `--denorm standard` (evaluate.py:292-295). The `legacy` C-DiffSET
254
+ # convention `(x + 0.5).clamp(0, 1)` is a 2x contrast stretch and must
255
+ # not be used with these numbers.
256
+ image = (image * 0.5 + 0.5).clamp(0.0, 1.0)
257
+
258
+ self.maybe_free_model_hooks()
259
+
260
+ if output_type == "pil":
261
+ image = self._to_pil(image)
262
+ elif output_type == "np":
263
+ image = image.permute(0, 2, 3, 1).float().cpu().numpy()
264
+
265
+ if not return_dict:
266
+ return (image,)
267
+ return ImagePipelineOutput(images=image)
qxs-saropt/scheduler/scheduler_config.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "FlowBridgeScheduler",
3
+ "_diffusers_version": "0.37.1",
4
+ "t_end": 1.0
5
+ }
qxs-saropt/scheduler/scheduler_flow_bridge.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET's Design-B flow bridge and its explicit-Euler solver.
2
+
3
+ Forward (training) process, with ``eps ~ N(0, I)`` and ``z_e`` the EO latent::
4
+
5
+ z_t = (1 - t) * eps + t * z_e (bridge.py:311, sigma_b = 0)
6
+ u* = z_e - eps (bridge.py:328 at sigma_b = 0)
7
+
8
+ Sampling starts from ``z_0 ~ N(0, I)`` and integrates the predicted velocity
9
+ with explicit Euler on a uniform grid ``linspace(0, t_end, nfe + 1)``
10
+ (bridge.py:519, 536). The bridge is deterministic: ``sigma_b = 0``, so no
11
+ stochastic term ever executes, and the only randomness in a sample is the
12
+ initial noise draw.
13
+
14
+ **Time direction.** ``t = 0`` is NOISE and ``t = 1`` is DATA, and the solver
15
+ integrates ``t`` **ascending** (bridge.py:86-88). That is the opposite of
16
+ `diffusers`' ``sigma`` convention: setting ``sigma := 1 - t`` recovers
17
+ ``FlowMatchEulerDiscreteScheduler``'s interpolation, but then this bridge's
18
+ velocity is the **negative** of the diffusers flow-matching target and the
19
+ network must still be fed ``1 - sigma``. This scheduler keeps ReFlowSET's own
20
+ sign and direction so neither flip is needed; ``timesteps`` therefore *increase*
21
+ from 0 towards 1, unlike every noise-schedule scheduler in `diffusers`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Optional, Union
28
+
29
+ import torch
30
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
31
+ from diffusers.schedulers.scheduling_utils import SchedulerMixin
32
+ from diffusers.utils import BaseOutput
33
+
34
+
35
+ @dataclass
36
+ class FlowBridgeSchedulerOutput(BaseOutput):
37
+ """Output of :meth:`FlowBridgeScheduler.step`.
38
+
39
+ Args:
40
+ prev_sample: The bridge state at the next time on the grid.
41
+ """
42
+
43
+ prev_sample: torch.Tensor
44
+
45
+
46
+ class FlowBridgeScheduler(SchedulerMixin, ConfigMixin):
47
+ """Explicit-Euler solver for ReFlowSET's Design-B flow bridge.
48
+
49
+ Args:
50
+ t_end: End time of the integration grid (1.0 — the EO endpoint). The
51
+ model is evaluated at ``linspace(0, t_end, nfe + 1)[:-1]`` and the
52
+ final Euler step lands on ``t_end``; the network is never queried at
53
+ ``t = t_end``.
54
+ """
55
+
56
+ order = 1
57
+
58
+ @register_to_config
59
+ def __init__(self, t_end: float = 1.0) -> None:
60
+ if not 0.0 < t_end <= 1.0:
61
+ raise ValueError(f"t_end must lie in (0, 1], got {t_end}")
62
+ self._grid: Optional[torch.Tensor] = None
63
+ self._step_index: Optional[int] = None
64
+ self.num_inference_steps: Optional[int] = None
65
+
66
+ @property
67
+ def timesteps(self) -> torch.Tensor:
68
+ """The ``nfe`` bridge times at which the model is evaluated, ascending."""
69
+ if self._grid is None:
70
+ raise ValueError("call set_timesteps() before reading timesteps")
71
+ return self._grid[:-1]
72
+
73
+ @property
74
+ def step_index(self) -> Optional[int]:
75
+ """Index of the next grid interval; ``None`` until the first :meth:`step`."""
76
+ return self._step_index
77
+
78
+ def set_timesteps(
79
+ self,
80
+ num_inference_steps: int,
81
+ device: Optional[Union[str, torch.device]] = None,
82
+ ) -> None:
83
+ """Build the uniform grid ``linspace(0, t_end, num_inference_steps + 1)``.
84
+
85
+ Args:
86
+ num_inference_steps: NFE — the number of velocity evaluations.
87
+ 50 reproduces the paper's main results; 4 is the efficiency
88
+ operating point.
89
+ device: Device the grid is built on.
90
+
91
+ There is no shift, no dynamic shifting, no Karras or exponential
92
+ spacing, and no timestep-spacing option: the reference solver uses a
93
+ plain uniform grid (bridge.py:519).
94
+ """
95
+ if num_inference_steps < 1:
96
+ raise ValueError(f"num_inference_steps must be >= 1, got {num_inference_steps}")
97
+ self.num_inference_steps = num_inference_steps
98
+ self._grid = torch.linspace(
99
+ 0.0, self.config.t_end, num_inference_steps + 1, device=device, dtype=torch.float32
100
+ )
101
+ self._step_index = 0
102
+
103
+ def step(
104
+ self,
105
+ model_output: torch.Tensor,
106
+ timestep: Union[float, torch.Tensor],
107
+ sample: torch.Tensor,
108
+ return_dict: bool = True,
109
+ ) -> Union[FlowBridgeSchedulerOutput, tuple[torch.Tensor]]:
110
+ """One explicit-Euler step: ``z + (t_next - t_cur) * v`` (bridge.py:536).
111
+
112
+ Args:
113
+ model_output: The predicted velocity ``dz/dt`` at ``timestep``,
114
+ already classifier-free-guided by the caller.
115
+ timestep: The current bridge time. Present for API compatibility and
116
+ checked against the grid; the step size comes from the grid.
117
+ sample: The current bridge state.
118
+ return_dict: Return a :class:`FlowBridgeSchedulerOutput` instead of a
119
+ tuple.
120
+
121
+ Steps must be taken in order, starting from the first entry of
122
+ :attr:`timesteps`.
123
+ """
124
+ if self._grid is None or self._step_index is None:
125
+ raise ValueError("call set_timesteps() before step()")
126
+ if self._step_index >= self.num_inference_steps:
127
+ raise ValueError(
128
+ f"already took {self.num_inference_steps} steps; call set_timesteps() again"
129
+ )
130
+ t_cur, t_next = self._grid[self._step_index], self._grid[self._step_index + 1]
131
+ if not torch.isclose(torch.as_tensor(timestep, dtype=torch.float32).to(t_cur.device), t_cur):
132
+ raise ValueError(
133
+ f"step {self._step_index} expects timestep {t_cur.item()}, got {float(timestep)}; "
134
+ "the flow bridge must be integrated in ascending grid order"
135
+ )
136
+
137
+ # The state is carried in float32 even if the model ran lower (bridge.py:515-517).
138
+ dtype = sample.dtype if sample.dtype in (torch.float32, torch.float64) else torch.float32
139
+ prev_sample = sample.to(dtype) + (t_next - t_cur) * model_output.to(dtype)
140
+ prev_sample = prev_sample.to(sample.dtype)
141
+
142
+ self._step_index += 1
143
+ if not return_dict:
144
+ return (prev_sample,)
145
+ return FlowBridgeSchedulerOutput(prev_sample=prev_sample)
146
+
147
+ def add_noise(
148
+ self,
149
+ original_samples: torch.Tensor,
150
+ noise: torch.Tensor,
151
+ timesteps: torch.Tensor,
152
+ ) -> torch.Tensor:
153
+ """The training-side bridge state ``z_t = (1 - t) * eps + t * z_e`` (bridge.py:311).
154
+
155
+ Args:
156
+ original_samples: The EO latent ``z_e`` (the ``t = 1`` endpoint).
157
+ noise: ``eps ~ N(0, I)`` (the ``t = 0`` endpoint).
158
+ timesteps: Bridge times in ``[0, 1]``, broadcastable over the batch.
159
+ """
160
+ t = timesteps.to(original_samples.device, original_samples.dtype)
161
+ t = t.view(-1, *([1] * (original_samples.ndim - 1)))
162
+ return (1.0 - t) * noise + t * original_samples
163
+
164
+ def get_velocity(
165
+ self,
166
+ sample: torch.Tensor,
167
+ noise: torch.Tensor,
168
+ timesteps: torch.Tensor,
169
+ ) -> torch.Tensor:
170
+ """The training target ``u* = z_e - eps`` (bridge.py:328 at ``sigma_b = 0``).
171
+
172
+ Constant along the path, hence independent of ``timesteps``; the argument
173
+ is kept for `diffusers` API compatibility.
174
+ """
175
+ del timesteps
176
+ return sample - noise
qxs-saropt/scheduler_flow_bridge.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET's Design-B flow bridge and its explicit-Euler solver.
2
+
3
+ Forward (training) process, with ``eps ~ N(0, I)`` and ``z_e`` the EO latent::
4
+
5
+ z_t = (1 - t) * eps + t * z_e (bridge.py:311, sigma_b = 0)
6
+ u* = z_e - eps (bridge.py:328 at sigma_b = 0)
7
+
8
+ Sampling starts from ``z_0 ~ N(0, I)`` and integrates the predicted velocity
9
+ with explicit Euler on a uniform grid ``linspace(0, t_end, nfe + 1)``
10
+ (bridge.py:519, 536). The bridge is deterministic: ``sigma_b = 0``, so no
11
+ stochastic term ever executes, and the only randomness in a sample is the
12
+ initial noise draw.
13
+
14
+ **Time direction.** ``t = 0`` is NOISE and ``t = 1`` is DATA, and the solver
15
+ integrates ``t`` **ascending** (bridge.py:86-88). That is the opposite of
16
+ `diffusers`' ``sigma`` convention: setting ``sigma := 1 - t`` recovers
17
+ ``FlowMatchEulerDiscreteScheduler``'s interpolation, but then this bridge's
18
+ velocity is the **negative** of the diffusers flow-matching target and the
19
+ network must still be fed ``1 - sigma``. This scheduler keeps ReFlowSET's own
20
+ sign and direction so neither flip is needed; ``timesteps`` therefore *increase*
21
+ from 0 towards 1, unlike every noise-schedule scheduler in `diffusers`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Optional, Union
28
+
29
+ import torch
30
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
31
+ from diffusers.schedulers.scheduling_utils import SchedulerMixin
32
+ from diffusers.utils import BaseOutput
33
+
34
+
35
+ @dataclass
36
+ class FlowBridgeSchedulerOutput(BaseOutput):
37
+ """Output of :meth:`FlowBridgeScheduler.step`.
38
+
39
+ Args:
40
+ prev_sample: The bridge state at the next time on the grid.
41
+ """
42
+
43
+ prev_sample: torch.Tensor
44
+
45
+
46
+ class FlowBridgeScheduler(SchedulerMixin, ConfigMixin):
47
+ """Explicit-Euler solver for ReFlowSET's Design-B flow bridge.
48
+
49
+ Args:
50
+ t_end: End time of the integration grid (1.0 — the EO endpoint). The
51
+ model is evaluated at ``linspace(0, t_end, nfe + 1)[:-1]`` and the
52
+ final Euler step lands on ``t_end``; the network is never queried at
53
+ ``t = t_end``.
54
+ """
55
+
56
+ order = 1
57
+
58
+ @register_to_config
59
+ def __init__(self, t_end: float = 1.0) -> None:
60
+ if not 0.0 < t_end <= 1.0:
61
+ raise ValueError(f"t_end must lie in (0, 1], got {t_end}")
62
+ self._grid: Optional[torch.Tensor] = None
63
+ self._step_index: Optional[int] = None
64
+ self.num_inference_steps: Optional[int] = None
65
+
66
+ @property
67
+ def timesteps(self) -> torch.Tensor:
68
+ """The ``nfe`` bridge times at which the model is evaluated, ascending."""
69
+ if self._grid is None:
70
+ raise ValueError("call set_timesteps() before reading timesteps")
71
+ return self._grid[:-1]
72
+
73
+ @property
74
+ def step_index(self) -> Optional[int]:
75
+ """Index of the next grid interval; ``None`` until the first :meth:`step`."""
76
+ return self._step_index
77
+
78
+ def set_timesteps(
79
+ self,
80
+ num_inference_steps: int,
81
+ device: Optional[Union[str, torch.device]] = None,
82
+ ) -> None:
83
+ """Build the uniform grid ``linspace(0, t_end, num_inference_steps + 1)``.
84
+
85
+ Args:
86
+ num_inference_steps: NFE — the number of velocity evaluations.
87
+ 50 reproduces the paper's main results; 4 is the efficiency
88
+ operating point.
89
+ device: Device the grid is built on.
90
+
91
+ There is no shift, no dynamic shifting, no Karras or exponential
92
+ spacing, and no timestep-spacing option: the reference solver uses a
93
+ plain uniform grid (bridge.py:519).
94
+ """
95
+ if num_inference_steps < 1:
96
+ raise ValueError(f"num_inference_steps must be >= 1, got {num_inference_steps}")
97
+ self.num_inference_steps = num_inference_steps
98
+ self._grid = torch.linspace(
99
+ 0.0, self.config.t_end, num_inference_steps + 1, device=device, dtype=torch.float32
100
+ )
101
+ self._step_index = 0
102
+
103
+ def step(
104
+ self,
105
+ model_output: torch.Tensor,
106
+ timestep: Union[float, torch.Tensor],
107
+ sample: torch.Tensor,
108
+ return_dict: bool = True,
109
+ ) -> Union[FlowBridgeSchedulerOutput, tuple[torch.Tensor]]:
110
+ """One explicit-Euler step: ``z + (t_next - t_cur) * v`` (bridge.py:536).
111
+
112
+ Args:
113
+ model_output: The predicted velocity ``dz/dt`` at ``timestep``,
114
+ already classifier-free-guided by the caller.
115
+ timestep: The current bridge time. Present for API compatibility and
116
+ checked against the grid; the step size comes from the grid.
117
+ sample: The current bridge state.
118
+ return_dict: Return a :class:`FlowBridgeSchedulerOutput` instead of a
119
+ tuple.
120
+
121
+ Steps must be taken in order, starting from the first entry of
122
+ :attr:`timesteps`.
123
+ """
124
+ if self._grid is None or self._step_index is None:
125
+ raise ValueError("call set_timesteps() before step()")
126
+ if self._step_index >= self.num_inference_steps:
127
+ raise ValueError(
128
+ f"already took {self.num_inference_steps} steps; call set_timesteps() again"
129
+ )
130
+ t_cur, t_next = self._grid[self._step_index], self._grid[self._step_index + 1]
131
+ if not torch.isclose(torch.as_tensor(timestep, dtype=torch.float32).to(t_cur.device), t_cur):
132
+ raise ValueError(
133
+ f"step {self._step_index} expects timestep {t_cur.item()}, got {float(timestep)}; "
134
+ "the flow bridge must be integrated in ascending grid order"
135
+ )
136
+
137
+ # The state is carried in float32 even if the model ran lower (bridge.py:515-517).
138
+ dtype = sample.dtype if sample.dtype in (torch.float32, torch.float64) else torch.float32
139
+ prev_sample = sample.to(dtype) + (t_next - t_cur) * model_output.to(dtype)
140
+ prev_sample = prev_sample.to(sample.dtype)
141
+
142
+ self._step_index += 1
143
+ if not return_dict:
144
+ return (prev_sample,)
145
+ return FlowBridgeSchedulerOutput(prev_sample=prev_sample)
146
+
147
+ def add_noise(
148
+ self,
149
+ original_samples: torch.Tensor,
150
+ noise: torch.Tensor,
151
+ timesteps: torch.Tensor,
152
+ ) -> torch.Tensor:
153
+ """The training-side bridge state ``z_t = (1 - t) * eps + t * z_e`` (bridge.py:311).
154
+
155
+ Args:
156
+ original_samples: The EO latent ``z_e`` (the ``t = 1`` endpoint).
157
+ noise: ``eps ~ N(0, I)`` (the ``t = 0`` endpoint).
158
+ timesteps: Bridge times in ``[0, 1]``, broadcastable over the batch.
159
+ """
160
+ t = timesteps.to(original_samples.device, original_samples.dtype)
161
+ t = t.view(-1, *([1] * (original_samples.ndim - 1)))
162
+ return (1.0 - t) * noise + t * original_samples
163
+
164
+ def get_velocity(
165
+ self,
166
+ sample: torch.Tensor,
167
+ noise: torch.Tensor,
168
+ timesteps: torch.Tensor,
169
+ ) -> torch.Tensor:
170
+ """The training target ``u* = z_e - eps`` (bridge.py:328 at ``sigma_b = 0``).
171
+
172
+ Constant along the path, hence independent of ``timesteps``; the argument
173
+ is kept for `diffusers` API compatibility.
174
+ """
175
+ del timesteps
176
+ return sample - noise
qxs-saropt/transformer/config.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "ReFlowSETTransformer2DModel",
3
+ "_diffusers_version": "0.37.1",
4
+ "axes_dim": [
5
+ 32,
6
+ 32
7
+ ],
8
+ "depth": 24,
9
+ "double_blocks": 8,
10
+ "double_merge": "token",
11
+ "hidden_size": 1024,
12
+ "in_channels": 128,
13
+ "mlp_ratio": 4.0,
14
+ "num_heads": 16,
15
+ "out_channels": 128,
16
+ "sample_size": 256,
17
+ "theta": 10000
18
+ }
qxs-saropt/transformer/diffusion_pytorch_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a549f999d19044f73cb62943dbfbc45fbd48cc0350b7670cb279fb10a3f4f1e
3
+ size 2037319452
qxs-saropt/transformer/transformer_reflowset.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET velocity transformer — a latent DiT with an EO/SAR double stream.
2
+
3
+ The network predicts the flow-bridge velocity ``dz/dt`` in the frozen FLUX.2
4
+ latent space. It takes the noisy EO latent ``[B, 128, h, w]``, a scalar bridge
5
+ time ``t`` in ``[0, 1]``, and the SAR conditioning latent of the same shape; the
6
+ first 8 of its 24 blocks are double-stream (one EO tower and one SAR tower over
7
+ a single joint attention), the remaining 16 are single-stream over the
8
+ concatenated ``[EO | SAR]`` sequence, and only the EO half is decoded.
9
+
10
+ This is an inference-only port. The training-only REPA projection head
11
+ (``repa_proj``) is a separate module in the reference implementation and is
12
+ deliberately absent here.
13
+
14
+ Deviations from `diffusers`' FLUX blocks that this file has to keep — each one
15
+ is silent if you get it wrong:
16
+
17
+ * ``FinalLayer`` unpacks ``shift, scale`` (dit.py:360), the **opposite** order of
18
+ ``AdaLayerNormContinuous``.
19
+ * The single-stream MLP is **SwiGLU** of width 2752, not a 4x GELU of width 4096.
20
+ * ``linear1``/``linear2`` are **bias-free**, and the QK-norm parameter is called
21
+ ``scale``, not ``weight``.
22
+ * The timestep is multiplied by 1000 *inside* the model and the sinusoid is
23
+ **cos first, then sin**.
24
+ * RoPE runs on **two** axes of **centred half-integer** coordinates, not on
25
+ FLUX's three axes of integers starting at 0.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Optional, Union
32
+
33
+ import torch
34
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
35
+ from diffusers.models.modeling_outputs import Transformer2DModelOutput
36
+ from diffusers.models.modeling_utils import ModelMixin
37
+ from torch import Tensor, nn
38
+ from torch.nn import functional as F
39
+
40
+ #: Width of the sinusoidal timestep embedding fed to ``time_in`` (dit.py:44).
41
+ #: A module constant, deliberately independent of ``hidden_size``.
42
+ TIME_EMBED_DIM = 256
43
+
44
+
45
+ def swiglu_hidden_dim(hidden_size: int, mlp_ratio: float) -> int:
46
+ """SwiGLU intermediate width (dit.py:120-127).
47
+
48
+ The canonical 2/3 rule rounded to a multiple of 64, so a gated MLP at
49
+ ``mlp_ratio=4.0`` costs the same parameters as a plain 4x GELU MLP.
50
+ ``hidden_size=1024, mlp_ratio=4.0 -> 2752``.
51
+ """
52
+ return int(round(hidden_size * mlp_ratio * 2 / 3 / 64)) * 64
53
+
54
+
55
+ class SwiGLU(nn.Module):
56
+ """``silu(first half) * second half`` — gate first, value second (dit.py:130-133)."""
57
+
58
+ def forward(self, x: Tensor) -> Tensor:
59
+ x1, x2 = x.chunk(2, dim=-1)
60
+ return F.silu(x1) * x2
61
+
62
+
63
+ class RMSNorm(nn.Module):
64
+ """RMS norm computed in float32. The parameter is named ``scale`` (dit.py:136-145)."""
65
+
66
+ def __init__(self, dim: int) -> None:
67
+ super().__init__()
68
+ self.scale = nn.Parameter(torch.ones(dim))
69
+
70
+ def forward(self, x: Tensor) -> Tensor:
71
+ x_dtype = x.dtype
72
+ x = x.float()
73
+ rrms = torch.rsqrt(torch.mean(x**2, dim=-1, keepdim=True) + 1e-6)
74
+ return (x * rrms).to(dtype=x_dtype) * self.scale
75
+
76
+
77
+ class QKNorm(nn.Module):
78
+ """Per-head query/key RMS norm, applied **before** RoPE (dit.py:148-155)."""
79
+
80
+ def __init__(self, dim: int) -> None:
81
+ super().__init__()
82
+ self.query_norm = RMSNorm(dim)
83
+ self.key_norm = RMSNorm(dim)
84
+
85
+ def forward(self, q: Tensor, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
86
+ return self.query_norm(q).to(v), self.key_norm(k).to(v)
87
+
88
+
89
+ class MLPEmbedder(nn.Module):
90
+ """``Linear -> SiLU -> Linear`` time-embedding MLP (dit.py:158-166).
91
+
92
+ Checkpoint keys are ``time_in.in_layer.*`` / ``time_in.out_layer.*``, not
93
+ diffusers' ``time_text_embed.timestep_embedder.linear_{1,2}``.
94
+ """
95
+
96
+ def __init__(self, in_dim: int, hidden_dim: int) -> None:
97
+ super().__init__()
98
+ self.in_layer = nn.Linear(in_dim, hidden_dim, bias=True)
99
+ self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=True)
100
+ self.silu = nn.SiLU()
101
+
102
+ def forward(self, x: Tensor) -> Tensor:
103
+ return self.out_layer(self.silu(self.in_layer(x)))
104
+
105
+
106
+ class Modulation(nn.Module):
107
+ """AdaLN-Zero triple. Order is ``shift, scale, gate`` (dit.py:169-181)."""
108
+
109
+ def __init__(self, dim: int) -> None:
110
+ super().__init__()
111
+ self.lin = nn.Linear(dim, 3 * dim, bias=True)
112
+
113
+ def forward(self, vec: Tensor) -> tuple[Tensor, Tensor, Tensor]:
114
+ out = self.lin(F.silu(vec))
115
+ if out.ndim == 2:
116
+ out = out[:, None, :]
117
+ shift, scale, gate = out.chunk(3, dim=-1)
118
+ return shift, scale, gate
119
+
120
+
121
+ def timestep_embedding(
122
+ t: Tensor, dim: int, max_period: int = 10000, time_factor: float = 1000.0
123
+ ) -> Tensor:
124
+ """Sinusoidal embedding of a fractional bridge time (dit.py:184-201).
125
+
126
+ Two things differ from `diffusers`' ``get_timestep_embedding`` defaults:
127
+ ``t`` is a fraction in ``[0, 1]`` that is scaled by ``time_factor = 1000``
128
+ **here**, and the concatenation order is ``[cos, sin]`` (FLUX's ordering,
129
+ i.e. ``flip_sin_to_cos=True``).
130
+ """
131
+ t = time_factor * t
132
+ half = dim // 2
133
+ freqs = torch.exp(
134
+ -math.log(max_period)
135
+ * torch.arange(start=0, end=half, device=t.device, dtype=torch.float32)
136
+ / half
137
+ )
138
+ args = t[:, None].float() * freqs[None]
139
+ embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
140
+ if dim % 2:
141
+ embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
142
+ if torch.is_floating_point(t):
143
+ embedding = embedding.to(t)
144
+ return embedding
145
+
146
+
147
+ def rope(pos: Tensor, dim: int, theta: int) -> Tensor:
148
+ """Per-axis rotation matrices ``[..., L, dim/2, 2, 2]`` (dit.py:204-211)."""
149
+ if dim % 2:
150
+ raise ValueError(f"RoPE axis dim must be even, got {dim}")
151
+ scale = torch.arange(0, dim, 2, dtype=pos.dtype, device=pos.device) / dim
152
+ omega = 1.0 / (theta**scale)
153
+ out = torch.einsum("...n,d->...nd", pos, omega)
154
+ out = torch.stack([torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1)
155
+ return out.reshape(*out.shape[:-1], 2, 2).float()
156
+
157
+
158
+ def apply_rope(xq: Tensor, xk: Tensor, freqs_cis: Tensor) -> tuple[Tensor, Tensor]:
159
+ """Rotate consecutive dimension pairs — the interleaved (FLUX) convention.
160
+
161
+ ``(x0, x1) -> (cos*x0 - sin*x1, sin*x0 + cos*x1)`` on ``(x[2k], x[2k+1])``
162
+ (dit.py:214-219). Equivalent to diffusers' ``apply_rotary_emb(...,
163
+ use_real_unbind_dim=-1)``; ``-2`` is the split-halves convention and is wrong
164
+ for these weights.
165
+ """
166
+ xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2)
167
+ xk_ = xk.float().reshape(*xk.shape[:-1], -1, 1, 2)
168
+ xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1]
169
+ xk_out = freqs_cis[..., 0] * xk_[..., 0] + freqs_cis[..., 1] * xk_[..., 1]
170
+ return xq_out.reshape(*xq.shape).type_as(xq), xk_out.reshape(*xk.shape).type_as(xk)
171
+
172
+
173
+ class EmbedND(nn.Module):
174
+ """Concatenates the per-axis RoPE ladders and inserts the head axis (dit.py:222-234).
175
+
176
+ Holds no parameters and no buffers: the grid is rebuilt on every forward,
177
+ which is what lets one checkpoint serve 256 and 512 inputs.
178
+ """
179
+
180
+ def __init__(self, theta: int, axes_dim: list[int]) -> None:
181
+ super().__init__()
182
+ self.theta = theta
183
+ self.axes_dim = axes_dim
184
+
185
+ def forward(self, ids: Tensor) -> Tensor:
186
+ emb = torch.cat(
187
+ [rope(ids[..., i], self.axes_dim[i], self.theta) for i in range(len(self.axes_dim))],
188
+ dim=-3,
189
+ )
190
+ return emb.unsqueeze(1)
191
+
192
+
193
+ def latent_image_ids(h: int, w: int, device, dtype=torch.float32) -> Tensor:
194
+ """Centred ``(y, x)`` coordinates for an ``h x w`` latent grid, ``[h*w, 2]``.
195
+
196
+ ``arange(n) - (n - 1) / 2`` with unit spacing (dit.py:237-252), so for even
197
+ ``n`` the coordinates are half-integers and the central 16x16 region of a
198
+ 32x32 grid carries exactly the coordinates a 256-trained model saw — RoPE
199
+ only extrapolates outwards, it never rescales. Row-major, so token
200
+ ``p = y * w + x``. This is **not** FLUX's 3-axis integer id grid.
201
+ """
202
+ y = torch.arange(h, device=device, dtype=dtype) - (h - 1) / 2
203
+ x = torch.arange(w, device=device, dtype=dtype) - (w - 1) / 2
204
+ ids = torch.zeros(h, w, 2, device=device, dtype=dtype)
205
+ ids[..., 0] = y[:, None]
206
+ ids[..., 1] = x[None, :]
207
+ return ids.reshape(h * w, 2)
208
+
209
+
210
+ class SingleStreamBlock(nn.Module):
211
+ """Fused attention + SwiGLU MLP under one modulation and one residual.
212
+
213
+ ``linear1`` emits ``[q | k | v | mlp_gate | mlp_value]`` in that order; the
214
+ qkv slab is K-major (``(K H D)``). Both linears are bias-free
215
+ (dit.py:255-300).
216
+ """
217
+
218
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
219
+ super().__init__()
220
+ self.hidden_size = hidden_size
221
+ self.num_heads = num_heads
222
+ head_dim = hidden_size // num_heads
223
+ self.mlp_hidden_dim = swiglu_hidden_dim(hidden_size, mlp_ratio)
224
+
225
+ self.linear1 = nn.Linear(hidden_size, 3 * hidden_size + 2 * self.mlp_hidden_dim, bias=False)
226
+ self.linear2 = nn.Linear(hidden_size + self.mlp_hidden_dim, hidden_size, bias=False)
227
+ self.norm = QKNorm(head_dim)
228
+ self.pre_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
229
+ self.mlp_act = SwiGLU()
230
+ self.modulation = Modulation(hidden_size)
231
+
232
+ def pre_attention(self, x: Tensor, vec: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
233
+ """Everything up to (not including) RoPE and attention (dit.py:272-288)."""
234
+ shift, scale, gate = self.modulation(vec)
235
+ x_mod = (1 + scale) * self.pre_norm(x) + shift
236
+
237
+ qkv, mlp = torch.split(
238
+ self.linear1(x_mod), [3 * self.hidden_size, 2 * self.mlp_hidden_dim], dim=-1
239
+ )
240
+ b, length, _ = qkv.shape
241
+ # "B L (K H D) -> K B H L D" with K=3, H=num_heads.
242
+ q, k, v = qkv.reshape(b, length, 3, self.num_heads, -1).permute(2, 0, 3, 1, 4)
243
+ q, k = self.norm(q, k, v)
244
+ return q, k, v, mlp, gate
245
+
246
+ def post_attention(self, x: Tensor, attn: Tensor, mlp: Tensor, gate: Tensor) -> Tensor:
247
+ """Output projection and the single gated residual (dit.py:290-294)."""
248
+ b, heads, length, head_dim = attn.shape
249
+ attn = attn.transpose(1, 2).reshape(b, length, heads * head_dim)
250
+ out = self.linear2(torch.cat((attn, self.mlp_act(mlp)), dim=-1))
251
+ return x + gate * out
252
+
253
+ def forward(self, x: Tensor, vec: Tensor, pe: Tensor) -> Tensor:
254
+ q, k, v, mlp, gate = self.pre_attention(x, vec)
255
+ q, k = apply_rope(q, k, pe)
256
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
257
+ return self.post_attention(x, attn, mlp, gate)
258
+
259
+
260
+ class DoubleStreamBlock(nn.Module):
261
+ """Two independent towers over **one** joint attention across ``[EO | SAR]``.
262
+
263
+ The towers have completely separate weights but share the modulation vector
264
+ ``vec`` and the RoPE grid, so an EO token and the SAR token at the same
265
+ ground position carry an identical phase (dit.py:303-343). SAR plays the
266
+ structural role text plays in FLUX.
267
+ """
268
+
269
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
270
+ super().__init__()
271
+ self.eo = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
272
+ self.sar = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
273
+
274
+ def forward(self, eo: Tensor, sar: Tensor, vec: Tensor, pe: Tensor) -> tuple[Tensor, Tensor]:
275
+ """``pe`` must already cover the joint 2P-token sequence."""
276
+ q_e, k_e, v_e, mlp_e, gate_e = self.eo.pre_attention(eo, vec)
277
+ q_s, k_s, v_s, mlp_s, gate_s = self.sar.pre_attention(sar, vec)
278
+
279
+ q = torch.cat((q_e, q_s), dim=2)
280
+ k = torch.cat((k_e, k_s), dim=2)
281
+ v = torch.cat((v_e, v_s), dim=2)
282
+ q, k = apply_rope(q, k, pe)
283
+
284
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
285
+ attn_e, attn_s = attn.split([q_e.shape[2], q_s.shape[2]], dim=2)
286
+ return (
287
+ self.eo.post_attention(eo, attn_e, mlp_e, gate_e),
288
+ self.sar.post_attention(sar, attn_s, mlp_s, gate_s),
289
+ )
290
+
291
+
292
+ class FinalLayer(nn.Module):
293
+ """AdaLN output layer.
294
+
295
+ ``adaLN`` unpacks ``shift, scale`` — the **opposite** order of diffusers'
296
+ ``AdaLayerNormContinuous`` (dit.py:346-362). ``logvar_proj`` belongs to a
297
+ beta-NLL loss that was never enabled (``loss.flow = mse``); its weights are
298
+ kept so the published checkpoint loads with ``strict=True``, but inference
299
+ never evaluates it — the sampler reads only the velocity (bridge.py:531).
300
+ """
301
+
302
+ def __init__(self, hidden_size: int, out_channels: int) -> None:
303
+ super().__init__()
304
+ self.norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
305
+ self.adaLN = nn.Linear(hidden_size, 2 * hidden_size, bias=True)
306
+ self.proj = nn.Linear(hidden_size, out_channels, bias=True)
307
+ self.logvar_proj = nn.Linear(hidden_size, 1, bias=True)
308
+
309
+ def forward(self, x: Tensor, vec: Tensor) -> Tensor:
310
+ mod = self.adaLN(F.silu(vec))
311
+ if mod.ndim == 2:
312
+ mod = mod[:, None, :]
313
+ shift, scale = mod.chunk(2, dim=-1)
314
+ return self.proj((1 + scale) * self.norm(x) + shift)
315
+
316
+
317
+ class ReFlowSETTransformer2DModel(ModelMixin, ConfigMixin):
318
+ """ReFlowSET's flow-velocity transformer (509.32 M parameters as configured).
319
+
320
+ Args:
321
+ in_channels: Channels of the packed FLUX.2 latent (128).
322
+ out_channels: Channels of the predicted velocity (128).
323
+ hidden_size: Residual width (1024).
324
+ depth: **Total** blocks, double plus single (24).
325
+ num_heads: Attention heads (16), so ``head_dim = 64``.
326
+ mlp_ratio: Nominal MLP ratio; the SwiGLU width is derived from it.
327
+ axes_dim: RoPE dims for the ``(y, x)`` axes; must sum to ``head_dim``.
328
+ theta: RoPE base period (10000).
329
+ sample_size: Input image resolution the released arm was trained at
330
+ (256 for QXS-SAROPT, 512 for SAR2Opt). Recorded for provenance
331
+ only: the forward pass derives every shape from its input and the
332
+ RoPE grid is rebuilt per call, so one checkpoint serves any size
333
+ divisible by 16.
334
+ double_blocks: Leading double-stream blocks (8); the remaining
335
+ ``depth - double_blocks`` are single-stream.
336
+ double_merge: How the two streams become one. ``"token"`` (the released
337
+ setting) concatenates on the sequence axis, so the single stack runs
338
+ over 2P tokens and the SAR half is dropped only at the very end;
339
+ ``"channel"`` fuses per position and keeps P tokens.
340
+
341
+ Forward contract: ``forward(hidden_states, timestep, condition)`` where
342
+ ``hidden_states`` is the bridge state ``[B, 128, h, w]``, ``timestep`` is the
343
+ bridge time in ``[0, 1]`` (**not** an integer diffusion step), and
344
+ ``condition`` is the SAR latent of the same shape or ``None``. ``None`` is
345
+ the classifier-free-guidance null branch and is turned into an all-zero
346
+ latent inside the model — there is no learned null token.
347
+ """
348
+
349
+ _supports_gradient_checkpointing = False
350
+
351
+ @register_to_config
352
+ def __init__(
353
+ self,
354
+ in_channels: int = 128,
355
+ out_channels: int = 128,
356
+ hidden_size: int = 1024,
357
+ depth: int = 24,
358
+ num_heads: int = 16,
359
+ mlp_ratio: float = 4.0,
360
+ axes_dim: tuple[int, ...] = (32, 32),
361
+ theta: int = 10000,
362
+ sample_size: Optional[int] = None,
363
+ double_blocks: int = 8,
364
+ double_merge: str = "token",
365
+ ) -> None:
366
+ super().__init__()
367
+ if hidden_size % num_heads != 0:
368
+ raise ValueError(f"hidden_size {hidden_size} must be divisible by num_heads {num_heads}")
369
+ pe_dim = hidden_size // num_heads
370
+ if sum(axes_dim) != pe_dim:
371
+ raise ValueError(f"axes_dim {list(axes_dim)} must sum to the per-head dim {pe_dim}")
372
+ if not 0 <= double_blocks < depth:
373
+ raise ValueError(f"double_blocks {double_blocks} must be in [0, depth={depth})")
374
+ if double_merge not in ("token", "channel"):
375
+ raise ValueError(f"double_merge must be 'token' or 'channel', got {double_merge!r}")
376
+
377
+ self.pe_embedder = EmbedND(theta=theta, axes_dim=list(axes_dim))
378
+ if double_blocks:
379
+ # Each stream gets its own 1x1 "patchify": they are two token
380
+ # sequences now, not two halves of one channel stack.
381
+ self.in_proj_eo = nn.Linear(in_channels, hidden_size, bias=True)
382
+ self.in_proj_sar = nn.Linear(in_channels, hidden_size, bias=True)
383
+ if double_merge == "channel":
384
+ self.merge = nn.Linear(2 * hidden_size, hidden_size, bias=True)
385
+ else:
386
+ self.in_proj = nn.Linear(2 * in_channels, hidden_size, bias=True)
387
+ self.time_in = MLPEmbedder(TIME_EMBED_DIM, hidden_size)
388
+ self.double_stream = nn.ModuleList(
389
+ [DoubleStreamBlock(hidden_size, num_heads, mlp_ratio) for _ in range(double_blocks)]
390
+ )
391
+ self.blocks = nn.ModuleList(
392
+ [
393
+ SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
394
+ for _ in range(depth - double_blocks)
395
+ ]
396
+ )
397
+ self.final_layer = FinalLayer(hidden_size, out_channels)
398
+
399
+ def forward(
400
+ self,
401
+ hidden_states: Tensor,
402
+ timestep: Tensor,
403
+ condition: Optional[Tensor] = None,
404
+ return_dict: bool = True,
405
+ ) -> Union[Transformer2DModelOutput, tuple[Tensor]]:
406
+ """Predict the flow velocity ``dz/dt``.
407
+
408
+ Args:
409
+ hidden_states: ``[B, in_channels, h, w]`` bridge state.
410
+ timestep: Bridge time in ``[0, 1]``; a scalar or ``[B]``.
411
+ condition: ``[B, in_channels, h, w]`` SAR latent, or ``None`` for the
412
+ null branch (an all-zero conditioning latent, dit.py:531-532).
413
+ return_dict: Return a ``Transformer2DModelOutput`` instead of a tuple.
414
+
415
+ Returns:
416
+ The velocity ``[B, out_channels, h, w]``. This is a flow velocity,
417
+ not ``epsilon`` and not diffusers' ``v_prediction``.
418
+ """
419
+ if hidden_states.ndim != 4:
420
+ raise ValueError(f"hidden_states must be [B, C, h, w], got {tuple(hidden_states.shape)}")
421
+ batch, _, h, w = hidden_states.shape
422
+ if condition is None:
423
+ condition = torch.zeros_like(hidden_states)
424
+ elif condition.shape != hidden_states.shape:
425
+ raise ValueError(
426
+ f"condition shape {tuple(condition.shape)} must match "
427
+ f"hidden_states shape {tuple(hidden_states.shape)}"
428
+ )
429
+ if timestep.ndim == 0:
430
+ timestep = timestep.expand(batch)
431
+
432
+ n_double = self.config.double_blocks
433
+ if n_double:
434
+ eo = self.in_proj_eo(hidden_states.flatten(2).transpose(1, 2)) # [B, P, D]
435
+ sar = self.in_proj_sar(condition.flatten(2).transpose(1, 2)) # [B, P, D]
436
+ ref = eo
437
+ else:
438
+ x = torch.cat([hidden_states, condition], dim=1).flatten(2).transpose(1, 2)
439
+ x = self.in_proj(x)
440
+ ref = x
441
+
442
+ vec = self.time_in(timestep_embedding(timestep, TIME_EMBED_DIM).to(ref.dtype))
443
+
444
+ ids = latent_image_ids(h, w, device=hidden_states.device, dtype=torch.float32)
445
+ pe = self.pe_embedder(ids[None].expand(batch, -1, -1))
446
+
447
+ num_tokens = ref.shape[1]
448
+ pe_single = pe
449
+ if n_double:
450
+ # Token axis of pe is dim 2 ([B, 1, L, head_dim/2, 2, 2]); repeating
451
+ # the same P coordinates gives EO and SAR one shared grid.
452
+ pe_joint = torch.cat((pe, pe), dim=2)
453
+ for block in self.double_stream:
454
+ eo, sar = block(eo, sar, vec, pe_joint)
455
+ if self.config.double_merge == "token":
456
+ x = torch.cat((eo, sar), dim=1) # [B, 2P, D]
457
+ pe_single = pe_joint
458
+ else:
459
+ x = self.merge(torch.cat((eo, sar), dim=-1)) # [B, P, D]
460
+
461
+ for block in self.blocks:
462
+ x = block(x, vec, pe_single)
463
+
464
+ if n_double and self.config.double_merge == "token":
465
+ x = x[:, :num_tokens] # drop the SAR half: only EO is decoded
466
+
467
+ v = self.final_layer(x, vec)
468
+ v = v.transpose(1, 2).reshape(batch, self.config.out_channels, h, w)
469
+ if not return_dict:
470
+ return (v,)
471
+ return Transformer2DModelOutput(sample=v)
qxs-saropt/transformer_reflowset.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET velocity transformer — a latent DiT with an EO/SAR double stream.
2
+
3
+ The network predicts the flow-bridge velocity ``dz/dt`` in the frozen FLUX.2
4
+ latent space. It takes the noisy EO latent ``[B, 128, h, w]``, a scalar bridge
5
+ time ``t`` in ``[0, 1]``, and the SAR conditioning latent of the same shape; the
6
+ first 8 of its 24 blocks are double-stream (one EO tower and one SAR tower over
7
+ a single joint attention), the remaining 16 are single-stream over the
8
+ concatenated ``[EO | SAR]`` sequence, and only the EO half is decoded.
9
+
10
+ This is an inference-only port. The training-only REPA projection head
11
+ (``repa_proj``) is a separate module in the reference implementation and is
12
+ deliberately absent here.
13
+
14
+ Deviations from `diffusers`' FLUX blocks that this file has to keep — each one
15
+ is silent if you get it wrong:
16
+
17
+ * ``FinalLayer`` unpacks ``shift, scale`` (dit.py:360), the **opposite** order of
18
+ ``AdaLayerNormContinuous``.
19
+ * The single-stream MLP is **SwiGLU** of width 2752, not a 4x GELU of width 4096.
20
+ * ``linear1``/``linear2`` are **bias-free**, and the QK-norm parameter is called
21
+ ``scale``, not ``weight``.
22
+ * The timestep is multiplied by 1000 *inside* the model and the sinusoid is
23
+ **cos first, then sin**.
24
+ * RoPE runs on **two** axes of **centred half-integer** coordinates, not on
25
+ FLUX's three axes of integers starting at 0.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Optional, Union
32
+
33
+ import torch
34
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
35
+ from diffusers.models.modeling_outputs import Transformer2DModelOutput
36
+ from diffusers.models.modeling_utils import ModelMixin
37
+ from torch import Tensor, nn
38
+ from torch.nn import functional as F
39
+
40
+ #: Width of the sinusoidal timestep embedding fed to ``time_in`` (dit.py:44).
41
+ #: A module constant, deliberately independent of ``hidden_size``.
42
+ TIME_EMBED_DIM = 256
43
+
44
+
45
+ def swiglu_hidden_dim(hidden_size: int, mlp_ratio: float) -> int:
46
+ """SwiGLU intermediate width (dit.py:120-127).
47
+
48
+ The canonical 2/3 rule rounded to a multiple of 64, so a gated MLP at
49
+ ``mlp_ratio=4.0`` costs the same parameters as a plain 4x GELU MLP.
50
+ ``hidden_size=1024, mlp_ratio=4.0 -> 2752``.
51
+ """
52
+ return int(round(hidden_size * mlp_ratio * 2 / 3 / 64)) * 64
53
+
54
+
55
+ class SwiGLU(nn.Module):
56
+ """``silu(first half) * second half`` — gate first, value second (dit.py:130-133)."""
57
+
58
+ def forward(self, x: Tensor) -> Tensor:
59
+ x1, x2 = x.chunk(2, dim=-1)
60
+ return F.silu(x1) * x2
61
+
62
+
63
+ class RMSNorm(nn.Module):
64
+ """RMS norm computed in float32. The parameter is named ``scale`` (dit.py:136-145)."""
65
+
66
+ def __init__(self, dim: int) -> None:
67
+ super().__init__()
68
+ self.scale = nn.Parameter(torch.ones(dim))
69
+
70
+ def forward(self, x: Tensor) -> Tensor:
71
+ x_dtype = x.dtype
72
+ x = x.float()
73
+ rrms = torch.rsqrt(torch.mean(x**2, dim=-1, keepdim=True) + 1e-6)
74
+ return (x * rrms).to(dtype=x_dtype) * self.scale
75
+
76
+
77
+ class QKNorm(nn.Module):
78
+ """Per-head query/key RMS norm, applied **before** RoPE (dit.py:148-155)."""
79
+
80
+ def __init__(self, dim: int) -> None:
81
+ super().__init__()
82
+ self.query_norm = RMSNorm(dim)
83
+ self.key_norm = RMSNorm(dim)
84
+
85
+ def forward(self, q: Tensor, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
86
+ return self.query_norm(q).to(v), self.key_norm(k).to(v)
87
+
88
+
89
+ class MLPEmbedder(nn.Module):
90
+ """``Linear -> SiLU -> Linear`` time-embedding MLP (dit.py:158-166).
91
+
92
+ Checkpoint keys are ``time_in.in_layer.*`` / ``time_in.out_layer.*``, not
93
+ diffusers' ``time_text_embed.timestep_embedder.linear_{1,2}``.
94
+ """
95
+
96
+ def __init__(self, in_dim: int, hidden_dim: int) -> None:
97
+ super().__init__()
98
+ self.in_layer = nn.Linear(in_dim, hidden_dim, bias=True)
99
+ self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=True)
100
+ self.silu = nn.SiLU()
101
+
102
+ def forward(self, x: Tensor) -> Tensor:
103
+ return self.out_layer(self.silu(self.in_layer(x)))
104
+
105
+
106
+ class Modulation(nn.Module):
107
+ """AdaLN-Zero triple. Order is ``shift, scale, gate`` (dit.py:169-181)."""
108
+
109
+ def __init__(self, dim: int) -> None:
110
+ super().__init__()
111
+ self.lin = nn.Linear(dim, 3 * dim, bias=True)
112
+
113
+ def forward(self, vec: Tensor) -> tuple[Tensor, Tensor, Tensor]:
114
+ out = self.lin(F.silu(vec))
115
+ if out.ndim == 2:
116
+ out = out[:, None, :]
117
+ shift, scale, gate = out.chunk(3, dim=-1)
118
+ return shift, scale, gate
119
+
120
+
121
+ def timestep_embedding(
122
+ t: Tensor, dim: int, max_period: int = 10000, time_factor: float = 1000.0
123
+ ) -> Tensor:
124
+ """Sinusoidal embedding of a fractional bridge time (dit.py:184-201).
125
+
126
+ Two things differ from `diffusers`' ``get_timestep_embedding`` defaults:
127
+ ``t`` is a fraction in ``[0, 1]`` that is scaled by ``time_factor = 1000``
128
+ **here**, and the concatenation order is ``[cos, sin]`` (FLUX's ordering,
129
+ i.e. ``flip_sin_to_cos=True``).
130
+ """
131
+ t = time_factor * t
132
+ half = dim // 2
133
+ freqs = torch.exp(
134
+ -math.log(max_period)
135
+ * torch.arange(start=0, end=half, device=t.device, dtype=torch.float32)
136
+ / half
137
+ )
138
+ args = t[:, None].float() * freqs[None]
139
+ embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
140
+ if dim % 2:
141
+ embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
142
+ if torch.is_floating_point(t):
143
+ embedding = embedding.to(t)
144
+ return embedding
145
+
146
+
147
+ def rope(pos: Tensor, dim: int, theta: int) -> Tensor:
148
+ """Per-axis rotation matrices ``[..., L, dim/2, 2, 2]`` (dit.py:204-211)."""
149
+ if dim % 2:
150
+ raise ValueError(f"RoPE axis dim must be even, got {dim}")
151
+ scale = torch.arange(0, dim, 2, dtype=pos.dtype, device=pos.device) / dim
152
+ omega = 1.0 / (theta**scale)
153
+ out = torch.einsum("...n,d->...nd", pos, omega)
154
+ out = torch.stack([torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1)
155
+ return out.reshape(*out.shape[:-1], 2, 2).float()
156
+
157
+
158
+ def apply_rope(xq: Tensor, xk: Tensor, freqs_cis: Tensor) -> tuple[Tensor, Tensor]:
159
+ """Rotate consecutive dimension pairs — the interleaved (FLUX) convention.
160
+
161
+ ``(x0, x1) -> (cos*x0 - sin*x1, sin*x0 + cos*x1)`` on ``(x[2k], x[2k+1])``
162
+ (dit.py:214-219). Equivalent to diffusers' ``apply_rotary_emb(...,
163
+ use_real_unbind_dim=-1)``; ``-2`` is the split-halves convention and is wrong
164
+ for these weights.
165
+ """
166
+ xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2)
167
+ xk_ = xk.float().reshape(*xk.shape[:-1], -1, 1, 2)
168
+ xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1]
169
+ xk_out = freqs_cis[..., 0] * xk_[..., 0] + freqs_cis[..., 1] * xk_[..., 1]
170
+ return xq_out.reshape(*xq.shape).type_as(xq), xk_out.reshape(*xk.shape).type_as(xk)
171
+
172
+
173
+ class EmbedND(nn.Module):
174
+ """Concatenates the per-axis RoPE ladders and inserts the head axis (dit.py:222-234).
175
+
176
+ Holds no parameters and no buffers: the grid is rebuilt on every forward,
177
+ which is what lets one checkpoint serve 256 and 512 inputs.
178
+ """
179
+
180
+ def __init__(self, theta: int, axes_dim: list[int]) -> None:
181
+ super().__init__()
182
+ self.theta = theta
183
+ self.axes_dim = axes_dim
184
+
185
+ def forward(self, ids: Tensor) -> Tensor:
186
+ emb = torch.cat(
187
+ [rope(ids[..., i], self.axes_dim[i], self.theta) for i in range(len(self.axes_dim))],
188
+ dim=-3,
189
+ )
190
+ return emb.unsqueeze(1)
191
+
192
+
193
+ def latent_image_ids(h: int, w: int, device, dtype=torch.float32) -> Tensor:
194
+ """Centred ``(y, x)`` coordinates for an ``h x w`` latent grid, ``[h*w, 2]``.
195
+
196
+ ``arange(n) - (n - 1) / 2`` with unit spacing (dit.py:237-252), so for even
197
+ ``n`` the coordinates are half-integers and the central 16x16 region of a
198
+ 32x32 grid carries exactly the coordinates a 256-trained model saw — RoPE
199
+ only extrapolates outwards, it never rescales. Row-major, so token
200
+ ``p = y * w + x``. This is **not** FLUX's 3-axis integer id grid.
201
+ """
202
+ y = torch.arange(h, device=device, dtype=dtype) - (h - 1) / 2
203
+ x = torch.arange(w, device=device, dtype=dtype) - (w - 1) / 2
204
+ ids = torch.zeros(h, w, 2, device=device, dtype=dtype)
205
+ ids[..., 0] = y[:, None]
206
+ ids[..., 1] = x[None, :]
207
+ return ids.reshape(h * w, 2)
208
+
209
+
210
+ class SingleStreamBlock(nn.Module):
211
+ """Fused attention + SwiGLU MLP under one modulation and one residual.
212
+
213
+ ``linear1`` emits ``[q | k | v | mlp_gate | mlp_value]`` in that order; the
214
+ qkv slab is K-major (``(K H D)``). Both linears are bias-free
215
+ (dit.py:255-300).
216
+ """
217
+
218
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
219
+ super().__init__()
220
+ self.hidden_size = hidden_size
221
+ self.num_heads = num_heads
222
+ head_dim = hidden_size // num_heads
223
+ self.mlp_hidden_dim = swiglu_hidden_dim(hidden_size, mlp_ratio)
224
+
225
+ self.linear1 = nn.Linear(hidden_size, 3 * hidden_size + 2 * self.mlp_hidden_dim, bias=False)
226
+ self.linear2 = nn.Linear(hidden_size + self.mlp_hidden_dim, hidden_size, bias=False)
227
+ self.norm = QKNorm(head_dim)
228
+ self.pre_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
229
+ self.mlp_act = SwiGLU()
230
+ self.modulation = Modulation(hidden_size)
231
+
232
+ def pre_attention(self, x: Tensor, vec: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
233
+ """Everything up to (not including) RoPE and attention (dit.py:272-288)."""
234
+ shift, scale, gate = self.modulation(vec)
235
+ x_mod = (1 + scale) * self.pre_norm(x) + shift
236
+
237
+ qkv, mlp = torch.split(
238
+ self.linear1(x_mod), [3 * self.hidden_size, 2 * self.mlp_hidden_dim], dim=-1
239
+ )
240
+ b, length, _ = qkv.shape
241
+ # "B L (K H D) -> K B H L D" with K=3, H=num_heads.
242
+ q, k, v = qkv.reshape(b, length, 3, self.num_heads, -1).permute(2, 0, 3, 1, 4)
243
+ q, k = self.norm(q, k, v)
244
+ return q, k, v, mlp, gate
245
+
246
+ def post_attention(self, x: Tensor, attn: Tensor, mlp: Tensor, gate: Tensor) -> Tensor:
247
+ """Output projection and the single gated residual (dit.py:290-294)."""
248
+ b, heads, length, head_dim = attn.shape
249
+ attn = attn.transpose(1, 2).reshape(b, length, heads * head_dim)
250
+ out = self.linear2(torch.cat((attn, self.mlp_act(mlp)), dim=-1))
251
+ return x + gate * out
252
+
253
+ def forward(self, x: Tensor, vec: Tensor, pe: Tensor) -> Tensor:
254
+ q, k, v, mlp, gate = self.pre_attention(x, vec)
255
+ q, k = apply_rope(q, k, pe)
256
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
257
+ return self.post_attention(x, attn, mlp, gate)
258
+
259
+
260
+ class DoubleStreamBlock(nn.Module):
261
+ """Two independent towers over **one** joint attention across ``[EO | SAR]``.
262
+
263
+ The towers have completely separate weights but share the modulation vector
264
+ ``vec`` and the RoPE grid, so an EO token and the SAR token at the same
265
+ ground position carry an identical phase (dit.py:303-343). SAR plays the
266
+ structural role text plays in FLUX.
267
+ """
268
+
269
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
270
+ super().__init__()
271
+ self.eo = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
272
+ self.sar = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
273
+
274
+ def forward(self, eo: Tensor, sar: Tensor, vec: Tensor, pe: Tensor) -> tuple[Tensor, Tensor]:
275
+ """``pe`` must already cover the joint 2P-token sequence."""
276
+ q_e, k_e, v_e, mlp_e, gate_e = self.eo.pre_attention(eo, vec)
277
+ q_s, k_s, v_s, mlp_s, gate_s = self.sar.pre_attention(sar, vec)
278
+
279
+ q = torch.cat((q_e, q_s), dim=2)
280
+ k = torch.cat((k_e, k_s), dim=2)
281
+ v = torch.cat((v_e, v_s), dim=2)
282
+ q, k = apply_rope(q, k, pe)
283
+
284
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
285
+ attn_e, attn_s = attn.split([q_e.shape[2], q_s.shape[2]], dim=2)
286
+ return (
287
+ self.eo.post_attention(eo, attn_e, mlp_e, gate_e),
288
+ self.sar.post_attention(sar, attn_s, mlp_s, gate_s),
289
+ )
290
+
291
+
292
+ class FinalLayer(nn.Module):
293
+ """AdaLN output layer.
294
+
295
+ ``adaLN`` unpacks ``shift, scale`` — the **opposite** order of diffusers'
296
+ ``AdaLayerNormContinuous`` (dit.py:346-362). ``logvar_proj`` belongs to a
297
+ beta-NLL loss that was never enabled (``loss.flow = mse``); its weights are
298
+ kept so the published checkpoint loads with ``strict=True``, but inference
299
+ never evaluates it — the sampler reads only the velocity (bridge.py:531).
300
+ """
301
+
302
+ def __init__(self, hidden_size: int, out_channels: int) -> None:
303
+ super().__init__()
304
+ self.norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
305
+ self.adaLN = nn.Linear(hidden_size, 2 * hidden_size, bias=True)
306
+ self.proj = nn.Linear(hidden_size, out_channels, bias=True)
307
+ self.logvar_proj = nn.Linear(hidden_size, 1, bias=True)
308
+
309
+ def forward(self, x: Tensor, vec: Tensor) -> Tensor:
310
+ mod = self.adaLN(F.silu(vec))
311
+ if mod.ndim == 2:
312
+ mod = mod[:, None, :]
313
+ shift, scale = mod.chunk(2, dim=-1)
314
+ return self.proj((1 + scale) * self.norm(x) + shift)
315
+
316
+
317
+ class ReFlowSETTransformer2DModel(ModelMixin, ConfigMixin):
318
+ """ReFlowSET's flow-velocity transformer (509.32 M parameters as configured).
319
+
320
+ Args:
321
+ in_channels: Channels of the packed FLUX.2 latent (128).
322
+ out_channels: Channels of the predicted velocity (128).
323
+ hidden_size: Residual width (1024).
324
+ depth: **Total** blocks, double plus single (24).
325
+ num_heads: Attention heads (16), so ``head_dim = 64``.
326
+ mlp_ratio: Nominal MLP ratio; the SwiGLU width is derived from it.
327
+ axes_dim: RoPE dims for the ``(y, x)`` axes; must sum to ``head_dim``.
328
+ theta: RoPE base period (10000).
329
+ sample_size: Input image resolution the released arm was trained at
330
+ (256 for QXS-SAROPT, 512 for SAR2Opt). Recorded for provenance
331
+ only: the forward pass derives every shape from its input and the
332
+ RoPE grid is rebuilt per call, so one checkpoint serves any size
333
+ divisible by 16.
334
+ double_blocks: Leading double-stream blocks (8); the remaining
335
+ ``depth - double_blocks`` are single-stream.
336
+ double_merge: How the two streams become one. ``"token"`` (the released
337
+ setting) concatenates on the sequence axis, so the single stack runs
338
+ over 2P tokens and the SAR half is dropped only at the very end;
339
+ ``"channel"`` fuses per position and keeps P tokens.
340
+
341
+ Forward contract: ``forward(hidden_states, timestep, condition)`` where
342
+ ``hidden_states`` is the bridge state ``[B, 128, h, w]``, ``timestep`` is the
343
+ bridge time in ``[0, 1]`` (**not** an integer diffusion step), and
344
+ ``condition`` is the SAR latent of the same shape or ``None``. ``None`` is
345
+ the classifier-free-guidance null branch and is turned into an all-zero
346
+ latent inside the model — there is no learned null token.
347
+ """
348
+
349
+ _supports_gradient_checkpointing = False
350
+
351
+ @register_to_config
352
+ def __init__(
353
+ self,
354
+ in_channels: int = 128,
355
+ out_channels: int = 128,
356
+ hidden_size: int = 1024,
357
+ depth: int = 24,
358
+ num_heads: int = 16,
359
+ mlp_ratio: float = 4.0,
360
+ axes_dim: tuple[int, ...] = (32, 32),
361
+ theta: int = 10000,
362
+ sample_size: Optional[int] = None,
363
+ double_blocks: int = 8,
364
+ double_merge: str = "token",
365
+ ) -> None:
366
+ super().__init__()
367
+ if hidden_size % num_heads != 0:
368
+ raise ValueError(f"hidden_size {hidden_size} must be divisible by num_heads {num_heads}")
369
+ pe_dim = hidden_size // num_heads
370
+ if sum(axes_dim) != pe_dim:
371
+ raise ValueError(f"axes_dim {list(axes_dim)} must sum to the per-head dim {pe_dim}")
372
+ if not 0 <= double_blocks < depth:
373
+ raise ValueError(f"double_blocks {double_blocks} must be in [0, depth={depth})")
374
+ if double_merge not in ("token", "channel"):
375
+ raise ValueError(f"double_merge must be 'token' or 'channel', got {double_merge!r}")
376
+
377
+ self.pe_embedder = EmbedND(theta=theta, axes_dim=list(axes_dim))
378
+ if double_blocks:
379
+ # Each stream gets its own 1x1 "patchify": they are two token
380
+ # sequences now, not two halves of one channel stack.
381
+ self.in_proj_eo = nn.Linear(in_channels, hidden_size, bias=True)
382
+ self.in_proj_sar = nn.Linear(in_channels, hidden_size, bias=True)
383
+ if double_merge == "channel":
384
+ self.merge = nn.Linear(2 * hidden_size, hidden_size, bias=True)
385
+ else:
386
+ self.in_proj = nn.Linear(2 * in_channels, hidden_size, bias=True)
387
+ self.time_in = MLPEmbedder(TIME_EMBED_DIM, hidden_size)
388
+ self.double_stream = nn.ModuleList(
389
+ [DoubleStreamBlock(hidden_size, num_heads, mlp_ratio) for _ in range(double_blocks)]
390
+ )
391
+ self.blocks = nn.ModuleList(
392
+ [
393
+ SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
394
+ for _ in range(depth - double_blocks)
395
+ ]
396
+ )
397
+ self.final_layer = FinalLayer(hidden_size, out_channels)
398
+
399
+ def forward(
400
+ self,
401
+ hidden_states: Tensor,
402
+ timestep: Tensor,
403
+ condition: Optional[Tensor] = None,
404
+ return_dict: bool = True,
405
+ ) -> Union[Transformer2DModelOutput, tuple[Tensor]]:
406
+ """Predict the flow velocity ``dz/dt``.
407
+
408
+ Args:
409
+ hidden_states: ``[B, in_channels, h, w]`` bridge state.
410
+ timestep: Bridge time in ``[0, 1]``; a scalar or ``[B]``.
411
+ condition: ``[B, in_channels, h, w]`` SAR latent, or ``None`` for the
412
+ null branch (an all-zero conditioning latent, dit.py:531-532).
413
+ return_dict: Return a ``Transformer2DModelOutput`` instead of a tuple.
414
+
415
+ Returns:
416
+ The velocity ``[B, out_channels, h, w]``. This is a flow velocity,
417
+ not ``epsilon`` and not diffusers' ``v_prediction``.
418
+ """
419
+ if hidden_states.ndim != 4:
420
+ raise ValueError(f"hidden_states must be [B, C, h, w], got {tuple(hidden_states.shape)}")
421
+ batch, _, h, w = hidden_states.shape
422
+ if condition is None:
423
+ condition = torch.zeros_like(hidden_states)
424
+ elif condition.shape != hidden_states.shape:
425
+ raise ValueError(
426
+ f"condition shape {tuple(condition.shape)} must match "
427
+ f"hidden_states shape {tuple(hidden_states.shape)}"
428
+ )
429
+ if timestep.ndim == 0:
430
+ timestep = timestep.expand(batch)
431
+
432
+ n_double = self.config.double_blocks
433
+ if n_double:
434
+ eo = self.in_proj_eo(hidden_states.flatten(2).transpose(1, 2)) # [B, P, D]
435
+ sar = self.in_proj_sar(condition.flatten(2).transpose(1, 2)) # [B, P, D]
436
+ ref = eo
437
+ else:
438
+ x = torch.cat([hidden_states, condition], dim=1).flatten(2).transpose(1, 2)
439
+ x = self.in_proj(x)
440
+ ref = x
441
+
442
+ vec = self.time_in(timestep_embedding(timestep, TIME_EMBED_DIM).to(ref.dtype))
443
+
444
+ ids = latent_image_ids(h, w, device=hidden_states.device, dtype=torch.float32)
445
+ pe = self.pe_embedder(ids[None].expand(batch, -1, -1))
446
+
447
+ num_tokens = ref.shape[1]
448
+ pe_single = pe
449
+ if n_double:
450
+ # Token axis of pe is dim 2 ([B, 1, L, head_dim/2, 2, 2]); repeating
451
+ # the same P coordinates gives EO and SAR one shared grid.
452
+ pe_joint = torch.cat((pe, pe), dim=2)
453
+ for block in self.double_stream:
454
+ eo, sar = block(eo, sar, vec, pe_joint)
455
+ if self.config.double_merge == "token":
456
+ x = torch.cat((eo, sar), dim=1) # [B, 2P, D]
457
+ pe_single = pe_joint
458
+ else:
459
+ x = self.merge(torch.cat((eo, sar), dim=-1)) # [B, P, D]
460
+
461
+ for block in self.blocks:
462
+ x = block(x, vec, pe_single)
463
+
464
+ if n_double and self.config.double_merge == "token":
465
+ x = x[:, :num_tokens] # drop the SAR half: only EO is decoded
466
+
467
+ v = self.final_layer(x, vec)
468
+ v = v.transpose(1, 2).reshape(batch, self.config.out_channels, h, w)
469
+ if not return_dict:
470
+ return (v,)
471
+ return Transformer2DModelOutput(sample=v)
qxs-saropt/vae/autoencoder_flux2.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen FLUX.2 autoencoder — the latent endpoint of ReFlowSET.
2
+
3
+ ReFlowSET never fine-tunes this module: it is loaded once, frozen, and used to
4
+ encode the SAR condition and to decode the sampled EO latent. The released
5
+ weights are the **Apache-2.0** FLUX.2-klein-base-4B copy of the autoencoder,
6
+ re-keyed to the layout below (see ``scripts/convert_flux2_ae.py``).
7
+
8
+ Three details of the checkpoint are non-standard for `diffusers` and are
9
+ preserved exactly, because the file must load with ``strict=True``:
10
+
11
+ * ``quant_conv`` lives **inside** ``encoder.*`` and is the last op of the
12
+ encoder forward; ``post_quant_conv`` lives **inside** ``decoder.*`` and is the
13
+ first op of the decoder forward. `diffusers`' ``AutoencoderKL`` makes both
14
+ siblings of the encoder/decoder.
15
+ * The latent normaliser is a real ``BatchNorm2d(128, affine=False)`` whose
16
+ running statistics ship in the checkpoint under ``bn.*`` — a per-channel mean
17
+ **and** variance, not a scalar ``scaling_factor``/``shift_factor``. Its
18
+ epsilon is ``1e-4``, not torch's ``1e-5``.
19
+ * ``encode`` returns the posterior **mean**; the log-variance chunk of the
20
+ encoder's moments is discarded, so encoding is deterministic and there is no
21
+ ``DiagonalGaussianDistribution`` and no ``.sample()``.
22
+
23
+ The public latent is ``[B, 128, H/16, W/16]``: an 8x convolutional stride
24
+ followed by a 2x2 space-to-depth pack that is part of the *autoencoder*, not of
25
+ the transformer.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+
32
+ import torch
33
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
34
+ from diffusers.models.modeling_utils import ModelMixin
35
+ from torch import Tensor, nn
36
+ from torch.nn import functional as F
37
+
38
+
39
+ def swish(x: Tensor) -> Tensor:
40
+ """``x * sigmoid(x)`` — the activation used throughout the FLUX.2 AE."""
41
+ return x * torch.sigmoid(x)
42
+
43
+
44
+ class AttnBlock(nn.Module):
45
+ """Single-head self-attention over the spatial grid (head dim == channels)."""
46
+
47
+ def __init__(self, in_channels: int) -> None:
48
+ super().__init__()
49
+ self.in_channels = in_channels
50
+ self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
51
+ self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1)
52
+ self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1)
53
+ self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1)
54
+ self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1)
55
+
56
+ def attention(self, h_: Tensor) -> Tensor:
57
+ h_ = self.norm(h_)
58
+ q, k, v = self.q(h_), self.k(h_), self.v(h_)
59
+ b, c, h, w = q.shape
60
+ # "b c h w -> b 1 (h w) c": ONE head whose head-dim is the full channel
61
+ # count (flux2_ae.py:70-73).
62
+ q = q.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
63
+ k = k.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
64
+ v = v.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
65
+ h_ = F.scaled_dot_product_attention(q, k, v)
66
+ return h_.squeeze(1).transpose(1, 2).reshape(b, c, h, w)
67
+
68
+ def forward(self, x: Tensor) -> Tensor:
69
+ return x + self.proj_out(self.attention(x))
70
+
71
+
72
+ class ResnetBlock(nn.Module):
73
+ def __init__(self, in_channels: int, out_channels: int) -> None:
74
+ super().__init__()
75
+ self.in_channels = in_channels
76
+ self.out_channels = out_channels
77
+ self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
78
+ self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
79
+ self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True)
80
+ self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
81
+ if in_channels != out_channels:
82
+ self.nin_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0)
83
+
84
+ def forward(self, x: Tensor) -> Tensor:
85
+ h = self.conv1(swish(self.norm1(x)))
86
+ h = self.conv2(swish(self.norm2(h)))
87
+ if self.in_channels != self.out_channels:
88
+ x = self.nin_shortcut(x)
89
+ return x + h
90
+
91
+
92
+ class Downsample(nn.Module):
93
+ """Stride-2 conv with FLUX's asymmetric ``(0, 1, 0, 1)`` pad (flux2_ae.py:111-121)."""
94
+
95
+ def __init__(self, in_channels: int) -> None:
96
+ super().__init__()
97
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=2, padding=0)
98
+
99
+ def forward(self, x: Tensor) -> Tensor:
100
+ return self.conv(F.pad(x, (0, 1, 0, 1), mode="constant", value=0))
101
+
102
+
103
+ class Upsample(nn.Module):
104
+ """Nearest-neighbour 2x followed by a 3x3 conv (flux2_ae.py:124-132)."""
105
+
106
+ def __init__(self, in_channels: int) -> None:
107
+ super().__init__()
108
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=1, padding=1)
109
+
110
+ def forward(self, x: Tensor) -> Tensor:
111
+ return self.conv(F.interpolate(x, scale_factor=2.0, mode="nearest"))
112
+
113
+
114
+ class Encoder(nn.Module):
115
+ """FLUX.2 encoder. Emits ``2 * z_channels`` moments; ``quant_conv`` is internal."""
116
+
117
+ def __init__(
118
+ self,
119
+ resolution: int,
120
+ in_channels: int,
121
+ ch: int,
122
+ ch_mult: list[int],
123
+ num_res_blocks: int,
124
+ z_channels: int,
125
+ ) -> None:
126
+ super().__init__()
127
+ # Declared first so the checkpoint key is `encoder.quant_conv.*`
128
+ # (flux2_ae.py:146) — diffusers keeps quant_conv outside the encoder.
129
+ self.quant_conv = nn.Conv2d(2 * z_channels, 2 * z_channels, 1)
130
+ self.ch = ch
131
+ self.num_resolutions = len(ch_mult)
132
+ self.num_res_blocks = num_res_blocks
133
+ self.resolution = resolution
134
+ self.in_channels = in_channels
135
+
136
+ self.conv_in = nn.Conv2d(in_channels, ch, kernel_size=3, stride=1, padding=1)
137
+
138
+ in_ch_mult = (1,) + tuple(ch_mult)
139
+ self.down = nn.ModuleList()
140
+ block_in = ch
141
+ for i_level in range(self.num_resolutions):
142
+ block = nn.ModuleList()
143
+ block_in = ch * in_ch_mult[i_level]
144
+ block_out = ch * ch_mult[i_level]
145
+ for _ in range(num_res_blocks):
146
+ block.append(ResnetBlock(block_in, block_out))
147
+ block_in = block_out
148
+ down = nn.Module()
149
+ down.block = block
150
+ # Empty at every level in this checkpoint: attention exists only in
151
+ # `mid` (flux2_ae.py:162). Kept so the forward guard is meaningful.
152
+ down.attn = nn.ModuleList()
153
+ if i_level != self.num_resolutions - 1:
154
+ down.downsample = Downsample(block_in)
155
+ self.down.append(down)
156
+
157
+ self.mid = nn.Module()
158
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
159
+ self.mid.attn_1 = AttnBlock(block_in)
160
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
161
+
162
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
163
+ self.conv_out = nn.Conv2d(block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1)
164
+
165
+ def forward(self, x: Tensor) -> Tensor:
166
+ hs = [self.conv_in(x)]
167
+ for i_level in range(self.num_resolutions):
168
+ for i_block in range(self.num_res_blocks):
169
+ h = self.down[i_level].block[i_block](hs[-1])
170
+ if len(self.down[i_level].attn) > 0:
171
+ h = self.down[i_level].attn[i_block](h)
172
+ hs.append(h)
173
+ if i_level != self.num_resolutions - 1:
174
+ hs.append(self.down[i_level].downsample(hs[-1]))
175
+
176
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(hs[-1])))
177
+ h = self.conv_out(swish(self.norm_out(h)))
178
+ return self.quant_conv(h) # last op of the encoder (flux2_ae.py:207)
179
+
180
+
181
+ class Decoder(nn.Module):
182
+ """FLUX.2 decoder. ``post_quant_conv`` is internal and runs first."""
183
+
184
+ def __init__(
185
+ self,
186
+ ch: int,
187
+ out_ch: int,
188
+ ch_mult: list[int],
189
+ num_res_blocks: int,
190
+ in_channels: int,
191
+ resolution: int,
192
+ z_channels: int,
193
+ ) -> None:
194
+ super().__init__()
195
+ # Checkpoint key `decoder.post_quant_conv.*` (flux2_ae.py:223).
196
+ self.post_quant_conv = nn.Conv2d(z_channels, z_channels, 1)
197
+ self.ch = ch
198
+ self.num_resolutions = len(ch_mult)
199
+ self.num_res_blocks = num_res_blocks
200
+ self.resolution = resolution
201
+ self.in_channels = in_channels
202
+
203
+ block_in = ch * ch_mult[self.num_resolutions - 1]
204
+ self.conv_in = nn.Conv2d(z_channels, block_in, kernel_size=3, stride=1, padding=1)
205
+
206
+ self.mid = nn.Module()
207
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
208
+ self.mid.attn_1 = AttnBlock(block_in)
209
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
210
+
211
+ self.up = nn.ModuleList()
212
+ for i_level in reversed(range(self.num_resolutions)):
213
+ block = nn.ModuleList()
214
+ block_out = ch * ch_mult[i_level]
215
+ for _ in range(num_res_blocks + 1):
216
+ block.append(ResnetBlock(block_in, block_out))
217
+ block_in = block_out
218
+ up = nn.Module()
219
+ up.block = block
220
+ up.attn = nn.ModuleList() # empty in this checkpoint (flux2_ae.py:249)
221
+ if i_level != 0:
222
+ up.upsample = Upsample(block_in)
223
+ self.up.insert(0, up) # prepend so `up.<i>` indexes by resolution level
224
+
225
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
226
+ self.conv_out = nn.Conv2d(block_in, out_ch, kernel_size=3, stride=1, padding=1)
227
+
228
+ def forward(self, z: Tensor) -> Tensor:
229
+ z = self.post_quant_conv(z) # first op of the decoder (flux2_ae.py:267)
230
+ upscale_dtype = next(self.up.parameters()).dtype
231
+
232
+ h = self.conv_in(z)
233
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(h)))
234
+ h = h.to(upscale_dtype)
235
+
236
+ for i_level in reversed(range(self.num_resolutions)):
237
+ for i_block in range(self.num_res_blocks + 1):
238
+ h = self.up[i_level].block[i_block](h)
239
+ if len(self.up[i_level].attn) > 0:
240
+ h = self.up[i_level].attn[i_block](h)
241
+ if i_level != 0:
242
+ h = self.up[i_level].upsample(h)
243
+
244
+ return self.conv_out(swish(self.norm_out(h)))
245
+
246
+
247
+ class AutoencoderFlux2(ModelMixin, ConfigMixin):
248
+ """Frozen FLUX.2 autoencoder with ReFlowSET's packed, BN-normalised latent.
249
+
250
+ ``encode`` maps ``[B, 3, H, W]`` in ``[-1, 1]`` to ``[B, 128, H/16, W/16]``
251
+ and ``decode`` inverts it. The module is frozen: ``train()`` is a no-op that
252
+ always selects eval mode, and the latent BatchNorm is additionally forced to
253
+ eval on every call so no batch statistic can ever leak into the latent.
254
+
255
+ Args:
256
+ resolution: Nominal training resolution of the original autoencoder.
257
+ Only used to size bookkeeping attributes; any ``H``, ``W`` divisible
258
+ by 16 may be encoded.
259
+ in_channels: Input image channels (3).
260
+ ch: Base width.
261
+ out_ch: Output image channels (3).
262
+ ch_mult: Per-level width multipliers; ``len(ch_mult) - 1`` downsamples.
263
+ num_res_blocks: Residual blocks per level.
264
+ z_channels: Pre-pack latent channels (32).
265
+ patch_size: Space-to-depth factor applied after the encoder (2), which
266
+ takes the latent from 32 channels at ``H/8`` to 128 at ``H/16``.
267
+ bn_eps: Epsilon of the latent BatchNorm. **1e-4**, not torch's 1e-5
268
+ (flux2_ae.py:331); using 1e-5 shifts the latent by up to 2.6e-5.
269
+ """
270
+
271
+ _supports_gradient_checkpointing = False
272
+
273
+ @register_to_config
274
+ def __init__(
275
+ self,
276
+ resolution: int = 256,
277
+ in_channels: int = 3,
278
+ ch: int = 128,
279
+ out_ch: int = 3,
280
+ ch_mult: tuple[int, ...] = (1, 2, 4, 4),
281
+ num_res_blocks: int = 2,
282
+ z_channels: int = 32,
283
+ patch_size: int = 2,
284
+ bn_eps: float = 1e-4,
285
+ ) -> None:
286
+ super().__init__()
287
+ ch_mult = list(ch_mult)
288
+ self.encoder = Encoder(
289
+ resolution=resolution,
290
+ in_channels=in_channels,
291
+ ch=ch,
292
+ ch_mult=ch_mult,
293
+ num_res_blocks=num_res_blocks,
294
+ z_channels=z_channels,
295
+ )
296
+ self.decoder = Decoder(
297
+ ch=ch,
298
+ out_ch=out_ch,
299
+ ch_mult=ch_mult,
300
+ num_res_blocks=num_res_blocks,
301
+ in_channels=in_channels,
302
+ resolution=resolution,
303
+ z_channels=z_channels,
304
+ )
305
+ # Per-channel latent normaliser with the checkpoint's running statistics.
306
+ # affine=False, so there is no weight/bias to load (flux2_ae.py:334-340).
307
+ self.bn = nn.BatchNorm2d(
308
+ patch_size * patch_size * z_channels,
309
+ eps=bn_eps,
310
+ momentum=0.1,
311
+ affine=False,
312
+ track_running_stats=True,
313
+ )
314
+
315
+ @property
316
+ def latent_channels(self) -> int:
317
+ """Channels of the public latent: ``patch_size**2 * z_channels`` = 128."""
318
+ return self.config.patch_size**2 * self.config.z_channels
319
+
320
+ @property
321
+ def spatial_factor(self) -> int:
322
+ """Total stride: 8x convolutional times ``patch_size`` packing = 16."""
323
+ return 2 ** (len(self.config.ch_mult) - 1) * self.config.patch_size
324
+
325
+ # ---- 2x2 space-to-depth pack / unpack -----------------------------------
326
+
327
+ def pack(self, z: Tensor) -> Tensor:
328
+ """``[B, C, H, W] -> [B, C*p*p, H/p, W/p]``, channel-major.
329
+
330
+ Bit-identical to the reference ``rearrange("... c (i pi) (j pj) -> ...
331
+ (c pi pj) i j")`` (flux2_ae.py:349-357). Note this is **not** diffusers'
332
+ ``_pack_latents``, whose channel grouping is transposed.
333
+ """
334
+ return F.pixel_unshuffle(z, self.config.patch_size)
335
+
336
+ def unpack(self, z: Tensor) -> Tensor:
337
+ """Exact inverse of :meth:`pack` (flux2_ae.py:359-367)."""
338
+ return F.pixel_shuffle(z, self.config.patch_size)
339
+
340
+ # ---- latent normalisation ----------------------------------------------
341
+
342
+ def normalize(self, z: Tensor) -> Tensor:
343
+ """``(z - running_mean) / sqrt(running_var + bn_eps)``, per channel."""
344
+ self.bn.eval() # forced every call (flux2_ae.py:372); train mode shifts z by ~1.67
345
+ return self.bn(z)
346
+
347
+ def inv_normalize(self, z: Tensor) -> Tensor:
348
+ """Exact inverse of :meth:`normalize` — same ``bn_eps`` (flux2_ae.py:375-379)."""
349
+ self.bn.eval()
350
+ s = torch.sqrt(self.bn.running_var.view(1, -1, 1, 1) + self.config.bn_eps)
351
+ m = self.bn.running_mean.view(1, -1, 1, 1)
352
+ return z * s + m
353
+
354
+ # ---- public API ---------------------------------------------------------
355
+
356
+ @torch.no_grad()
357
+ def encode(self, x: Tensor) -> Tensor:
358
+ """Encode an image to the packed, normalised latent.
359
+
360
+ Args:
361
+ x: ``[B, 3, H, W]`` in ``[-1, 1]``; ``H`` and ``W`` divisible by 16.
362
+
363
+ Returns:
364
+ ``[B, 128, H/16, W/16]`` — the posterior **mean**, packed and
365
+ BN-normalised. The encoder's log-variance chunk is discarded
366
+ (flux2_ae.py:396), so this is deterministic: there is no posterior
367
+ distribution object and nothing to sample.
368
+ """
369
+ if x.ndim != 4 or x.shape[1] != self.config.in_channels:
370
+ raise ValueError(
371
+ f"encode expects [B, {self.config.in_channels}, H, W], got {tuple(x.shape)}"
372
+ )
373
+ h, w = x.shape[-2:]
374
+ if h % self.spatial_factor or w % self.spatial_factor:
375
+ raise ValueError(
376
+ f"encode requires H and W divisible by {self.spatial_factor}, got {h}x{w}"
377
+ )
378
+ moments = self.encoder(x)
379
+ mean = torch.chunk(moments, 2, dim=1)[0]
380
+ return self.normalize(self.pack(mean))
381
+
382
+ @torch.no_grad()
383
+ def decode(self, z: Tensor) -> Tensor:
384
+ """Decode a packed, normalised latent ``[B, 128, h, w]`` to ``[B, 3, 16h, 16w]``.
385
+
386
+ The output is approximately ``[-1, 1]`` and is **not** clamped here; the
387
+ pipeline applies ``(x * 0.5 + 0.5).clamp(0, 1)``.
388
+ """
389
+ if z.ndim != 4 or z.shape[1] != self.latent_channels:
390
+ raise ValueError(
391
+ f"decode expects [B, {self.latent_channels}, h, w], got {tuple(z.shape)}"
392
+ )
393
+ return self.decoder(self.unpack(self.inv_normalize(z)))
394
+
395
+ # ---- construction / freezing -------------------------------------------
396
+
397
+ @classmethod
398
+ def from_single_file(
399
+ cls,
400
+ path: str | os.PathLike,
401
+ torch_dtype: torch.dtype = torch.float32,
402
+ ) -> "AutoencoderFlux2":
403
+ """Load the single-file ``ae.safetensors`` (BFL key names) with ``strict=True``.
404
+
405
+ The released file is the Apache-2.0 FLUX.2-klein-base-4B autoencoder
406
+ re-keyed to this layout; it is stored in bfloat16 and is upcast to
407
+ ``torch_dtype``. ReFlowSET runs the autoencoder in float32.
408
+ """
409
+ from safetensors.torch import load_file
410
+
411
+ path = os.fspath(path)
412
+ if not os.path.isfile(path):
413
+ raise FileNotFoundError(
414
+ f"FLUX.2 autoencoder weights not found at: {path}. Expected the "
415
+ "single-file 'ae.safetensors' shipped with ReFlowSET."
416
+ )
417
+ model = cls()
418
+ model.load_state_dict(load_file(path, device="cpu"), strict=True)
419
+ model.to(dtype=torch_dtype)
420
+ model.eval()
421
+ model.requires_grad_(False)
422
+ return model
423
+
424
+ def train(self, mode: bool = True) -> "AutoencoderFlux2":
425
+ """The autoencoder is frozen: never leave eval mode (flux2_ae.py:437-439)."""
426
+ return super().train(False)
qxs-saropt/vae/config.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "AutoencoderFlux2",
3
+ "_diffusers_version": "0.37.1",
4
+ "bn_eps": 0.0001,
5
+ "ch": 128,
6
+ "ch_mult": [
7
+ 1,
8
+ 2,
9
+ 4,
10
+ 4
11
+ ],
12
+ "in_channels": 3,
13
+ "num_res_blocks": 2,
14
+ "out_ch": 3,
15
+ "patch_size": 2,
16
+ "resolution": 256,
17
+ "z_channels": 32
18
+ }
qxs-saropt/vae/diffusion_pytorch_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c69bfd7e39b3c26f044905d93f87c5730ae533bf77526ee863ac9c6463948a32
3
+ size 168118886
sar2opt/autoencoder_flux2.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen FLUX.2 autoencoder — the latent endpoint of ReFlowSET.
2
+
3
+ ReFlowSET never fine-tunes this module: it is loaded once, frozen, and used to
4
+ encode the SAR condition and to decode the sampled EO latent. The released
5
+ weights are the **Apache-2.0** FLUX.2-klein-base-4B copy of the autoencoder,
6
+ re-keyed to the layout below (see ``scripts/convert_flux2_ae.py``).
7
+
8
+ Three details of the checkpoint are non-standard for `diffusers` and are
9
+ preserved exactly, because the file must load with ``strict=True``:
10
+
11
+ * ``quant_conv`` lives **inside** ``encoder.*`` and is the last op of the
12
+ encoder forward; ``post_quant_conv`` lives **inside** ``decoder.*`` and is the
13
+ first op of the decoder forward. `diffusers`' ``AutoencoderKL`` makes both
14
+ siblings of the encoder/decoder.
15
+ * The latent normaliser is a real ``BatchNorm2d(128, affine=False)`` whose
16
+ running statistics ship in the checkpoint under ``bn.*`` — a per-channel mean
17
+ **and** variance, not a scalar ``scaling_factor``/``shift_factor``. Its
18
+ epsilon is ``1e-4``, not torch's ``1e-5``.
19
+ * ``encode`` returns the posterior **mean**; the log-variance chunk of the
20
+ encoder's moments is discarded, so encoding is deterministic and there is no
21
+ ``DiagonalGaussianDistribution`` and no ``.sample()``.
22
+
23
+ The public latent is ``[B, 128, H/16, W/16]``: an 8x convolutional stride
24
+ followed by a 2x2 space-to-depth pack that is part of the *autoencoder*, not of
25
+ the transformer.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+
32
+ import torch
33
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
34
+ from diffusers.models.modeling_utils import ModelMixin
35
+ from torch import Tensor, nn
36
+ from torch.nn import functional as F
37
+
38
+
39
+ def swish(x: Tensor) -> Tensor:
40
+ """``x * sigmoid(x)`` — the activation used throughout the FLUX.2 AE."""
41
+ return x * torch.sigmoid(x)
42
+
43
+
44
+ class AttnBlock(nn.Module):
45
+ """Single-head self-attention over the spatial grid (head dim == channels)."""
46
+
47
+ def __init__(self, in_channels: int) -> None:
48
+ super().__init__()
49
+ self.in_channels = in_channels
50
+ self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
51
+ self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1)
52
+ self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1)
53
+ self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1)
54
+ self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1)
55
+
56
+ def attention(self, h_: Tensor) -> Tensor:
57
+ h_ = self.norm(h_)
58
+ q, k, v = self.q(h_), self.k(h_), self.v(h_)
59
+ b, c, h, w = q.shape
60
+ # "b c h w -> b 1 (h w) c": ONE head whose head-dim is the full channel
61
+ # count (flux2_ae.py:70-73).
62
+ q = q.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
63
+ k = k.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
64
+ v = v.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
65
+ h_ = F.scaled_dot_product_attention(q, k, v)
66
+ return h_.squeeze(1).transpose(1, 2).reshape(b, c, h, w)
67
+
68
+ def forward(self, x: Tensor) -> Tensor:
69
+ return x + self.proj_out(self.attention(x))
70
+
71
+
72
+ class ResnetBlock(nn.Module):
73
+ def __init__(self, in_channels: int, out_channels: int) -> None:
74
+ super().__init__()
75
+ self.in_channels = in_channels
76
+ self.out_channels = out_channels
77
+ self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
78
+ self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
79
+ self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True)
80
+ self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
81
+ if in_channels != out_channels:
82
+ self.nin_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0)
83
+
84
+ def forward(self, x: Tensor) -> Tensor:
85
+ h = self.conv1(swish(self.norm1(x)))
86
+ h = self.conv2(swish(self.norm2(h)))
87
+ if self.in_channels != self.out_channels:
88
+ x = self.nin_shortcut(x)
89
+ return x + h
90
+
91
+
92
+ class Downsample(nn.Module):
93
+ """Stride-2 conv with FLUX's asymmetric ``(0, 1, 0, 1)`` pad (flux2_ae.py:111-121)."""
94
+
95
+ def __init__(self, in_channels: int) -> None:
96
+ super().__init__()
97
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=2, padding=0)
98
+
99
+ def forward(self, x: Tensor) -> Tensor:
100
+ return self.conv(F.pad(x, (0, 1, 0, 1), mode="constant", value=0))
101
+
102
+
103
+ class Upsample(nn.Module):
104
+ """Nearest-neighbour 2x followed by a 3x3 conv (flux2_ae.py:124-132)."""
105
+
106
+ def __init__(self, in_channels: int) -> None:
107
+ super().__init__()
108
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=1, padding=1)
109
+
110
+ def forward(self, x: Tensor) -> Tensor:
111
+ return self.conv(F.interpolate(x, scale_factor=2.0, mode="nearest"))
112
+
113
+
114
+ class Encoder(nn.Module):
115
+ """FLUX.2 encoder. Emits ``2 * z_channels`` moments; ``quant_conv`` is internal."""
116
+
117
+ def __init__(
118
+ self,
119
+ resolution: int,
120
+ in_channels: int,
121
+ ch: int,
122
+ ch_mult: list[int],
123
+ num_res_blocks: int,
124
+ z_channels: int,
125
+ ) -> None:
126
+ super().__init__()
127
+ # Declared first so the checkpoint key is `encoder.quant_conv.*`
128
+ # (flux2_ae.py:146) — diffusers keeps quant_conv outside the encoder.
129
+ self.quant_conv = nn.Conv2d(2 * z_channels, 2 * z_channels, 1)
130
+ self.ch = ch
131
+ self.num_resolutions = len(ch_mult)
132
+ self.num_res_blocks = num_res_blocks
133
+ self.resolution = resolution
134
+ self.in_channels = in_channels
135
+
136
+ self.conv_in = nn.Conv2d(in_channels, ch, kernel_size=3, stride=1, padding=1)
137
+
138
+ in_ch_mult = (1,) + tuple(ch_mult)
139
+ self.down = nn.ModuleList()
140
+ block_in = ch
141
+ for i_level in range(self.num_resolutions):
142
+ block = nn.ModuleList()
143
+ block_in = ch * in_ch_mult[i_level]
144
+ block_out = ch * ch_mult[i_level]
145
+ for _ in range(num_res_blocks):
146
+ block.append(ResnetBlock(block_in, block_out))
147
+ block_in = block_out
148
+ down = nn.Module()
149
+ down.block = block
150
+ # Empty at every level in this checkpoint: attention exists only in
151
+ # `mid` (flux2_ae.py:162). Kept so the forward guard is meaningful.
152
+ down.attn = nn.ModuleList()
153
+ if i_level != self.num_resolutions - 1:
154
+ down.downsample = Downsample(block_in)
155
+ self.down.append(down)
156
+
157
+ self.mid = nn.Module()
158
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
159
+ self.mid.attn_1 = AttnBlock(block_in)
160
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
161
+
162
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
163
+ self.conv_out = nn.Conv2d(block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1)
164
+
165
+ def forward(self, x: Tensor) -> Tensor:
166
+ hs = [self.conv_in(x)]
167
+ for i_level in range(self.num_resolutions):
168
+ for i_block in range(self.num_res_blocks):
169
+ h = self.down[i_level].block[i_block](hs[-1])
170
+ if len(self.down[i_level].attn) > 0:
171
+ h = self.down[i_level].attn[i_block](h)
172
+ hs.append(h)
173
+ if i_level != self.num_resolutions - 1:
174
+ hs.append(self.down[i_level].downsample(hs[-1]))
175
+
176
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(hs[-1])))
177
+ h = self.conv_out(swish(self.norm_out(h)))
178
+ return self.quant_conv(h) # last op of the encoder (flux2_ae.py:207)
179
+
180
+
181
+ class Decoder(nn.Module):
182
+ """FLUX.2 decoder. ``post_quant_conv`` is internal and runs first."""
183
+
184
+ def __init__(
185
+ self,
186
+ ch: int,
187
+ out_ch: int,
188
+ ch_mult: list[int],
189
+ num_res_blocks: int,
190
+ in_channels: int,
191
+ resolution: int,
192
+ z_channels: int,
193
+ ) -> None:
194
+ super().__init__()
195
+ # Checkpoint key `decoder.post_quant_conv.*` (flux2_ae.py:223).
196
+ self.post_quant_conv = nn.Conv2d(z_channels, z_channels, 1)
197
+ self.ch = ch
198
+ self.num_resolutions = len(ch_mult)
199
+ self.num_res_blocks = num_res_blocks
200
+ self.resolution = resolution
201
+ self.in_channels = in_channels
202
+
203
+ block_in = ch * ch_mult[self.num_resolutions - 1]
204
+ self.conv_in = nn.Conv2d(z_channels, block_in, kernel_size=3, stride=1, padding=1)
205
+
206
+ self.mid = nn.Module()
207
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
208
+ self.mid.attn_1 = AttnBlock(block_in)
209
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
210
+
211
+ self.up = nn.ModuleList()
212
+ for i_level in reversed(range(self.num_resolutions)):
213
+ block = nn.ModuleList()
214
+ block_out = ch * ch_mult[i_level]
215
+ for _ in range(num_res_blocks + 1):
216
+ block.append(ResnetBlock(block_in, block_out))
217
+ block_in = block_out
218
+ up = nn.Module()
219
+ up.block = block
220
+ up.attn = nn.ModuleList() # empty in this checkpoint (flux2_ae.py:249)
221
+ if i_level != 0:
222
+ up.upsample = Upsample(block_in)
223
+ self.up.insert(0, up) # prepend so `up.<i>` indexes by resolution level
224
+
225
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
226
+ self.conv_out = nn.Conv2d(block_in, out_ch, kernel_size=3, stride=1, padding=1)
227
+
228
+ def forward(self, z: Tensor) -> Tensor:
229
+ z = self.post_quant_conv(z) # first op of the decoder (flux2_ae.py:267)
230
+ upscale_dtype = next(self.up.parameters()).dtype
231
+
232
+ h = self.conv_in(z)
233
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(h)))
234
+ h = h.to(upscale_dtype)
235
+
236
+ for i_level in reversed(range(self.num_resolutions)):
237
+ for i_block in range(self.num_res_blocks + 1):
238
+ h = self.up[i_level].block[i_block](h)
239
+ if len(self.up[i_level].attn) > 0:
240
+ h = self.up[i_level].attn[i_block](h)
241
+ if i_level != 0:
242
+ h = self.up[i_level].upsample(h)
243
+
244
+ return self.conv_out(swish(self.norm_out(h)))
245
+
246
+
247
+ class AutoencoderFlux2(ModelMixin, ConfigMixin):
248
+ """Frozen FLUX.2 autoencoder with ReFlowSET's packed, BN-normalised latent.
249
+
250
+ ``encode`` maps ``[B, 3, H, W]`` in ``[-1, 1]`` to ``[B, 128, H/16, W/16]``
251
+ and ``decode`` inverts it. The module is frozen: ``train()`` is a no-op that
252
+ always selects eval mode, and the latent BatchNorm is additionally forced to
253
+ eval on every call so no batch statistic can ever leak into the latent.
254
+
255
+ Args:
256
+ resolution: Nominal training resolution of the original autoencoder.
257
+ Only used to size bookkeeping attributes; any ``H``, ``W`` divisible
258
+ by 16 may be encoded.
259
+ in_channels: Input image channels (3).
260
+ ch: Base width.
261
+ out_ch: Output image channels (3).
262
+ ch_mult: Per-level width multipliers; ``len(ch_mult) - 1`` downsamples.
263
+ num_res_blocks: Residual blocks per level.
264
+ z_channels: Pre-pack latent channels (32).
265
+ patch_size: Space-to-depth factor applied after the encoder (2), which
266
+ takes the latent from 32 channels at ``H/8`` to 128 at ``H/16``.
267
+ bn_eps: Epsilon of the latent BatchNorm. **1e-4**, not torch's 1e-5
268
+ (flux2_ae.py:331); using 1e-5 shifts the latent by up to 2.6e-5.
269
+ """
270
+
271
+ _supports_gradient_checkpointing = False
272
+
273
+ @register_to_config
274
+ def __init__(
275
+ self,
276
+ resolution: int = 256,
277
+ in_channels: int = 3,
278
+ ch: int = 128,
279
+ out_ch: int = 3,
280
+ ch_mult: tuple[int, ...] = (1, 2, 4, 4),
281
+ num_res_blocks: int = 2,
282
+ z_channels: int = 32,
283
+ patch_size: int = 2,
284
+ bn_eps: float = 1e-4,
285
+ ) -> None:
286
+ super().__init__()
287
+ ch_mult = list(ch_mult)
288
+ self.encoder = Encoder(
289
+ resolution=resolution,
290
+ in_channels=in_channels,
291
+ ch=ch,
292
+ ch_mult=ch_mult,
293
+ num_res_blocks=num_res_blocks,
294
+ z_channels=z_channels,
295
+ )
296
+ self.decoder = Decoder(
297
+ ch=ch,
298
+ out_ch=out_ch,
299
+ ch_mult=ch_mult,
300
+ num_res_blocks=num_res_blocks,
301
+ in_channels=in_channels,
302
+ resolution=resolution,
303
+ z_channels=z_channels,
304
+ )
305
+ # Per-channel latent normaliser with the checkpoint's running statistics.
306
+ # affine=False, so there is no weight/bias to load (flux2_ae.py:334-340).
307
+ self.bn = nn.BatchNorm2d(
308
+ patch_size * patch_size * z_channels,
309
+ eps=bn_eps,
310
+ momentum=0.1,
311
+ affine=False,
312
+ track_running_stats=True,
313
+ )
314
+
315
+ @property
316
+ def latent_channels(self) -> int:
317
+ """Channels of the public latent: ``patch_size**2 * z_channels`` = 128."""
318
+ return self.config.patch_size**2 * self.config.z_channels
319
+
320
+ @property
321
+ def spatial_factor(self) -> int:
322
+ """Total stride: 8x convolutional times ``patch_size`` packing = 16."""
323
+ return 2 ** (len(self.config.ch_mult) - 1) * self.config.patch_size
324
+
325
+ # ---- 2x2 space-to-depth pack / unpack -----------------------------------
326
+
327
+ def pack(self, z: Tensor) -> Tensor:
328
+ """``[B, C, H, W] -> [B, C*p*p, H/p, W/p]``, channel-major.
329
+
330
+ Bit-identical to the reference ``rearrange("... c (i pi) (j pj) -> ...
331
+ (c pi pj) i j")`` (flux2_ae.py:349-357). Note this is **not** diffusers'
332
+ ``_pack_latents``, whose channel grouping is transposed.
333
+ """
334
+ return F.pixel_unshuffle(z, self.config.patch_size)
335
+
336
+ def unpack(self, z: Tensor) -> Tensor:
337
+ """Exact inverse of :meth:`pack` (flux2_ae.py:359-367)."""
338
+ return F.pixel_shuffle(z, self.config.patch_size)
339
+
340
+ # ---- latent normalisation ----------------------------------------------
341
+
342
+ def normalize(self, z: Tensor) -> Tensor:
343
+ """``(z - running_mean) / sqrt(running_var + bn_eps)``, per channel."""
344
+ self.bn.eval() # forced every call (flux2_ae.py:372); train mode shifts z by ~1.67
345
+ return self.bn(z)
346
+
347
+ def inv_normalize(self, z: Tensor) -> Tensor:
348
+ """Exact inverse of :meth:`normalize` — same ``bn_eps`` (flux2_ae.py:375-379)."""
349
+ self.bn.eval()
350
+ s = torch.sqrt(self.bn.running_var.view(1, -1, 1, 1) + self.config.bn_eps)
351
+ m = self.bn.running_mean.view(1, -1, 1, 1)
352
+ return z * s + m
353
+
354
+ # ---- public API ---------------------------------------------------------
355
+
356
+ @torch.no_grad()
357
+ def encode(self, x: Tensor) -> Tensor:
358
+ """Encode an image to the packed, normalised latent.
359
+
360
+ Args:
361
+ x: ``[B, 3, H, W]`` in ``[-1, 1]``; ``H`` and ``W`` divisible by 16.
362
+
363
+ Returns:
364
+ ``[B, 128, H/16, W/16]`` — the posterior **mean**, packed and
365
+ BN-normalised. The encoder's log-variance chunk is discarded
366
+ (flux2_ae.py:396), so this is deterministic: there is no posterior
367
+ distribution object and nothing to sample.
368
+ """
369
+ if x.ndim != 4 or x.shape[1] != self.config.in_channels:
370
+ raise ValueError(
371
+ f"encode expects [B, {self.config.in_channels}, H, W], got {tuple(x.shape)}"
372
+ )
373
+ h, w = x.shape[-2:]
374
+ if h % self.spatial_factor or w % self.spatial_factor:
375
+ raise ValueError(
376
+ f"encode requires H and W divisible by {self.spatial_factor}, got {h}x{w}"
377
+ )
378
+ moments = self.encoder(x)
379
+ mean = torch.chunk(moments, 2, dim=1)[0]
380
+ return self.normalize(self.pack(mean))
381
+
382
+ @torch.no_grad()
383
+ def decode(self, z: Tensor) -> Tensor:
384
+ """Decode a packed, normalised latent ``[B, 128, h, w]`` to ``[B, 3, 16h, 16w]``.
385
+
386
+ The output is approximately ``[-1, 1]`` and is **not** clamped here; the
387
+ pipeline applies ``(x * 0.5 + 0.5).clamp(0, 1)``.
388
+ """
389
+ if z.ndim != 4 or z.shape[1] != self.latent_channels:
390
+ raise ValueError(
391
+ f"decode expects [B, {self.latent_channels}, h, w], got {tuple(z.shape)}"
392
+ )
393
+ return self.decoder(self.unpack(self.inv_normalize(z)))
394
+
395
+ # ---- construction / freezing -------------------------------------------
396
+
397
+ @classmethod
398
+ def from_single_file(
399
+ cls,
400
+ path: str | os.PathLike,
401
+ torch_dtype: torch.dtype = torch.float32,
402
+ ) -> "AutoencoderFlux2":
403
+ """Load the single-file ``ae.safetensors`` (BFL key names) with ``strict=True``.
404
+
405
+ The released file is the Apache-2.0 FLUX.2-klein-base-4B autoencoder
406
+ re-keyed to this layout; it is stored in bfloat16 and is upcast to
407
+ ``torch_dtype``. ReFlowSET runs the autoencoder in float32.
408
+ """
409
+ from safetensors.torch import load_file
410
+
411
+ path = os.fspath(path)
412
+ if not os.path.isfile(path):
413
+ raise FileNotFoundError(
414
+ f"FLUX.2 autoencoder weights not found at: {path}. Expected the "
415
+ "single-file 'ae.safetensors' shipped with ReFlowSET."
416
+ )
417
+ model = cls()
418
+ model.load_state_dict(load_file(path, device="cpu"), strict=True)
419
+ model.to(dtype=torch_dtype)
420
+ model.eval()
421
+ model.requires_grad_(False)
422
+ return model
423
+
424
+ def train(self, mode: bool = True) -> "AutoencoderFlux2":
425
+ """The autoencoder is frozen: never leave eval mode (flux2_ae.py:437-439)."""
426
+ return super().train(False)
sar2opt/model_index.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "ReFlowSETPipeline",
3
+ "_diffusers_version": "0.37.1",
4
+ "transformer": [
5
+ "transformer_reflowset",
6
+ "ReFlowSETTransformer2DModel"
7
+ ],
8
+ "vae": [
9
+ "autoencoder_flux2",
10
+ "AutoencoderFlux2"
11
+ ],
12
+ "scheduler": [
13
+ "scheduler_flow_bridge",
14
+ "FlowBridgeScheduler"
15
+ ]
16
+ }
sar2opt/pipeline.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET SAR -> EO translation pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Optional, Union
7
+
8
+ import numpy as np
9
+ import torch
10
+ from diffusers.pipelines.pipeline_utils import DiffusionPipeline, ImagePipelineOutput
11
+ from diffusers.utils.torch_utils import randn_tensor
12
+ from PIL import Image
13
+
14
+ from .autoencoder_flux2 import AutoencoderFlux2
15
+ from .scheduler_flow_bridge import FlowBridgeScheduler
16
+ from .transformer_reflowset import ReFlowSETTransformer2DModel
17
+
18
+ #: PIL modes the SAR loader accepts. The reference loader calls ``np.array(im)``
19
+ #: with no ``convert()`` (datasets.py:454, 574), so a 16-bit (``I;16``) or
20
+ #: palette (``P``) raster would flow straight into ``x / 127.5 - 1`` and be
21
+ #: badly out of range. That is an unguarded trap upstream; it is guarded here.
22
+ _ACCEPTED_SAR_MODES = ("L", "RGB", "RGBA")
23
+
24
+
25
+ class ReFlowSETPipeline(DiffusionPipeline):
26
+ """Generate an EO image from a SAR image with ReFlowSET's flow bridge.
27
+
28
+ Args:
29
+ transformer: The velocity transformer.
30
+ vae: The frozen FLUX.2 autoencoder that defines the latent space.
31
+ scheduler: The Design-B flow-bridge Euler solver.
32
+
33
+ To reproduce the paper's numbers, sample at ``num_inference_steps=50``,
34
+ ``guidance_scale=1.5``, float32, one image per call, with a generator freshly
35
+ seeded to 2024 on the compute device before each call — every test image in
36
+ the reported evaluation starts from the same seeded noise draw, and CPU-drawn
37
+ noise does not reproduce a CUDA draw.
38
+ """
39
+
40
+ model_cpu_offload_seq = "transformer->vae"
41
+
42
+ def __init__(
43
+ self,
44
+ transformer: ReFlowSETTransformer2DModel,
45
+ vae: AutoencoderFlux2,
46
+ scheduler: FlowBridgeScheduler,
47
+ ) -> None:
48
+ super().__init__()
49
+ self.register_modules(transformer=transformer, vae=vae, scheduler=scheduler)
50
+
51
+ # ---- preprocessing (datasets.py:124-205, 452-474, 572-589) --------------
52
+
53
+ @staticmethod
54
+ def _sar_hwc(raster: Union[Image.Image, np.ndarray]) -> np.ndarray:
55
+ """SAR raster -> ``[H, W, C]`` float32 in ``[0, 255]``, collapsed to 1 channel."""
56
+ if isinstance(raster, Image.Image):
57
+ if raster.mode not in _ACCEPTED_SAR_MODES:
58
+ raise ValueError(
59
+ f"SAR image mode {raster.mode!r} is not an 8-bit display raster; expected "
60
+ f"one of {_ACCEPTED_SAR_MODES}. ReFlowSET was trained on 8-bit display "
61
+ "quicklooks (sar_value_domain='display_png'); convert with .convert('L') "
62
+ "and be aware that the contrast stretch you choose is part of the input."
63
+ )
64
+ # No .convert() on the SAR side, matching datasets.py:454, 574.
65
+ arr = np.array(raster)
66
+ else:
67
+ arr = np.asarray(raster)
68
+ if arr.ndim == 2: # PIL mode "L" (datasets.py:171-172)
69
+ arr = arr[:, :, None]
70
+ if arr.shape[-1] == 4: # drop a container alpha channel (datasets.py:173-174)
71
+ arr = arr[..., :3]
72
+ arr = arr.astype(np.float32)
73
+ if arr.shape[-1] > 1:
74
+ # Exact-equality test, tol=0.0 (datasets.py:100-111): a display RGB
75
+ # quicklook collapses to its single amplitude channel.
76
+ if np.abs(arr - arr[..., :1]).max() == 0.0:
77
+ arr = arr[..., :1]
78
+ else:
79
+ warnings.warn(
80
+ "SAR raster has non-identical colour channels; feeding all 3 to the "
81
+ "frozen encoder. The released arms were trained on single-channel "
82
+ "amplitude quicklooks, so this is an undeclared input.",
83
+ RuntimeWarning,
84
+ stacklevel=3,
85
+ )
86
+ return arr
87
+
88
+ @staticmethod
89
+ def _center_crop(arr: np.ndarray, crop: int) -> np.ndarray:
90
+ """Center-crop ``[H, W, C]`` to ``crop`` — **never** resize (datasets.py:145-165).
91
+
92
+ The offsets are albumentations' ``CenterCrop`` arithmetic ``(n - c) // 2``:
93
+ the SAR2Opt protocol takes the central 512 of 600 at offset 44.
94
+ """
95
+ h, w = arr.shape[:2]
96
+ if h < crop or w < crop:
97
+ raise ValueError(
98
+ f"image {h}x{w} is smaller than the requested crop {crop}; ReFlowSET never "
99
+ "upscales an input"
100
+ )
101
+ top, left = (h - crop) // 2, (w - crop) // 2
102
+ return arr[top : top + crop, left : left + crop]
103
+
104
+ def preprocess(
105
+ self,
106
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
107
+ crop: Optional[int] = None,
108
+ ) -> torch.Tensor:
109
+ """Build the model-boundary SAR tensor ``[B, 3, H, W]`` in ``[-1, 1]``.
110
+
111
+ Args:
112
+ sar: A PIL image, a list of PIL images, an ``[H, W]`` / ``[H, W, C]``
113
+ uint8 array, or a float tensor already in ``[-1, 1]`` shaped
114
+ ``[H, W]``, ``[C, H, W]`` or ``[B, C, H, W]``.
115
+ crop: Center-crop size applied before normalisation. ``None``
116
+ center-crops to the arm's own training resolution when the
117
+ raster is larger and not already a multiple of the latent
118
+ stride -- which is exactly the SAR2Opt 600 -> 512 protocol the
119
+ reported numbers use. Pass an explicit size to override, or
120
+ ``0`` to keep the native raster and fail loudly if it does not
121
+ fit.
122
+
123
+ Images are read as 8-bit display rasters and mapped to ``[-1, 1]`` by
124
+ ``x / 127.5 - 1`` (datasets.py:124-126) with no per-image statistics, no
125
+ percentile stretch and no resize. The single SAR channel is then
126
+ replicated to 3 at the model boundary (evaluate.py:566-570), because the
127
+ frozen FLUX.2 encoder is the same one that encodes EO — ReFlowSET has no
128
+ separate SAR encoder.
129
+ """
130
+ if crop == 0:
131
+ crop = None
132
+ elif crop is None:
133
+ # Fall back to the resolution this arm was trained at. Cropping is
134
+ # the protocol (train.py random-crops, evaluate.py center-crops);
135
+ # ReFlowSET never resizes, so an un-croppable raster is an error
136
+ # rather than something to silently rescale.
137
+ crop = self.transformer.config.sample_size
138
+
139
+ if isinstance(sar, torch.Tensor):
140
+ x = sar.float()
141
+ if x.ndim == 2:
142
+ x = x[None, None]
143
+ elif x.ndim == 3:
144
+ x = x[None]
145
+ elif x.ndim != 4:
146
+ raise ValueError(f"SAR tensor must have 2, 3 or 4 dims, got {tuple(sar.shape)}")
147
+ h, w = x.shape[-2:]
148
+ if crop is not None and (h, w) != (crop, crop):
149
+ if h < crop or w < crop:
150
+ raise ValueError(f"tensor {h}x{w} is smaller than the requested crop {crop}")
151
+ top, left = (h - crop) // 2, (w - crop) // 2
152
+ x = x[..., top : top + crop, left : left + crop]
153
+ else:
154
+ images = sar if isinstance(sar, list) else [sar]
155
+ arrays = []
156
+ for item in images:
157
+ if not isinstance(item, (Image.Image, np.ndarray)):
158
+ raise TypeError(f"unsupported SAR input type {type(item)!r}")
159
+ arr = self._sar_hwc(item)
160
+ if crop is not None and arr.shape[:2] != (crop, crop):
161
+ arr = self._center_crop(arr, crop)
162
+ arrays.append(np.ascontiguousarray(arr.transpose(2, 0, 1)))
163
+ x = torch.from_numpy(np.stack(arrays)) / 127.5 - 1.0
164
+
165
+ # Train-side clamp (train.py:770); a no-op on 8-bit input, which maps
166
+ # exactly onto [-1, 1].
167
+ x = x.clamp(-1.0, 1.0)
168
+ if x.shape[1] == 1:
169
+ x = x.repeat(1, 3, 1, 1)
170
+ elif x.shape[1] != 3:
171
+ raise ValueError(
172
+ f"the frozen FLUX.2 encoder takes 1 or 3 SAR channels, got {x.shape[1]}"
173
+ )
174
+ factor = self.vae.spatial_factor
175
+ if x.shape[-2] % factor or x.shape[-1] % factor:
176
+ raise ValueError(
177
+ f"SAR size {x.shape[-2]}x{x.shape[-1]} must be divisible by {factor}; pass "
178
+ "crop= to center-crop (ReFlowSET never resizes)"
179
+ )
180
+ return x
181
+
182
+ # ---- postprocessing -----------------------------------------------------
183
+
184
+ @staticmethod
185
+ def _to_pil(images: torch.Tensor) -> list[Image.Image]:
186
+ """``[B, 3, H, W]`` in ``[0, 1]`` -> PIL, quantised round-half-up.
187
+
188
+ ``255 * x + 0.5`` truncated is what ``torchvision.utils.save_image``
189
+ does and is therefore what the released PNGs contain; numpy's
190
+ ``round()`` is banker's rounding and would differ on exact halves.
191
+ """
192
+ arr = (images * 255 + 0.5).clamp(0, 255).to(torch.uint8)
193
+ arr = arr.permute(0, 2, 3, 1).cpu().numpy()
194
+ return [Image.fromarray(a) for a in arr]
195
+
196
+ @torch.no_grad()
197
+ def __call__(
198
+ self,
199
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
200
+ num_inference_steps: int = 50,
201
+ guidance_scale: float = 1.5,
202
+ generator: Optional[Union[torch.Generator, list[torch.Generator]]] = None,
203
+ output_type: str = "pil",
204
+ crop: Optional[int] = None,
205
+ return_dict: bool = True,
206
+ ) -> Union[ImagePipelineOutput, tuple[list]]:
207
+ """Translate a SAR image into an EO image.
208
+
209
+ Args:
210
+ sar: SAR input; see :meth:`preprocess`.
211
+ num_inference_steps: NFE, the number of velocity evaluations. The
212
+ paper's main results are NFE 50; NFE 4 is the efficiency
213
+ operating point and trades FID for PSNR/SSIM, so the two must
214
+ not be mixed in one comparison.
215
+ guidance_scale: Classifier-free guidance scale. 1.5 is the published
216
+ setting; 1.0 disables guidance and halves the cost.
217
+ generator: Generator for the initial noise. Create it on the compute
218
+ device — CPU-drawn noise does not reproduce a CUDA draw.
219
+ output_type: ``"pil"``, ``"np"`` or ``"pt"``.
220
+ crop: Center-crop size applied to the SAR input before encoding.
221
+ return_dict: Return an ``ImagePipelineOutput`` instead of a tuple.
222
+
223
+ Returns:
224
+ The generated EO image(s) in ``[0, 1]`` (or as PIL).
225
+ """
226
+ if output_type not in ("pil", "np", "pt"):
227
+ raise ValueError(f"output_type must be 'pil', 'np' or 'pt', got {output_type!r}")
228
+
229
+ device = self._execution_device
230
+ dtype = self.transformer.dtype
231
+
232
+ sar_pm1 = self.preprocess(sar, crop=crop).to(device=device, dtype=self.vae.dtype)
233
+ # The SAR condition is encoded by the SAME frozen autoencoder that
234
+ # defines the EO latent space (evaluate.py:553-577).
235
+ z_s = self.vae.encode(sar_pm1).to(dtype)
236
+
237
+ self.scheduler.set_timesteps(num_inference_steps, device=device)
238
+ # Design B: the bridge starts at t = 0 from pure Gaussian noise
239
+ # (bridge.py:409-433), NOT from the SAR latent.
240
+ latents = randn_tensor(z_s.shape, generator=generator, device=device, dtype=z_s.dtype)
241
+
242
+ for t in self.progress_bar(self.scheduler.timesteps):
243
+ timestep = t.expand(latents.shape[0])
244
+ velocity = self.transformer(latents, timestep, z_s, return_dict=False)[0]
245
+ if guidance_scale != 1.0:
246
+ # Two passes; the null branch is cond=None, which the transformer
247
+ # turns into an all-zero conditioning latent (bridge.py:530-535).
248
+ uncond = self.transformer(latents, timestep, None, return_dict=False)[0]
249
+ velocity = uncond + guidance_scale * (velocity - uncond)
250
+ latents = self.scheduler.step(velocity, t, latents, return_dict=False)[0]
251
+
252
+ image = self.vae.decode(latents.to(self.vae.dtype))
253
+ # `--denorm standard` (evaluate.py:292-295). The `legacy` C-DiffSET
254
+ # convention `(x + 0.5).clamp(0, 1)` is a 2x contrast stretch and must
255
+ # not be used with these numbers.
256
+ image = (image * 0.5 + 0.5).clamp(0.0, 1.0)
257
+
258
+ self.maybe_free_model_hooks()
259
+
260
+ if output_type == "pil":
261
+ image = self._to_pil(image)
262
+ elif output_type == "np":
263
+ image = image.permute(0, 2, 3, 1).float().cpu().numpy()
264
+
265
+ if not return_dict:
266
+ return (image,)
267
+ return ImagePipelineOutput(images=image)
sar2opt/pipeline_reflowset.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET SAR -> EO translation pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import Optional, Union
7
+
8
+ import numpy as np
9
+ import torch
10
+ from diffusers.pipelines.pipeline_utils import DiffusionPipeline, ImagePipelineOutput
11
+ from diffusers.utils.torch_utils import randn_tensor
12
+ from PIL import Image
13
+
14
+ from .autoencoder_flux2 import AutoencoderFlux2
15
+ from .scheduler_flow_bridge import FlowBridgeScheduler
16
+ from .transformer_reflowset import ReFlowSETTransformer2DModel
17
+
18
+ #: PIL modes the SAR loader accepts. The reference loader calls ``np.array(im)``
19
+ #: with no ``convert()`` (datasets.py:454, 574), so a 16-bit (``I;16``) or
20
+ #: palette (``P``) raster would flow straight into ``x / 127.5 - 1`` and be
21
+ #: badly out of range. That is an unguarded trap upstream; it is guarded here.
22
+ _ACCEPTED_SAR_MODES = ("L", "RGB", "RGBA")
23
+
24
+
25
+ class ReFlowSETPipeline(DiffusionPipeline):
26
+ """Generate an EO image from a SAR image with ReFlowSET's flow bridge.
27
+
28
+ Args:
29
+ transformer: The velocity transformer.
30
+ vae: The frozen FLUX.2 autoencoder that defines the latent space.
31
+ scheduler: The Design-B flow-bridge Euler solver.
32
+
33
+ To reproduce the paper's numbers, sample at ``num_inference_steps=50``,
34
+ ``guidance_scale=1.5``, float32, one image per call, with a generator freshly
35
+ seeded to 2024 on the compute device before each call — every test image in
36
+ the reported evaluation starts from the same seeded noise draw, and CPU-drawn
37
+ noise does not reproduce a CUDA draw.
38
+ """
39
+
40
+ model_cpu_offload_seq = "transformer->vae"
41
+
42
+ def __init__(
43
+ self,
44
+ transformer: ReFlowSETTransformer2DModel,
45
+ vae: AutoencoderFlux2,
46
+ scheduler: FlowBridgeScheduler,
47
+ ) -> None:
48
+ super().__init__()
49
+ self.register_modules(transformer=transformer, vae=vae, scheduler=scheduler)
50
+
51
+ # ---- preprocessing (datasets.py:124-205, 452-474, 572-589) --------------
52
+
53
+ @staticmethod
54
+ def _sar_hwc(raster: Union[Image.Image, np.ndarray]) -> np.ndarray:
55
+ """SAR raster -> ``[H, W, C]`` float32 in ``[0, 255]``, collapsed to 1 channel."""
56
+ if isinstance(raster, Image.Image):
57
+ if raster.mode not in _ACCEPTED_SAR_MODES:
58
+ raise ValueError(
59
+ f"SAR image mode {raster.mode!r} is not an 8-bit display raster; expected "
60
+ f"one of {_ACCEPTED_SAR_MODES}. ReFlowSET was trained on 8-bit display "
61
+ "quicklooks (sar_value_domain='display_png'); convert with .convert('L') "
62
+ "and be aware that the contrast stretch you choose is part of the input."
63
+ )
64
+ # No .convert() on the SAR side, matching datasets.py:454, 574.
65
+ arr = np.array(raster)
66
+ else:
67
+ arr = np.asarray(raster)
68
+ if arr.ndim == 2: # PIL mode "L" (datasets.py:171-172)
69
+ arr = arr[:, :, None]
70
+ if arr.shape[-1] == 4: # drop a container alpha channel (datasets.py:173-174)
71
+ arr = arr[..., :3]
72
+ arr = arr.astype(np.float32)
73
+ if arr.shape[-1] > 1:
74
+ # Exact-equality test, tol=0.0 (datasets.py:100-111): a display RGB
75
+ # quicklook collapses to its single amplitude channel.
76
+ if np.abs(arr - arr[..., :1]).max() == 0.0:
77
+ arr = arr[..., :1]
78
+ else:
79
+ warnings.warn(
80
+ "SAR raster has non-identical colour channels; feeding all 3 to the "
81
+ "frozen encoder. The released arms were trained on single-channel "
82
+ "amplitude quicklooks, so this is an undeclared input.",
83
+ RuntimeWarning,
84
+ stacklevel=3,
85
+ )
86
+ return arr
87
+
88
+ @staticmethod
89
+ def _center_crop(arr: np.ndarray, crop: int) -> np.ndarray:
90
+ """Center-crop ``[H, W, C]`` to ``crop`` — **never** resize (datasets.py:145-165).
91
+
92
+ The offsets are albumentations' ``CenterCrop`` arithmetic ``(n - c) // 2``:
93
+ the SAR2Opt protocol takes the central 512 of 600 at offset 44.
94
+ """
95
+ h, w = arr.shape[:2]
96
+ if h < crop or w < crop:
97
+ raise ValueError(
98
+ f"image {h}x{w} is smaller than the requested crop {crop}; ReFlowSET never "
99
+ "upscales an input"
100
+ )
101
+ top, left = (h - crop) // 2, (w - crop) // 2
102
+ return arr[top : top + crop, left : left + crop]
103
+
104
+ def preprocess(
105
+ self,
106
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
107
+ crop: Optional[int] = None,
108
+ ) -> torch.Tensor:
109
+ """Build the model-boundary SAR tensor ``[B, 3, H, W]`` in ``[-1, 1]``.
110
+
111
+ Args:
112
+ sar: A PIL image, a list of PIL images, an ``[H, W]`` / ``[H, W, C]``
113
+ uint8 array, or a float tensor already in ``[-1, 1]`` shaped
114
+ ``[H, W]``, ``[C, H, W]`` or ``[B, C, H, W]``.
115
+ crop: Center-crop size applied before normalisation. ``None``
116
+ center-crops to the arm's own training resolution when the
117
+ raster is larger and not already a multiple of the latent
118
+ stride -- which is exactly the SAR2Opt 600 -> 512 protocol the
119
+ reported numbers use. Pass an explicit size to override, or
120
+ ``0`` to keep the native raster and fail loudly if it does not
121
+ fit.
122
+
123
+ Images are read as 8-bit display rasters and mapped to ``[-1, 1]`` by
124
+ ``x / 127.5 - 1`` (datasets.py:124-126) with no per-image statistics, no
125
+ percentile stretch and no resize. The single SAR channel is then
126
+ replicated to 3 at the model boundary (evaluate.py:566-570), because the
127
+ frozen FLUX.2 encoder is the same one that encodes EO — ReFlowSET has no
128
+ separate SAR encoder.
129
+ """
130
+ if crop == 0:
131
+ crop = None
132
+ elif crop is None:
133
+ # Fall back to the resolution this arm was trained at. Cropping is
134
+ # the protocol (train.py random-crops, evaluate.py center-crops);
135
+ # ReFlowSET never resizes, so an un-croppable raster is an error
136
+ # rather than something to silently rescale.
137
+ crop = self.transformer.config.sample_size
138
+
139
+ if isinstance(sar, torch.Tensor):
140
+ x = sar.float()
141
+ if x.ndim == 2:
142
+ x = x[None, None]
143
+ elif x.ndim == 3:
144
+ x = x[None]
145
+ elif x.ndim != 4:
146
+ raise ValueError(f"SAR tensor must have 2, 3 or 4 dims, got {tuple(sar.shape)}")
147
+ h, w = x.shape[-2:]
148
+ if crop is not None and (h, w) != (crop, crop):
149
+ if h < crop or w < crop:
150
+ raise ValueError(f"tensor {h}x{w} is smaller than the requested crop {crop}")
151
+ top, left = (h - crop) // 2, (w - crop) // 2
152
+ x = x[..., top : top + crop, left : left + crop]
153
+ else:
154
+ images = sar if isinstance(sar, list) else [sar]
155
+ arrays = []
156
+ for item in images:
157
+ if not isinstance(item, (Image.Image, np.ndarray)):
158
+ raise TypeError(f"unsupported SAR input type {type(item)!r}")
159
+ arr = self._sar_hwc(item)
160
+ if crop is not None and arr.shape[:2] != (crop, crop):
161
+ arr = self._center_crop(arr, crop)
162
+ arrays.append(np.ascontiguousarray(arr.transpose(2, 0, 1)))
163
+ x = torch.from_numpy(np.stack(arrays)) / 127.5 - 1.0
164
+
165
+ # Train-side clamp (train.py:770); a no-op on 8-bit input, which maps
166
+ # exactly onto [-1, 1].
167
+ x = x.clamp(-1.0, 1.0)
168
+ if x.shape[1] == 1:
169
+ x = x.repeat(1, 3, 1, 1)
170
+ elif x.shape[1] != 3:
171
+ raise ValueError(
172
+ f"the frozen FLUX.2 encoder takes 1 or 3 SAR channels, got {x.shape[1]}"
173
+ )
174
+ factor = self.vae.spatial_factor
175
+ if x.shape[-2] % factor or x.shape[-1] % factor:
176
+ raise ValueError(
177
+ f"SAR size {x.shape[-2]}x{x.shape[-1]} must be divisible by {factor}; pass "
178
+ "crop= to center-crop (ReFlowSET never resizes)"
179
+ )
180
+ return x
181
+
182
+ # ---- postprocessing -----------------------------------------------------
183
+
184
+ @staticmethod
185
+ def _to_pil(images: torch.Tensor) -> list[Image.Image]:
186
+ """``[B, 3, H, W]`` in ``[0, 1]`` -> PIL, quantised round-half-up.
187
+
188
+ ``255 * x + 0.5`` truncated is what ``torchvision.utils.save_image``
189
+ does and is therefore what the released PNGs contain; numpy's
190
+ ``round()`` is banker's rounding and would differ on exact halves.
191
+ """
192
+ arr = (images * 255 + 0.5).clamp(0, 255).to(torch.uint8)
193
+ arr = arr.permute(0, 2, 3, 1).cpu().numpy()
194
+ return [Image.fromarray(a) for a in arr]
195
+
196
+ @torch.no_grad()
197
+ def __call__(
198
+ self,
199
+ sar: Union[Image.Image, np.ndarray, torch.Tensor, list],
200
+ num_inference_steps: int = 50,
201
+ guidance_scale: float = 1.5,
202
+ generator: Optional[Union[torch.Generator, list[torch.Generator]]] = None,
203
+ output_type: str = "pil",
204
+ crop: Optional[int] = None,
205
+ return_dict: bool = True,
206
+ ) -> Union[ImagePipelineOutput, tuple[list]]:
207
+ """Translate a SAR image into an EO image.
208
+
209
+ Args:
210
+ sar: SAR input; see :meth:`preprocess`.
211
+ num_inference_steps: NFE, the number of velocity evaluations. The
212
+ paper's main results are NFE 50; NFE 4 is the efficiency
213
+ operating point and trades FID for PSNR/SSIM, so the two must
214
+ not be mixed in one comparison.
215
+ guidance_scale: Classifier-free guidance scale. 1.5 is the published
216
+ setting; 1.0 disables guidance and halves the cost.
217
+ generator: Generator for the initial noise. Create it on the compute
218
+ device — CPU-drawn noise does not reproduce a CUDA draw.
219
+ output_type: ``"pil"``, ``"np"`` or ``"pt"``.
220
+ crop: Center-crop size applied to the SAR input before encoding.
221
+ return_dict: Return an ``ImagePipelineOutput`` instead of a tuple.
222
+
223
+ Returns:
224
+ The generated EO image(s) in ``[0, 1]`` (or as PIL).
225
+ """
226
+ if output_type not in ("pil", "np", "pt"):
227
+ raise ValueError(f"output_type must be 'pil', 'np' or 'pt', got {output_type!r}")
228
+
229
+ device = self._execution_device
230
+ dtype = self.transformer.dtype
231
+
232
+ sar_pm1 = self.preprocess(sar, crop=crop).to(device=device, dtype=self.vae.dtype)
233
+ # The SAR condition is encoded by the SAME frozen autoencoder that
234
+ # defines the EO latent space (evaluate.py:553-577).
235
+ z_s = self.vae.encode(sar_pm1).to(dtype)
236
+
237
+ self.scheduler.set_timesteps(num_inference_steps, device=device)
238
+ # Design B: the bridge starts at t = 0 from pure Gaussian noise
239
+ # (bridge.py:409-433), NOT from the SAR latent.
240
+ latents = randn_tensor(z_s.shape, generator=generator, device=device, dtype=z_s.dtype)
241
+
242
+ for t in self.progress_bar(self.scheduler.timesteps):
243
+ timestep = t.expand(latents.shape[0])
244
+ velocity = self.transformer(latents, timestep, z_s, return_dict=False)[0]
245
+ if guidance_scale != 1.0:
246
+ # Two passes; the null branch is cond=None, which the transformer
247
+ # turns into an all-zero conditioning latent (bridge.py:530-535).
248
+ uncond = self.transformer(latents, timestep, None, return_dict=False)[0]
249
+ velocity = uncond + guidance_scale * (velocity - uncond)
250
+ latents = self.scheduler.step(velocity, t, latents, return_dict=False)[0]
251
+
252
+ image = self.vae.decode(latents.to(self.vae.dtype))
253
+ # `--denorm standard` (evaluate.py:292-295). The `legacy` C-DiffSET
254
+ # convention `(x + 0.5).clamp(0, 1)` is a 2x contrast stretch and must
255
+ # not be used with these numbers.
256
+ image = (image * 0.5 + 0.5).clamp(0.0, 1.0)
257
+
258
+ self.maybe_free_model_hooks()
259
+
260
+ if output_type == "pil":
261
+ image = self._to_pil(image)
262
+ elif output_type == "np":
263
+ image = image.permute(0, 2, 3, 1).float().cpu().numpy()
264
+
265
+ if not return_dict:
266
+ return (image,)
267
+ return ImagePipelineOutput(images=image)
sar2opt/scheduler/scheduler_config.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "FlowBridgeScheduler",
3
+ "_diffusers_version": "0.37.1",
4
+ "t_end": 1.0
5
+ }
sar2opt/scheduler/scheduler_flow_bridge.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET's Design-B flow bridge and its explicit-Euler solver.
2
+
3
+ Forward (training) process, with ``eps ~ N(0, I)`` and ``z_e`` the EO latent::
4
+
5
+ z_t = (1 - t) * eps + t * z_e (bridge.py:311, sigma_b = 0)
6
+ u* = z_e - eps (bridge.py:328 at sigma_b = 0)
7
+
8
+ Sampling starts from ``z_0 ~ N(0, I)`` and integrates the predicted velocity
9
+ with explicit Euler on a uniform grid ``linspace(0, t_end, nfe + 1)``
10
+ (bridge.py:519, 536). The bridge is deterministic: ``sigma_b = 0``, so no
11
+ stochastic term ever executes, and the only randomness in a sample is the
12
+ initial noise draw.
13
+
14
+ **Time direction.** ``t = 0`` is NOISE and ``t = 1`` is DATA, and the solver
15
+ integrates ``t`` **ascending** (bridge.py:86-88). That is the opposite of
16
+ `diffusers`' ``sigma`` convention: setting ``sigma := 1 - t`` recovers
17
+ ``FlowMatchEulerDiscreteScheduler``'s interpolation, but then this bridge's
18
+ velocity is the **negative** of the diffusers flow-matching target and the
19
+ network must still be fed ``1 - sigma``. This scheduler keeps ReFlowSET's own
20
+ sign and direction so neither flip is needed; ``timesteps`` therefore *increase*
21
+ from 0 towards 1, unlike every noise-schedule scheduler in `diffusers`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Optional, Union
28
+
29
+ import torch
30
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
31
+ from diffusers.schedulers.scheduling_utils import SchedulerMixin
32
+ from diffusers.utils import BaseOutput
33
+
34
+
35
+ @dataclass
36
+ class FlowBridgeSchedulerOutput(BaseOutput):
37
+ """Output of :meth:`FlowBridgeScheduler.step`.
38
+
39
+ Args:
40
+ prev_sample: The bridge state at the next time on the grid.
41
+ """
42
+
43
+ prev_sample: torch.Tensor
44
+
45
+
46
+ class FlowBridgeScheduler(SchedulerMixin, ConfigMixin):
47
+ """Explicit-Euler solver for ReFlowSET's Design-B flow bridge.
48
+
49
+ Args:
50
+ t_end: End time of the integration grid (1.0 — the EO endpoint). The
51
+ model is evaluated at ``linspace(0, t_end, nfe + 1)[:-1]`` and the
52
+ final Euler step lands on ``t_end``; the network is never queried at
53
+ ``t = t_end``.
54
+ """
55
+
56
+ order = 1
57
+
58
+ @register_to_config
59
+ def __init__(self, t_end: float = 1.0) -> None:
60
+ if not 0.0 < t_end <= 1.0:
61
+ raise ValueError(f"t_end must lie in (0, 1], got {t_end}")
62
+ self._grid: Optional[torch.Tensor] = None
63
+ self._step_index: Optional[int] = None
64
+ self.num_inference_steps: Optional[int] = None
65
+
66
+ @property
67
+ def timesteps(self) -> torch.Tensor:
68
+ """The ``nfe`` bridge times at which the model is evaluated, ascending."""
69
+ if self._grid is None:
70
+ raise ValueError("call set_timesteps() before reading timesteps")
71
+ return self._grid[:-1]
72
+
73
+ @property
74
+ def step_index(self) -> Optional[int]:
75
+ """Index of the next grid interval; ``None`` until the first :meth:`step`."""
76
+ return self._step_index
77
+
78
+ def set_timesteps(
79
+ self,
80
+ num_inference_steps: int,
81
+ device: Optional[Union[str, torch.device]] = None,
82
+ ) -> None:
83
+ """Build the uniform grid ``linspace(0, t_end, num_inference_steps + 1)``.
84
+
85
+ Args:
86
+ num_inference_steps: NFE — the number of velocity evaluations.
87
+ 50 reproduces the paper's main results; 4 is the efficiency
88
+ operating point.
89
+ device: Device the grid is built on.
90
+
91
+ There is no shift, no dynamic shifting, no Karras or exponential
92
+ spacing, and no timestep-spacing option: the reference solver uses a
93
+ plain uniform grid (bridge.py:519).
94
+ """
95
+ if num_inference_steps < 1:
96
+ raise ValueError(f"num_inference_steps must be >= 1, got {num_inference_steps}")
97
+ self.num_inference_steps = num_inference_steps
98
+ self._grid = torch.linspace(
99
+ 0.0, self.config.t_end, num_inference_steps + 1, device=device, dtype=torch.float32
100
+ )
101
+ self._step_index = 0
102
+
103
+ def step(
104
+ self,
105
+ model_output: torch.Tensor,
106
+ timestep: Union[float, torch.Tensor],
107
+ sample: torch.Tensor,
108
+ return_dict: bool = True,
109
+ ) -> Union[FlowBridgeSchedulerOutput, tuple[torch.Tensor]]:
110
+ """One explicit-Euler step: ``z + (t_next - t_cur) * v`` (bridge.py:536).
111
+
112
+ Args:
113
+ model_output: The predicted velocity ``dz/dt`` at ``timestep``,
114
+ already classifier-free-guided by the caller.
115
+ timestep: The current bridge time. Present for API compatibility and
116
+ checked against the grid; the step size comes from the grid.
117
+ sample: The current bridge state.
118
+ return_dict: Return a :class:`FlowBridgeSchedulerOutput` instead of a
119
+ tuple.
120
+
121
+ Steps must be taken in order, starting from the first entry of
122
+ :attr:`timesteps`.
123
+ """
124
+ if self._grid is None or self._step_index is None:
125
+ raise ValueError("call set_timesteps() before step()")
126
+ if self._step_index >= self.num_inference_steps:
127
+ raise ValueError(
128
+ f"already took {self.num_inference_steps} steps; call set_timesteps() again"
129
+ )
130
+ t_cur, t_next = self._grid[self._step_index], self._grid[self._step_index + 1]
131
+ if not torch.isclose(torch.as_tensor(timestep, dtype=torch.float32).to(t_cur.device), t_cur):
132
+ raise ValueError(
133
+ f"step {self._step_index} expects timestep {t_cur.item()}, got {float(timestep)}; "
134
+ "the flow bridge must be integrated in ascending grid order"
135
+ )
136
+
137
+ # The state is carried in float32 even if the model ran lower (bridge.py:515-517).
138
+ dtype = sample.dtype if sample.dtype in (torch.float32, torch.float64) else torch.float32
139
+ prev_sample = sample.to(dtype) + (t_next - t_cur) * model_output.to(dtype)
140
+ prev_sample = prev_sample.to(sample.dtype)
141
+
142
+ self._step_index += 1
143
+ if not return_dict:
144
+ return (prev_sample,)
145
+ return FlowBridgeSchedulerOutput(prev_sample=prev_sample)
146
+
147
+ def add_noise(
148
+ self,
149
+ original_samples: torch.Tensor,
150
+ noise: torch.Tensor,
151
+ timesteps: torch.Tensor,
152
+ ) -> torch.Tensor:
153
+ """The training-side bridge state ``z_t = (1 - t) * eps + t * z_e`` (bridge.py:311).
154
+
155
+ Args:
156
+ original_samples: The EO latent ``z_e`` (the ``t = 1`` endpoint).
157
+ noise: ``eps ~ N(0, I)`` (the ``t = 0`` endpoint).
158
+ timesteps: Bridge times in ``[0, 1]``, broadcastable over the batch.
159
+ """
160
+ t = timesteps.to(original_samples.device, original_samples.dtype)
161
+ t = t.view(-1, *([1] * (original_samples.ndim - 1)))
162
+ return (1.0 - t) * noise + t * original_samples
163
+
164
+ def get_velocity(
165
+ self,
166
+ sample: torch.Tensor,
167
+ noise: torch.Tensor,
168
+ timesteps: torch.Tensor,
169
+ ) -> torch.Tensor:
170
+ """The training target ``u* = z_e - eps`` (bridge.py:328 at ``sigma_b = 0``).
171
+
172
+ Constant along the path, hence independent of ``timesteps``; the argument
173
+ is kept for `diffusers` API compatibility.
174
+ """
175
+ del timesteps
176
+ return sample - noise
sar2opt/scheduler_flow_bridge.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET's Design-B flow bridge and its explicit-Euler solver.
2
+
3
+ Forward (training) process, with ``eps ~ N(0, I)`` and ``z_e`` the EO latent::
4
+
5
+ z_t = (1 - t) * eps + t * z_e (bridge.py:311, sigma_b = 0)
6
+ u* = z_e - eps (bridge.py:328 at sigma_b = 0)
7
+
8
+ Sampling starts from ``z_0 ~ N(0, I)`` and integrates the predicted velocity
9
+ with explicit Euler on a uniform grid ``linspace(0, t_end, nfe + 1)``
10
+ (bridge.py:519, 536). The bridge is deterministic: ``sigma_b = 0``, so no
11
+ stochastic term ever executes, and the only randomness in a sample is the
12
+ initial noise draw.
13
+
14
+ **Time direction.** ``t = 0`` is NOISE and ``t = 1`` is DATA, and the solver
15
+ integrates ``t`` **ascending** (bridge.py:86-88). That is the opposite of
16
+ `diffusers`' ``sigma`` convention: setting ``sigma := 1 - t`` recovers
17
+ ``FlowMatchEulerDiscreteScheduler``'s interpolation, but then this bridge's
18
+ velocity is the **negative** of the diffusers flow-matching target and the
19
+ network must still be fed ``1 - sigma``. This scheduler keeps ReFlowSET's own
20
+ sign and direction so neither flip is needed; ``timesteps`` therefore *increase*
21
+ from 0 towards 1, unlike every noise-schedule scheduler in `diffusers`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Optional, Union
28
+
29
+ import torch
30
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
31
+ from diffusers.schedulers.scheduling_utils import SchedulerMixin
32
+ from diffusers.utils import BaseOutput
33
+
34
+
35
+ @dataclass
36
+ class FlowBridgeSchedulerOutput(BaseOutput):
37
+ """Output of :meth:`FlowBridgeScheduler.step`.
38
+
39
+ Args:
40
+ prev_sample: The bridge state at the next time on the grid.
41
+ """
42
+
43
+ prev_sample: torch.Tensor
44
+
45
+
46
+ class FlowBridgeScheduler(SchedulerMixin, ConfigMixin):
47
+ """Explicit-Euler solver for ReFlowSET's Design-B flow bridge.
48
+
49
+ Args:
50
+ t_end: End time of the integration grid (1.0 — the EO endpoint). The
51
+ model is evaluated at ``linspace(0, t_end, nfe + 1)[:-1]`` and the
52
+ final Euler step lands on ``t_end``; the network is never queried at
53
+ ``t = t_end``.
54
+ """
55
+
56
+ order = 1
57
+
58
+ @register_to_config
59
+ def __init__(self, t_end: float = 1.0) -> None:
60
+ if not 0.0 < t_end <= 1.0:
61
+ raise ValueError(f"t_end must lie in (0, 1], got {t_end}")
62
+ self._grid: Optional[torch.Tensor] = None
63
+ self._step_index: Optional[int] = None
64
+ self.num_inference_steps: Optional[int] = None
65
+
66
+ @property
67
+ def timesteps(self) -> torch.Tensor:
68
+ """The ``nfe`` bridge times at which the model is evaluated, ascending."""
69
+ if self._grid is None:
70
+ raise ValueError("call set_timesteps() before reading timesteps")
71
+ return self._grid[:-1]
72
+
73
+ @property
74
+ def step_index(self) -> Optional[int]:
75
+ """Index of the next grid interval; ``None`` until the first :meth:`step`."""
76
+ return self._step_index
77
+
78
+ def set_timesteps(
79
+ self,
80
+ num_inference_steps: int,
81
+ device: Optional[Union[str, torch.device]] = None,
82
+ ) -> None:
83
+ """Build the uniform grid ``linspace(0, t_end, num_inference_steps + 1)``.
84
+
85
+ Args:
86
+ num_inference_steps: NFE — the number of velocity evaluations.
87
+ 50 reproduces the paper's main results; 4 is the efficiency
88
+ operating point.
89
+ device: Device the grid is built on.
90
+
91
+ There is no shift, no dynamic shifting, no Karras or exponential
92
+ spacing, and no timestep-spacing option: the reference solver uses a
93
+ plain uniform grid (bridge.py:519).
94
+ """
95
+ if num_inference_steps < 1:
96
+ raise ValueError(f"num_inference_steps must be >= 1, got {num_inference_steps}")
97
+ self.num_inference_steps = num_inference_steps
98
+ self._grid = torch.linspace(
99
+ 0.0, self.config.t_end, num_inference_steps + 1, device=device, dtype=torch.float32
100
+ )
101
+ self._step_index = 0
102
+
103
+ def step(
104
+ self,
105
+ model_output: torch.Tensor,
106
+ timestep: Union[float, torch.Tensor],
107
+ sample: torch.Tensor,
108
+ return_dict: bool = True,
109
+ ) -> Union[FlowBridgeSchedulerOutput, tuple[torch.Tensor]]:
110
+ """One explicit-Euler step: ``z + (t_next - t_cur) * v`` (bridge.py:536).
111
+
112
+ Args:
113
+ model_output: The predicted velocity ``dz/dt`` at ``timestep``,
114
+ already classifier-free-guided by the caller.
115
+ timestep: The current bridge time. Present for API compatibility and
116
+ checked against the grid; the step size comes from the grid.
117
+ sample: The current bridge state.
118
+ return_dict: Return a :class:`FlowBridgeSchedulerOutput` instead of a
119
+ tuple.
120
+
121
+ Steps must be taken in order, starting from the first entry of
122
+ :attr:`timesteps`.
123
+ """
124
+ if self._grid is None or self._step_index is None:
125
+ raise ValueError("call set_timesteps() before step()")
126
+ if self._step_index >= self.num_inference_steps:
127
+ raise ValueError(
128
+ f"already took {self.num_inference_steps} steps; call set_timesteps() again"
129
+ )
130
+ t_cur, t_next = self._grid[self._step_index], self._grid[self._step_index + 1]
131
+ if not torch.isclose(torch.as_tensor(timestep, dtype=torch.float32).to(t_cur.device), t_cur):
132
+ raise ValueError(
133
+ f"step {self._step_index} expects timestep {t_cur.item()}, got {float(timestep)}; "
134
+ "the flow bridge must be integrated in ascending grid order"
135
+ )
136
+
137
+ # The state is carried in float32 even if the model ran lower (bridge.py:515-517).
138
+ dtype = sample.dtype if sample.dtype in (torch.float32, torch.float64) else torch.float32
139
+ prev_sample = sample.to(dtype) + (t_next - t_cur) * model_output.to(dtype)
140
+ prev_sample = prev_sample.to(sample.dtype)
141
+
142
+ self._step_index += 1
143
+ if not return_dict:
144
+ return (prev_sample,)
145
+ return FlowBridgeSchedulerOutput(prev_sample=prev_sample)
146
+
147
+ def add_noise(
148
+ self,
149
+ original_samples: torch.Tensor,
150
+ noise: torch.Tensor,
151
+ timesteps: torch.Tensor,
152
+ ) -> torch.Tensor:
153
+ """The training-side bridge state ``z_t = (1 - t) * eps + t * z_e`` (bridge.py:311).
154
+
155
+ Args:
156
+ original_samples: The EO latent ``z_e`` (the ``t = 1`` endpoint).
157
+ noise: ``eps ~ N(0, I)`` (the ``t = 0`` endpoint).
158
+ timesteps: Bridge times in ``[0, 1]``, broadcastable over the batch.
159
+ """
160
+ t = timesteps.to(original_samples.device, original_samples.dtype)
161
+ t = t.view(-1, *([1] * (original_samples.ndim - 1)))
162
+ return (1.0 - t) * noise + t * original_samples
163
+
164
+ def get_velocity(
165
+ self,
166
+ sample: torch.Tensor,
167
+ noise: torch.Tensor,
168
+ timesteps: torch.Tensor,
169
+ ) -> torch.Tensor:
170
+ """The training target ``u* = z_e - eps`` (bridge.py:328 at ``sigma_b = 0``).
171
+
172
+ Constant along the path, hence independent of ``timesteps``; the argument
173
+ is kept for `diffusers` API compatibility.
174
+ """
175
+ del timesteps
176
+ return sample - noise
sar2opt/transformer/config.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "ReFlowSETTransformer2DModel",
3
+ "_diffusers_version": "0.37.1",
4
+ "axes_dim": [
5
+ 32,
6
+ 32
7
+ ],
8
+ "depth": 24,
9
+ "double_blocks": 8,
10
+ "double_merge": "token",
11
+ "hidden_size": 1024,
12
+ "in_channels": 128,
13
+ "mlp_ratio": 4.0,
14
+ "num_heads": 16,
15
+ "out_channels": 128,
16
+ "sample_size": 512,
17
+ "theta": 10000
18
+ }
sar2opt/transformer/diffusion_pytorch_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:14d5a1aaf41a32d2077c33f9aa19d356ac07611bb4d9d1f7c4ea61b9f6630481
3
+ size 2037319452
sar2opt/transformer/transformer_reflowset.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET velocity transformer — a latent DiT with an EO/SAR double stream.
2
+
3
+ The network predicts the flow-bridge velocity ``dz/dt`` in the frozen FLUX.2
4
+ latent space. It takes the noisy EO latent ``[B, 128, h, w]``, a scalar bridge
5
+ time ``t`` in ``[0, 1]``, and the SAR conditioning latent of the same shape; the
6
+ first 8 of its 24 blocks are double-stream (one EO tower and one SAR tower over
7
+ a single joint attention), the remaining 16 are single-stream over the
8
+ concatenated ``[EO | SAR]`` sequence, and only the EO half is decoded.
9
+
10
+ This is an inference-only port. The training-only REPA projection head
11
+ (``repa_proj``) is a separate module in the reference implementation and is
12
+ deliberately absent here.
13
+
14
+ Deviations from `diffusers`' FLUX blocks that this file has to keep — each one
15
+ is silent if you get it wrong:
16
+
17
+ * ``FinalLayer`` unpacks ``shift, scale`` (dit.py:360), the **opposite** order of
18
+ ``AdaLayerNormContinuous``.
19
+ * The single-stream MLP is **SwiGLU** of width 2752, not a 4x GELU of width 4096.
20
+ * ``linear1``/``linear2`` are **bias-free**, and the QK-norm parameter is called
21
+ ``scale``, not ``weight``.
22
+ * The timestep is multiplied by 1000 *inside* the model and the sinusoid is
23
+ **cos first, then sin**.
24
+ * RoPE runs on **two** axes of **centred half-integer** coordinates, not on
25
+ FLUX's three axes of integers starting at 0.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Optional, Union
32
+
33
+ import torch
34
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
35
+ from diffusers.models.modeling_outputs import Transformer2DModelOutput
36
+ from diffusers.models.modeling_utils import ModelMixin
37
+ from torch import Tensor, nn
38
+ from torch.nn import functional as F
39
+
40
+ #: Width of the sinusoidal timestep embedding fed to ``time_in`` (dit.py:44).
41
+ #: A module constant, deliberately independent of ``hidden_size``.
42
+ TIME_EMBED_DIM = 256
43
+
44
+
45
+ def swiglu_hidden_dim(hidden_size: int, mlp_ratio: float) -> int:
46
+ """SwiGLU intermediate width (dit.py:120-127).
47
+
48
+ The canonical 2/3 rule rounded to a multiple of 64, so a gated MLP at
49
+ ``mlp_ratio=4.0`` costs the same parameters as a plain 4x GELU MLP.
50
+ ``hidden_size=1024, mlp_ratio=4.0 -> 2752``.
51
+ """
52
+ return int(round(hidden_size * mlp_ratio * 2 / 3 / 64)) * 64
53
+
54
+
55
+ class SwiGLU(nn.Module):
56
+ """``silu(first half) * second half`` — gate first, value second (dit.py:130-133)."""
57
+
58
+ def forward(self, x: Tensor) -> Tensor:
59
+ x1, x2 = x.chunk(2, dim=-1)
60
+ return F.silu(x1) * x2
61
+
62
+
63
+ class RMSNorm(nn.Module):
64
+ """RMS norm computed in float32. The parameter is named ``scale`` (dit.py:136-145)."""
65
+
66
+ def __init__(self, dim: int) -> None:
67
+ super().__init__()
68
+ self.scale = nn.Parameter(torch.ones(dim))
69
+
70
+ def forward(self, x: Tensor) -> Tensor:
71
+ x_dtype = x.dtype
72
+ x = x.float()
73
+ rrms = torch.rsqrt(torch.mean(x**2, dim=-1, keepdim=True) + 1e-6)
74
+ return (x * rrms).to(dtype=x_dtype) * self.scale
75
+
76
+
77
+ class QKNorm(nn.Module):
78
+ """Per-head query/key RMS norm, applied **before** RoPE (dit.py:148-155)."""
79
+
80
+ def __init__(self, dim: int) -> None:
81
+ super().__init__()
82
+ self.query_norm = RMSNorm(dim)
83
+ self.key_norm = RMSNorm(dim)
84
+
85
+ def forward(self, q: Tensor, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
86
+ return self.query_norm(q).to(v), self.key_norm(k).to(v)
87
+
88
+
89
+ class MLPEmbedder(nn.Module):
90
+ """``Linear -> SiLU -> Linear`` time-embedding MLP (dit.py:158-166).
91
+
92
+ Checkpoint keys are ``time_in.in_layer.*`` / ``time_in.out_layer.*``, not
93
+ diffusers' ``time_text_embed.timestep_embedder.linear_{1,2}``.
94
+ """
95
+
96
+ def __init__(self, in_dim: int, hidden_dim: int) -> None:
97
+ super().__init__()
98
+ self.in_layer = nn.Linear(in_dim, hidden_dim, bias=True)
99
+ self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=True)
100
+ self.silu = nn.SiLU()
101
+
102
+ def forward(self, x: Tensor) -> Tensor:
103
+ return self.out_layer(self.silu(self.in_layer(x)))
104
+
105
+
106
+ class Modulation(nn.Module):
107
+ """AdaLN-Zero triple. Order is ``shift, scale, gate`` (dit.py:169-181)."""
108
+
109
+ def __init__(self, dim: int) -> None:
110
+ super().__init__()
111
+ self.lin = nn.Linear(dim, 3 * dim, bias=True)
112
+
113
+ def forward(self, vec: Tensor) -> tuple[Tensor, Tensor, Tensor]:
114
+ out = self.lin(F.silu(vec))
115
+ if out.ndim == 2:
116
+ out = out[:, None, :]
117
+ shift, scale, gate = out.chunk(3, dim=-1)
118
+ return shift, scale, gate
119
+
120
+
121
+ def timestep_embedding(
122
+ t: Tensor, dim: int, max_period: int = 10000, time_factor: float = 1000.0
123
+ ) -> Tensor:
124
+ """Sinusoidal embedding of a fractional bridge time (dit.py:184-201).
125
+
126
+ Two things differ from `diffusers`' ``get_timestep_embedding`` defaults:
127
+ ``t`` is a fraction in ``[0, 1]`` that is scaled by ``time_factor = 1000``
128
+ **here**, and the concatenation order is ``[cos, sin]`` (FLUX's ordering,
129
+ i.e. ``flip_sin_to_cos=True``).
130
+ """
131
+ t = time_factor * t
132
+ half = dim // 2
133
+ freqs = torch.exp(
134
+ -math.log(max_period)
135
+ * torch.arange(start=0, end=half, device=t.device, dtype=torch.float32)
136
+ / half
137
+ )
138
+ args = t[:, None].float() * freqs[None]
139
+ embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
140
+ if dim % 2:
141
+ embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
142
+ if torch.is_floating_point(t):
143
+ embedding = embedding.to(t)
144
+ return embedding
145
+
146
+
147
+ def rope(pos: Tensor, dim: int, theta: int) -> Tensor:
148
+ """Per-axis rotation matrices ``[..., L, dim/2, 2, 2]`` (dit.py:204-211)."""
149
+ if dim % 2:
150
+ raise ValueError(f"RoPE axis dim must be even, got {dim}")
151
+ scale = torch.arange(0, dim, 2, dtype=pos.dtype, device=pos.device) / dim
152
+ omega = 1.0 / (theta**scale)
153
+ out = torch.einsum("...n,d->...nd", pos, omega)
154
+ out = torch.stack([torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1)
155
+ return out.reshape(*out.shape[:-1], 2, 2).float()
156
+
157
+
158
+ def apply_rope(xq: Tensor, xk: Tensor, freqs_cis: Tensor) -> tuple[Tensor, Tensor]:
159
+ """Rotate consecutive dimension pairs — the interleaved (FLUX) convention.
160
+
161
+ ``(x0, x1) -> (cos*x0 - sin*x1, sin*x0 + cos*x1)`` on ``(x[2k], x[2k+1])``
162
+ (dit.py:214-219). Equivalent to diffusers' ``apply_rotary_emb(...,
163
+ use_real_unbind_dim=-1)``; ``-2`` is the split-halves convention and is wrong
164
+ for these weights.
165
+ """
166
+ xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2)
167
+ xk_ = xk.float().reshape(*xk.shape[:-1], -1, 1, 2)
168
+ xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1]
169
+ xk_out = freqs_cis[..., 0] * xk_[..., 0] + freqs_cis[..., 1] * xk_[..., 1]
170
+ return xq_out.reshape(*xq.shape).type_as(xq), xk_out.reshape(*xk.shape).type_as(xk)
171
+
172
+
173
+ class EmbedND(nn.Module):
174
+ """Concatenates the per-axis RoPE ladders and inserts the head axis (dit.py:222-234).
175
+
176
+ Holds no parameters and no buffers: the grid is rebuilt on every forward,
177
+ which is what lets one checkpoint serve 256 and 512 inputs.
178
+ """
179
+
180
+ def __init__(self, theta: int, axes_dim: list[int]) -> None:
181
+ super().__init__()
182
+ self.theta = theta
183
+ self.axes_dim = axes_dim
184
+
185
+ def forward(self, ids: Tensor) -> Tensor:
186
+ emb = torch.cat(
187
+ [rope(ids[..., i], self.axes_dim[i], self.theta) for i in range(len(self.axes_dim))],
188
+ dim=-3,
189
+ )
190
+ return emb.unsqueeze(1)
191
+
192
+
193
+ def latent_image_ids(h: int, w: int, device, dtype=torch.float32) -> Tensor:
194
+ """Centred ``(y, x)`` coordinates for an ``h x w`` latent grid, ``[h*w, 2]``.
195
+
196
+ ``arange(n) - (n - 1) / 2`` with unit spacing (dit.py:237-252), so for even
197
+ ``n`` the coordinates are half-integers and the central 16x16 region of a
198
+ 32x32 grid carries exactly the coordinates a 256-trained model saw — RoPE
199
+ only extrapolates outwards, it never rescales. Row-major, so token
200
+ ``p = y * w + x``. This is **not** FLUX's 3-axis integer id grid.
201
+ """
202
+ y = torch.arange(h, device=device, dtype=dtype) - (h - 1) / 2
203
+ x = torch.arange(w, device=device, dtype=dtype) - (w - 1) / 2
204
+ ids = torch.zeros(h, w, 2, device=device, dtype=dtype)
205
+ ids[..., 0] = y[:, None]
206
+ ids[..., 1] = x[None, :]
207
+ return ids.reshape(h * w, 2)
208
+
209
+
210
+ class SingleStreamBlock(nn.Module):
211
+ """Fused attention + SwiGLU MLP under one modulation and one residual.
212
+
213
+ ``linear1`` emits ``[q | k | v | mlp_gate | mlp_value]`` in that order; the
214
+ qkv slab is K-major (``(K H D)``). Both linears are bias-free
215
+ (dit.py:255-300).
216
+ """
217
+
218
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
219
+ super().__init__()
220
+ self.hidden_size = hidden_size
221
+ self.num_heads = num_heads
222
+ head_dim = hidden_size // num_heads
223
+ self.mlp_hidden_dim = swiglu_hidden_dim(hidden_size, mlp_ratio)
224
+
225
+ self.linear1 = nn.Linear(hidden_size, 3 * hidden_size + 2 * self.mlp_hidden_dim, bias=False)
226
+ self.linear2 = nn.Linear(hidden_size + self.mlp_hidden_dim, hidden_size, bias=False)
227
+ self.norm = QKNorm(head_dim)
228
+ self.pre_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
229
+ self.mlp_act = SwiGLU()
230
+ self.modulation = Modulation(hidden_size)
231
+
232
+ def pre_attention(self, x: Tensor, vec: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
233
+ """Everything up to (not including) RoPE and attention (dit.py:272-288)."""
234
+ shift, scale, gate = self.modulation(vec)
235
+ x_mod = (1 + scale) * self.pre_norm(x) + shift
236
+
237
+ qkv, mlp = torch.split(
238
+ self.linear1(x_mod), [3 * self.hidden_size, 2 * self.mlp_hidden_dim], dim=-1
239
+ )
240
+ b, length, _ = qkv.shape
241
+ # "B L (K H D) -> K B H L D" with K=3, H=num_heads.
242
+ q, k, v = qkv.reshape(b, length, 3, self.num_heads, -1).permute(2, 0, 3, 1, 4)
243
+ q, k = self.norm(q, k, v)
244
+ return q, k, v, mlp, gate
245
+
246
+ def post_attention(self, x: Tensor, attn: Tensor, mlp: Tensor, gate: Tensor) -> Tensor:
247
+ """Output projection and the single gated residual (dit.py:290-294)."""
248
+ b, heads, length, head_dim = attn.shape
249
+ attn = attn.transpose(1, 2).reshape(b, length, heads * head_dim)
250
+ out = self.linear2(torch.cat((attn, self.mlp_act(mlp)), dim=-1))
251
+ return x + gate * out
252
+
253
+ def forward(self, x: Tensor, vec: Tensor, pe: Tensor) -> Tensor:
254
+ q, k, v, mlp, gate = self.pre_attention(x, vec)
255
+ q, k = apply_rope(q, k, pe)
256
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
257
+ return self.post_attention(x, attn, mlp, gate)
258
+
259
+
260
+ class DoubleStreamBlock(nn.Module):
261
+ """Two independent towers over **one** joint attention across ``[EO | SAR]``.
262
+
263
+ The towers have completely separate weights but share the modulation vector
264
+ ``vec`` and the RoPE grid, so an EO token and the SAR token at the same
265
+ ground position carry an identical phase (dit.py:303-343). SAR plays the
266
+ structural role text plays in FLUX.
267
+ """
268
+
269
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
270
+ super().__init__()
271
+ self.eo = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
272
+ self.sar = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
273
+
274
+ def forward(self, eo: Tensor, sar: Tensor, vec: Tensor, pe: Tensor) -> tuple[Tensor, Tensor]:
275
+ """``pe`` must already cover the joint 2P-token sequence."""
276
+ q_e, k_e, v_e, mlp_e, gate_e = self.eo.pre_attention(eo, vec)
277
+ q_s, k_s, v_s, mlp_s, gate_s = self.sar.pre_attention(sar, vec)
278
+
279
+ q = torch.cat((q_e, q_s), dim=2)
280
+ k = torch.cat((k_e, k_s), dim=2)
281
+ v = torch.cat((v_e, v_s), dim=2)
282
+ q, k = apply_rope(q, k, pe)
283
+
284
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
285
+ attn_e, attn_s = attn.split([q_e.shape[2], q_s.shape[2]], dim=2)
286
+ return (
287
+ self.eo.post_attention(eo, attn_e, mlp_e, gate_e),
288
+ self.sar.post_attention(sar, attn_s, mlp_s, gate_s),
289
+ )
290
+
291
+
292
+ class FinalLayer(nn.Module):
293
+ """AdaLN output layer.
294
+
295
+ ``adaLN`` unpacks ``shift, scale`` — the **opposite** order of diffusers'
296
+ ``AdaLayerNormContinuous`` (dit.py:346-362). ``logvar_proj`` belongs to a
297
+ beta-NLL loss that was never enabled (``loss.flow = mse``); its weights are
298
+ kept so the published checkpoint loads with ``strict=True``, but inference
299
+ never evaluates it — the sampler reads only the velocity (bridge.py:531).
300
+ """
301
+
302
+ def __init__(self, hidden_size: int, out_channels: int) -> None:
303
+ super().__init__()
304
+ self.norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
305
+ self.adaLN = nn.Linear(hidden_size, 2 * hidden_size, bias=True)
306
+ self.proj = nn.Linear(hidden_size, out_channels, bias=True)
307
+ self.logvar_proj = nn.Linear(hidden_size, 1, bias=True)
308
+
309
+ def forward(self, x: Tensor, vec: Tensor) -> Tensor:
310
+ mod = self.adaLN(F.silu(vec))
311
+ if mod.ndim == 2:
312
+ mod = mod[:, None, :]
313
+ shift, scale = mod.chunk(2, dim=-1)
314
+ return self.proj((1 + scale) * self.norm(x) + shift)
315
+
316
+
317
+ class ReFlowSETTransformer2DModel(ModelMixin, ConfigMixin):
318
+ """ReFlowSET's flow-velocity transformer (509.32 M parameters as configured).
319
+
320
+ Args:
321
+ in_channels: Channels of the packed FLUX.2 latent (128).
322
+ out_channels: Channels of the predicted velocity (128).
323
+ hidden_size: Residual width (1024).
324
+ depth: **Total** blocks, double plus single (24).
325
+ num_heads: Attention heads (16), so ``head_dim = 64``.
326
+ mlp_ratio: Nominal MLP ratio; the SwiGLU width is derived from it.
327
+ axes_dim: RoPE dims for the ``(y, x)`` axes; must sum to ``head_dim``.
328
+ theta: RoPE base period (10000).
329
+ sample_size: Input image resolution the released arm was trained at
330
+ (256 for QXS-SAROPT, 512 for SAR2Opt). Recorded for provenance
331
+ only: the forward pass derives every shape from its input and the
332
+ RoPE grid is rebuilt per call, so one checkpoint serves any size
333
+ divisible by 16.
334
+ double_blocks: Leading double-stream blocks (8); the remaining
335
+ ``depth - double_blocks`` are single-stream.
336
+ double_merge: How the two streams become one. ``"token"`` (the released
337
+ setting) concatenates on the sequence axis, so the single stack runs
338
+ over 2P tokens and the SAR half is dropped only at the very end;
339
+ ``"channel"`` fuses per position and keeps P tokens.
340
+
341
+ Forward contract: ``forward(hidden_states, timestep, condition)`` where
342
+ ``hidden_states`` is the bridge state ``[B, 128, h, w]``, ``timestep`` is the
343
+ bridge time in ``[0, 1]`` (**not** an integer diffusion step), and
344
+ ``condition`` is the SAR latent of the same shape or ``None``. ``None`` is
345
+ the classifier-free-guidance null branch and is turned into an all-zero
346
+ latent inside the model — there is no learned null token.
347
+ """
348
+
349
+ _supports_gradient_checkpointing = False
350
+
351
+ @register_to_config
352
+ def __init__(
353
+ self,
354
+ in_channels: int = 128,
355
+ out_channels: int = 128,
356
+ hidden_size: int = 1024,
357
+ depth: int = 24,
358
+ num_heads: int = 16,
359
+ mlp_ratio: float = 4.0,
360
+ axes_dim: tuple[int, ...] = (32, 32),
361
+ theta: int = 10000,
362
+ sample_size: Optional[int] = None,
363
+ double_blocks: int = 8,
364
+ double_merge: str = "token",
365
+ ) -> None:
366
+ super().__init__()
367
+ if hidden_size % num_heads != 0:
368
+ raise ValueError(f"hidden_size {hidden_size} must be divisible by num_heads {num_heads}")
369
+ pe_dim = hidden_size // num_heads
370
+ if sum(axes_dim) != pe_dim:
371
+ raise ValueError(f"axes_dim {list(axes_dim)} must sum to the per-head dim {pe_dim}")
372
+ if not 0 <= double_blocks < depth:
373
+ raise ValueError(f"double_blocks {double_blocks} must be in [0, depth={depth})")
374
+ if double_merge not in ("token", "channel"):
375
+ raise ValueError(f"double_merge must be 'token' or 'channel', got {double_merge!r}")
376
+
377
+ self.pe_embedder = EmbedND(theta=theta, axes_dim=list(axes_dim))
378
+ if double_blocks:
379
+ # Each stream gets its own 1x1 "patchify": they are two token
380
+ # sequences now, not two halves of one channel stack.
381
+ self.in_proj_eo = nn.Linear(in_channels, hidden_size, bias=True)
382
+ self.in_proj_sar = nn.Linear(in_channels, hidden_size, bias=True)
383
+ if double_merge == "channel":
384
+ self.merge = nn.Linear(2 * hidden_size, hidden_size, bias=True)
385
+ else:
386
+ self.in_proj = nn.Linear(2 * in_channels, hidden_size, bias=True)
387
+ self.time_in = MLPEmbedder(TIME_EMBED_DIM, hidden_size)
388
+ self.double_stream = nn.ModuleList(
389
+ [DoubleStreamBlock(hidden_size, num_heads, mlp_ratio) for _ in range(double_blocks)]
390
+ )
391
+ self.blocks = nn.ModuleList(
392
+ [
393
+ SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
394
+ for _ in range(depth - double_blocks)
395
+ ]
396
+ )
397
+ self.final_layer = FinalLayer(hidden_size, out_channels)
398
+
399
+ def forward(
400
+ self,
401
+ hidden_states: Tensor,
402
+ timestep: Tensor,
403
+ condition: Optional[Tensor] = None,
404
+ return_dict: bool = True,
405
+ ) -> Union[Transformer2DModelOutput, tuple[Tensor]]:
406
+ """Predict the flow velocity ``dz/dt``.
407
+
408
+ Args:
409
+ hidden_states: ``[B, in_channels, h, w]`` bridge state.
410
+ timestep: Bridge time in ``[0, 1]``; a scalar or ``[B]``.
411
+ condition: ``[B, in_channels, h, w]`` SAR latent, or ``None`` for the
412
+ null branch (an all-zero conditioning latent, dit.py:531-532).
413
+ return_dict: Return a ``Transformer2DModelOutput`` instead of a tuple.
414
+
415
+ Returns:
416
+ The velocity ``[B, out_channels, h, w]``. This is a flow velocity,
417
+ not ``epsilon`` and not diffusers' ``v_prediction``.
418
+ """
419
+ if hidden_states.ndim != 4:
420
+ raise ValueError(f"hidden_states must be [B, C, h, w], got {tuple(hidden_states.shape)}")
421
+ batch, _, h, w = hidden_states.shape
422
+ if condition is None:
423
+ condition = torch.zeros_like(hidden_states)
424
+ elif condition.shape != hidden_states.shape:
425
+ raise ValueError(
426
+ f"condition shape {tuple(condition.shape)} must match "
427
+ f"hidden_states shape {tuple(hidden_states.shape)}"
428
+ )
429
+ if timestep.ndim == 0:
430
+ timestep = timestep.expand(batch)
431
+
432
+ n_double = self.config.double_blocks
433
+ if n_double:
434
+ eo = self.in_proj_eo(hidden_states.flatten(2).transpose(1, 2)) # [B, P, D]
435
+ sar = self.in_proj_sar(condition.flatten(2).transpose(1, 2)) # [B, P, D]
436
+ ref = eo
437
+ else:
438
+ x = torch.cat([hidden_states, condition], dim=1).flatten(2).transpose(1, 2)
439
+ x = self.in_proj(x)
440
+ ref = x
441
+
442
+ vec = self.time_in(timestep_embedding(timestep, TIME_EMBED_DIM).to(ref.dtype))
443
+
444
+ ids = latent_image_ids(h, w, device=hidden_states.device, dtype=torch.float32)
445
+ pe = self.pe_embedder(ids[None].expand(batch, -1, -1))
446
+
447
+ num_tokens = ref.shape[1]
448
+ pe_single = pe
449
+ if n_double:
450
+ # Token axis of pe is dim 2 ([B, 1, L, head_dim/2, 2, 2]); repeating
451
+ # the same P coordinates gives EO and SAR one shared grid.
452
+ pe_joint = torch.cat((pe, pe), dim=2)
453
+ for block in self.double_stream:
454
+ eo, sar = block(eo, sar, vec, pe_joint)
455
+ if self.config.double_merge == "token":
456
+ x = torch.cat((eo, sar), dim=1) # [B, 2P, D]
457
+ pe_single = pe_joint
458
+ else:
459
+ x = self.merge(torch.cat((eo, sar), dim=-1)) # [B, P, D]
460
+
461
+ for block in self.blocks:
462
+ x = block(x, vec, pe_single)
463
+
464
+ if n_double and self.config.double_merge == "token":
465
+ x = x[:, :num_tokens] # drop the SAR half: only EO is decoded
466
+
467
+ v = self.final_layer(x, vec)
468
+ v = v.transpose(1, 2).reshape(batch, self.config.out_channels, h, w)
469
+ if not return_dict:
470
+ return (v,)
471
+ return Transformer2DModelOutput(sample=v)
sar2opt/transformer_reflowset.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET velocity transformer — a latent DiT with an EO/SAR double stream.
2
+
3
+ The network predicts the flow-bridge velocity ``dz/dt`` in the frozen FLUX.2
4
+ latent space. It takes the noisy EO latent ``[B, 128, h, w]``, a scalar bridge
5
+ time ``t`` in ``[0, 1]``, and the SAR conditioning latent of the same shape; the
6
+ first 8 of its 24 blocks are double-stream (one EO tower and one SAR tower over
7
+ a single joint attention), the remaining 16 are single-stream over the
8
+ concatenated ``[EO | SAR]`` sequence, and only the EO half is decoded.
9
+
10
+ This is an inference-only port. The training-only REPA projection head
11
+ (``repa_proj``) is a separate module in the reference implementation and is
12
+ deliberately absent here.
13
+
14
+ Deviations from `diffusers`' FLUX blocks that this file has to keep — each one
15
+ is silent if you get it wrong:
16
+
17
+ * ``FinalLayer`` unpacks ``shift, scale`` (dit.py:360), the **opposite** order of
18
+ ``AdaLayerNormContinuous``.
19
+ * The single-stream MLP is **SwiGLU** of width 2752, not a 4x GELU of width 4096.
20
+ * ``linear1``/``linear2`` are **bias-free**, and the QK-norm parameter is called
21
+ ``scale``, not ``weight``.
22
+ * The timestep is multiplied by 1000 *inside* the model and the sinusoid is
23
+ **cos first, then sin**.
24
+ * RoPE runs on **two** axes of **centred half-integer** coordinates, not on
25
+ FLUX's three axes of integers starting at 0.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Optional, Union
32
+
33
+ import torch
34
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
35
+ from diffusers.models.modeling_outputs import Transformer2DModelOutput
36
+ from diffusers.models.modeling_utils import ModelMixin
37
+ from torch import Tensor, nn
38
+ from torch.nn import functional as F
39
+
40
+ #: Width of the sinusoidal timestep embedding fed to ``time_in`` (dit.py:44).
41
+ #: A module constant, deliberately independent of ``hidden_size``.
42
+ TIME_EMBED_DIM = 256
43
+
44
+
45
+ def swiglu_hidden_dim(hidden_size: int, mlp_ratio: float) -> int:
46
+ """SwiGLU intermediate width (dit.py:120-127).
47
+
48
+ The canonical 2/3 rule rounded to a multiple of 64, so a gated MLP at
49
+ ``mlp_ratio=4.0`` costs the same parameters as a plain 4x GELU MLP.
50
+ ``hidden_size=1024, mlp_ratio=4.0 -> 2752``.
51
+ """
52
+ return int(round(hidden_size * mlp_ratio * 2 / 3 / 64)) * 64
53
+
54
+
55
+ class SwiGLU(nn.Module):
56
+ """``silu(first half) * second half`` — gate first, value second (dit.py:130-133)."""
57
+
58
+ def forward(self, x: Tensor) -> Tensor:
59
+ x1, x2 = x.chunk(2, dim=-1)
60
+ return F.silu(x1) * x2
61
+
62
+
63
+ class RMSNorm(nn.Module):
64
+ """RMS norm computed in float32. The parameter is named ``scale`` (dit.py:136-145)."""
65
+
66
+ def __init__(self, dim: int) -> None:
67
+ super().__init__()
68
+ self.scale = nn.Parameter(torch.ones(dim))
69
+
70
+ def forward(self, x: Tensor) -> Tensor:
71
+ x_dtype = x.dtype
72
+ x = x.float()
73
+ rrms = torch.rsqrt(torch.mean(x**2, dim=-1, keepdim=True) + 1e-6)
74
+ return (x * rrms).to(dtype=x_dtype) * self.scale
75
+
76
+
77
+ class QKNorm(nn.Module):
78
+ """Per-head query/key RMS norm, applied **before** RoPE (dit.py:148-155)."""
79
+
80
+ def __init__(self, dim: int) -> None:
81
+ super().__init__()
82
+ self.query_norm = RMSNorm(dim)
83
+ self.key_norm = RMSNorm(dim)
84
+
85
+ def forward(self, q: Tensor, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
86
+ return self.query_norm(q).to(v), self.key_norm(k).to(v)
87
+
88
+
89
+ class MLPEmbedder(nn.Module):
90
+ """``Linear -> SiLU -> Linear`` time-embedding MLP (dit.py:158-166).
91
+
92
+ Checkpoint keys are ``time_in.in_layer.*`` / ``time_in.out_layer.*``, not
93
+ diffusers' ``time_text_embed.timestep_embedder.linear_{1,2}``.
94
+ """
95
+
96
+ def __init__(self, in_dim: int, hidden_dim: int) -> None:
97
+ super().__init__()
98
+ self.in_layer = nn.Linear(in_dim, hidden_dim, bias=True)
99
+ self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=True)
100
+ self.silu = nn.SiLU()
101
+
102
+ def forward(self, x: Tensor) -> Tensor:
103
+ return self.out_layer(self.silu(self.in_layer(x)))
104
+
105
+
106
+ class Modulation(nn.Module):
107
+ """AdaLN-Zero triple. Order is ``shift, scale, gate`` (dit.py:169-181)."""
108
+
109
+ def __init__(self, dim: int) -> None:
110
+ super().__init__()
111
+ self.lin = nn.Linear(dim, 3 * dim, bias=True)
112
+
113
+ def forward(self, vec: Tensor) -> tuple[Tensor, Tensor, Tensor]:
114
+ out = self.lin(F.silu(vec))
115
+ if out.ndim == 2:
116
+ out = out[:, None, :]
117
+ shift, scale, gate = out.chunk(3, dim=-1)
118
+ return shift, scale, gate
119
+
120
+
121
+ def timestep_embedding(
122
+ t: Tensor, dim: int, max_period: int = 10000, time_factor: float = 1000.0
123
+ ) -> Tensor:
124
+ """Sinusoidal embedding of a fractional bridge time (dit.py:184-201).
125
+
126
+ Two things differ from `diffusers`' ``get_timestep_embedding`` defaults:
127
+ ``t`` is a fraction in ``[0, 1]`` that is scaled by ``time_factor = 1000``
128
+ **here**, and the concatenation order is ``[cos, sin]`` (FLUX's ordering,
129
+ i.e. ``flip_sin_to_cos=True``).
130
+ """
131
+ t = time_factor * t
132
+ half = dim // 2
133
+ freqs = torch.exp(
134
+ -math.log(max_period)
135
+ * torch.arange(start=0, end=half, device=t.device, dtype=torch.float32)
136
+ / half
137
+ )
138
+ args = t[:, None].float() * freqs[None]
139
+ embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
140
+ if dim % 2:
141
+ embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
142
+ if torch.is_floating_point(t):
143
+ embedding = embedding.to(t)
144
+ return embedding
145
+
146
+
147
+ def rope(pos: Tensor, dim: int, theta: int) -> Tensor:
148
+ """Per-axis rotation matrices ``[..., L, dim/2, 2, 2]`` (dit.py:204-211)."""
149
+ if dim % 2:
150
+ raise ValueError(f"RoPE axis dim must be even, got {dim}")
151
+ scale = torch.arange(0, dim, 2, dtype=pos.dtype, device=pos.device) / dim
152
+ omega = 1.0 / (theta**scale)
153
+ out = torch.einsum("...n,d->...nd", pos, omega)
154
+ out = torch.stack([torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1)
155
+ return out.reshape(*out.shape[:-1], 2, 2).float()
156
+
157
+
158
+ def apply_rope(xq: Tensor, xk: Tensor, freqs_cis: Tensor) -> tuple[Tensor, Tensor]:
159
+ """Rotate consecutive dimension pairs — the interleaved (FLUX) convention.
160
+
161
+ ``(x0, x1) -> (cos*x0 - sin*x1, sin*x0 + cos*x1)`` on ``(x[2k], x[2k+1])``
162
+ (dit.py:214-219). Equivalent to diffusers' ``apply_rotary_emb(...,
163
+ use_real_unbind_dim=-1)``; ``-2`` is the split-halves convention and is wrong
164
+ for these weights.
165
+ """
166
+ xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2)
167
+ xk_ = xk.float().reshape(*xk.shape[:-1], -1, 1, 2)
168
+ xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1]
169
+ xk_out = freqs_cis[..., 0] * xk_[..., 0] + freqs_cis[..., 1] * xk_[..., 1]
170
+ return xq_out.reshape(*xq.shape).type_as(xq), xk_out.reshape(*xk.shape).type_as(xk)
171
+
172
+
173
+ class EmbedND(nn.Module):
174
+ """Concatenates the per-axis RoPE ladders and inserts the head axis (dit.py:222-234).
175
+
176
+ Holds no parameters and no buffers: the grid is rebuilt on every forward,
177
+ which is what lets one checkpoint serve 256 and 512 inputs.
178
+ """
179
+
180
+ def __init__(self, theta: int, axes_dim: list[int]) -> None:
181
+ super().__init__()
182
+ self.theta = theta
183
+ self.axes_dim = axes_dim
184
+
185
+ def forward(self, ids: Tensor) -> Tensor:
186
+ emb = torch.cat(
187
+ [rope(ids[..., i], self.axes_dim[i], self.theta) for i in range(len(self.axes_dim))],
188
+ dim=-3,
189
+ )
190
+ return emb.unsqueeze(1)
191
+
192
+
193
+ def latent_image_ids(h: int, w: int, device, dtype=torch.float32) -> Tensor:
194
+ """Centred ``(y, x)`` coordinates for an ``h x w`` latent grid, ``[h*w, 2]``.
195
+
196
+ ``arange(n) - (n - 1) / 2`` with unit spacing (dit.py:237-252), so for even
197
+ ``n`` the coordinates are half-integers and the central 16x16 region of a
198
+ 32x32 grid carries exactly the coordinates a 256-trained model saw — RoPE
199
+ only extrapolates outwards, it never rescales. Row-major, so token
200
+ ``p = y * w + x``. This is **not** FLUX's 3-axis integer id grid.
201
+ """
202
+ y = torch.arange(h, device=device, dtype=dtype) - (h - 1) / 2
203
+ x = torch.arange(w, device=device, dtype=dtype) - (w - 1) / 2
204
+ ids = torch.zeros(h, w, 2, device=device, dtype=dtype)
205
+ ids[..., 0] = y[:, None]
206
+ ids[..., 1] = x[None, :]
207
+ return ids.reshape(h * w, 2)
208
+
209
+
210
+ class SingleStreamBlock(nn.Module):
211
+ """Fused attention + SwiGLU MLP under one modulation and one residual.
212
+
213
+ ``linear1`` emits ``[q | k | v | mlp_gate | mlp_value]`` in that order; the
214
+ qkv slab is K-major (``(K H D)``). Both linears are bias-free
215
+ (dit.py:255-300).
216
+ """
217
+
218
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
219
+ super().__init__()
220
+ self.hidden_size = hidden_size
221
+ self.num_heads = num_heads
222
+ head_dim = hidden_size // num_heads
223
+ self.mlp_hidden_dim = swiglu_hidden_dim(hidden_size, mlp_ratio)
224
+
225
+ self.linear1 = nn.Linear(hidden_size, 3 * hidden_size + 2 * self.mlp_hidden_dim, bias=False)
226
+ self.linear2 = nn.Linear(hidden_size + self.mlp_hidden_dim, hidden_size, bias=False)
227
+ self.norm = QKNorm(head_dim)
228
+ self.pre_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
229
+ self.mlp_act = SwiGLU()
230
+ self.modulation = Modulation(hidden_size)
231
+
232
+ def pre_attention(self, x: Tensor, vec: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
233
+ """Everything up to (not including) RoPE and attention (dit.py:272-288)."""
234
+ shift, scale, gate = self.modulation(vec)
235
+ x_mod = (1 + scale) * self.pre_norm(x) + shift
236
+
237
+ qkv, mlp = torch.split(
238
+ self.linear1(x_mod), [3 * self.hidden_size, 2 * self.mlp_hidden_dim], dim=-1
239
+ )
240
+ b, length, _ = qkv.shape
241
+ # "B L (K H D) -> K B H L D" with K=3, H=num_heads.
242
+ q, k, v = qkv.reshape(b, length, 3, self.num_heads, -1).permute(2, 0, 3, 1, 4)
243
+ q, k = self.norm(q, k, v)
244
+ return q, k, v, mlp, gate
245
+
246
+ def post_attention(self, x: Tensor, attn: Tensor, mlp: Tensor, gate: Tensor) -> Tensor:
247
+ """Output projection and the single gated residual (dit.py:290-294)."""
248
+ b, heads, length, head_dim = attn.shape
249
+ attn = attn.transpose(1, 2).reshape(b, length, heads * head_dim)
250
+ out = self.linear2(torch.cat((attn, self.mlp_act(mlp)), dim=-1))
251
+ return x + gate * out
252
+
253
+ def forward(self, x: Tensor, vec: Tensor, pe: Tensor) -> Tensor:
254
+ q, k, v, mlp, gate = self.pre_attention(x, vec)
255
+ q, k = apply_rope(q, k, pe)
256
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
257
+ return self.post_attention(x, attn, mlp, gate)
258
+
259
+
260
+ class DoubleStreamBlock(nn.Module):
261
+ """Two independent towers over **one** joint attention across ``[EO | SAR]``.
262
+
263
+ The towers have completely separate weights but share the modulation vector
264
+ ``vec`` and the RoPE grid, so an EO token and the SAR token at the same
265
+ ground position carry an identical phase (dit.py:303-343). SAR plays the
266
+ structural role text plays in FLUX.
267
+ """
268
+
269
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
270
+ super().__init__()
271
+ self.eo = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
272
+ self.sar = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
273
+
274
+ def forward(self, eo: Tensor, sar: Tensor, vec: Tensor, pe: Tensor) -> tuple[Tensor, Tensor]:
275
+ """``pe`` must already cover the joint 2P-token sequence."""
276
+ q_e, k_e, v_e, mlp_e, gate_e = self.eo.pre_attention(eo, vec)
277
+ q_s, k_s, v_s, mlp_s, gate_s = self.sar.pre_attention(sar, vec)
278
+
279
+ q = torch.cat((q_e, q_s), dim=2)
280
+ k = torch.cat((k_e, k_s), dim=2)
281
+ v = torch.cat((v_e, v_s), dim=2)
282
+ q, k = apply_rope(q, k, pe)
283
+
284
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
285
+ attn_e, attn_s = attn.split([q_e.shape[2], q_s.shape[2]], dim=2)
286
+ return (
287
+ self.eo.post_attention(eo, attn_e, mlp_e, gate_e),
288
+ self.sar.post_attention(sar, attn_s, mlp_s, gate_s),
289
+ )
290
+
291
+
292
+ class FinalLayer(nn.Module):
293
+ """AdaLN output layer.
294
+
295
+ ``adaLN`` unpacks ``shift, scale`` — the **opposite** order of diffusers'
296
+ ``AdaLayerNormContinuous`` (dit.py:346-362). ``logvar_proj`` belongs to a
297
+ beta-NLL loss that was never enabled (``loss.flow = mse``); its weights are
298
+ kept so the published checkpoint loads with ``strict=True``, but inference
299
+ never evaluates it — the sampler reads only the velocity (bridge.py:531).
300
+ """
301
+
302
+ def __init__(self, hidden_size: int, out_channels: int) -> None:
303
+ super().__init__()
304
+ self.norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
305
+ self.adaLN = nn.Linear(hidden_size, 2 * hidden_size, bias=True)
306
+ self.proj = nn.Linear(hidden_size, out_channels, bias=True)
307
+ self.logvar_proj = nn.Linear(hidden_size, 1, bias=True)
308
+
309
+ def forward(self, x: Tensor, vec: Tensor) -> Tensor:
310
+ mod = self.adaLN(F.silu(vec))
311
+ if mod.ndim == 2:
312
+ mod = mod[:, None, :]
313
+ shift, scale = mod.chunk(2, dim=-1)
314
+ return self.proj((1 + scale) * self.norm(x) + shift)
315
+
316
+
317
+ class ReFlowSETTransformer2DModel(ModelMixin, ConfigMixin):
318
+ """ReFlowSET's flow-velocity transformer (509.32 M parameters as configured).
319
+
320
+ Args:
321
+ in_channels: Channels of the packed FLUX.2 latent (128).
322
+ out_channels: Channels of the predicted velocity (128).
323
+ hidden_size: Residual width (1024).
324
+ depth: **Total** blocks, double plus single (24).
325
+ num_heads: Attention heads (16), so ``head_dim = 64``.
326
+ mlp_ratio: Nominal MLP ratio; the SwiGLU width is derived from it.
327
+ axes_dim: RoPE dims for the ``(y, x)`` axes; must sum to ``head_dim``.
328
+ theta: RoPE base period (10000).
329
+ sample_size: Input image resolution the released arm was trained at
330
+ (256 for QXS-SAROPT, 512 for SAR2Opt). Recorded for provenance
331
+ only: the forward pass derives every shape from its input and the
332
+ RoPE grid is rebuilt per call, so one checkpoint serves any size
333
+ divisible by 16.
334
+ double_blocks: Leading double-stream blocks (8); the remaining
335
+ ``depth - double_blocks`` are single-stream.
336
+ double_merge: How the two streams become one. ``"token"`` (the released
337
+ setting) concatenates on the sequence axis, so the single stack runs
338
+ over 2P tokens and the SAR half is dropped only at the very end;
339
+ ``"channel"`` fuses per position and keeps P tokens.
340
+
341
+ Forward contract: ``forward(hidden_states, timestep, condition)`` where
342
+ ``hidden_states`` is the bridge state ``[B, 128, h, w]``, ``timestep`` is the
343
+ bridge time in ``[0, 1]`` (**not** an integer diffusion step), and
344
+ ``condition`` is the SAR latent of the same shape or ``None``. ``None`` is
345
+ the classifier-free-guidance null branch and is turned into an all-zero
346
+ latent inside the model — there is no learned null token.
347
+ """
348
+
349
+ _supports_gradient_checkpointing = False
350
+
351
+ @register_to_config
352
+ def __init__(
353
+ self,
354
+ in_channels: int = 128,
355
+ out_channels: int = 128,
356
+ hidden_size: int = 1024,
357
+ depth: int = 24,
358
+ num_heads: int = 16,
359
+ mlp_ratio: float = 4.0,
360
+ axes_dim: tuple[int, ...] = (32, 32),
361
+ theta: int = 10000,
362
+ sample_size: Optional[int] = None,
363
+ double_blocks: int = 8,
364
+ double_merge: str = "token",
365
+ ) -> None:
366
+ super().__init__()
367
+ if hidden_size % num_heads != 0:
368
+ raise ValueError(f"hidden_size {hidden_size} must be divisible by num_heads {num_heads}")
369
+ pe_dim = hidden_size // num_heads
370
+ if sum(axes_dim) != pe_dim:
371
+ raise ValueError(f"axes_dim {list(axes_dim)} must sum to the per-head dim {pe_dim}")
372
+ if not 0 <= double_blocks < depth:
373
+ raise ValueError(f"double_blocks {double_blocks} must be in [0, depth={depth})")
374
+ if double_merge not in ("token", "channel"):
375
+ raise ValueError(f"double_merge must be 'token' or 'channel', got {double_merge!r}")
376
+
377
+ self.pe_embedder = EmbedND(theta=theta, axes_dim=list(axes_dim))
378
+ if double_blocks:
379
+ # Each stream gets its own 1x1 "patchify": they are two token
380
+ # sequences now, not two halves of one channel stack.
381
+ self.in_proj_eo = nn.Linear(in_channels, hidden_size, bias=True)
382
+ self.in_proj_sar = nn.Linear(in_channels, hidden_size, bias=True)
383
+ if double_merge == "channel":
384
+ self.merge = nn.Linear(2 * hidden_size, hidden_size, bias=True)
385
+ else:
386
+ self.in_proj = nn.Linear(2 * in_channels, hidden_size, bias=True)
387
+ self.time_in = MLPEmbedder(TIME_EMBED_DIM, hidden_size)
388
+ self.double_stream = nn.ModuleList(
389
+ [DoubleStreamBlock(hidden_size, num_heads, mlp_ratio) for _ in range(double_blocks)]
390
+ )
391
+ self.blocks = nn.ModuleList(
392
+ [
393
+ SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
394
+ for _ in range(depth - double_blocks)
395
+ ]
396
+ )
397
+ self.final_layer = FinalLayer(hidden_size, out_channels)
398
+
399
+ def forward(
400
+ self,
401
+ hidden_states: Tensor,
402
+ timestep: Tensor,
403
+ condition: Optional[Tensor] = None,
404
+ return_dict: bool = True,
405
+ ) -> Union[Transformer2DModelOutput, tuple[Tensor]]:
406
+ """Predict the flow velocity ``dz/dt``.
407
+
408
+ Args:
409
+ hidden_states: ``[B, in_channels, h, w]`` bridge state.
410
+ timestep: Bridge time in ``[0, 1]``; a scalar or ``[B]``.
411
+ condition: ``[B, in_channels, h, w]`` SAR latent, or ``None`` for the
412
+ null branch (an all-zero conditioning latent, dit.py:531-532).
413
+ return_dict: Return a ``Transformer2DModelOutput`` instead of a tuple.
414
+
415
+ Returns:
416
+ The velocity ``[B, out_channels, h, w]``. This is a flow velocity,
417
+ not ``epsilon`` and not diffusers' ``v_prediction``.
418
+ """
419
+ if hidden_states.ndim != 4:
420
+ raise ValueError(f"hidden_states must be [B, C, h, w], got {tuple(hidden_states.shape)}")
421
+ batch, _, h, w = hidden_states.shape
422
+ if condition is None:
423
+ condition = torch.zeros_like(hidden_states)
424
+ elif condition.shape != hidden_states.shape:
425
+ raise ValueError(
426
+ f"condition shape {tuple(condition.shape)} must match "
427
+ f"hidden_states shape {tuple(hidden_states.shape)}"
428
+ )
429
+ if timestep.ndim == 0:
430
+ timestep = timestep.expand(batch)
431
+
432
+ n_double = self.config.double_blocks
433
+ if n_double:
434
+ eo = self.in_proj_eo(hidden_states.flatten(2).transpose(1, 2)) # [B, P, D]
435
+ sar = self.in_proj_sar(condition.flatten(2).transpose(1, 2)) # [B, P, D]
436
+ ref = eo
437
+ else:
438
+ x = torch.cat([hidden_states, condition], dim=1).flatten(2).transpose(1, 2)
439
+ x = self.in_proj(x)
440
+ ref = x
441
+
442
+ vec = self.time_in(timestep_embedding(timestep, TIME_EMBED_DIM).to(ref.dtype))
443
+
444
+ ids = latent_image_ids(h, w, device=hidden_states.device, dtype=torch.float32)
445
+ pe = self.pe_embedder(ids[None].expand(batch, -1, -1))
446
+
447
+ num_tokens = ref.shape[1]
448
+ pe_single = pe
449
+ if n_double:
450
+ # Token axis of pe is dim 2 ([B, 1, L, head_dim/2, 2, 2]); repeating
451
+ # the same P coordinates gives EO and SAR one shared grid.
452
+ pe_joint = torch.cat((pe, pe), dim=2)
453
+ for block in self.double_stream:
454
+ eo, sar = block(eo, sar, vec, pe_joint)
455
+ if self.config.double_merge == "token":
456
+ x = torch.cat((eo, sar), dim=1) # [B, 2P, D]
457
+ pe_single = pe_joint
458
+ else:
459
+ x = self.merge(torch.cat((eo, sar), dim=-1)) # [B, P, D]
460
+
461
+ for block in self.blocks:
462
+ x = block(x, vec, pe_single)
463
+
464
+ if n_double and self.config.double_merge == "token":
465
+ x = x[:, :num_tokens] # drop the SAR half: only EO is decoded
466
+
467
+ v = self.final_layer(x, vec)
468
+ v = v.transpose(1, 2).reshape(batch, self.config.out_channels, h, w)
469
+ if not return_dict:
470
+ return (v,)
471
+ return Transformer2DModelOutput(sample=v)
sar2opt/vae/autoencoder_flux2.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen FLUX.2 autoencoder — the latent endpoint of ReFlowSET.
2
+
3
+ ReFlowSET never fine-tunes this module: it is loaded once, frozen, and used to
4
+ encode the SAR condition and to decode the sampled EO latent. The released
5
+ weights are the **Apache-2.0** FLUX.2-klein-base-4B copy of the autoencoder,
6
+ re-keyed to the layout below (see ``scripts/convert_flux2_ae.py``).
7
+
8
+ Three details of the checkpoint are non-standard for `diffusers` and are
9
+ preserved exactly, because the file must load with ``strict=True``:
10
+
11
+ * ``quant_conv`` lives **inside** ``encoder.*`` and is the last op of the
12
+ encoder forward; ``post_quant_conv`` lives **inside** ``decoder.*`` and is the
13
+ first op of the decoder forward. `diffusers`' ``AutoencoderKL`` makes both
14
+ siblings of the encoder/decoder.
15
+ * The latent normaliser is a real ``BatchNorm2d(128, affine=False)`` whose
16
+ running statistics ship in the checkpoint under ``bn.*`` — a per-channel mean
17
+ **and** variance, not a scalar ``scaling_factor``/``shift_factor``. Its
18
+ epsilon is ``1e-4``, not torch's ``1e-5``.
19
+ * ``encode`` returns the posterior **mean**; the log-variance chunk of the
20
+ encoder's moments is discarded, so encoding is deterministic and there is no
21
+ ``DiagonalGaussianDistribution`` and no ``.sample()``.
22
+
23
+ The public latent is ``[B, 128, H/16, W/16]``: an 8x convolutional stride
24
+ followed by a 2x2 space-to-depth pack that is part of the *autoencoder*, not of
25
+ the transformer.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+
32
+ import torch
33
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
34
+ from diffusers.models.modeling_utils import ModelMixin
35
+ from torch import Tensor, nn
36
+ from torch.nn import functional as F
37
+
38
+
39
+ def swish(x: Tensor) -> Tensor:
40
+ """``x * sigmoid(x)`` — the activation used throughout the FLUX.2 AE."""
41
+ return x * torch.sigmoid(x)
42
+
43
+
44
+ class AttnBlock(nn.Module):
45
+ """Single-head self-attention over the spatial grid (head dim == channels)."""
46
+
47
+ def __init__(self, in_channels: int) -> None:
48
+ super().__init__()
49
+ self.in_channels = in_channels
50
+ self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
51
+ self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1)
52
+ self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1)
53
+ self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1)
54
+ self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1)
55
+
56
+ def attention(self, h_: Tensor) -> Tensor:
57
+ h_ = self.norm(h_)
58
+ q, k, v = self.q(h_), self.k(h_), self.v(h_)
59
+ b, c, h, w = q.shape
60
+ # "b c h w -> b 1 (h w) c": ONE head whose head-dim is the full channel
61
+ # count (flux2_ae.py:70-73).
62
+ q = q.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
63
+ k = k.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
64
+ v = v.reshape(b, c, h * w).transpose(1, 2).unsqueeze(1).contiguous()
65
+ h_ = F.scaled_dot_product_attention(q, k, v)
66
+ return h_.squeeze(1).transpose(1, 2).reshape(b, c, h, w)
67
+
68
+ def forward(self, x: Tensor) -> Tensor:
69
+ return x + self.proj_out(self.attention(x))
70
+
71
+
72
+ class ResnetBlock(nn.Module):
73
+ def __init__(self, in_channels: int, out_channels: int) -> None:
74
+ super().__init__()
75
+ self.in_channels = in_channels
76
+ self.out_channels = out_channels
77
+ self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
78
+ self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
79
+ self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True)
80
+ self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
81
+ if in_channels != out_channels:
82
+ self.nin_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0)
83
+
84
+ def forward(self, x: Tensor) -> Tensor:
85
+ h = self.conv1(swish(self.norm1(x)))
86
+ h = self.conv2(swish(self.norm2(h)))
87
+ if self.in_channels != self.out_channels:
88
+ x = self.nin_shortcut(x)
89
+ return x + h
90
+
91
+
92
+ class Downsample(nn.Module):
93
+ """Stride-2 conv with FLUX's asymmetric ``(0, 1, 0, 1)`` pad (flux2_ae.py:111-121)."""
94
+
95
+ def __init__(self, in_channels: int) -> None:
96
+ super().__init__()
97
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=2, padding=0)
98
+
99
+ def forward(self, x: Tensor) -> Tensor:
100
+ return self.conv(F.pad(x, (0, 1, 0, 1), mode="constant", value=0))
101
+
102
+
103
+ class Upsample(nn.Module):
104
+ """Nearest-neighbour 2x followed by a 3x3 conv (flux2_ae.py:124-132)."""
105
+
106
+ def __init__(self, in_channels: int) -> None:
107
+ super().__init__()
108
+ self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=1, padding=1)
109
+
110
+ def forward(self, x: Tensor) -> Tensor:
111
+ return self.conv(F.interpolate(x, scale_factor=2.0, mode="nearest"))
112
+
113
+
114
+ class Encoder(nn.Module):
115
+ """FLUX.2 encoder. Emits ``2 * z_channels`` moments; ``quant_conv`` is internal."""
116
+
117
+ def __init__(
118
+ self,
119
+ resolution: int,
120
+ in_channels: int,
121
+ ch: int,
122
+ ch_mult: list[int],
123
+ num_res_blocks: int,
124
+ z_channels: int,
125
+ ) -> None:
126
+ super().__init__()
127
+ # Declared first so the checkpoint key is `encoder.quant_conv.*`
128
+ # (flux2_ae.py:146) — diffusers keeps quant_conv outside the encoder.
129
+ self.quant_conv = nn.Conv2d(2 * z_channels, 2 * z_channels, 1)
130
+ self.ch = ch
131
+ self.num_resolutions = len(ch_mult)
132
+ self.num_res_blocks = num_res_blocks
133
+ self.resolution = resolution
134
+ self.in_channels = in_channels
135
+
136
+ self.conv_in = nn.Conv2d(in_channels, ch, kernel_size=3, stride=1, padding=1)
137
+
138
+ in_ch_mult = (1,) + tuple(ch_mult)
139
+ self.down = nn.ModuleList()
140
+ block_in = ch
141
+ for i_level in range(self.num_resolutions):
142
+ block = nn.ModuleList()
143
+ block_in = ch * in_ch_mult[i_level]
144
+ block_out = ch * ch_mult[i_level]
145
+ for _ in range(num_res_blocks):
146
+ block.append(ResnetBlock(block_in, block_out))
147
+ block_in = block_out
148
+ down = nn.Module()
149
+ down.block = block
150
+ # Empty at every level in this checkpoint: attention exists only in
151
+ # `mid` (flux2_ae.py:162). Kept so the forward guard is meaningful.
152
+ down.attn = nn.ModuleList()
153
+ if i_level != self.num_resolutions - 1:
154
+ down.downsample = Downsample(block_in)
155
+ self.down.append(down)
156
+
157
+ self.mid = nn.Module()
158
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
159
+ self.mid.attn_1 = AttnBlock(block_in)
160
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
161
+
162
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
163
+ self.conv_out = nn.Conv2d(block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1)
164
+
165
+ def forward(self, x: Tensor) -> Tensor:
166
+ hs = [self.conv_in(x)]
167
+ for i_level in range(self.num_resolutions):
168
+ for i_block in range(self.num_res_blocks):
169
+ h = self.down[i_level].block[i_block](hs[-1])
170
+ if len(self.down[i_level].attn) > 0:
171
+ h = self.down[i_level].attn[i_block](h)
172
+ hs.append(h)
173
+ if i_level != self.num_resolutions - 1:
174
+ hs.append(self.down[i_level].downsample(hs[-1]))
175
+
176
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(hs[-1])))
177
+ h = self.conv_out(swish(self.norm_out(h)))
178
+ return self.quant_conv(h) # last op of the encoder (flux2_ae.py:207)
179
+
180
+
181
+ class Decoder(nn.Module):
182
+ """FLUX.2 decoder. ``post_quant_conv`` is internal and runs first."""
183
+
184
+ def __init__(
185
+ self,
186
+ ch: int,
187
+ out_ch: int,
188
+ ch_mult: list[int],
189
+ num_res_blocks: int,
190
+ in_channels: int,
191
+ resolution: int,
192
+ z_channels: int,
193
+ ) -> None:
194
+ super().__init__()
195
+ # Checkpoint key `decoder.post_quant_conv.*` (flux2_ae.py:223).
196
+ self.post_quant_conv = nn.Conv2d(z_channels, z_channels, 1)
197
+ self.ch = ch
198
+ self.num_resolutions = len(ch_mult)
199
+ self.num_res_blocks = num_res_blocks
200
+ self.resolution = resolution
201
+ self.in_channels = in_channels
202
+
203
+ block_in = ch * ch_mult[self.num_resolutions - 1]
204
+ self.conv_in = nn.Conv2d(z_channels, block_in, kernel_size=3, stride=1, padding=1)
205
+
206
+ self.mid = nn.Module()
207
+ self.mid.block_1 = ResnetBlock(block_in, block_in)
208
+ self.mid.attn_1 = AttnBlock(block_in)
209
+ self.mid.block_2 = ResnetBlock(block_in, block_in)
210
+
211
+ self.up = nn.ModuleList()
212
+ for i_level in reversed(range(self.num_resolutions)):
213
+ block = nn.ModuleList()
214
+ block_out = ch * ch_mult[i_level]
215
+ for _ in range(num_res_blocks + 1):
216
+ block.append(ResnetBlock(block_in, block_out))
217
+ block_in = block_out
218
+ up = nn.Module()
219
+ up.block = block
220
+ up.attn = nn.ModuleList() # empty in this checkpoint (flux2_ae.py:249)
221
+ if i_level != 0:
222
+ up.upsample = Upsample(block_in)
223
+ self.up.insert(0, up) # prepend so `up.<i>` indexes by resolution level
224
+
225
+ self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True)
226
+ self.conv_out = nn.Conv2d(block_in, out_ch, kernel_size=3, stride=1, padding=1)
227
+
228
+ def forward(self, z: Tensor) -> Tensor:
229
+ z = self.post_quant_conv(z) # first op of the decoder (flux2_ae.py:267)
230
+ upscale_dtype = next(self.up.parameters()).dtype
231
+
232
+ h = self.conv_in(z)
233
+ h = self.mid.block_2(self.mid.attn_1(self.mid.block_1(h)))
234
+ h = h.to(upscale_dtype)
235
+
236
+ for i_level in reversed(range(self.num_resolutions)):
237
+ for i_block in range(self.num_res_blocks + 1):
238
+ h = self.up[i_level].block[i_block](h)
239
+ if len(self.up[i_level].attn) > 0:
240
+ h = self.up[i_level].attn[i_block](h)
241
+ if i_level != 0:
242
+ h = self.up[i_level].upsample(h)
243
+
244
+ return self.conv_out(swish(self.norm_out(h)))
245
+
246
+
247
+ class AutoencoderFlux2(ModelMixin, ConfigMixin):
248
+ """Frozen FLUX.2 autoencoder with ReFlowSET's packed, BN-normalised latent.
249
+
250
+ ``encode`` maps ``[B, 3, H, W]`` in ``[-1, 1]`` to ``[B, 128, H/16, W/16]``
251
+ and ``decode`` inverts it. The module is frozen: ``train()`` is a no-op that
252
+ always selects eval mode, and the latent BatchNorm is additionally forced to
253
+ eval on every call so no batch statistic can ever leak into the latent.
254
+
255
+ Args:
256
+ resolution: Nominal training resolution of the original autoencoder.
257
+ Only used to size bookkeeping attributes; any ``H``, ``W`` divisible
258
+ by 16 may be encoded.
259
+ in_channels: Input image channels (3).
260
+ ch: Base width.
261
+ out_ch: Output image channels (3).
262
+ ch_mult: Per-level width multipliers; ``len(ch_mult) - 1`` downsamples.
263
+ num_res_blocks: Residual blocks per level.
264
+ z_channels: Pre-pack latent channels (32).
265
+ patch_size: Space-to-depth factor applied after the encoder (2), which
266
+ takes the latent from 32 channels at ``H/8`` to 128 at ``H/16``.
267
+ bn_eps: Epsilon of the latent BatchNorm. **1e-4**, not torch's 1e-5
268
+ (flux2_ae.py:331); using 1e-5 shifts the latent by up to 2.6e-5.
269
+ """
270
+
271
+ _supports_gradient_checkpointing = False
272
+
273
+ @register_to_config
274
+ def __init__(
275
+ self,
276
+ resolution: int = 256,
277
+ in_channels: int = 3,
278
+ ch: int = 128,
279
+ out_ch: int = 3,
280
+ ch_mult: tuple[int, ...] = (1, 2, 4, 4),
281
+ num_res_blocks: int = 2,
282
+ z_channels: int = 32,
283
+ patch_size: int = 2,
284
+ bn_eps: float = 1e-4,
285
+ ) -> None:
286
+ super().__init__()
287
+ ch_mult = list(ch_mult)
288
+ self.encoder = Encoder(
289
+ resolution=resolution,
290
+ in_channels=in_channels,
291
+ ch=ch,
292
+ ch_mult=ch_mult,
293
+ num_res_blocks=num_res_blocks,
294
+ z_channels=z_channels,
295
+ )
296
+ self.decoder = Decoder(
297
+ ch=ch,
298
+ out_ch=out_ch,
299
+ ch_mult=ch_mult,
300
+ num_res_blocks=num_res_blocks,
301
+ in_channels=in_channels,
302
+ resolution=resolution,
303
+ z_channels=z_channels,
304
+ )
305
+ # Per-channel latent normaliser with the checkpoint's running statistics.
306
+ # affine=False, so there is no weight/bias to load (flux2_ae.py:334-340).
307
+ self.bn = nn.BatchNorm2d(
308
+ patch_size * patch_size * z_channels,
309
+ eps=bn_eps,
310
+ momentum=0.1,
311
+ affine=False,
312
+ track_running_stats=True,
313
+ )
314
+
315
+ @property
316
+ def latent_channels(self) -> int:
317
+ """Channels of the public latent: ``patch_size**2 * z_channels`` = 128."""
318
+ return self.config.patch_size**2 * self.config.z_channels
319
+
320
+ @property
321
+ def spatial_factor(self) -> int:
322
+ """Total stride: 8x convolutional times ``patch_size`` packing = 16."""
323
+ return 2 ** (len(self.config.ch_mult) - 1) * self.config.patch_size
324
+
325
+ # ---- 2x2 space-to-depth pack / unpack -----------------------------------
326
+
327
+ def pack(self, z: Tensor) -> Tensor:
328
+ """``[B, C, H, W] -> [B, C*p*p, H/p, W/p]``, channel-major.
329
+
330
+ Bit-identical to the reference ``rearrange("... c (i pi) (j pj) -> ...
331
+ (c pi pj) i j")`` (flux2_ae.py:349-357). Note this is **not** diffusers'
332
+ ``_pack_latents``, whose channel grouping is transposed.
333
+ """
334
+ return F.pixel_unshuffle(z, self.config.patch_size)
335
+
336
+ def unpack(self, z: Tensor) -> Tensor:
337
+ """Exact inverse of :meth:`pack` (flux2_ae.py:359-367)."""
338
+ return F.pixel_shuffle(z, self.config.patch_size)
339
+
340
+ # ---- latent normalisation ----------------------------------------------
341
+
342
+ def normalize(self, z: Tensor) -> Tensor:
343
+ """``(z - running_mean) / sqrt(running_var + bn_eps)``, per channel."""
344
+ self.bn.eval() # forced every call (flux2_ae.py:372); train mode shifts z by ~1.67
345
+ return self.bn(z)
346
+
347
+ def inv_normalize(self, z: Tensor) -> Tensor:
348
+ """Exact inverse of :meth:`normalize` — same ``bn_eps`` (flux2_ae.py:375-379)."""
349
+ self.bn.eval()
350
+ s = torch.sqrt(self.bn.running_var.view(1, -1, 1, 1) + self.config.bn_eps)
351
+ m = self.bn.running_mean.view(1, -1, 1, 1)
352
+ return z * s + m
353
+
354
+ # ---- public API ---------------------------------------------------------
355
+
356
+ @torch.no_grad()
357
+ def encode(self, x: Tensor) -> Tensor:
358
+ """Encode an image to the packed, normalised latent.
359
+
360
+ Args:
361
+ x: ``[B, 3, H, W]`` in ``[-1, 1]``; ``H`` and ``W`` divisible by 16.
362
+
363
+ Returns:
364
+ ``[B, 128, H/16, W/16]`` — the posterior **mean**, packed and
365
+ BN-normalised. The encoder's log-variance chunk is discarded
366
+ (flux2_ae.py:396), so this is deterministic: there is no posterior
367
+ distribution object and nothing to sample.
368
+ """
369
+ if x.ndim != 4 or x.shape[1] != self.config.in_channels:
370
+ raise ValueError(
371
+ f"encode expects [B, {self.config.in_channels}, H, W], got {tuple(x.shape)}"
372
+ )
373
+ h, w = x.shape[-2:]
374
+ if h % self.spatial_factor or w % self.spatial_factor:
375
+ raise ValueError(
376
+ f"encode requires H and W divisible by {self.spatial_factor}, got {h}x{w}"
377
+ )
378
+ moments = self.encoder(x)
379
+ mean = torch.chunk(moments, 2, dim=1)[0]
380
+ return self.normalize(self.pack(mean))
381
+
382
+ @torch.no_grad()
383
+ def decode(self, z: Tensor) -> Tensor:
384
+ """Decode a packed, normalised latent ``[B, 128, h, w]`` to ``[B, 3, 16h, 16w]``.
385
+
386
+ The output is approximately ``[-1, 1]`` and is **not** clamped here; the
387
+ pipeline applies ``(x * 0.5 + 0.5).clamp(0, 1)``.
388
+ """
389
+ if z.ndim != 4 or z.shape[1] != self.latent_channels:
390
+ raise ValueError(
391
+ f"decode expects [B, {self.latent_channels}, h, w], got {tuple(z.shape)}"
392
+ )
393
+ return self.decoder(self.unpack(self.inv_normalize(z)))
394
+
395
+ # ---- construction / freezing -------------------------------------------
396
+
397
+ @classmethod
398
+ def from_single_file(
399
+ cls,
400
+ path: str | os.PathLike,
401
+ torch_dtype: torch.dtype = torch.float32,
402
+ ) -> "AutoencoderFlux2":
403
+ """Load the single-file ``ae.safetensors`` (BFL key names) with ``strict=True``.
404
+
405
+ The released file is the Apache-2.0 FLUX.2-klein-base-4B autoencoder
406
+ re-keyed to this layout; it is stored in bfloat16 and is upcast to
407
+ ``torch_dtype``. ReFlowSET runs the autoencoder in float32.
408
+ """
409
+ from safetensors.torch import load_file
410
+
411
+ path = os.fspath(path)
412
+ if not os.path.isfile(path):
413
+ raise FileNotFoundError(
414
+ f"FLUX.2 autoencoder weights not found at: {path}. Expected the "
415
+ "single-file 'ae.safetensors' shipped with ReFlowSET."
416
+ )
417
+ model = cls()
418
+ model.load_state_dict(load_file(path, device="cpu"), strict=True)
419
+ model.to(dtype=torch_dtype)
420
+ model.eval()
421
+ model.requires_grad_(False)
422
+ return model
423
+
424
+ def train(self, mode: bool = True) -> "AutoencoderFlux2":
425
+ """The autoencoder is frozen: never leave eval mode (flux2_ae.py:437-439)."""
426
+ return super().train(False)
sar2opt/vae/config.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "AutoencoderFlux2",
3
+ "_diffusers_version": "0.37.1",
4
+ "bn_eps": 0.0001,
5
+ "ch": 128,
6
+ "ch_mult": [
7
+ 1,
8
+ 2,
9
+ 4,
10
+ 4
11
+ ],
12
+ "in_channels": 3,
13
+ "num_res_blocks": 2,
14
+ "out_ch": 3,
15
+ "patch_size": 2,
16
+ "resolution": 256,
17
+ "z_channels": 32
18
+ }
sar2opt/vae/diffusion_pytorch_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c69bfd7e39b3c26f044905d93f87c5730ae533bf77526ee863ac9c6463948a32
3
+ size 168118886
scheduler_flow_bridge.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET's Design-B flow bridge and its explicit-Euler solver.
2
+
3
+ Forward (training) process, with ``eps ~ N(0, I)`` and ``z_e`` the EO latent::
4
+
5
+ z_t = (1 - t) * eps + t * z_e (bridge.py:311, sigma_b = 0)
6
+ u* = z_e - eps (bridge.py:328 at sigma_b = 0)
7
+
8
+ Sampling starts from ``z_0 ~ N(0, I)`` and integrates the predicted velocity
9
+ with explicit Euler on a uniform grid ``linspace(0, t_end, nfe + 1)``
10
+ (bridge.py:519, 536). The bridge is deterministic: ``sigma_b = 0``, so no
11
+ stochastic term ever executes, and the only randomness in a sample is the
12
+ initial noise draw.
13
+
14
+ **Time direction.** ``t = 0`` is NOISE and ``t = 1`` is DATA, and the solver
15
+ integrates ``t`` **ascending** (bridge.py:86-88). That is the opposite of
16
+ `diffusers`' ``sigma`` convention: setting ``sigma := 1 - t`` recovers
17
+ ``FlowMatchEulerDiscreteScheduler``'s interpolation, but then this bridge's
18
+ velocity is the **negative** of the diffusers flow-matching target and the
19
+ network must still be fed ``1 - sigma``. This scheduler keeps ReFlowSET's own
20
+ sign and direction so neither flip is needed; ``timesteps`` therefore *increase*
21
+ from 0 towards 1, unlike every noise-schedule scheduler in `diffusers`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Optional, Union
28
+
29
+ import torch
30
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
31
+ from diffusers.schedulers.scheduling_utils import SchedulerMixin
32
+ from diffusers.utils import BaseOutput
33
+
34
+
35
+ @dataclass
36
+ class FlowBridgeSchedulerOutput(BaseOutput):
37
+ """Output of :meth:`FlowBridgeScheduler.step`.
38
+
39
+ Args:
40
+ prev_sample: The bridge state at the next time on the grid.
41
+ """
42
+
43
+ prev_sample: torch.Tensor
44
+
45
+
46
+ class FlowBridgeScheduler(SchedulerMixin, ConfigMixin):
47
+ """Explicit-Euler solver for ReFlowSET's Design-B flow bridge.
48
+
49
+ Args:
50
+ t_end: End time of the integration grid (1.0 — the EO endpoint). The
51
+ model is evaluated at ``linspace(0, t_end, nfe + 1)[:-1]`` and the
52
+ final Euler step lands on ``t_end``; the network is never queried at
53
+ ``t = t_end``.
54
+ """
55
+
56
+ order = 1
57
+
58
+ @register_to_config
59
+ def __init__(self, t_end: float = 1.0) -> None:
60
+ if not 0.0 < t_end <= 1.0:
61
+ raise ValueError(f"t_end must lie in (0, 1], got {t_end}")
62
+ self._grid: Optional[torch.Tensor] = None
63
+ self._step_index: Optional[int] = None
64
+ self.num_inference_steps: Optional[int] = None
65
+
66
+ @property
67
+ def timesteps(self) -> torch.Tensor:
68
+ """The ``nfe`` bridge times at which the model is evaluated, ascending."""
69
+ if self._grid is None:
70
+ raise ValueError("call set_timesteps() before reading timesteps")
71
+ return self._grid[:-1]
72
+
73
+ @property
74
+ def step_index(self) -> Optional[int]:
75
+ """Index of the next grid interval; ``None`` until the first :meth:`step`."""
76
+ return self._step_index
77
+
78
+ def set_timesteps(
79
+ self,
80
+ num_inference_steps: int,
81
+ device: Optional[Union[str, torch.device]] = None,
82
+ ) -> None:
83
+ """Build the uniform grid ``linspace(0, t_end, num_inference_steps + 1)``.
84
+
85
+ Args:
86
+ num_inference_steps: NFE — the number of velocity evaluations.
87
+ 50 reproduces the paper's main results; 4 is the efficiency
88
+ operating point.
89
+ device: Device the grid is built on.
90
+
91
+ There is no shift, no dynamic shifting, no Karras or exponential
92
+ spacing, and no timestep-spacing option: the reference solver uses a
93
+ plain uniform grid (bridge.py:519).
94
+ """
95
+ if num_inference_steps < 1:
96
+ raise ValueError(f"num_inference_steps must be >= 1, got {num_inference_steps}")
97
+ self.num_inference_steps = num_inference_steps
98
+ self._grid = torch.linspace(
99
+ 0.0, self.config.t_end, num_inference_steps + 1, device=device, dtype=torch.float32
100
+ )
101
+ self._step_index = 0
102
+
103
+ def step(
104
+ self,
105
+ model_output: torch.Tensor,
106
+ timestep: Union[float, torch.Tensor],
107
+ sample: torch.Tensor,
108
+ return_dict: bool = True,
109
+ ) -> Union[FlowBridgeSchedulerOutput, tuple[torch.Tensor]]:
110
+ """One explicit-Euler step: ``z + (t_next - t_cur) * v`` (bridge.py:536).
111
+
112
+ Args:
113
+ model_output: The predicted velocity ``dz/dt`` at ``timestep``,
114
+ already classifier-free-guided by the caller.
115
+ timestep: The current bridge time. Present for API compatibility and
116
+ checked against the grid; the step size comes from the grid.
117
+ sample: The current bridge state.
118
+ return_dict: Return a :class:`FlowBridgeSchedulerOutput` instead of a
119
+ tuple.
120
+
121
+ Steps must be taken in order, starting from the first entry of
122
+ :attr:`timesteps`.
123
+ """
124
+ if self._grid is None or self._step_index is None:
125
+ raise ValueError("call set_timesteps() before step()")
126
+ if self._step_index >= self.num_inference_steps:
127
+ raise ValueError(
128
+ f"already took {self.num_inference_steps} steps; call set_timesteps() again"
129
+ )
130
+ t_cur, t_next = self._grid[self._step_index], self._grid[self._step_index + 1]
131
+ if not torch.isclose(torch.as_tensor(timestep, dtype=torch.float32).to(t_cur.device), t_cur):
132
+ raise ValueError(
133
+ f"step {self._step_index} expects timestep {t_cur.item()}, got {float(timestep)}; "
134
+ "the flow bridge must be integrated in ascending grid order"
135
+ )
136
+
137
+ # The state is carried in float32 even if the model ran lower (bridge.py:515-517).
138
+ dtype = sample.dtype if sample.dtype in (torch.float32, torch.float64) else torch.float32
139
+ prev_sample = sample.to(dtype) + (t_next - t_cur) * model_output.to(dtype)
140
+ prev_sample = prev_sample.to(sample.dtype)
141
+
142
+ self._step_index += 1
143
+ if not return_dict:
144
+ return (prev_sample,)
145
+ return FlowBridgeSchedulerOutput(prev_sample=prev_sample)
146
+
147
+ def add_noise(
148
+ self,
149
+ original_samples: torch.Tensor,
150
+ noise: torch.Tensor,
151
+ timesteps: torch.Tensor,
152
+ ) -> torch.Tensor:
153
+ """The training-side bridge state ``z_t = (1 - t) * eps + t * z_e`` (bridge.py:311).
154
+
155
+ Args:
156
+ original_samples: The EO latent ``z_e`` (the ``t = 1`` endpoint).
157
+ noise: ``eps ~ N(0, I)`` (the ``t = 0`` endpoint).
158
+ timesteps: Bridge times in ``[0, 1]``, broadcastable over the batch.
159
+ """
160
+ t = timesteps.to(original_samples.device, original_samples.dtype)
161
+ t = t.view(-1, *([1] * (original_samples.ndim - 1)))
162
+ return (1.0 - t) * noise + t * original_samples
163
+
164
+ def get_velocity(
165
+ self,
166
+ sample: torch.Tensor,
167
+ noise: torch.Tensor,
168
+ timesteps: torch.Tensor,
169
+ ) -> torch.Tensor:
170
+ """The training target ``u* = z_e - eps`` (bridge.py:328 at ``sigma_b = 0``).
171
+
172
+ Constant along the path, hence independent of ``timesteps``; the argument
173
+ is kept for `diffusers` API compatibility.
174
+ """
175
+ del timesteps
176
+ return sample - noise
transformer_reflowset.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReFlowSET velocity transformer — a latent DiT with an EO/SAR double stream.
2
+
3
+ The network predicts the flow-bridge velocity ``dz/dt`` in the frozen FLUX.2
4
+ latent space. It takes the noisy EO latent ``[B, 128, h, w]``, a scalar bridge
5
+ time ``t`` in ``[0, 1]``, and the SAR conditioning latent of the same shape; the
6
+ first 8 of its 24 blocks are double-stream (one EO tower and one SAR tower over
7
+ a single joint attention), the remaining 16 are single-stream over the
8
+ concatenated ``[EO | SAR]`` sequence, and only the EO half is decoded.
9
+
10
+ This is an inference-only port. The training-only REPA projection head
11
+ (``repa_proj``) is a separate module in the reference implementation and is
12
+ deliberately absent here.
13
+
14
+ Deviations from `diffusers`' FLUX blocks that this file has to keep — each one
15
+ is silent if you get it wrong:
16
+
17
+ * ``FinalLayer`` unpacks ``shift, scale`` (dit.py:360), the **opposite** order of
18
+ ``AdaLayerNormContinuous``.
19
+ * The single-stream MLP is **SwiGLU** of width 2752, not a 4x GELU of width 4096.
20
+ * ``linear1``/``linear2`` are **bias-free**, and the QK-norm parameter is called
21
+ ``scale``, not ``weight``.
22
+ * The timestep is multiplied by 1000 *inside* the model and the sinusoid is
23
+ **cos first, then sin**.
24
+ * RoPE runs on **two** axes of **centred half-integer** coordinates, not on
25
+ FLUX's three axes of integers starting at 0.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Optional, Union
32
+
33
+ import torch
34
+ from diffusers.configuration_utils import ConfigMixin, register_to_config
35
+ from diffusers.models.modeling_outputs import Transformer2DModelOutput
36
+ from diffusers.models.modeling_utils import ModelMixin
37
+ from torch import Tensor, nn
38
+ from torch.nn import functional as F
39
+
40
+ #: Width of the sinusoidal timestep embedding fed to ``time_in`` (dit.py:44).
41
+ #: A module constant, deliberately independent of ``hidden_size``.
42
+ TIME_EMBED_DIM = 256
43
+
44
+
45
+ def swiglu_hidden_dim(hidden_size: int, mlp_ratio: float) -> int:
46
+ """SwiGLU intermediate width (dit.py:120-127).
47
+
48
+ The canonical 2/3 rule rounded to a multiple of 64, so a gated MLP at
49
+ ``mlp_ratio=4.0`` costs the same parameters as a plain 4x GELU MLP.
50
+ ``hidden_size=1024, mlp_ratio=4.0 -> 2752``.
51
+ """
52
+ return int(round(hidden_size * mlp_ratio * 2 / 3 / 64)) * 64
53
+
54
+
55
+ class SwiGLU(nn.Module):
56
+ """``silu(first half) * second half`` — gate first, value second (dit.py:130-133)."""
57
+
58
+ def forward(self, x: Tensor) -> Tensor:
59
+ x1, x2 = x.chunk(2, dim=-1)
60
+ return F.silu(x1) * x2
61
+
62
+
63
+ class RMSNorm(nn.Module):
64
+ """RMS norm computed in float32. The parameter is named ``scale`` (dit.py:136-145)."""
65
+
66
+ def __init__(self, dim: int) -> None:
67
+ super().__init__()
68
+ self.scale = nn.Parameter(torch.ones(dim))
69
+
70
+ def forward(self, x: Tensor) -> Tensor:
71
+ x_dtype = x.dtype
72
+ x = x.float()
73
+ rrms = torch.rsqrt(torch.mean(x**2, dim=-1, keepdim=True) + 1e-6)
74
+ return (x * rrms).to(dtype=x_dtype) * self.scale
75
+
76
+
77
+ class QKNorm(nn.Module):
78
+ """Per-head query/key RMS norm, applied **before** RoPE (dit.py:148-155)."""
79
+
80
+ def __init__(self, dim: int) -> None:
81
+ super().__init__()
82
+ self.query_norm = RMSNorm(dim)
83
+ self.key_norm = RMSNorm(dim)
84
+
85
+ def forward(self, q: Tensor, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]:
86
+ return self.query_norm(q).to(v), self.key_norm(k).to(v)
87
+
88
+
89
+ class MLPEmbedder(nn.Module):
90
+ """``Linear -> SiLU -> Linear`` time-embedding MLP (dit.py:158-166).
91
+
92
+ Checkpoint keys are ``time_in.in_layer.*`` / ``time_in.out_layer.*``, not
93
+ diffusers' ``time_text_embed.timestep_embedder.linear_{1,2}``.
94
+ """
95
+
96
+ def __init__(self, in_dim: int, hidden_dim: int) -> None:
97
+ super().__init__()
98
+ self.in_layer = nn.Linear(in_dim, hidden_dim, bias=True)
99
+ self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=True)
100
+ self.silu = nn.SiLU()
101
+
102
+ def forward(self, x: Tensor) -> Tensor:
103
+ return self.out_layer(self.silu(self.in_layer(x)))
104
+
105
+
106
+ class Modulation(nn.Module):
107
+ """AdaLN-Zero triple. Order is ``shift, scale, gate`` (dit.py:169-181)."""
108
+
109
+ def __init__(self, dim: int) -> None:
110
+ super().__init__()
111
+ self.lin = nn.Linear(dim, 3 * dim, bias=True)
112
+
113
+ def forward(self, vec: Tensor) -> tuple[Tensor, Tensor, Tensor]:
114
+ out = self.lin(F.silu(vec))
115
+ if out.ndim == 2:
116
+ out = out[:, None, :]
117
+ shift, scale, gate = out.chunk(3, dim=-1)
118
+ return shift, scale, gate
119
+
120
+
121
+ def timestep_embedding(
122
+ t: Tensor, dim: int, max_period: int = 10000, time_factor: float = 1000.0
123
+ ) -> Tensor:
124
+ """Sinusoidal embedding of a fractional bridge time (dit.py:184-201).
125
+
126
+ Two things differ from `diffusers`' ``get_timestep_embedding`` defaults:
127
+ ``t`` is a fraction in ``[0, 1]`` that is scaled by ``time_factor = 1000``
128
+ **here**, and the concatenation order is ``[cos, sin]`` (FLUX's ordering,
129
+ i.e. ``flip_sin_to_cos=True``).
130
+ """
131
+ t = time_factor * t
132
+ half = dim // 2
133
+ freqs = torch.exp(
134
+ -math.log(max_period)
135
+ * torch.arange(start=0, end=half, device=t.device, dtype=torch.float32)
136
+ / half
137
+ )
138
+ args = t[:, None].float() * freqs[None]
139
+ embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
140
+ if dim % 2:
141
+ embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
142
+ if torch.is_floating_point(t):
143
+ embedding = embedding.to(t)
144
+ return embedding
145
+
146
+
147
+ def rope(pos: Tensor, dim: int, theta: int) -> Tensor:
148
+ """Per-axis rotation matrices ``[..., L, dim/2, 2, 2]`` (dit.py:204-211)."""
149
+ if dim % 2:
150
+ raise ValueError(f"RoPE axis dim must be even, got {dim}")
151
+ scale = torch.arange(0, dim, 2, dtype=pos.dtype, device=pos.device) / dim
152
+ omega = 1.0 / (theta**scale)
153
+ out = torch.einsum("...n,d->...nd", pos, omega)
154
+ out = torch.stack([torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1)
155
+ return out.reshape(*out.shape[:-1], 2, 2).float()
156
+
157
+
158
+ def apply_rope(xq: Tensor, xk: Tensor, freqs_cis: Tensor) -> tuple[Tensor, Tensor]:
159
+ """Rotate consecutive dimension pairs — the interleaved (FLUX) convention.
160
+
161
+ ``(x0, x1) -> (cos*x0 - sin*x1, sin*x0 + cos*x1)`` on ``(x[2k], x[2k+1])``
162
+ (dit.py:214-219). Equivalent to diffusers' ``apply_rotary_emb(...,
163
+ use_real_unbind_dim=-1)``; ``-2`` is the split-halves convention and is wrong
164
+ for these weights.
165
+ """
166
+ xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2)
167
+ xk_ = xk.float().reshape(*xk.shape[:-1], -1, 1, 2)
168
+ xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1]
169
+ xk_out = freqs_cis[..., 0] * xk_[..., 0] + freqs_cis[..., 1] * xk_[..., 1]
170
+ return xq_out.reshape(*xq.shape).type_as(xq), xk_out.reshape(*xk.shape).type_as(xk)
171
+
172
+
173
+ class EmbedND(nn.Module):
174
+ """Concatenates the per-axis RoPE ladders and inserts the head axis (dit.py:222-234).
175
+
176
+ Holds no parameters and no buffers: the grid is rebuilt on every forward,
177
+ which is what lets one checkpoint serve 256 and 512 inputs.
178
+ """
179
+
180
+ def __init__(self, theta: int, axes_dim: list[int]) -> None:
181
+ super().__init__()
182
+ self.theta = theta
183
+ self.axes_dim = axes_dim
184
+
185
+ def forward(self, ids: Tensor) -> Tensor:
186
+ emb = torch.cat(
187
+ [rope(ids[..., i], self.axes_dim[i], self.theta) for i in range(len(self.axes_dim))],
188
+ dim=-3,
189
+ )
190
+ return emb.unsqueeze(1)
191
+
192
+
193
+ def latent_image_ids(h: int, w: int, device, dtype=torch.float32) -> Tensor:
194
+ """Centred ``(y, x)`` coordinates for an ``h x w`` latent grid, ``[h*w, 2]``.
195
+
196
+ ``arange(n) - (n - 1) / 2`` with unit spacing (dit.py:237-252), so for even
197
+ ``n`` the coordinates are half-integers and the central 16x16 region of a
198
+ 32x32 grid carries exactly the coordinates a 256-trained model saw — RoPE
199
+ only extrapolates outwards, it never rescales. Row-major, so token
200
+ ``p = y * w + x``. This is **not** FLUX's 3-axis integer id grid.
201
+ """
202
+ y = torch.arange(h, device=device, dtype=dtype) - (h - 1) / 2
203
+ x = torch.arange(w, device=device, dtype=dtype) - (w - 1) / 2
204
+ ids = torch.zeros(h, w, 2, device=device, dtype=dtype)
205
+ ids[..., 0] = y[:, None]
206
+ ids[..., 1] = x[None, :]
207
+ return ids.reshape(h * w, 2)
208
+
209
+
210
+ class SingleStreamBlock(nn.Module):
211
+ """Fused attention + SwiGLU MLP under one modulation and one residual.
212
+
213
+ ``linear1`` emits ``[q | k | v | mlp_gate | mlp_value]`` in that order; the
214
+ qkv slab is K-major (``(K H D)``). Both linears are bias-free
215
+ (dit.py:255-300).
216
+ """
217
+
218
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
219
+ super().__init__()
220
+ self.hidden_size = hidden_size
221
+ self.num_heads = num_heads
222
+ head_dim = hidden_size // num_heads
223
+ self.mlp_hidden_dim = swiglu_hidden_dim(hidden_size, mlp_ratio)
224
+
225
+ self.linear1 = nn.Linear(hidden_size, 3 * hidden_size + 2 * self.mlp_hidden_dim, bias=False)
226
+ self.linear2 = nn.Linear(hidden_size + self.mlp_hidden_dim, hidden_size, bias=False)
227
+ self.norm = QKNorm(head_dim)
228
+ self.pre_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
229
+ self.mlp_act = SwiGLU()
230
+ self.modulation = Modulation(hidden_size)
231
+
232
+ def pre_attention(self, x: Tensor, vec: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor]:
233
+ """Everything up to (not including) RoPE and attention (dit.py:272-288)."""
234
+ shift, scale, gate = self.modulation(vec)
235
+ x_mod = (1 + scale) * self.pre_norm(x) + shift
236
+
237
+ qkv, mlp = torch.split(
238
+ self.linear1(x_mod), [3 * self.hidden_size, 2 * self.mlp_hidden_dim], dim=-1
239
+ )
240
+ b, length, _ = qkv.shape
241
+ # "B L (K H D) -> K B H L D" with K=3, H=num_heads.
242
+ q, k, v = qkv.reshape(b, length, 3, self.num_heads, -1).permute(2, 0, 3, 1, 4)
243
+ q, k = self.norm(q, k, v)
244
+ return q, k, v, mlp, gate
245
+
246
+ def post_attention(self, x: Tensor, attn: Tensor, mlp: Tensor, gate: Tensor) -> Tensor:
247
+ """Output projection and the single gated residual (dit.py:290-294)."""
248
+ b, heads, length, head_dim = attn.shape
249
+ attn = attn.transpose(1, 2).reshape(b, length, heads * head_dim)
250
+ out = self.linear2(torch.cat((attn, self.mlp_act(mlp)), dim=-1))
251
+ return x + gate * out
252
+
253
+ def forward(self, x: Tensor, vec: Tensor, pe: Tensor) -> Tensor:
254
+ q, k, v, mlp, gate = self.pre_attention(x, vec)
255
+ q, k = apply_rope(q, k, pe)
256
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
257
+ return self.post_attention(x, attn, mlp, gate)
258
+
259
+
260
+ class DoubleStreamBlock(nn.Module):
261
+ """Two independent towers over **one** joint attention across ``[EO | SAR]``.
262
+
263
+ The towers have completely separate weights but share the modulation vector
264
+ ``vec`` and the RoPE grid, so an EO token and the SAR token at the same
265
+ ground position carry an identical phase (dit.py:303-343). SAR plays the
266
+ structural role text plays in FLUX.
267
+ """
268
+
269
+ def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float = 4.0) -> None:
270
+ super().__init__()
271
+ self.eo = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
272
+ self.sar = SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
273
+
274
+ def forward(self, eo: Tensor, sar: Tensor, vec: Tensor, pe: Tensor) -> tuple[Tensor, Tensor]:
275
+ """``pe`` must already cover the joint 2P-token sequence."""
276
+ q_e, k_e, v_e, mlp_e, gate_e = self.eo.pre_attention(eo, vec)
277
+ q_s, k_s, v_s, mlp_s, gate_s = self.sar.pre_attention(sar, vec)
278
+
279
+ q = torch.cat((q_e, q_s), dim=2)
280
+ k = torch.cat((k_e, k_s), dim=2)
281
+ v = torch.cat((v_e, v_s), dim=2)
282
+ q, k = apply_rope(q, k, pe)
283
+
284
+ attn = F.scaled_dot_product_attention(q, k, v, is_causal=False)
285
+ attn_e, attn_s = attn.split([q_e.shape[2], q_s.shape[2]], dim=2)
286
+ return (
287
+ self.eo.post_attention(eo, attn_e, mlp_e, gate_e),
288
+ self.sar.post_attention(sar, attn_s, mlp_s, gate_s),
289
+ )
290
+
291
+
292
+ class FinalLayer(nn.Module):
293
+ """AdaLN output layer.
294
+
295
+ ``adaLN`` unpacks ``shift, scale`` — the **opposite** order of diffusers'
296
+ ``AdaLayerNormContinuous`` (dit.py:346-362). ``logvar_proj`` belongs to a
297
+ beta-NLL loss that was never enabled (``loss.flow = mse``); its weights are
298
+ kept so the published checkpoint loads with ``strict=True``, but inference
299
+ never evaluates it — the sampler reads only the velocity (bridge.py:531).
300
+ """
301
+
302
+ def __init__(self, hidden_size: int, out_channels: int) -> None:
303
+ super().__init__()
304
+ self.norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
305
+ self.adaLN = nn.Linear(hidden_size, 2 * hidden_size, bias=True)
306
+ self.proj = nn.Linear(hidden_size, out_channels, bias=True)
307
+ self.logvar_proj = nn.Linear(hidden_size, 1, bias=True)
308
+
309
+ def forward(self, x: Tensor, vec: Tensor) -> Tensor:
310
+ mod = self.adaLN(F.silu(vec))
311
+ if mod.ndim == 2:
312
+ mod = mod[:, None, :]
313
+ shift, scale = mod.chunk(2, dim=-1)
314
+ return self.proj((1 + scale) * self.norm(x) + shift)
315
+
316
+
317
+ class ReFlowSETTransformer2DModel(ModelMixin, ConfigMixin):
318
+ """ReFlowSET's flow-velocity transformer (509.32 M parameters as configured).
319
+
320
+ Args:
321
+ in_channels: Channels of the packed FLUX.2 latent (128).
322
+ out_channels: Channels of the predicted velocity (128).
323
+ hidden_size: Residual width (1024).
324
+ depth: **Total** blocks, double plus single (24).
325
+ num_heads: Attention heads (16), so ``head_dim = 64``.
326
+ mlp_ratio: Nominal MLP ratio; the SwiGLU width is derived from it.
327
+ axes_dim: RoPE dims for the ``(y, x)`` axes; must sum to ``head_dim``.
328
+ theta: RoPE base period (10000).
329
+ sample_size: Input image resolution the released arm was trained at
330
+ (256 for QXS-SAROPT, 512 for SAR2Opt). Recorded for provenance
331
+ only: the forward pass derives every shape from its input and the
332
+ RoPE grid is rebuilt per call, so one checkpoint serves any size
333
+ divisible by 16.
334
+ double_blocks: Leading double-stream blocks (8); the remaining
335
+ ``depth - double_blocks`` are single-stream.
336
+ double_merge: How the two streams become one. ``"token"`` (the released
337
+ setting) concatenates on the sequence axis, so the single stack runs
338
+ over 2P tokens and the SAR half is dropped only at the very end;
339
+ ``"channel"`` fuses per position and keeps P tokens.
340
+
341
+ Forward contract: ``forward(hidden_states, timestep, condition)`` where
342
+ ``hidden_states`` is the bridge state ``[B, 128, h, w]``, ``timestep`` is the
343
+ bridge time in ``[0, 1]`` (**not** an integer diffusion step), and
344
+ ``condition`` is the SAR latent of the same shape or ``None``. ``None`` is
345
+ the classifier-free-guidance null branch and is turned into an all-zero
346
+ latent inside the model — there is no learned null token.
347
+ """
348
+
349
+ _supports_gradient_checkpointing = False
350
+
351
+ @register_to_config
352
+ def __init__(
353
+ self,
354
+ in_channels: int = 128,
355
+ out_channels: int = 128,
356
+ hidden_size: int = 1024,
357
+ depth: int = 24,
358
+ num_heads: int = 16,
359
+ mlp_ratio: float = 4.0,
360
+ axes_dim: tuple[int, ...] = (32, 32),
361
+ theta: int = 10000,
362
+ sample_size: Optional[int] = None,
363
+ double_blocks: int = 8,
364
+ double_merge: str = "token",
365
+ ) -> None:
366
+ super().__init__()
367
+ if hidden_size % num_heads != 0:
368
+ raise ValueError(f"hidden_size {hidden_size} must be divisible by num_heads {num_heads}")
369
+ pe_dim = hidden_size // num_heads
370
+ if sum(axes_dim) != pe_dim:
371
+ raise ValueError(f"axes_dim {list(axes_dim)} must sum to the per-head dim {pe_dim}")
372
+ if not 0 <= double_blocks < depth:
373
+ raise ValueError(f"double_blocks {double_blocks} must be in [0, depth={depth})")
374
+ if double_merge not in ("token", "channel"):
375
+ raise ValueError(f"double_merge must be 'token' or 'channel', got {double_merge!r}")
376
+
377
+ self.pe_embedder = EmbedND(theta=theta, axes_dim=list(axes_dim))
378
+ if double_blocks:
379
+ # Each stream gets its own 1x1 "patchify": they are two token
380
+ # sequences now, not two halves of one channel stack.
381
+ self.in_proj_eo = nn.Linear(in_channels, hidden_size, bias=True)
382
+ self.in_proj_sar = nn.Linear(in_channels, hidden_size, bias=True)
383
+ if double_merge == "channel":
384
+ self.merge = nn.Linear(2 * hidden_size, hidden_size, bias=True)
385
+ else:
386
+ self.in_proj = nn.Linear(2 * in_channels, hidden_size, bias=True)
387
+ self.time_in = MLPEmbedder(TIME_EMBED_DIM, hidden_size)
388
+ self.double_stream = nn.ModuleList(
389
+ [DoubleStreamBlock(hidden_size, num_heads, mlp_ratio) for _ in range(double_blocks)]
390
+ )
391
+ self.blocks = nn.ModuleList(
392
+ [
393
+ SingleStreamBlock(hidden_size, num_heads, mlp_ratio)
394
+ for _ in range(depth - double_blocks)
395
+ ]
396
+ )
397
+ self.final_layer = FinalLayer(hidden_size, out_channels)
398
+
399
+ def forward(
400
+ self,
401
+ hidden_states: Tensor,
402
+ timestep: Tensor,
403
+ condition: Optional[Tensor] = None,
404
+ return_dict: bool = True,
405
+ ) -> Union[Transformer2DModelOutput, tuple[Tensor]]:
406
+ """Predict the flow velocity ``dz/dt``.
407
+
408
+ Args:
409
+ hidden_states: ``[B, in_channels, h, w]`` bridge state.
410
+ timestep: Bridge time in ``[0, 1]``; a scalar or ``[B]``.
411
+ condition: ``[B, in_channels, h, w]`` SAR latent, or ``None`` for the
412
+ null branch (an all-zero conditioning latent, dit.py:531-532).
413
+ return_dict: Return a ``Transformer2DModelOutput`` instead of a tuple.
414
+
415
+ Returns:
416
+ The velocity ``[B, out_channels, h, w]``. This is a flow velocity,
417
+ not ``epsilon`` and not diffusers' ``v_prediction``.
418
+ """
419
+ if hidden_states.ndim != 4:
420
+ raise ValueError(f"hidden_states must be [B, C, h, w], got {tuple(hidden_states.shape)}")
421
+ batch, _, h, w = hidden_states.shape
422
+ if condition is None:
423
+ condition = torch.zeros_like(hidden_states)
424
+ elif condition.shape != hidden_states.shape:
425
+ raise ValueError(
426
+ f"condition shape {tuple(condition.shape)} must match "
427
+ f"hidden_states shape {tuple(hidden_states.shape)}"
428
+ )
429
+ if timestep.ndim == 0:
430
+ timestep = timestep.expand(batch)
431
+
432
+ n_double = self.config.double_blocks
433
+ if n_double:
434
+ eo = self.in_proj_eo(hidden_states.flatten(2).transpose(1, 2)) # [B, P, D]
435
+ sar = self.in_proj_sar(condition.flatten(2).transpose(1, 2)) # [B, P, D]
436
+ ref = eo
437
+ else:
438
+ x = torch.cat([hidden_states, condition], dim=1).flatten(2).transpose(1, 2)
439
+ x = self.in_proj(x)
440
+ ref = x
441
+
442
+ vec = self.time_in(timestep_embedding(timestep, TIME_EMBED_DIM).to(ref.dtype))
443
+
444
+ ids = latent_image_ids(h, w, device=hidden_states.device, dtype=torch.float32)
445
+ pe = self.pe_embedder(ids[None].expand(batch, -1, -1))
446
+
447
+ num_tokens = ref.shape[1]
448
+ pe_single = pe
449
+ if n_double:
450
+ # Token axis of pe is dim 2 ([B, 1, L, head_dim/2, 2, 2]); repeating
451
+ # the same P coordinates gives EO and SAR one shared grid.
452
+ pe_joint = torch.cat((pe, pe), dim=2)
453
+ for block in self.double_stream:
454
+ eo, sar = block(eo, sar, vec, pe_joint)
455
+ if self.config.double_merge == "token":
456
+ x = torch.cat((eo, sar), dim=1) # [B, 2P, D]
457
+ pe_single = pe_joint
458
+ else:
459
+ x = self.merge(torch.cat((eo, sar), dim=-1)) # [B, P, D]
460
+
461
+ for block in self.blocks:
462
+ x = block(x, vec, pe_single)
463
+
464
+ if n_double and self.config.double_merge == "token":
465
+ x = x[:, :num_tokens] # drop the SAR half: only EO is decoded
466
+
467
+ v = self.final_layer(x, vec)
468
+ v = v.transpose(1, 2).reshape(batch, self.config.out_channels, h, w)
469
+ if not return_dict:
470
+ return (v,)
471
+ return Transformer2DModelOutput(sample=v)