Hunyuan3D-2.2-mrp-MLX/hy3dpaint/hunyuanpaintpbr_mlx/vae_mlx.py
modelbeast e4cfa9d1e9 Clean MLX build for MODELBEAST (inference-only)
Fork of dgrauet/Hunyuan3D-2.1-mlx + our generate_e2e.py CLI, env-tunable
remesh (HY3D_REMESH_FACES), and HARDWARE.md. Upstream training data
(mini_trainset) and demo images stripped — inference needs none of it.
Full upstream history: github.com/dgrauet/Hunyuan3D-2.1-mlx
2026-07-16 14:38:14 +10:00

418 lines
13 KiB
Python

"""MLX port of Stable Diffusion 2.1 AutoencoderKL (VAE).
Encodes images to latent space and decodes latents back to images.
All tensors use NHWC layout: (B, H, W, C).
"""
import math
from typing import Dict, Optional, Tuple
import mlx.core as mx
import mlx.nn as nn
# ---------------------------------------------------------------------------
# Building Blocks
# ---------------------------------------------------------------------------
class ResnetBlock2D(nn.Module):
"""Residual block: GroupNorm -> SiLU -> Conv -> GroupNorm -> SiLU -> Conv + skip."""
def __init__(self, in_channels: int, out_channels: int, num_groups: int = 32):
super().__init__()
self.norm1 = nn.GroupNorm(num_groups, in_channels, pytorch_compatible=True)
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1)
self.norm2 = nn.GroupNorm(num_groups, out_channels, pytorch_compatible=True)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
if in_channels != out_channels:
self.conv_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1)
else:
self.conv_shortcut = None
def __call__(self, x: mx.array) -> mx.array:
residual = x
h = self.norm1(x)
h = nn.silu(h)
h = self.conv1(h)
h = self.norm2(h)
h = nn.silu(h)
h = self.conv2(h)
if self.conv_shortcut is not None:
residual = self.conv_shortcut(residual)
return h + residual
class AttentionBlock(nn.Module):
"""Single-head self-attention for VAE mid block.
input -> GroupNorm -> reshape (B, H*W, C) -> Q,K,V -> attention -> proj_out -> reshape -> + input
"""
def __init__(self, channels: int, num_groups: int = 32):
super().__init__()
self.group_norm = nn.GroupNorm(num_groups, channels, pytorch_compatible=True)
self.to_q = nn.Linear(channels, channels)
self.to_k = nn.Linear(channels, channels)
self.to_v = nn.Linear(channels, channels)
self.to_out = nn.Linear(channels, channels)
self.channels = channels
def __call__(self, x: mx.array) -> mx.array:
residual = x
B, H, W, C = x.shape
h = self.group_norm(x)
h = h.reshape(B, H * W, C)
q = self.to_q(h)
k = self.to_k(h)
v = self.to_v(h)
# Single-head attention: (B, L, C)
scale = 1.0 / math.sqrt(C)
scores = (q @ k.transpose(0, 2, 1)) * scale # (B, L, L)
weights = mx.softmax(scores, axis=-1)
attn_out = weights @ v # (B, L, C)
attn_out = self.to_out(attn_out)
attn_out = attn_out.reshape(B, H, W, C)
return attn_out + residual
class Downsample2D(nn.Module):
"""Spatial downsample via stride-2 convolution."""
def __init__(self, channels: int):
super().__init__()
self.conv = nn.Conv2d(channels, channels, kernel_size=3, stride=2, padding=1)
def __call__(self, x: mx.array) -> mx.array:
return self.conv(x)
class Upsample2D(nn.Module):
"""Spatial upsample via nearest-neighbor interpolation + convolution."""
def __init__(self, channels: int):
super().__init__()
self.upsample = nn.Upsample(scale_factor=2.0, mode="nearest")
self.conv = nn.Conv2d(channels, channels, kernel_size=3, padding=1)
def __call__(self, x: mx.array) -> mx.array:
x = self.upsample(x)
return self.conv(x)
# ---------------------------------------------------------------------------
# Encoder / Decoder Blocks
# ---------------------------------------------------------------------------
class DownEncoderBlock2D(nn.Module):
"""Encoder block: N ResnetBlocks + optional Downsample."""
def __init__(
self,
in_channels: int,
out_channels: int,
num_layers: int = 2,
add_downsample: bool = True,
):
super().__init__()
self.resnets = []
for i in range(num_layers):
ch_in = in_channels if i == 0 else out_channels
self.resnets.append(ResnetBlock2D(ch_in, out_channels))
self.downsamplers = None
if add_downsample:
self.downsamplers = [Downsample2D(out_channels)]
def __call__(self, x: mx.array) -> mx.array:
for resnet in self.resnets:
x = resnet(x)
if self.downsamplers is not None:
for ds in self.downsamplers:
x = ds(x)
return x
class UpDecoderBlock2D(nn.Module):
"""Decoder block: N ResnetBlocks + optional Upsample."""
def __init__(
self,
in_channels: int,
out_channels: int,
num_layers: int = 3,
add_upsample: bool = True,
):
super().__init__()
self.resnets = []
for i in range(num_layers):
ch_in = in_channels if i == 0 else out_channels
self.resnets.append(ResnetBlock2D(ch_in, out_channels))
self.upsamplers = None
if add_upsample:
self.upsamplers = [Upsample2D(out_channels)]
def __call__(self, x: mx.array) -> mx.array:
for resnet in self.resnets:
x = resnet(x)
if self.upsamplers is not None:
for us in self.upsamplers:
x = us(x)
return x
class MidBlock2D(nn.Module):
"""Mid block: ResnetBlock -> Attention -> ResnetBlock."""
def __init__(self, channels: int):
super().__init__()
self.resnets = [
ResnetBlock2D(channels, channels),
ResnetBlock2D(channels, channels),
]
self.attentions = [AttentionBlock(channels)]
def __call__(self, x: mx.array) -> mx.array:
x = self.resnets[0](x)
x = self.attentions[0](x)
x = self.resnets[1](x)
return x
# ---------------------------------------------------------------------------
# Encoder / Decoder
# ---------------------------------------------------------------------------
class Encoder(nn.Module):
"""VAE Encoder: image (B, H, W, 3) -> latent parameters (B, H/8, W/8, 8)."""
def __init__(
self,
in_channels: int = 3,
latent_channels: int = 4,
block_out_channels: Tuple[int, ...] = (128, 256, 512, 512),
):
super().__init__()
self.conv_in = nn.Conv2d(in_channels, block_out_channels[0], kernel_size=3, padding=1)
self.down_blocks = []
for i, out_ch in enumerate(block_out_channels):
in_ch = block_out_channels[i - 1] if i > 0 else block_out_channels[0]
add_downsample = i < len(block_out_channels) - 1
self.down_blocks.append(
DownEncoderBlock2D(in_ch, out_ch, num_layers=2, add_downsample=add_downsample)
)
self.mid_block = MidBlock2D(block_out_channels[-1])
self.conv_norm_out = nn.GroupNorm(32, block_out_channels[-1], pytorch_compatible=True)
# 2 * latent_channels for mean + logvar
self.conv_out = nn.Conv2d(block_out_channels[-1], 2 * latent_channels, kernel_size=3, padding=1)
def __call__(self, x: mx.array) -> mx.array:
h = self.conv_in(x)
for block in self.down_blocks:
h = block(h)
h = self.mid_block(h)
h = self.conv_norm_out(h)
h = nn.silu(h)
h = self.conv_out(h)
return h
class Decoder(nn.Module):
"""VAE Decoder: latent (B, H/8, W/8, 4) -> image (B, H, W, 3)."""
def __init__(
self,
out_channels: int = 3,
latent_channels: int = 4,
block_out_channels: Tuple[int, ...] = (128, 256, 512, 512),
):
super().__init__()
reversed_channels = list(reversed(block_out_channels))
self.conv_in = nn.Conv2d(latent_channels, reversed_channels[0], kernel_size=3, padding=1)
self.mid_block = MidBlock2D(reversed_channels[0])
self.up_blocks = []
for i, out_ch in enumerate(reversed_channels):
in_ch = reversed_channels[i - 1] if i > 0 else reversed_channels[0]
add_upsample = i < len(reversed_channels) - 1
self.up_blocks.append(
UpDecoderBlock2D(in_ch, out_ch, num_layers=3, add_upsample=add_upsample)
)
self.conv_norm_out = nn.GroupNorm(32, reversed_channels[-1], pytorch_compatible=True)
self.conv_out = nn.Conv2d(reversed_channels[-1], out_channels, kernel_size=3, padding=1)
def __call__(self, z: mx.array) -> mx.array:
h = self.conv_in(z)
h = self.mid_block(h)
for block in self.up_blocks:
h = block(h)
h = self.conv_norm_out(h)
h = nn.silu(h)
h = self.conv_out(h)
return h
# ---------------------------------------------------------------------------
# AutoencoderKL
# ---------------------------------------------------------------------------
class AutoencoderKLMLX(nn.Module):
"""Stable Diffusion 2.1 VAE ported to MLX.
All tensors are NHWC: (B, H, W, C).
Encodes images in [-1, 1] to latents, decodes latents back to images.
"""
def __init__(
self,
in_channels: int = 3,
out_channels: int = 3,
latent_channels: int = 4,
block_out_channels: Tuple[int, ...] = (128, 256, 512, 512),
scaling_factor: float = 0.18215,
):
super().__init__()
self.scaling_factor = scaling_factor
self.encoder = Encoder(in_channels, latent_channels, block_out_channels)
self.decoder = Decoder(out_channels, latent_channels, block_out_channels)
# Post-quantization conv pair (identity-initialized in SD)
self.quant_conv = nn.Conv2d(2 * latent_channels, 2 * latent_channels, kernel_size=1)
self.post_quant_conv = nn.Conv2d(latent_channels, latent_channels, kernel_size=1)
def encode(self, x: mx.array) -> mx.array:
"""Encode image to latent mean (for inference, no sampling).
Args:
x: (B, H, W, 3) float32 images in [-1, 1].
Returns:
(B, H/8, W/8, 4) latent mean, scaled by scaling_factor.
"""
h = self.encoder(x)
h = self.quant_conv(h)
# Split into mean and logvar along channel axis
mean, _logvar = mx.split(h, 2, axis=-1)
return mean * self.scaling_factor
def decode(self, z: mx.array) -> mx.array:
"""Decode latent to image.
Args:
z: (B, H/8, W/8, 4) latents (already scaled by scaling_factor).
Returns:
(B, H, W, 3) decoded image.
"""
z = z / self.scaling_factor
z = self.post_quant_conv(z)
return self.decoder(z)
def __call__(self, x: mx.array) -> mx.array:
"""Encode then decode (for testing roundtrips)."""
latent = self.encode(x)
return self.decode(latent)
# ---------------------------------------------------------------------------
# Weight Conversion: PyTorch -> MLX
# ---------------------------------------------------------------------------
def convert_vae_weights_to_mlx(pytorch_state_dict: dict) -> dict:
"""Convert PyTorch VAE state dict to MLX format.
Handles:
- Conv2d weights: (out_ch, in_ch, kH, kW) -> (out_ch, kH, kW, in_ch)
- Linear weights: transpose
- GroupNorm weight/bias: unchanged
Args:
pytorch_state_dict: PyTorch state dict (already on CPU, as numpy or torch tensors).
Returns:
dict of MLX arrays ready for model.load_weights().
"""
import numpy as np
mlx_weights = {}
for key, value in pytorch_state_dict.items():
# Convert torch tensor to numpy if needed
if hasattr(value, "detach"):
value = value.detach().cpu().numpy()
elif not isinstance(value, np.ndarray):
value = np.array(value)
if "conv" in key and "weight" in key and value.ndim == 4:
# Conv2d: PyTorch (out_ch, in_ch, kH, kW) -> MLX (out_ch, kH, kW, in_ch)
value = np.transpose(value, (0, 2, 3, 1))
elif value.ndim == 2 and "norm" not in key:
# Linear weight: transpose
value = value.T
mlx_weights[key] = mx.array(value)
return mlx_weights
def map_diffusers_vae_keys(diffusers_state_dict: dict) -> dict:
"""Map diffusers VAE key names to AutoencoderKLMLX key names.
Diffusers uses keys like:
encoder.down_blocks.0.resnets.0.norm1.weight
decoder.mid_block.attentions.0.group_norm.weight
decoder.mid_block.attentions.0.to_q.weight (as key/query/value)
This function maps the attention projection keys from diffusers format
to our format.
Args:
diffusers_state_dict: state dict with diffusers key names.
Returns:
dict with keys matching AutoencoderKLMLX parameter names.
"""
mapped = {}
for key, value in diffusers_state_dict.items():
new_key = key
# Diffusers attention uses 'key' for to_k, 'query' for to_q, etc.
new_key = new_key.replace(".query.", ".to_q.")
new_key = new_key.replace(".key.", ".to_k.")
new_key = new_key.replace(".value.", ".to_v.")
new_key = new_key.replace(".proj_attn.", ".to_out.")
# Diffusers conv_shortcut is named nin_shortcut in some older checkpoints
new_key = new_key.replace(".nin_shortcut.", ".conv_shortcut.")
mapped[new_key] = value
return mapped