File size: 15,452 Bytes
f1fa854 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 | # Learned spatial upscaler for MiniMax-H3 video latents (24 channels).
#
# Network, temporal chunking and checkpoint layout from Comfyui_Minimax_h3_latent_Upscaler
# (https://github.com/LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler, MIT License),
# nodes/minimax_h3_latent_upscaler_3d.py.
#
# Latent space: ComfyUI's H3 VAE already returns mean/std-normalized latents (as this repo's
# does), and the node normalizes them once more with the same per-channel stats before the
# network, undoing it after. The network was trained in that twice-normalized space, so
# `upscale()` applies the same extra normalization to this repo's latents; calling the network
# on them directly gives a magenta dot lattice.
import re
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.checkpoint import checkpoint
if __package__:
from .stats import _VIDEO_LATENTS_MEAN, _VIDEO_LATENTS_STD
else:
from stats import _VIDEO_LATENTS_MEAN, _VIDEO_LATENTS_STD
VAE_DOWNSAMPLE = 16
FRAME_PER_TOKEN = (1, 4, 4, 4, 4) # pixel frames per latent in each 17-frame chunk of H3's VAE
def latent_times(T, fps):
"""Centre time (s, from the first frame) of each of T latents of a clip at fps."""
start, out = 0, []
for k in range(T):
span = FRAME_PER_TOKEN[k % len(FRAME_PER_TOKEN)]
out.append((start + (span - 1) / 2) / fps)
start += span
return torch.tensor(out, dtype=torch.float64)
def time_resample_matrix(t_in, fps_in, t_out, fps_out):
"""[t_out, t_in] linear interpolation weights from latents at fps_in to latents at fps_out over the same
start instant, by their centre times (held at the ends)."""
m = torch.zeros(t_out, t_in, dtype=torch.float64)
if t_in == 1:
m[:, 0] = 1
return m
src = latent_times(t_in, fps_in)
dst = latent_times(t_out, fps_out).clamp(src[0].item(), src[-1].item())
hi = torch.searchsorted(src, dst, right=True).clamp(1, t_in - 1)
lo = hi - 1
w = ((dst - src[lo]) / (src[hi] - src[lo])).clamp(0, 1)
rows = torch.arange(t_out)
m[rows, lo] = 1 - w
m[rows, hi] += w
return m
def normalization(channels):
return nn.GroupNorm(32, channels)
def zero_module(module):
for p in module.parameters():
p.detach().zero_()
return module
class ResBlockEmb3D(nn.Module):
def __init__(self, channels, emb_channels, dropout=0, out_channels=None):
super().__init__()
self.out_channels = out_channels or channels
self.in_layers = nn.Sequential(
normalization(channels), nn.SiLU(),
nn.Conv3d(channels, self.out_channels, 3, padding=1),
)
self.emb_layers = nn.Sequential(nn.SiLU(), nn.Linear(emb_channels, 2 * self.out_channels))
self.out_norm = normalization(self.out_channels)
self.out_layers = nn.Sequential(
nn.SiLU(), nn.Dropout(p=dropout),
zero_module(nn.Conv3d(self.out_channels, self.out_channels, 3, padding=1)),
)
self.skip = nn.Conv3d(channels, self.out_channels, 1) if self.out_channels != channels else nn.Identity()
def forward(self, x, emb):
h = self.in_layers(x)
emb_out = self.emb_layers(emb).type(h.dtype)
while len(emb_out.shape) < len(h.shape):
emb_out = emb_out[..., None]
scale, shift = torch.chunk(emb_out, 2, dim=1)
h = self.out_norm(h) * (1 + scale) + shift
h = self.out_layers(h)
return self.skip(x) + h
class TemporalConv(nn.Module):
def __init__(self, channels, kernel_size=5):
super().__init__()
self.norm = normalization(channels)
self.dwconv = nn.Conv3d(channels, channels, kernel_size=(kernel_size, 1, 1), padding=(kernel_size // 2, 0, 0), groups=channels)
self.pwconv = nn.Conv3d(channels, channels, kernel_size=1)
nn.init.zeros_(self.pwconv.weight)
nn.init.zeros_(self.pwconv.bias)
def forward(self, x):
return x + self.pwconv(self.dwconv(F.silu(self.norm(x))))
class MiniMaxH3LatentUpscaler(nn.Module):
"""3D-conv latent resizer: conv_in -> in_blocks -> trilinear resize -> out_blocks -> conv_out.
The upscale factor is fed to every ResBlock through a small embedding, so one checkpoint
covers 1x-4x. Only H and W change; T is preserved.
temporal=True adds frame-rate upscaling (e.g. 8 / 12 / 16 -> 24 fps) to the same network: a second
embedding of the temporal factor (fps_out / fps_in), whose last layer starts at zero, is added to the
spatial one (so a spatial checkpoint loads unchanged and behaves the same at equal frame rates), and the
middle resize first interpolates the features in time by each latent's centre time (H3 latents are not
evenly spaced: the first of every 17-frame chunk covers 1 frame, the others 4), then in H and W.
"""
def __init__(self, in_channels=24, in_blocks=12, out_blocks=12, channels=512, dropout=0.1, temporal_every=2, temporal_kernel=5,
temporal=False):
super().__init__()
self.conv_in = nn.Conv3d(in_channels, channels, 3, padding=1)
embed_dim = 64
self.embed = nn.Sequential(nn.Linear(1, embed_dim), nn.SiLU(), nn.Linear(embed_dim, embed_dim))
self.t_embed = None
if temporal:
self.t_embed = nn.Sequential(nn.Linear(1, embed_dim), nn.SiLU(), zero_module(nn.Linear(embed_dim, embed_dim)))
self.in_blocks = self._stage(in_blocks, channels, embed_dim, dropout, temporal_every, temporal_kernel)
self.out_blocks = self._stage(out_blocks, channels, embed_dim, dropout, temporal_every, temporal_kernel)
self.norm_out = normalization(channels)
self.conv_out = nn.Conv3d(channels, in_channels, 3, padding=1)
self.temporal_kernel = temporal_kernel if temporal_every > 0 else 0
self.gradient_checkpointing = False
@staticmethod
def _stage(num_blocks, channels, embed_dim, dropout, temporal_every, temporal_kernel):
blocks = nn.ModuleList()
for b in range(num_blocks):
blocks.append(ResBlockEmb3D(channels, embed_dim, dropout))
if temporal_every > 0 and b % temporal_every == 0:
blocks.append(TemporalConv(channels, temporal_kernel))
return blocks
def _run_blocks(self, blocks, x, emb):
for block in blocks:
if isinstance(block, ResBlockEmb3D):
args = (x, emb.expand(x.shape[0], -1))
else:
args = (x,)
x = checkpoint(block, *args, use_reentrant=False) if self.gradient_checkpointing and self.training else block(*args)
return x
def _forward_segment(self, x, scale, size, fps=None):
emb = self.embed(torch.tensor([[scale - 1]], dtype=x.dtype, device=x.device))
if self.t_embed is not None:
t_scale = fps[1] / fps[0] if fps else 1.0
emb = emb + self.t_embed(torch.tensor([[t_scale - 1]], dtype=x.dtype, device=x.device))
x = self._run_blocks(self.in_blocks, self.conv_in(x), emb)
if fps and fps[0] != fps[1]: # in time by centre times; trilinear below then only resizes H and W
m = time_resample_matrix(x.shape[2], fps[0], size[0], fps[1]).to(device=x.device, dtype=x.dtype)
x = torch.einsum("oi,bcihw->bcohw", m, x)
x = F.interpolate(x, size=size, mode="trilinear", align_corners=False)
x = self._run_blocks(self.out_blocks, x, emb)
return self.conv_out(F.silu(self.norm_out(x)))
def forward(self, x, scale, target_size, chunk=32, fps=None):
"""x: [B, 24, T, H, W] normalized latents; target_size: (T, H_out, W_out); fps: (in, out) frame rates of a
frame-rate upscale (both clips start at the same instant), None or equal rates for a spatial one.
Long clips run in temporal chunks of `chunk` latent frames with replicate padding and
linearly weighted overlaps (overlap = the temporal kernel), so VRAM stays bounded and
the last frames do not flicker. chunk=None runs the whole clip at once.
"""
frame_rate = fps is not None and fps[0] != fps[1]
if tuple(target_size) == tuple(x.shape[-3:]) and not frame_rate:
return x
B, C, T, _, _ = x.shape
if frame_rate:
if chunk is not None and T > chunk:
raise ValueError("temporal chunking is not implemented for frame-rate upscaling; use chunk=None")
return self._forward_segment(x, scale, target_size, fps)
if chunk is None or T <= chunk:
return self._forward_segment(x, scale, target_size)
overlap = self.temporal_kernel
x_padded = F.pad(x, (0, 0, 0, 0, overlap, overlap), mode="replicate")
out = torch.zeros(B, C, T, target_size[-2], target_size[-1], device=x.device, dtype=x.dtype)
weight_sum = torch.zeros(1, 1, T, 1, 1, device=x.device, dtype=x.dtype)
for seg_start in range(0, T, chunk):
seg_end = min(T, seg_start + chunk)
out_start, out_end = max(0, seg_start - overlap), min(T, seg_end + overlap)
lo, hi = max(0, out_start - overlap), min(T + 2 * overlap, out_end + overlap)
seg_out = self._forward_segment(x_padded[:, :, lo:hi].contiguous(), scale, (hi - lo, target_size[-2], target_size[-1]))
s0 = out_start + overlap - lo
valid = seg_out[:, :, s0:s0 + out_end - out_start]
weight = torch.ones(out_end - out_start, device=x.device, dtype=x.dtype)
if seg_start > out_start:
n = seg_start - out_start
weight[:n] = torch.arange(1, n + 1, device=x.device, dtype=x.dtype) / (n + 1)
if out_end > seg_end:
n = out_end - seg_end
weight[-n:] = torch.arange(n, 0, -1, device=x.device, dtype=x.dtype) / (n + 1)
out[:, :, out_start:out_end] += valid * weight.view(1, 1, -1, 1, 1)
weight_sum[:, :, out_start:out_end] += weight.view(1, 1, -1, 1, 1)
return out / weight_sum.clamp(min=1e-8)
def upscale(self, latents, scale, target_size, chunk=32, fps=None):
"""Upscale normalized H3 latents (as the VAE/pipeline produce them) to target_size (T, H, W)."""
dtype = next(self.parameters()).dtype
mean = torch.tensor(_VIDEO_LATENTS_MEAN, device=latents.device, dtype=dtype).view(1, -1, 1, 1, 1)
std = torch.tensor(_VIDEO_LATENTS_STD, device=latents.device, dtype=dtype).view(1, -1, 1, 1, 1)
out = self((latents.to(dtype) - mean) / std, scale, target_size, chunk=chunk, fps=fps)
return (out * std + mean).to(latents.dtype)
@staticmethod
def config_from_state_dict(sd):
"""Architecture is read off the checkpoint (block counts, channels, temporal kernel)."""
cfg = {"in_channels": sd["conv_in.weight"].shape[1], "channels": sd["conv_in.weight"].shape[0]}
for stage in ("in_blocks", "out_blocks"):
cfg[stage] = len({m.group(1) for k in sd if (m := re.match(rf"{stage}\.(\d+)\.in_layers\.", k))})
dwconv = [v for k, v in sd.items() if k.endswith("dwconv.weight")]
cfg["temporal_every"] = 2 if dwconv else 0
cfg["temporal_kernel"] = dwconv[0].shape[2] if dwconv else 5
cfg["temporal"] = any(k.startswith("t_embed.") for k in sd)
return cfg
@classmethod
def from_file(cls, path, device="cuda", dtype=torch.float16):
from safetensors.torch import load_file
sd = load_file(path, device="cpu") if path.endswith(".safetensors") else torch.load(path, map_location="cpu", weights_only=False)
sd = sd.get("model", sd)
if any(k.startswith("upscaler.") for k in sd):
sd = {k[len("upscaler."):]: v for k, v in sd.items() if k.startswith("upscaler.")}
sd = {k: v.to(torch.float16) if v.dtype == torch.float8_e4m3fn else v for k, v in sd.items()}
model = cls(**cls.config_from_state_dict(sd))
model.load_state_dict(sd, strict=True)
return model.to(device=device, dtype=dtype).eval().requires_grad_(False)
class MiniMaxH3TemporalUpscaler(MiniMaxH3LatentUpscaler):
"""H3 backbone with temporal feature interpolation instead of spatial resizing.
The parameter layout is identical to MiniMaxH3LatentUpscaler, so its spatial
checkpoint can initialize this model. The learned task still changes and needs
temporal fine tuning.
"""
fixed_temporal_scale = 2
def _forward_segment(self, x, scale, size):
emb = self.embed(torch.tensor([[scale - 1]], dtype=x.dtype, device=x.device))
x = self._run_blocks(self.in_blocks, self.conv_in(x), emb)
x = F.interpolate(x, size=size, mode="trilinear", align_corners=True)
x = self._run_blocks(self.out_blocks, x, emb)
return self.conv_out(F.silu(self.norm_out(x)))
def forward(self, x, scale=2.0, target_size=None, chunk=None):
if x.ndim != 5 or x.shape[1] != self.conv_in.in_channels or x.shape[2] < 2:
raise ValueError(f"expected [B,{self.conv_in.in_channels},T>=2,H,W], got {tuple(x.shape)}")
expected = (2 * x.shape[2] - 1, x.shape[3], x.shape[4])
if scale != 2.0:
raise ValueError(f"temporal interpolation has fixed x2 scale, got {scale}")
if target_size is not None and tuple(target_size) != expected:
raise ValueError(f"temporal interpolation gives {expected}, got target_size={tuple(target_size)}")
if chunk is not None:
raise ValueError("temporal chunking is not supported; use chunk=None")
return self._forward_segment(x, scale, expected)
def upscale(self, latents, scale=2.0, target_size=None, chunk=None):
return super().upscale(latents, scale, target_size or
(2 * latents.shape[2] - 1, latents.shape[3], latents.shape[4]), chunk=chunk)
def target_latent_size(latent_h, latent_w, megapixels=None, scale=None, width=None, height=None, align=32):
"""Output latent (H, W) and the effective scale, as the ComfyUI node computes them.
Exactly one of `megapixels`, `scale` or (`width`, `height`) in pixels. The pixel target is
rounded to `align` and then to the VAE's 16px grid.
"""
if megapixels is not None:
aspect = latent_w / latent_h
h_px = (megapixels * 1024 * 1024 / aspect) ** 0.5
w_px = h_px * aspect
elif scale is not None:
w_px, h_px = latent_w * VAE_DOWNSAMPLE * scale, latent_h * VAE_DOWNSAMPLE * scale
else:
w_px, h_px = float(width), float(height)
effective_scale = (w_px / (latent_w * VAE_DOWNSAMPLE) + h_px / (latent_h * VAE_DOWNSAMPLE)) / 2.0
w_px = round(round(w_px / align) * align / VAE_DOWNSAMPLE) * VAE_DOWNSAMPLE
h_px = round(round(h_px / align) * align / VAE_DOWNSAMPLE) * VAE_DOWNSAMPLE
h_out, w_out = max(1, h_px // VAE_DOWNSAMPLE), max(1, w_px // VAE_DOWNSAMPLE)
if effective_scale < 1.0 and (w_out < latent_w or h_out < latent_h):
raise ValueError("the latent upscaler only upscales (effective scale >= 1)")
return (h_out, w_out), effective_scale
if __name__ == "__main__":
model = MiniMaxH3LatentUpscaler(
channels=512, in_blocks=12, out_blocks=12
).cuda().eval()
low = torch.randn(1, 24, 5, 12, 20, device="cuda") # [B, C, T, H, W]
with torch.no_grad():
out = model(low, scale=2.0, target_size=(5, 24, 40), chunk=None)
print(out.shape) # [1, 24, 5, 24, 40]
|