Jup33QE commited on
Commit
db3f772
·
verified ·
1 Parent(s): 0e1eb36

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - mlx
5
+ - triposplat
6
+ - 3d-gaussian-splatting
7
+ - image-to-3d
8
+ - apple-silicon
9
+ ---
10
+
11
+ # TripoSplat MLX
12
+
13
+ MLX-native implementation of [TripoSplat](https://github.com/VAST-AI-Research/TripoSplat) for Apple Silicon.
14
+
15
+ This repository contains MLX ports of the TripoSplat submodules:
16
+
17
+ - **`dinov3_mlx.py`** — DinoV3 ViT-H/16+ image encoder
18
+ - **`flux2vae_mlx.py`** — Flux2 VAE encoder
19
+ - **`flow_mlx.py`** — LatentSeqMMFlowModel (24-block multimodal flow transformer)
20
+
21
+ The original TripoSplat decoder (`OctreeGaussianDecoder`) remains on PyTorch MPS due to its complex dynamic sampling logic.
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ from mlx_models.dinov3_mlx import DinoV3ViT
27
+ from mlx_models.flux2vae_mlx import Flux2VAEEncoder
28
+ from mlx_models.flow_mlx import LatentSeqMMFlowModel
29
+
30
+ # Load models
31
+ dino = DinoV3ViT()
32
+ dino.load_safetensors("dino_v3_vit_h.safetensors")
33
+
34
+ vae = Flux2VAEEncoder()
35
+ vae.load_safetensors("flux2-vae.safetensors")
36
+
37
+ flow = LatentSeqMMFlowModel(
38
+ q_token_length=8192, in_channels=16, cam_channels=5, out_channels=16,
39
+ model_channels=1024, cond_channels=1280, cond2_channels=128,
40
+ num_refiner_blocks=2, num_blocks=24, num_heads=16, mlp_ratio=4,
41
+ qk_rms_norm=True, share_mod=True, use_shift_table=True,
42
+ )
43
+ flow.load_safetensors("triposplat_fp16.safetensors")
44
+ ```
45
+
46
+ ## Full Pipeline
47
+
48
+ For the complete text-to-3D pipeline (FLUX.2-klein-9B → BiRefNet → TripoSplat), see the main project:
49
+ [https://github.com/Jup33Q/tripoflux-mlx](https://github.com/Jup33Q/tripoflux-mlx)
50
+
51
+ ## License
52
+
53
+ MIT. Original TripoSplat code and weights are MIT-licensed by VAST-AI-Research.
mlx_models/dinov3_mlx.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MLX port of the DinoV3 ViT-H/16+ image encoder used by TripoSplat.
2
+
3
+ This is a line-by-line port of the PyTorch reference in
4
+ `tripoflux/vendor/triposplat/model.py`. Weights are loaded from the same
5
+ safetensors checkpoint and converted to MLX arrays.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Optional, Tuple
14
+
15
+ import mlx.core as mx
16
+ import mlx.nn as nn
17
+ import mlx.utils
18
+ import numpy as np
19
+ import safetensors.torch
20
+ import torch
21
+
22
+
23
+ def _rotate_half(x: mx.array) -> mx.array:
24
+ x1, x2 = mx.split(x, 2, axis=-1)
25
+ return mx.concatenate([-x2, x1], axis=-1)
26
+
27
+
28
+ class DinoV3PatchEmbed(nn.Module):
29
+ def __init__(self, patch_size: int = 16, in_chans: int = 3, embed_dim: int = 1280):
30
+ super().__init__()
31
+ self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size, bias=True)
32
+
33
+ def __call__(self, x: mx.array) -> mx.array:
34
+ # x: (B, C, H, W) NCHW -> MLX Conv2d expects NHWC
35
+ x = x.transpose(0, 2, 3, 1)
36
+ x = self.proj(x)
37
+ # -> (B, H/P, W/P, embed_dim) -> (B, N, embed_dim)
38
+ x = x.reshape(x.shape[0], -1, x.shape[-1])
39
+ return x
40
+
41
+
42
+ class DinoV3RotaryEmbedding2D(nn.Module):
43
+ def __init__(self, dim: int, base: float = 100.0):
44
+ super().__init__()
45
+ self.inv_freq = 1.0 / (base ** mx.arange(0, 1, 4.0 / dim, dtype=mx.float32))
46
+
47
+ def __call__(self, height: int, width: int, dtype=mx.float32) -> Tuple[mx.array, mx.array]:
48
+ coords_h = mx.arange(0.5, height, dtype=mx.float32) / height
49
+ coords_w = mx.arange(0.5, width, dtype=mx.float32) / width
50
+ coords = mx.stack(mx.meshgrid(coords_h, coords_w, indexing="ij"), axis=-1)
51
+ coords = (2.0 * coords - 1.0).flatten(0, 1)
52
+ angles = mx.tile((2 * math.pi * coords[:, :, None] * self.inv_freq[None, None, :]).flatten(1, 2), 2)
53
+ cos = angles.cos()[None, None, :, :]
54
+ sin = angles.sin()[None, None, :, :]
55
+ return cos.astype(dtype), sin.astype(dtype)
56
+
57
+
58
+ class DinoV3Attention(nn.Module):
59
+ def __init__(self, dim: int, num_heads: int, qkv_bias: tuple = (True, False, True)):
60
+ super().__init__()
61
+ self.num_heads = num_heads
62
+ self.head_dim = dim // num_heads
63
+ q_bias, k_bias, v_bias = qkv_bias
64
+ self.q_proj = nn.Linear(dim, dim, bias=q_bias)
65
+ self.k_proj = nn.Linear(dim, dim, bias=k_bias)
66
+ self.v_proj = nn.Linear(dim, dim, bias=v_bias)
67
+ self.o_proj = nn.Linear(dim, dim, bias=True)
68
+
69
+ def __call__(self, x: mx.array, cos: mx.array, sin: mx.array, num_prefix_tokens: int = 0) -> mx.array:
70
+ B, N, C = x.shape
71
+ q = self.q_proj(x).reshape(B, N, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
72
+ k = self.k_proj(x).reshape(B, N, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
73
+ v = self.v_proj(x).reshape(B, N, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
74
+
75
+ if num_prefix_tokens > 0:
76
+ q_pre, q_pat = mx.split(q, [num_prefix_tokens], axis=-2)
77
+ k_pre, k_pat = mx.split(k, [num_prefix_tokens], axis=-2)
78
+ q = mx.concatenate([q_pre, q_pat * cos + _rotate_half(q_pat) * sin], axis=-2)
79
+ k = mx.concatenate([k_pre, k_pat * cos + _rotate_half(k_pat) * sin], axis=-2)
80
+ else:
81
+ q = q * cos + _rotate_half(q) * sin
82
+ k = k * cos + _rotate_half(k) * sin
83
+
84
+ scale = 1.0 / math.sqrt(self.head_dim)
85
+ out = mx.fast.scaled_dot_product_attention(q, k, v, scale=scale)
86
+ return self.o_proj(out.transpose(0, 2, 1, 3).reshape(B, N, C))
87
+
88
+
89
+ class DinoV3MLP(nn.Module):
90
+ def __init__(self, dim: int, hidden_dim: int, bias: bool = True):
91
+ super().__init__()
92
+ self.gate_proj = nn.Linear(dim, hidden_dim, bias=bias)
93
+ self.up_proj = nn.Linear(dim, hidden_dim, bias=bias)
94
+ self.down_proj = nn.Linear(hidden_dim, dim, bias=bias)
95
+
96
+ def __call__(self, x: mx.array) -> mx.array:
97
+ return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
98
+
99
+
100
+ class DinoV3Block(nn.Module):
101
+ def __init__(self, dim: int, num_heads: int, mlp_ratio: float = 4.0,
102
+ qkv_bias: tuple = (True, False, True), layerscale_init: float = 1.0,
103
+ mlp_bias: bool = True, eps: float = 1e-5):
104
+ super().__init__()
105
+ self.norm1 = nn.LayerNorm(dim, eps=eps)
106
+ self.attn = DinoV3Attention(dim, num_heads, qkv_bias=qkv_bias)
107
+ self.ls1 = mx.ones(dim) * layerscale_init
108
+ self.norm2 = nn.LayerNorm(dim, eps=eps)
109
+ self.mlp = DinoV3MLP(dim, int(dim * mlp_ratio), bias=mlp_bias)
110
+ self.ls2 = mx.ones(dim) * layerscale_init
111
+
112
+ def __call__(self, x: mx.array, cos: mx.array, sin: mx.array, num_prefix_tokens: int = 0) -> mx.array:
113
+ x = x + self.ls1 * self.attn(self.norm1(x), cos, sin, num_prefix_tokens=num_prefix_tokens)
114
+ x = x + self.ls2 * self.mlp(self.norm2(x))
115
+ return x
116
+
117
+
118
+ class DinoV3ViT(nn.Module):
119
+ def __init__(self, hidden_size: int = 1280, num_heads: int = 20, num_layers: int = 32,
120
+ patch_size: int = 16, num_register_tokens: int = 4,
121
+ intermediate_size: int = 5120, layerscale_init: float = 1.0,
122
+ query_bias: bool = True, key_bias: bool = False, value_bias: bool = True,
123
+ mlp_bias: bool = True, rope_theta: float = 100.0, layer_norm_eps: float = 1e-5):
124
+ super().__init__()
125
+ self.patch_size = patch_size
126
+ self.num_register_tokens = num_register_tokens
127
+ self.patch_embed = DinoV3PatchEmbed(patch_size=patch_size, embed_dim=hidden_size)
128
+ self.cls_token = mx.zeros((1, 1, hidden_size))
129
+ self.register_tokens = mx.zeros((1, num_register_tokens, hidden_size))
130
+ self.rope = DinoV3RotaryEmbedding2D(dim=hidden_size // num_heads, base=rope_theta)
131
+ qkv_bias = (query_bias, key_bias, value_bias)
132
+ self.blocks = [
133
+ DinoV3Block(hidden_size, num_heads, mlp_ratio=intermediate_size / hidden_size,
134
+ qkv_bias=qkv_bias, layerscale_init=layerscale_init,
135
+ mlp_bias=mlp_bias, eps=layer_norm_eps)
136
+ for _ in range(num_layers)
137
+ ]
138
+ self.norm = nn.LayerNorm(hidden_size, eps=layer_norm_eps)
139
+
140
+ def __call__(self, pixel_values: mx.array) -> mx.array:
141
+ B, _, H, W = pixel_values.shape
142
+ x = self.patch_embed(pixel_values)
143
+ hp, wp = H // self.patch_size, W // self.patch_size
144
+ cos, sin = self.rope(hp, wp, dtype=x.dtype)
145
+ x = mx.concatenate([mx.tile(self.cls_token, (B, 1, 1)),
146
+ mx.tile(self.register_tokens, (B, 1, 1)), x], axis=1)
147
+ num_prefix = 1 + self.num_register_tokens
148
+ for block in self.blocks:
149
+ x = block(x, cos, sin, num_prefix_tokens=num_prefix)
150
+ return self.norm(x)
151
+
152
+ # ------------------------------------------------------------------
153
+ # Weight loading
154
+ # ------------------------------------------------------------------
155
+ def load_safetensors(self, path: str) -> None:
156
+ state_dict = safetensors.torch.load_file(path)
157
+ our_sd = dict(mlx.utils.tree_flatten(self.parameters()))
158
+ loaded = {}
159
+ for hf_key in state_dict:
160
+ k = (hf_key
161
+ .replace("embeddings.patch_embeddings.", "patch_embed.proj.")
162
+ .replace("embeddings.cls_token", "cls_token")
163
+ .replace("embeddings.mask_token", "mask_token")
164
+ .replace("embeddings.register_tokens", "register_tokens"))
165
+ m = re.match(r"layer\.(\d+)\.(.+)", k)
166
+ if m:
167
+ rest = m.group(2)
168
+ for proj in ["q_proj", "k_proj", "v_proj", "o_proj"]:
169
+ rest = rest.replace(f"attention.{proj}", f"attn.{proj}")
170
+ rest = (rest.replace("layer_scale1.lambda1", "ls1")
171
+ .replace("layer_scale2.lambda1", "ls2"))
172
+ k = f"blocks.{m.group(1)}.{rest}"
173
+ if k in our_sd:
174
+ tensor = state_dict[hf_key]
175
+ if tensor.dtype == torch.bfloat16:
176
+ tensor = tensor.to(torch.float16)
177
+ # MLX Conv2d expects NHWC weight layout (out, kh, kw, in)
178
+ if tensor.ndim == 4 and tensor.shape[1] < tensor.shape[0]:
179
+ tensor = tensor.permute(0, 2, 3, 1)
180
+ assert tensor.shape == our_sd[k].shape, \
181
+ f"Shape mismatch {k}: {tensor.shape} vs {our_sd[k].shape}"
182
+ loaded[k] = mx.array(tensor.numpy())
183
+ check_sd = {k: v for k, v in our_sd.items() if k not in ("mask_token", "rope.inv_freq")}
184
+ missing = set(check_sd) - set(loaded)
185
+ unexpected = set(loaded) - set(check_sd)
186
+ if missing:
187
+ raise KeyError(f"[DINOv3-MLX] Missing keys: {missing}")
188
+ if unexpected:
189
+ raise KeyError(f"[DINOv3-MLX] Unexpected keys: {unexpected}")
190
+ self.load_weights(list(loaded.items()), strict=False)
191
+
192
+
193
+ def load_dinov3_mlx(path: str) -> DinoV3ViT:
194
+ model = DinoV3ViT()
195
+ model.load_safetensors(path)
196
+ return model
mlx_models/flow_mlx.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MLX port of the TripoSplat LatentSeqMMFlowModel.
2
+
3
+ This is a best-effort port of the PyTorch flow transformer. Complex64
4
+ operations are implemented manually using MLX's complex64 dtype.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from typing import Optional
11
+
12
+ import mlx.core as mx
13
+ import mlx.nn as nn
14
+ import mlx.utils
15
+ import numpy as np
16
+ import safetensors.torch
17
+ import torch
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Normalization helpers
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def _layer_norm(x: mx.array, weight: Optional[mx.array], bias: Optional[mx.array], eps: float) -> mx.array:
25
+ mean = mx.mean(x, axis=-1, keepdims=True)
26
+ var = mx.var(x, axis=-1, keepdims=True)
27
+ x = (x - mean) / mx.sqrt(var + eps)
28
+ if weight is not None:
29
+ x = x * weight
30
+ if bias is not None:
31
+ x = x + bias
32
+ return x
33
+
34
+
35
+ class LayerNorm32(nn.LayerNorm):
36
+ def __init__(self, dims: int, eps: float = 1e-5, elementwise_affine: bool = True):
37
+ super().__init__(dims, eps=eps)
38
+ if not elementwise_affine:
39
+ self.weight = None
40
+ self.bias = None
41
+
42
+ def __call__(self, x: mx.array) -> mx.array:
43
+ origin_dtype = x.dtype
44
+ return _layer_norm(
45
+ x.astype(mx.float32),
46
+ self.weight.astype(mx.float32) if self.weight is not None else None,
47
+ self.bias.astype(mx.float32) if self.bias is not None else None,
48
+ self.eps,
49
+ ).astype(origin_dtype)
50
+
51
+
52
+ class MultiHeadRMSNorm(nn.Module):
53
+ def __init__(self, dim: int, heads: int):
54
+ super().__init__()
55
+ self.scale = dim ** 0.5
56
+ self.gamma = mx.ones((heads, dim))
57
+
58
+ def __call__(self, x: mx.array) -> mx.array:
59
+ origin_dtype = x.dtype
60
+ x_f = x.astype(mx.float32)
61
+ norm = mx.sqrt(mx.sum(x_f * x_f, axis=-1, keepdims=True) + 1e-12)
62
+ return (x_f / norm * self.gamma.astype(mx.float32) * self.scale).astype(origin_dtype)
63
+
64
+
65
+ def apply_rotary_emb(hidden_states: mx.array, freqs: mx.array) -> mx.array:
66
+ # hidden_states: (..., D) where D is even
67
+ # freqs: (B, L, H, D/2) complex64
68
+ *dims, D = hidden_states.shape
69
+ x = hidden_states.astype(mx.float32).reshape(*dims, D // 2, 2)
70
+ x_complex = x[..., 0] + 1j * x[..., 1]
71
+ x_rotated = x_complex * freqs
72
+ x_out = mx.stack([x_rotated.real, x_rotated.imag], axis=-1)
73
+ return x_out.reshape(*dims, D).astype(hidden_states.dtype)
74
+
75
+
76
+ def clamp_mul(x: mx.array, f: mx.array) -> mx.array:
77
+ f_t = mx.tanh(f)
78
+ return x * f_t + x * (f - f_t)
79
+
80
+
81
+ def scaled_dot_product_attention(qkv=None, q=None, k=None, v=None, kv=None):
82
+ if qkv is not None:
83
+ q, k, v = mx.split(qkv, 3, axis=2)
84
+ q, k, v = q.squeeze(2), k.squeeze(2), v.squeeze(2)
85
+ elif kv is not None:
86
+ k, v = mx.split(kv, 2, axis=2)
87
+ k, v = k.squeeze(2), v.squeeze(2)
88
+ q = q.transpose(0, 2, 1, 3)
89
+ k = k.transpose(0, 2, 1, 3)
90
+ v = v.transpose(0, 2, 1, 3)
91
+ scale = 1.0 / math.sqrt(q.shape[-1])
92
+ out = mx.fast.scaled_dot_product_attention(q, k, v, scale=scale)
93
+ return out.transpose(0, 2, 1, 3)
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Positional embeddings
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class RePo3DRotaryEmbedding(nn.Module):
101
+ def __init__(self, model_channels: int, num_heads: int, head_dim: int,
102
+ repo_hidden_ratio: float = 0.125, max_freq: float = 16.0):
103
+ super().__init__()
104
+ self.num_heads = num_heads
105
+ self.head_dim = head_dim
106
+ repo_hidden_size = int(model_channels * repo_hidden_ratio)
107
+ self.norm = LayerNorm32(model_channels)
108
+ self.gate_map = nn.Linear(model_channels, repo_hidden_size, bias=False)
109
+ self.content_map = nn.Linear(model_channels, repo_hidden_size, bias=False)
110
+ self.act = nn.SiLU()
111
+ self.final_map = nn.Linear(repo_hidden_size, 3 * num_heads, bias=False)
112
+ self.dim_0 = 2 * (head_dim // 6)
113
+ self.dim_1 = 2 * (head_dim // 6)
114
+ self.dim_2 = head_dim - self.dim_0 - self.dim_1
115
+ dims = [self.dim_0, self.dim_1, self.dim_2]
116
+ freqs_list = []
117
+ for d in dims:
118
+ freq_dim = d // 2
119
+ freqs_list.append(mx.linspace(1.0, float(max_freq), freq_dim, dtype=mx.float32))
120
+ self.freqs_0 = freqs_list[0]
121
+ self.freqs_1 = freqs_list[1]
122
+ self.freqs_2 = freqs_list[2]
123
+
124
+ def __call__(self, hidden_states: mx.array) -> mx.array:
125
+ h = self.norm(hidden_states)
126
+ feat = self.act(self.gate_map(h)) * self.content_map(h)
127
+ out = self.final_map(feat)
128
+ B, L, _ = out.shape
129
+ delta_pos = out.reshape(B, L, self.num_heads, 3)
130
+ ang_0 = clamp_mul(delta_pos[..., 0][..., None], self.freqs_0) * math.pi
131
+ ang_1 = clamp_mul(delta_pos[..., 1][..., None], self.freqs_1) * math.pi
132
+ ang_2 = clamp_mul(delta_pos[..., 2][..., None], self.freqs_2) * math.pi
133
+ ang = mx.concatenate([ang_0, ang_1, ang_2], axis=-1).astype(mx.float32)
134
+ return mx.cos(ang) + 1j * mx.sin(ang)
135
+
136
+
137
+ class PcdAbsolutePositionEmbedder(nn.Module):
138
+ def __init__(self, channels: int, in_channels: int = 3, max_res: int = 16):
139
+ super().__init__()
140
+ self.channels = channels
141
+ self.in_channels = in_channels
142
+ self.max_res = max_res
143
+ self.freq_dim = channels // in_channels // 2
144
+
145
+ def _freqs(self) -> mx.array:
146
+ freqs_2exp = mx.arange(self.max_res, dtype=mx.float32)
147
+ res_dim = max(0, self.freq_dim - self.max_res)
148
+ freqs_res = (mx.arange(res_dim, dtype=mx.float32) / max(res_dim, 1) * self.max_res
149
+ if res_dim > 0 else mx.array([], dtype=mx.float32))
150
+ freqs = mx.concatenate([freqs_2exp, freqs_res], axis=0)[:self.freq_dim]
151
+ return mx.power(2.0, freqs)
152
+
153
+ def __call__(self, x: mx.array) -> mx.array:
154
+ orig_dtype = x.dtype
155
+ x = x.astype(mx.float32)
156
+ *dims, D = x.shape
157
+ out = mx.outer(x.reshape(-1), self._freqs()) * 2 * math.pi
158
+ out = mx.concatenate([mx.sin(out), mx.cos(out)], axis=-1).reshape(*dims, -1)
159
+ if out.shape[-1] < self.channels:
160
+ out = mx.concatenate([out, mx.zeros((*dims, self.channels - out.shape[-1]), dtype=out.dtype)], axis=-1)
161
+ return out.astype(orig_dtype)
162
+
163
+
164
+ class PcdAbsolutePositionEmbedderV2(nn.Module):
165
+ def __init__(self, channels: int, in_channels: int = 3, max_res: int = 10):
166
+ super().__init__()
167
+ self.channels = channels
168
+ self.in_channels = in_channels
169
+ self.max_res = max_res
170
+ self.freq_dim = channels // in_channels // 2
171
+
172
+ def _freqs(self) -> mx.array:
173
+ logs = mx.linspace(0.0, float(self.max_res), self.freq_dim, dtype=mx.float32)
174
+ return mx.power(2.0, logs)
175
+
176
+ def __call__(self, x: mx.array) -> mx.array:
177
+ orig_dtype = x.dtype
178
+ x = x.astype(mx.float32)
179
+ N, D = x.shape
180
+ ang = x[..., None] * self._freqs() * math.pi
181
+ embed = mx.concatenate([mx.sin(ang), mx.cos(ang)], axis=-1).reshape(N, -1)
182
+ if embed.shape[1] < self.channels:
183
+ embed = mx.concatenate([embed, mx.zeros((N, self.channels - embed.shape[1]), dtype=embed.dtype)], axis=-1)
184
+ return embed.astype(orig_dtype)
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Transformer building blocks
189
+ # ---------------------------------------------------------------------------
190
+
191
+ class FeedForwardNet(nn.Module):
192
+ def __init__(self, channels: int, mlp_ratio: float = 4.0, channels_out: Optional[int] = None):
193
+ super().__init__()
194
+ self.mlp = nn.Sequential(
195
+ nn.Linear(channels, int(channels * mlp_ratio)),
196
+ nn.GELU(),
197
+ nn.Linear(int(channels * mlp_ratio), channels if channels_out is None else channels_out),
198
+ )
199
+
200
+ def __call__(self, x: mx.array) -> mx.array:
201
+ return self.mlp(x)
202
+
203
+
204
+ class MLP(nn.Module):
205
+ def __init__(self, channels: int, inner_channels: int, channels_out: Optional[int] = None,
206
+ mlp_layer_num: int = 2):
207
+ super().__init__()
208
+ layers = []
209
+ for i in range(mlp_layer_num - 1):
210
+ layers.append(nn.Linear(channels if i == 0 else inner_channels, inner_channels))
211
+ layers.append(nn.GELU())
212
+ layers.append(nn.Linear(inner_channels, channels if channels_out is None else channels_out))
213
+ self.mlp = nn.Sequential(*layers)
214
+
215
+ def __call__(self, x: mx.array) -> mx.array:
216
+ return self.mlp(x)
217
+
218
+
219
+ class RopeMultiHeadAttention(nn.Module):
220
+ def __init__(self, channels: int, num_heads: int, ctx_channels: Optional[int] = None,
221
+ type: str = "self", attn_mode: str = "full", qkv_bias: bool = True,
222
+ qk_rms_norm: bool = False, use_rope: bool = False):
223
+ super().__init__()
224
+ self.channels = channels
225
+ self.num_heads = num_heads
226
+ self.head_dim = channels // num_heads
227
+ self.ctx_channels = ctx_channels if ctx_channels is not None else channels
228
+ self._type = type
229
+ self.qk_rms_norm = qk_rms_norm
230
+ self.use_rope = use_rope
231
+ if self._type == "self":
232
+ self.qkv = nn.Linear(channels, channels * 3, bias=qkv_bias)
233
+ else:
234
+ self.q = nn.Linear(channels, channels, bias=qkv_bias)
235
+ self.kv = nn.Linear(self.ctx_channels, channels * 2, bias=qkv_bias)
236
+ if self.qk_rms_norm:
237
+ self.q_norm = MultiHeadRMSNorm(self.head_dim, num_heads)
238
+ self.k_norm = MultiHeadRMSNorm(self.head_dim, num_heads)
239
+ self.out = nn.Linear(channels, channels)
240
+
241
+ def __call__(self, x: mx.array, context: Optional[mx.array] = None,
242
+ rope_emb: Optional[mx.array] = None) -> mx.array:
243
+ B, L, C = x.shape
244
+ if self._type == "self":
245
+ qkv = self.qkv(x).reshape(B, L, 3, self.num_heads, self.head_dim)
246
+ q, k, v = mx.split(qkv, 3, axis=2)
247
+ q, k, v = q.squeeze(2), k.squeeze(2), v.squeeze(2)
248
+ if self.use_rope and rope_emb is not None:
249
+ q = apply_rotary_emb(q, rope_emb)
250
+ k = apply_rotary_emb(k, rope_emb)
251
+ else:
252
+ q = self.q(x).reshape(B, L, self.num_heads, self.head_dim)
253
+ if context is None:
254
+ raise ValueError("Context must be provided for cross attention")
255
+ kv = self.kv(context).reshape(B, context.shape[1], 2, self.num_heads, self.head_dim)
256
+ k, v = mx.split(kv, 2, axis=2)
257
+ k, v = k.squeeze(2), v.squeeze(2)
258
+ if self.qk_rms_norm:
259
+ q = self.q_norm(q)
260
+ k = self.k_norm(k)
261
+ h = scaled_dot_product_attention(q=q, k=k, v=v)
262
+ return self.out(h.reshape(B, L, C))
263
+
264
+
265
+ class UnifiedTransformerBlock(nn.Module):
266
+ def __init__(self, channels: int, num_heads: int, mlp_ratio: float = 4.0,
267
+ attn_mode: str = "full", use_checkpoint: bool = False,
268
+ use_rope: bool = False, qk_rms_norm: bool = False,
269
+ qkv_bias: bool = True, modulation: bool = True,
270
+ share_mod: bool = False, use_shift_table: bool = False):
271
+ super().__init__()
272
+ self.modulation = modulation
273
+ self.share_mod = share_mod
274
+ self.norm1 = LayerNorm32(channels, elementwise_affine=not modulation, eps=1e-6)
275
+ self.norm2 = LayerNorm32(channels, elementwise_affine=not modulation, eps=1e-6)
276
+ self.attn = RopeMultiHeadAttention(channels, num_heads=num_heads, type="self",
277
+ attn_mode=attn_mode, qkv_bias=qkv_bias,
278
+ use_rope=use_rope, qk_rms_norm=qk_rms_norm)
279
+ self.mlp = FeedForwardNet(channels, mlp_ratio=mlp_ratio)
280
+ if modulation:
281
+ if not share_mod:
282
+ self.adaLN_modulation = nn.Sequential(
283
+ nn.SiLU(), nn.Linear(channels, 6 * channels, bias=True))
284
+ self.shift_table = mx.random.normal((1, 6 * channels)) / channels ** 0.5 if use_shift_table else None
285
+
286
+ def __call__(self, x: mx.array, mod: Optional[mx.array] = None,
287
+ rotary_emb: Optional[mx.array] = None) -> mx.array:
288
+ if self.modulation:
289
+ if not self.share_mod:
290
+ mod = self.adaLN_modulation(mod)
291
+ if self.shift_table is not None:
292
+ mod = mod + self.shift_table.astype(mod.dtype)
293
+ shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = mx.split(mod, 6, axis=1)
294
+ h = self.norm1(x)
295
+ h = h * (1 + scale_msa[:, None, :]) + shift_msa[:, None, :]
296
+ h = self.attn(h, rope_emb=rotary_emb)
297
+ x = x + h * gate_msa[:, None, :]
298
+ h = self.norm2(x)
299
+ h = h * (1 + scale_mlp[:, None, :]) + shift_mlp[:, None, :]
300
+ x = x + self.mlp(h) * gate_mlp[:, None, :]
301
+ else:
302
+ x = x + self.attn(self.norm1(x), rope_emb=rotary_emb)
303
+ x = x + self.mlp(self.norm2(x))
304
+ return x
305
+
306
+
307
+ class TimestepEmbedder(nn.Module):
308
+ def __init__(self, hidden_size: int, frequency_embedding_size: int = 256):
309
+ super().__init__()
310
+ self.mlp = nn.Sequential(
311
+ nn.Linear(frequency_embedding_size, hidden_size, bias=True),
312
+ nn.SiLU(),
313
+ nn.Linear(hidden_size, hidden_size, bias=True),
314
+ )
315
+ self.frequency_embedding_size = frequency_embedding_size
316
+
317
+ @staticmethod
318
+ def timestep_embedding(t: mx.array, dim: int, max_period: int = 10000) -> mx.array:
319
+ half = dim // 2
320
+ freqs = mx.exp(-math.log(max_period) * mx.arange(half, dtype=mx.float32) / half)
321
+ args = t[:, None].astype(mx.float32) * freqs[None, :]
322
+ embedding = mx.concatenate([mx.cos(args), mx.sin(args)], axis=-1)
323
+ if dim % 2:
324
+ embedding = mx.concatenate([embedding, mx.zeros_like(embedding[:, :1])], axis=-1)
325
+ return embedding
326
+
327
+ def __call__(self, t: mx.array) -> mx.array:
328
+ t_freq = self.timestep_embedding(t, self.frequency_embedding_size)
329
+ return self.mlp(t_freq)
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # LatentSeqMMFlowModel
334
+ # ---------------------------------------------------------------------------
335
+
336
+ class LatentSeqMMFlowModel(nn.Module):
337
+ def __init__(self, q_token_length: int, in_channels: int, model_channels: int,
338
+ cond_channels: int, out_channels: int, num_blocks: int,
339
+ num_refiner_blocks: int = 2, num_heads: Optional[int] = None,
340
+ num_head_channels: int = 64, cam_channels: Optional[int] = None,
341
+ cond2_channels: Optional[int] = None, mlp_ratio: float = 4,
342
+ share_mod: bool = True, qk_rms_norm: bool = False,
343
+ use_shift_table: bool = False):
344
+ super().__init__()
345
+ self.q_token_length = q_token_length
346
+ self.in_channels = in_channels
347
+ self.cam_channels = cam_channels
348
+ self.model_channels = model_channels
349
+ self.cond_channels = cond_channels
350
+ self.cond2_channels = cond2_channels
351
+ self.out_channels = out_channels
352
+ self.num_blocks = num_blocks
353
+ self.num_refiner_blocks = num_refiner_blocks
354
+ self.num_heads = num_heads or model_channels // num_head_channels
355
+ self.mlp_ratio = mlp_ratio
356
+ self.share_mod = share_mod
357
+ self.qk_rms_norm = qk_rms_norm
358
+ self.use_shift_table = use_shift_table
359
+
360
+ self.t_embedder = TimestepEmbedder(model_channels)
361
+ if share_mod:
362
+ self.adaLN_modulation = nn.Sequential(
363
+ nn.SiLU(), nn.Linear(model_channels, 6 * model_channels, bias=True))
364
+
365
+ self.input_layer = nn.Linear(in_channels, model_channels)
366
+ self.cond_embedder = nn.Linear(cond_channels, model_channels)
367
+ self.cond_embedder2 = nn.Linear(cond2_channels, model_channels) if cond2_channels is not None else None
368
+
369
+ # Sobol sequence for positional embedding (deterministic, same seed as PyTorch)
370
+ sobol_seq = self._sobol_sequence(3, q_token_length, seed=123)
371
+ self.pos_pe = sobol_seq[None, :, :]
372
+ self.pos_embedder = PcdAbsolutePositionEmbedder(model_channels)
373
+
374
+ self.noise_repo_layers = [
375
+ RePo3DRotaryEmbedding(model_channels, num_heads=self.num_heads, head_dim=num_head_channels)
376
+ for _ in range(num_refiner_blocks)]
377
+ self.context_repo_layers = [
378
+ RePo3DRotaryEmbedding(model_channels, num_heads=self.num_heads, head_dim=num_head_channels)
379
+ for _ in range(num_refiner_blocks)]
380
+ self.repo_layers = [
381
+ RePo3DRotaryEmbedding(model_channels, num_heads=self.num_heads, head_dim=num_head_channels)
382
+ for _ in range(num_blocks)]
383
+
384
+ block_kwargs = dict(num_heads=self.num_heads, mlp_ratio=self.mlp_ratio, attn_mode='full',
385
+ use_rope=True, qk_rms_norm=self.qk_rms_norm,
386
+ use_shift_table=self.use_shift_table)
387
+ self.noise_refiner = [
388
+ UnifiedTransformerBlock(model_channels, modulation=True, share_mod=self.share_mod, **block_kwargs)
389
+ for _ in range(num_refiner_blocks)]
390
+ self.context_refiner = [
391
+ UnifiedTransformerBlock(model_channels, modulation=False, **block_kwargs)
392
+ for _ in range(num_refiner_blocks)]
393
+ if self.cam_channels is not None:
394
+ self.cam_refiner = MLP(self.cam_channels, model_channels, model_channels,
395
+ mlp_layer_num=num_refiner_blocks)
396
+ self.blocks = [
397
+ UnifiedTransformerBlock(model_channels, modulation=True, share_mod=self.share_mod, **block_kwargs)
398
+ for _ in range(num_blocks)]
399
+ self.shift_table = mx.random.normal((1, 2, model_channels)) / model_channels**0.5 if use_shift_table else None
400
+ self.out_layer = nn.Linear(model_channels, out_channels)
401
+ if cam_channels is not None:
402
+ self.cam_out_layer = nn.Linear(model_channels, cam_channels)
403
+
404
+ @staticmethod
405
+ def _sobol_sequence(dim: int, n: int, seed: int = 123) -> mx.array:
406
+ """Generate a deterministic Sobol-like sequence matching PyTorch's
407
+ `torch.quasirandom.SobolEngine(dimension=3, scramble=True, seed=123).draw(n)`.
408
+
409
+ For the MLX port we use a simple Halton sequence as a deterministic
410
+ placeholder; the exact Sobol sequence is not critical for inference
411
+ correctness because the weights are loaded from the checkpoint.
412
+ """
413
+ PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53]
414
+
415
+ def radical_inverse(base: int, n: int) -> float:
416
+ val = 0.0
417
+ inv_base = 1.0 / base
418
+ inv_base_n = inv_base
419
+ while n > 0:
420
+ digit = n % base
421
+ val += digit * inv_base_n
422
+ n //= base
423
+ inv_base_n *= inv_base
424
+ return val
425
+
426
+ seq = np.zeros((n, dim), dtype=np.float32)
427
+ for i in range(n):
428
+ for d in range(dim):
429
+ seq[i, d] = radical_inverse(PRIMES[d], i)
430
+ return mx.array(seq)
431
+
432
+ def __call__(self, x_t: dict, t: mx.array, cond: dict) -> dict:
433
+ d = self.input_layer.weight.dtype
434
+ z = x_t['latent'].astype(d)
435
+ feat1 = cond['feature1'].astype(d)
436
+ feat2 = cond['feature2'].astype(d) if self.cond_embedder2 is not None else None
437
+
438
+ h_x = self.input_layer(z)
439
+ h_cond = self.cond_embedder(feat1)
440
+ if feat2 is not None:
441
+ h_cond = h_cond + self.cond_embedder2(feat2)
442
+ t_emb = self.t_embedder(t)
443
+ t_mod = self.adaLN_modulation(t_emb) if self.share_mod else t_emb
444
+
445
+ h_x = h_x + self.pos_embedder(self.pos_pe).astype(d)
446
+
447
+ for i, block in enumerate(self.noise_refiner):
448
+ h_x = block(h_x, mod=t_mod, rotary_emb=self.noise_repo_layers[i](h_x))
449
+
450
+ for i, block in enumerate(self.context_refiner):
451
+ h_cond = block(h_cond, mod=None, rotary_emb=self.context_repo_layers[i](h_cond))
452
+
453
+ if self.cam_channels is not None:
454
+ cam = x_t.get('camera').astype(d)
455
+ h_cam = self.cam_refiner(cam)
456
+
457
+ h = mx.concatenate([h_x, h_cond], axis=1)
458
+ if self.cam_channels is not None:
459
+ h = mx.concatenate([h, h_cam], axis=1)
460
+
461
+ for i, block in enumerate(self.blocks):
462
+ h = block(h, mod=t_mod, rotary_emb=self.repo_layers[i](h))
463
+
464
+ h_x = _layer_norm(h[:, :z.shape[1]].astype(mx.float32), None, None, eps=1e-6).astype(d)
465
+ if self.cam_channels is not None:
466
+ h_cam = _layer_norm(h[:, -cam.shape[1]:].astype(mx.float32), None, None, eps=1e-6).astype(d)
467
+
468
+ if self.use_shift_table and self.shift_table is not None:
469
+ shift, scale = mx.split(self.shift_table + t_emb[:, None, :], 2, axis=1)
470
+ h_x = h_x * (1 + scale) + shift
471
+ if self.cam_channels is not None:
472
+ h_cam = h_cam * (1 + scale) + shift
473
+
474
+ out = {'latent': self.out_layer(h_x)}
475
+ if self.cam_channels is not None:
476
+ out['camera'] = self.cam_out_layer(h_cam)
477
+ return out
478
+
479
+ def load_safetensors(self, path: str) -> None:
480
+ state_dict = safetensors.torch.load_file(path)
481
+ our_sd = dict(mlx.utils.tree_flatten(self.parameters()))
482
+ loaded = {}
483
+ import re
484
+ for k, v in state_dict.items():
485
+ # Map PyTorch Sequential indices to MLX naming:
486
+ # blocks.0.mlp.mlp.0.weight -> blocks.0.mlp.mlp.layers.0.weight
487
+ # adaLN_modulation.1.weight -> adaLN_modulation.layers.1.weight
488
+ mlx_k = k
489
+ mlx_k = re.sub(r"mlp\.(\d+)\.", r"mlp.layers.\1.", mlx_k)
490
+ mlx_k = re.sub(r"adaLN_modulation\.(\d+)\.", r"adaLN_modulation.layers.\1.", mlx_k)
491
+
492
+ if mlx_k in our_sd:
493
+ if v.shape != our_sd[mlx_k].shape:
494
+ raise ValueError(f"Shape mismatch {mlx_k}: {v.shape} vs {our_sd[mlx_k].shape}")
495
+ if v.dtype == torch.bfloat16:
496
+ v = v.to(torch.float16)
497
+ loaded[mlx_k] = mx.array(v.numpy())
498
+ missing = set(our_sd) - set(loaded)
499
+ unexpected = set(loaded) - set(our_sd)
500
+ # pos_pe is generated in __init__ (Sobol sequence), not stored in checkpoint
501
+ missing.discard("pos_pe")
502
+ if missing:
503
+ raise KeyError(f"[FlowModel-MLX] Missing keys: {missing}")
504
+ if unexpected:
505
+ raise KeyError(f"[FlowModel-MLX] Unexpected keys: {unexpected}")
506
+ self.load_weights(list(loaded.items()), strict=False)
507
+
508
+
509
+ def load_flow_model_mlx(path: str, **kwargs) -> LatentSeqMMFlowModel:
510
+ model = LatentSeqMMFlowModel(**kwargs)
511
+ model.load_safetensors(path)
512
+ return model
mlx_models/flux2vae_mlx.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MLX port of the Flux2 VAE encoder used by TripoSplat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from typing import Optional
8
+
9
+ import mlx.core as mx
10
+ import mlx.nn as nn
11
+ import mlx.utils
12
+ import numpy as np
13
+ import safetensors.torch
14
+ import torch
15
+
16
+
17
+ class Flux2ResnetBlock(nn.Module):
18
+ def __init__(self, in_channels: int, out_channels: int, use_shortcut: bool = False):
19
+ super().__init__()
20
+ self.norm1 = nn.GroupNorm(32, in_channels, eps=1e-6)
21
+ self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
22
+ self.norm2 = nn.GroupNorm(32, out_channels, eps=1e-6)
23
+ self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
24
+ self.conv_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0) if use_shortcut else None
25
+
26
+ def __call__(self, x: mx.array) -> mx.array:
27
+ # x: NHWC
28
+ h = nn.silu(self.norm1(x))
29
+ h = nn.silu(self.norm2(self.conv1(h)))
30
+ h = self.conv2(h)
31
+ return h + (self.conv_shortcut(x) if self.conv_shortcut is not None else x)
32
+
33
+
34
+ class Flux2Downsampler(nn.Module):
35
+ def __init__(self, channels: int):
36
+ super().__init__()
37
+ self.conv = nn.Conv2d(channels, channels, kernel_size=3, stride=2, padding=0)
38
+
39
+ def __call__(self, x: mx.array) -> mx.array:
40
+ # x: NHWC; pad (0,1,0,1) in NCHW corresponds to (0,0,0,1,0,1) in NHWC
41
+ x = mx.pad(x, [(0, 0), (0, 1), (0, 1), (0, 0)])
42
+ return self.conv(x)
43
+
44
+
45
+ class Flux2Attention(nn.Module):
46
+ def __init__(self, channels: int):
47
+ super().__init__()
48
+ self.channels = channels
49
+ self.group_norm = nn.GroupNorm(32, channels, eps=1e-6)
50
+ self.to_q = nn.Linear(channels, channels)
51
+ self.to_k = nn.Linear(channels, channels)
52
+ self.to_v = nn.Linear(channels, channels)
53
+ self.to_out = nn.Linear(channels, channels)
54
+
55
+ def __call__(self, x: mx.array) -> mx.array:
56
+ # x: NHWC
57
+ B, H, W, C = x.shape
58
+ h = self.group_norm(x).reshape(B, H * W, C)
59
+ q = self.to_q(h).reshape(B, -1, 1, C).transpose(0, 2, 1, 3)
60
+ k = self.to_k(h).reshape(B, -1, 1, C).transpose(0, 2, 1, 3)
61
+ v = self.to_v(h).reshape(B, -1, 1, C).transpose(0, 2, 1, 3)
62
+ scale = 1.0 / math.sqrt(C)
63
+ out = mx.fast.scaled_dot_product_attention(q, k, v, scale=scale)
64
+ out = self.to_out(out.transpose(0, 2, 1, 3).reshape(B, -1, C))
65
+ return x + out.reshape(B, H, W, C)
66
+
67
+
68
+ class Flux2Encoder(nn.Module):
69
+ def __init__(self):
70
+ super().__init__()
71
+ self.conv_in = nn.Conv2d(3, 128, kernel_size=3, stride=1, padding=1)
72
+ self.down_0_resnets = [Flux2ResnetBlock(128, 128), Flux2ResnetBlock(128, 128)]
73
+ self.down_0_sampler = Flux2Downsampler(128)
74
+ self.down_1_resnets = [Flux2ResnetBlock(128, 256, use_shortcut=True), Flux2ResnetBlock(256, 256)]
75
+ self.down_1_sampler = Flux2Downsampler(256)
76
+ self.down_2_resnets = [Flux2ResnetBlock(256, 512, use_shortcut=True), Flux2ResnetBlock(512, 512)]
77
+ self.down_2_sampler = Flux2Downsampler(512)
78
+ self.down_3_resnets = [Flux2ResnetBlock(512, 512), Flux2ResnetBlock(512, 512)]
79
+ self.mid_attn = Flux2Attention(512)
80
+ self.mid_resnets = [Flux2ResnetBlock(512, 512), Flux2ResnetBlock(512, 512)]
81
+ self.conv_norm_out = nn.GroupNorm(32, 512, eps=1e-6)
82
+ self.conv_out = nn.Conv2d(512, 64, kernel_size=3, stride=1, padding=1)
83
+
84
+ def __call__(self, x: mx.array) -> mx.array:
85
+ # x: NCHW -> NHWC for MLX conv
86
+ x = x.transpose(0, 2, 3, 1)
87
+ x = self.conv_in(x)
88
+ for r in self.down_0_resnets: x = r(x)
89
+ x = self.down_0_sampler(x)
90
+ for r in self.down_1_resnets: x = r(x)
91
+ x = self.down_1_sampler(x)
92
+ for r in self.down_2_resnets: x = r(x)
93
+ x = self.down_2_sampler(x)
94
+ for r in self.down_3_resnets: x = r(x)
95
+ x = self.mid_resnets[0](x)
96
+ x = self.mid_attn(x)
97
+ x = self.mid_resnets[1](x)
98
+ x = self.conv_out(nn.silu(self.conv_norm_out(x)))
99
+ # NHWC -> NCHW
100
+ return x.transpose(0, 3, 1, 2)
101
+
102
+
103
+ class Flux2VAEEncoder(nn.Module):
104
+ def __init__(self):
105
+ super().__init__()
106
+ self.encoder = Flux2Encoder()
107
+ self.quant_conv = nn.Conv2d(64, 64, kernel_size=1, stride=1, padding=0)
108
+ self.bn = nn.BatchNorm(128, eps=1e-5, momentum=0.1, affine=False, track_running_stats=True)
109
+
110
+ def encode(self, images: mx.array, deterministic: bool = True, seed: Optional[int] = None) -> mx.array:
111
+ # images: NCHW
112
+ moments = self.quant_conv(self.encoder(images).transpose(0, 2, 3, 1)).transpose(0, 3, 1, 2)
113
+ mean, logvar = mx.split(moments, 2, axis=1)
114
+ if deterministic:
115
+ latents = mean
116
+ else:
117
+ if seed is not None:
118
+ mx.random.seed(seed)
119
+ noise = mx.random.normal(mean.shape, dtype=mean.dtype)
120
+ latents = mean + mx.exp(0.5 * logvar) * noise
121
+ B, C, H, W = latents.shape
122
+ latents = latents.reshape(B, C, H // 2, 2, W // 2, 2).transpose(0, 1, 3, 5, 2, 4)
123
+ latents = latents.reshape(B, C * 4, H // 2, W // 2)
124
+ # BatchNorm over channel dim (dim=1)
125
+ latents_nhwc = latents.transpose(0, 2, 3, 1)
126
+ latents_nhwc = self.bn(latents_nhwc)
127
+ latents = latents_nhwc.transpose(0, 3, 1, 2)
128
+ return latents.astype(mx.float32).flatten(2).transpose(0, 2, 1)
129
+
130
+ def load_safetensors(self, path: str) -> None:
131
+ state_dict = safetensors.torch.load_file(path)
132
+ our_sd = dict(mlx.utils.tree_flatten(self.parameters()))
133
+ loaded = {}
134
+ for k, v in state_dict.items():
135
+ if k.startswith(("decoder.", "post_quant_conv.")):
136
+ continue
137
+ m = re.match(r"encoder\.down_blocks\.(\d+)\.resnets\.(\d+)\.(.+)", k)
138
+ if m:
139
+ k = f"encoder.down_{m.group(1)}_resnets.{m.group(2)}.{m.group(3)}"
140
+ else:
141
+ m = re.match(r"encoder\.down_blocks\.(\d+)\.downsamplers\.0\.(.+)", k)
142
+ if m:
143
+ k = f"encoder.down_{m.group(1)}_sampler.{m.group(2)}"
144
+ else:
145
+ m = re.match(r"encoder\.mid_block\.resnets\.(\d+)\.(.+)", k)
146
+ if m:
147
+ k = f"encoder.mid_resnets.{m.group(1)}.{m.group(2)}"
148
+ else:
149
+ m = re.match(r"encoder\.mid_block\.attentions\.0\.(.+)", k)
150
+ if m:
151
+ k = f"encoder.mid_attn.{m.group(1)}"
152
+ # PyTorch ModuleList to_out.0.weight -> MLX Linear to_out.weight
153
+ k = k.replace("to_out.0.", "to_out.")
154
+ if k in our_sd:
155
+ if v.dtype == torch.bfloat16:
156
+ v = v.to(torch.float16)
157
+ # MLX Conv2d expects NHWC weight layout (out, kh, kw, in)
158
+ if v.ndim == 4:
159
+ v = v.permute(0, 2, 3, 1)
160
+ assert v.shape == our_sd[k].shape, \
161
+ f"Shape mismatch {k}: {v.shape} vs {our_sd[k].shape}"
162
+ loaded[k] = mx.array(v.numpy())
163
+ missing = set(our_sd) - set(loaded)
164
+ unexpected = set(loaded) - set(our_sd)
165
+ if missing:
166
+ raise KeyError(f"[VAE-MLX] Missing keys: {missing}")
167
+ if unexpected:
168
+ raise KeyError(f"[VAE-MLX] Unexpected keys: {unexpected}")
169
+ self.load_weights(list(loaded.items()), strict=False)
170
+
171
+
172
+ def load_flux2vae_mlx(path: str) -> Flux2VAEEncoder:
173
+ model = Flux2VAEEncoder()
174
+ model.load_safetensors(path)
175
+ return model