The flux2 student takes the packed, BatchNorm-normalized (B,128,H/16,W/16) latent as is and unpatchifies it 2x2 inside the LQ projection (upstream PID_SR4X_V1PT5_FOR_FLUX2). Same prompt prefix and 4-step schedule as qwenimage, so the bundled null caption carries over. - pid_net: latent_unpatchify_factor - pid_core: flux2 kwargs, family detected from the first LQ conv's input width (16 / 32), output scale read from the net (32 / 64 px per cell), 128-ch latents passed through unnormalized, family mismatch raises - loader: "(auto-download) flux2 / MOSS-Image"; the bare "(auto-download)" stays qwenimage. Downloads go to the first registered pid folder (extra_model_paths.yaml first) and skip a checkpoint already present in any of them - decode: tile_latent / tile_overlap keep their 32-px unit across families; use_calib is ignored for flux2 Checked on two MOSS cached latents: clean load (0 missing / 0 unexpected), area-downsampled output 31.6 / 34.7 dB PSNR against the FLUX.2 VAE decode, 8.2 GB peak with 2048 px tiles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
554 lines
27 KiB
Python
554 lines
27 KiB
Python
"""Self-contained PiD-Qwen 4-step pixel-diffusion decoder core.
|
|
|
|
No hydra / imaginaire / gemma. Everything here is derived from the live
|
|
nv-tlabs PiD `qwenimage` 2kto4k checkpoint config (captured by introspection):
|
|
|
|
* NET_KWARGS — exact PidNet constructor args
|
|
* STUDENT_T_LIST etc — the distilled 4-step SDE schedule
|
|
* gemma is not loaded at decode time. The distill student uses no CFG, so there
|
|
is no negative branch to encode — the net just needs a *fixed* null caption.
|
|
The faithful null is `gemma(chi_prompt + "")` (== the model's
|
|
`_encode_text_raw([""])`): the qwenimage checkpoint was distilled with a long
|
|
`chi_prompt` instruction prefixed to every caption, so an all-zero y is
|
|
off-distribution. That null is pre-baked and bundled with the node (~1.4MB;
|
|
regen recipe in README "Provenance"), so gemma is never downloaded. The decode
|
|
fns take a `caption_embs` arg; passing None falls back to a zero caption
|
|
(NaN-safe — the y_embedder is `Linear(2304→D, bias=True)` then RMSNorm — but
|
|
off-distribution; the node always passes the faithful null).
|
|
|
|
The PiD net consumes a *normalized* latent (LQ_latent) and emits RGB pixels
|
|
directly — there is NO VAE decode at the end, and the VAE is not needed at all (we
|
|
feed the latent straight in). Output spatial size = latent_grid * vae_down * 4.
|
|
|
|
Two latent families, picked from the checkpoint:
|
|
* qwenimage — 16-ch Qwen-Image VAE latent at /8 (Anima, Qwen-Image), per-channel
|
|
mean/std normalized here.
|
|
* flux2 — FLUX.2 VAE latent packed 2x2 to 128-ch at /16 and BatchNorm-normalized
|
|
(MOSS-Image's DiT latent, ComfyUI's Flux2 LATENT). Fed as is; the net
|
|
unpatchifies it to 32-ch at /8 inside its LQ projection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import torch
|
|
|
|
from .pid_net import PidNet
|
|
|
|
# ---- Exact PidNet constructor kwargs, per checkpoint generation ----
|
|
# Mirrors PID_SR4X / PID_SR4X_V1PT5 in the upstream config
|
|
# (pid/_src/configs/common/defaults/net.py). v1 is the original 2kto4k release
|
|
# (now under checkpoints_deprecated/ on the Hub); v1.5 is the current one.
|
|
#
|
|
# `sr_scale=4` is the architectural SR factor — output = latent_grid * 8 (VAE) * 4,
|
|
# i.e. 4x the source latent's *native VAE decode*, NOT 4x of 2k. The "res2kto4k" in
|
|
# the checkpoint name is the trained HQ output range (2048-4096px), which maps to a
|
|
# source latent of 512-1024px. A 1024px generation decodes to 4096px.
|
|
NET_KWARGS_V1 = dict(
|
|
in_channels=3, num_groups=24, hidden_size=1536, pixel_hidden_size=16,
|
|
pixel_attn_hidden_size=1152, pixel_num_groups=16, patch_depth=14, pixel_depth=2,
|
|
num_text_blocks=4, patch_size=16, txt_embed_dim=2304, txt_max_length=300,
|
|
use_text_rope=True, text_rope_theta=10000.0, rope_mode="ntk_aware",
|
|
rope_ref_h=1024, rope_ref_w=1024, repa_encoder_index=6, enable_ed=False,
|
|
ed_compress_ratio=1, ed_depth_per_stage=1, ed_window_size=2, ed_num_heads=None,
|
|
ed_hidden_size=None, ed_use_token_shuffle=True, lq_inject_mode="controlnet",
|
|
lq_in_channels=0, lq_latent_channels=16, lq_hidden_dim=512, lq_num_res_blocks=4,
|
|
lq_conv_padding_mode="zeros", lq_gate_type="sigma_aware_per_token_per_dim",
|
|
lq_interval=2, zero_init_lq=True, train_lq_proj_only=False, sr_scale=4,
|
|
latent_spatial_down_factor=8, pit_lq_inject=False,
|
|
)
|
|
|
|
# PiD v1.5 deltas: replicate conv padding (kills the corner grid artifacts), rope
|
|
# reference at the 2048 training res, a wider LQ trunk, the cheaper per-token scalar
|
|
# gate, and LQ injection into the PiT pixel blocks.
|
|
# (lq_aux_rgb_head=True upstream is a training-only head — its weights are not in the
|
|
# released EMA, so it is not built here.)
|
|
NET_KWARGS_V1PT5 = dict(
|
|
NET_KWARGS_V1,
|
|
rope_ref_h=2048, rope_ref_w=2048,
|
|
lq_hidden_dim=1024,
|
|
lq_conv_padding_mode="replicate",
|
|
lq_gate_type="sigma_aware_per_token",
|
|
pit_lq_inject=True,
|
|
)
|
|
|
|
# PiD v1.5 for FLUX.2 latents (upstream PID_SR4X_V1PT5_FOR_FLUX2): takes the packed,
|
|
# BN-normalized (B,128,H/16,W/16) latent and unpatchifies it to (B,32,H/8,W/8)
|
|
# before the LQ trunk. Same text prompt / schedule as qwenimage.
|
|
NET_KWARGS_V1PT5_FLUX2 = dict(
|
|
NET_KWARGS_V1PT5,
|
|
lq_latent_channels=128,
|
|
latent_spatial_down_factor=16,
|
|
lq_latent_unpatchify_factor=2,
|
|
)
|
|
|
|
# Default for a freshly-fetched official checkpoint.
|
|
NET_KWARGS = NET_KWARGS_V1PT5
|
|
|
|
_LATENT_PROJ_IN_KEY = "lq_proj.latent_proj.0.weight"
|
|
|
|
|
|
def net_kwargs_for_state_dict(sd: dict) -> dict:
|
|
"""Pick the PidNet kwargs matching a checkpoint's actual architecture.
|
|
|
|
v1.5 is identified by its dedicated PiT injection head (`lq_proj.pit_head`), which
|
|
v1 does not have. Within v1.5, the first LQ conv's input width names the latent
|
|
family: 16 for qwenimage / flux, 32 for flux2 (128-ch packed latent unpatchified
|
|
2x2). The distilled student may omit lq_proj keys; without the conv weight it
|
|
falls back to the qwenimage layout."""
|
|
keys = {k[len("net."):] if k.startswith("net.") else k for k in sd}
|
|
if "lq_proj.pit_head.weight" not in keys:
|
|
return NET_KWARGS_V1
|
|
w = sd.get(_LATENT_PROJ_IN_KEY, sd.get("net." + _LATENT_PROJ_IN_KEY))
|
|
in_ch = int(w.shape[1]) if w is not None else 16
|
|
if in_ch == 16:
|
|
return NET_KWARGS_V1PT5
|
|
if in_ch == 32:
|
|
return NET_KWARGS_V1PT5_FLUX2
|
|
raise ValueError(f"unsupported PiD v1.5 latent projection with {in_ch} input channels")
|
|
|
|
|
|
def latent_family(net_kwargs: dict) -> str:
|
|
return "flux2" if net_kwargs.get("lq_latent_channels") == 128 else "qwenimage"
|
|
|
|
|
|
def latent_to_pixel_factor(net: PidNet) -> int:
|
|
"""Output pixels per source-latent cell: vae_down * sr_scale (32 for qwenimage,
|
|
64 for flux2)."""
|
|
return net.lq_proj.latent_spatial_down_factor * net.sr_scale
|
|
|
|
SR_SCALE = 4
|
|
MODEL_MAX_LENGTH = 300
|
|
CAPTION_CHANNELS = 2304
|
|
|
|
# Cached faithful null (gemma(chi_prompt + "")) — bundled blob; regen recipe in README.
|
|
# A (1, MODEL_MAX_LENGTH, CAPTION_CHANNELS) tensor — the same y the official student
|
|
# sees for an empty user prompt. Lives alongside the ckpt in models/pid/.
|
|
NULL_CAPTION_FILENAME = "pid_null_caption_gemma.safetensors"
|
|
NULL_CAPTION_KEY = "null_caption_embs"
|
|
FM_TIMESCALE = 1000.0
|
|
STUDENT_T_LIST = [0.999, 0.866, 0.634, 0.342, 0.0] # 4-step SDE schedule
|
|
STUDENT_SAMPLE_STEPS = 4
|
|
|
|
# Qwen-Image VAE per-channel latent normalization (== ComfyUI Wan21 format,
|
|
# scale_factor 1.0; == anima_lora qwen_vae.latents_mean/std).
|
|
QWEN_LATENTS_MEAN = [-0.7571, -0.7089, -0.9113, 0.1075, -0.1745, 0.9653, -0.1517, 1.5508,
|
|
0.4134, -0.0715, 0.5517, -0.3632, -0.1922, -0.9497, 0.2503, -0.2921]
|
|
QWEN_LATENTS_STD = [2.8184, 1.4541, 2.3275, 2.6558, 1.2196, 1.7708, 2.6052, 2.0743,
|
|
3.2687, 2.1526, 2.8652, 1.5579, 1.6382, 1.1253, 2.8251, 1.9160]
|
|
|
|
|
|
def read_pid_state_dict(ckpt_path: str) -> dict:
|
|
"""Read a consolidated PiD checkpoint into a bare state dict.
|
|
|
|
The official `model_ema_bf16.pth` stores keys under a `net.` prefix
|
|
(PixelDiTModel.state_dict(prefix='net.')); strip it. Also tolerates a bare-key
|
|
state dict and a {'state_dict'/'model': ...} wrapper."""
|
|
sd = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
|
if isinstance(sd, dict) and "state_dict" in sd and isinstance(sd["state_dict"], dict):
|
|
sd = sd["state_dict"]
|
|
elif isinstance(sd, dict) and "model" in sd and isinstance(sd["model"], dict):
|
|
sd = sd["model"]
|
|
if any(k.startswith("net.") for k in sd):
|
|
sd = {k[len("net."):]: v for k, v in sd.items() if k.startswith("net.")}
|
|
return sd
|
|
|
|
|
|
def build_pid_net(device="cuda", dtype=torch.bfloat16, net_kwargs: dict = None) -> PidNet:
|
|
net = PidNet(**(net_kwargs or NET_KWARGS))
|
|
net = net.to(device=device, dtype=dtype).eval().requires_grad_(False)
|
|
return net
|
|
|
|
|
|
def load_pid_weights(net: PidNet, sd: dict):
|
|
"""Load an already-read state dict (see `read_pid_state_dict`) into a PidNet.
|
|
|
|
Returns (missing, unexpected) where `missing` is split-categorized by the caller.
|
|
The load is necessarily `strict=False` (the official student legitimately omits
|
|
`lq_proj` keys — see PidDistillModel.load_state_dict), but that also means wrong
|
|
net kwargs would load silently. `categorize_load_keys` lets the loader surface a
|
|
real arch/kwargs mismatch (any non-`lq_proj` missing, or *any* unexpected key)
|
|
loudly instead of hiding it behind the expected lq_proj omissions."""
|
|
missing, unexpected = net.load_state_dict(sd, strict=False)
|
|
return missing, unexpected
|
|
|
|
|
|
def categorize_load_keys(missing, unexpected):
|
|
"""Split load_state_dict results into (expected_missing, suspect_missing, unexpected).
|
|
|
|
`lq_proj` keys are EXPECTED to be missing for the distilled student. Anything else
|
|
missing — or any unexpected key at all — signals NET_KWARGS doesn't match the
|
|
checkpoint architecture and should be treated as an error, not a note."""
|
|
expected_missing = [k for k in missing if "lq_proj" in k]
|
|
suspect_missing = [k for k in missing if "lq_proj" not in k]
|
|
return expected_missing, suspect_missing, list(unexpected)
|
|
|
|
|
|
def load_null_caption_embs(path: str, device, dtype=torch.bfloat16) -> torch.Tensor:
|
|
"""Load the bundled `gemma(chi_prompt + "")` null caption (regen recipe in
|
|
README "Provenance"). Returns (1, MODEL_MAX_LENGTH, CAPTION_CHANNELS)."""
|
|
from safetensors.torch import load_file
|
|
|
|
sd = load_file(path)
|
|
if NULL_CAPTION_KEY not in sd:
|
|
raise KeyError(
|
|
f"{path} has no '{NULL_CAPTION_KEY}' tensor (keys: {list(sd)}). "
|
|
f"Regenerate per README 'Provenance'."
|
|
)
|
|
emb = sd[NULL_CAPTION_KEY]
|
|
if emb.ndim == 2:
|
|
emb = emb.unsqueeze(0)
|
|
exp = (MODEL_MAX_LENGTH, CAPTION_CHANNELS)
|
|
if tuple(emb.shape[-2:]) != exp:
|
|
raise ValueError(
|
|
f"null caption shape {tuple(emb.shape)} != expected (*, {exp[0]}, {exp[1]}). "
|
|
f"Regenerate per README 'Provenance'."
|
|
)
|
|
return emb.to(device=device, dtype=dtype)
|
|
|
|
|
|
# Bundled PiD->native-VAE color-match transform (linear_M 3x3 + linear_b 3).
|
|
# Lives alongside the node. Derived by anima_lora bench/pid/fit_color_calib.py:
|
|
# the qwenimage PiD checkpoint color-drifts vs the native Qwen VAE (flat +
|
|
# desaturated; upstream-known but fixed only for the flux2 `_2606` checkpoint).
|
|
# This static transform corrects the systematic drift at decode time.
|
|
COLOR_CALIB_FILENAME = "pid_color_calib.safetensors"
|
|
|
|
|
|
def load_color_calib(path: str, device, dtype=torch.float32):
|
|
"""Load the color-match transform. Returns (M, b) float32 on `device`:
|
|
M is (3,3), b is (3,). Apply on [0,1] RGB, channel-last:
|
|
``out = (rgb @ M.T + b).clamp(0,1)`` (see `apply_color_calib`)."""
|
|
from safetensors.torch import load_file
|
|
|
|
sd = load_file(path)
|
|
if "linear_M" not in sd or "linear_b" not in sd:
|
|
raise KeyError(
|
|
f"{path} missing linear_M/linear_b (keys: {list(sd)}). "
|
|
f"Regenerate via anima_lora bench/pid/fit_color_calib.py."
|
|
)
|
|
M = sd["linear_M"].to(device=device, dtype=torch.float32)
|
|
b = sd["linear_b"].to(device=device, dtype=torch.float32)
|
|
if tuple(M.shape) != (3, 3) or tuple(b.shape) != (3,):
|
|
raise ValueError(f"color calib shapes M={tuple(M.shape)} b={tuple(b.shape)} != (3,3)/(3,).")
|
|
return M, b
|
|
|
|
|
|
def apply_color_calib(img01: torch.Tensor, M: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
|
|
"""img01: (...,3) RGB in [0,1], channel-last. Returns calibrated [0,1] (same
|
|
dtype/device as input). Linear per-pixel map fit PiD->native-VAE; cheap and
|
|
resolution-independent."""
|
|
Mf = M.to(device=img01.device, dtype=torch.float32)
|
|
bf = b.to(device=img01.device, dtype=torch.float32)
|
|
out = img01.float() @ Mf.T + bf
|
|
return out.clamp(0, 1).to(img01.dtype)
|
|
|
|
|
|
def comfy_latent_to_lq(samples: torch.Tensor, device, dtype=torch.bfloat16,
|
|
latent_channels: int = 16) -> torch.Tensor:
|
|
"""ComfyUI LATENT['samples'] -> PiD LQ_latent, 4D.
|
|
|
|
16-ch (raw Qwen VAE latent, 4D or 5D): per-channel normalized (mu-mean)/std.
|
|
128-ch (FLUX.2 packed latent, already BN-normalized — MOSS-Image's DiT latent and
|
|
ComfyUI's Flux2 LATENT): passed through unchanged.
|
|
`latent_channels` is what the loaded net expects; a mismatch raises."""
|
|
x = samples
|
|
if x.ndim == 5: # (B,C,T,h,w) -> drop singleton frame
|
|
x = x[:, :, 0]
|
|
if x.shape[1] != latent_channels:
|
|
family = {16: "Qwen-Image / Anima", 128: "FLUX.2 / MOSS-Image"}
|
|
raise ValueError(
|
|
f"latent has {x.shape[1]} channels ({family.get(x.shape[1], 'unknown')}) but the "
|
|
f"loaded PiD checkpoint expects {latent_channels} "
|
|
f"({family.get(latent_channels, 'unknown')}). Load the matching PiD checkpoint."
|
|
)
|
|
if latent_channels == 128:
|
|
return x.to(device=device, dtype=dtype)
|
|
mean = torch.tensor(QWEN_LATENTS_MEAN, device=x.device, dtype=torch.float32).view(1, 16, 1, 1)
|
|
std = torch.tensor(QWEN_LATENTS_STD, device=x.device, dtype=torch.float32).view(1, 16, 1, 1)
|
|
lq = (x.float() - mean) / std
|
|
return lq.to(device=device, dtype=dtype)
|
|
|
|
|
|
# Whether a net's transformer blocks are already compiled is tracked by a flag set
|
|
# ON the net object (see _compile_blocks_inplace) rather than a module-level id()
|
|
# set: an id() set both leaks across the process and can mis-fire if a freed net's
|
|
# address is reused. A per-object attribute is GC'd with the net and survives the
|
|
# ComfyUI ModelPatcher's evict/reload (which moves the *same* object CPU<->GPU,
|
|
# never recreates it), so the compile stays a one-shot per net.
|
|
|
|
# Tri-state cache: None = not yet probed, True/False = host C++ compiler present.
|
|
_COMPILE_OK = None
|
|
|
|
|
|
def _host_cpp_compiler() -> str | None:
|
|
"""Return the host C++ compiler torch.compile's inductor backend would invoke,
|
|
or None if none is on PATH. Mirrors inductor's own lookup so we can decide BEFORE
|
|
compiling whether the build can possibly succeed.
|
|
|
|
inductor codegens a C++ wrapper/kernel and shells out to a compiler: ``cl`` on
|
|
Windows (MSVC), g++/clang++ elsewhere. The build is lazy — it happens at the
|
|
first *call* of a compiled graph, deep in the sample loop — so a missing compiler
|
|
surfaces as ``Compiler: cl is not found`` mid-decode and kills the run. A trivial
|
|
up-front probe is unreliable (a pointwise op can run via Triton alone, never
|
|
touching the C++ wrapper, so it passes while the real blocks still fail), so we
|
|
check for the compiler executable directly instead."""
|
|
for var in ("CXX", "CC"): # honor explicit overrides, like inductor does
|
|
c = os.environ.get(var)
|
|
if c and shutil.which(c):
|
|
return c
|
|
candidates = ("cl",) if sys.platform == "win32" else ("g++", "c++", "clang++", "gcc", "cc")
|
|
for c in candidates:
|
|
if shutil.which(c):
|
|
return c
|
|
return None
|
|
|
|
|
|
def _compiler_available(device, dtype) -> bool:
|
|
"""Whether torch.compile can build on this machine (cached). If no host C++
|
|
compiler is found, warn once and return False so callers fall back to eager
|
|
instead of crashing mid-decode with ``Compiler: cl is not found``."""
|
|
global _COMPILE_OK
|
|
if _COMPILE_OK is not None:
|
|
return _COMPILE_OK
|
|
cc = _host_cpp_compiler()
|
|
if cc is None:
|
|
print(
|
|
"[AnimaPiD] no host C++ compiler found — torch.compile would crash this "
|
|
"decode (inductor needs one to build kernels). Decoding eagerly; the "
|
|
"'compile' toggle is a no-op on this machine.\n"
|
|
"[AnimaPiD] Windows: install MSVC Build Tools and run ComfyUI from a "
|
|
"'x64 Native Tools Command Prompt' (so 'cl.exe' is on PATH) to enable "
|
|
"compile; otherwise just leave the Decode node's 'compile' off."
|
|
)
|
|
_COMPILE_OK = False
|
|
else:
|
|
# Compiler present, but guard against any *other* inductor build failure
|
|
# (toolchain mismatch, missing headers, …) degrading to eager rather than
|
|
# raising. suppress_errors makes dynamo run the original eager bytecode on a
|
|
# backend compile error instead of propagating it.
|
|
try:
|
|
import torch._dynamo
|
|
torch._dynamo.config.suppress_errors = True
|
|
except Exception: # noqa: BLE001 — best-effort; absence just means no net change
|
|
pass
|
|
_COMPILE_OK = True
|
|
return _COMPILE_OK
|
|
|
|
|
|
def _compile_blocks_inplace(net: PidNet) -> None:
|
|
"""Per-block `torch.compile`, mirroring anima_lora's `DiT.compile_blocks` and
|
|
the AnimaBlockCompile ComfyUI node: compile each transformer block as its own
|
|
small graph rather than tracing the whole net in one frame.
|
|
|
|
PiD's `patch_blocks` (14x, identical) and `pixel_blocks` (2x, identical) each
|
|
collapse to a single graph reused across the stack — far faster to compile and
|
|
far less likely to graph-break or silently fall back to eager than whole-net
|
|
compile. RoPE / positional info is passed INTO each block as args (computed at
|
|
net level), so the blocks compile cleanly without the `precompute_positional_
|
|
caches` dance the whole-net path needed, and the eager net-level forward keeps
|
|
building those caches as normal.
|
|
|
|
Idempotent (compiles once per net), guarded by a flag on the net object so the
|
|
one-shot survives the ModelPatcher's evict/reload without re-wrapping. Output-
|
|
resolution changes are handled by dynamo's own per-shape specialization — a new
|
|
size recompiles automatically; tiled decode keeps every tile the same size, so
|
|
the blocks compile just once. (A CPU<->GPU move from an eviction does make dynamo
|
|
re-specialize per device on the next forward, but the wrappers are reused.)"""
|
|
if getattr(net, "_pid_blocks_compiled", False):
|
|
return
|
|
for blocks in (net.patch_blocks, net.pixel_blocks):
|
|
for i in range(len(blocks)):
|
|
blocks[i] = torch.compile(blocks[i], mode="default", dynamic=False)
|
|
net._pid_blocks_compiled = True
|
|
|
|
|
|
def get_runner(net: PidNet, dtype, enable: bool) -> PidNet:
|
|
"""Return the net to call in the sample loop. With `enable` AND a working
|
|
inductor backend on this machine, the net's transformer blocks are
|
|
torch.compiled in place, once, per-block (see `_compile_blocks_inplace`) and the
|
|
same (now-compiled) net is returned; otherwise the eager net is returned
|
|
unchanged. The `_compiler_available` probe guards against machines without a host
|
|
C compiler (e.g. Windows lacking `cl.exe`), where compiling would crash the
|
|
decode at first block execution rather than at compile time."""
|
|
if enable:
|
|
device = next(net.parameters()).device
|
|
if _compiler_available(device, dtype):
|
|
_compile_blocks_inplace(net)
|
|
return net
|
|
|
|
|
|
def _t_list(num_steps: int, device) -> torch.Tensor:
|
|
full = torch.tensor(STUDENT_T_LIST, device=device, dtype=torch.float32)
|
|
if num_steps == STUDENT_SAMPLE_STEPS:
|
|
return full
|
|
idx = torch.linspace(0, len(full) - 1, num_steps + 1).round().long()
|
|
return full[idx]
|
|
|
|
|
|
@torch.no_grad()
|
|
def pid_decode_latent(net: PidNet, lq_latent: torch.Tensor, *, steps: int = 4,
|
|
sigma: float = 0.0, seed: int = 0, dtype=torch.bfloat16,
|
|
compile: bool = False, caption_embs: torch.Tensor = None,
|
|
_runner=None, step_cb=None, lq_features=None) -> torch.Tensor:
|
|
"""Run the 4-step SDE student. lq_latent: normalized (B,16,h,w) or (B,128,h,w).
|
|
Returns pixels (B,3,H,W) in [-1,1] with H=h*up, W=w*up, up=latent_to_pixel_factor
|
|
(32 qwenimage, 64 flux2).
|
|
|
|
`caption_embs` is the fixed null caption y the net conditions on. Pass None for
|
|
the zeros null (off-distribution but NaN-safe); pass a (1 or B, 300, 2304) tensor
|
|
(e.g. from `load_null_caption_embs`) for the faithful gemma(chi_prompt + "") null.
|
|
`compile=True` torch.compiles the net (cached per output resolution; first
|
|
call per (H,W) is slow). `_runner` lets callers (e.g. the tiled path) pass a
|
|
pre-built compiled net so all same-size tiles share one graph. `step_cb`, if
|
|
given, is called once per completed SDE step (drives a host progress bar).
|
|
`lq_features`, if given, is a precomputed per-block LQ token list (already
|
|
sliced to this call's patch grid) that bypasses the net's own LQ projection —
|
|
the tiled decoder uses it so every tile conditions on whole-image-normalized
|
|
features (see `pid_decode_latent_tiled`)."""
|
|
device = lq_latent.device
|
|
B, _, lh, lw = lq_latent.shape
|
|
up = latent_to_pixel_factor(net)
|
|
H, W = lh * up, lw * up
|
|
run = _runner if _runner is not None else get_runner(net, dtype, compile)
|
|
|
|
if caption_embs is None:
|
|
cap = torch.zeros(B, MODEL_MAX_LENGTH, CAPTION_CHANNELS, device=device, dtype=dtype)
|
|
else:
|
|
cap = caption_embs.to(device=device, dtype=dtype)
|
|
if cap.shape[0] == 1 and B > 1:
|
|
cap = cap.expand(B, -1, -1)
|
|
deg = torch.full((B,), float(sigma), device=device, dtype=torch.float32)
|
|
gen = torch.Generator(device=device).manual_seed(int(seed))
|
|
x = torch.randn(B, 3, H, W, device=device, dtype=torch.float32, generator=gen)
|
|
|
|
tl = _t_list(steps, device)
|
|
autocast = torch.autocast("cuda", dtype=dtype) if device.type == "cuda" else torch.autocast("cpu", dtype=dtype)
|
|
with autocast:
|
|
for t_cur, t_next in zip(tl[:-1], tl[1:]):
|
|
t_scaled = t_cur.expand(B) * FM_TIMESCALE
|
|
if lq_features is not None:
|
|
v = run(x.to(dtype), t_scaled, cap, lq_features=lq_features, degrade_sigma=deg)
|
|
else:
|
|
v = run(x.to(dtype), t_scaled, cap, lq_latent=lq_latent, degrade_sigma=deg)
|
|
s = [B] + [1] * (x.ndim - 1)
|
|
t_c = t_cur.double().view(*s)
|
|
x0 = (x.double() - t_c * v.double()) # velocity -> x0
|
|
if t_next.item() > 0:
|
|
eps = torch.randn(x0.shape, device=device, dtype=torch.float64, generator=gen)
|
|
t_n = t_next.double().view(1).expand(s)
|
|
x = ((1.0 - t_n) * x0 + t_n * eps).float()
|
|
else:
|
|
x = x0.float()
|
|
if step_cb is not None:
|
|
step_cb()
|
|
return x.clamp(-1, 1)
|
|
|
|
|
|
def _tile_positions(dim: int, tile: int, stride: int):
|
|
"""Start indices so every tile is exactly `tile` wide (last snapped to edge)."""
|
|
if dim <= tile:
|
|
return [0], dim
|
|
pos = list(range(0, dim - tile + 1, stride))
|
|
if pos[-1] != dim - tile:
|
|
pos.append(dim - tile)
|
|
return pos, tile
|
|
|
|
|
|
def count_tiles(lq_latent: torch.Tensor, tile: int, overlap: int) -> int:
|
|
"""How many tiles `pid_decode_latent_tiled` will process for this latent —
|
|
lets the host size a progress bar (total = steps * count_tiles)."""
|
|
Hh, Ww = lq_latent.shape[-2], lq_latent.shape[-1]
|
|
stride = max(1, tile - overlap)
|
|
ys, _ = _tile_positions(Hh, tile, stride)
|
|
xs, _ = _tile_positions(Ww, tile, stride)
|
|
return len(ys) * len(xs)
|
|
|
|
|
|
def _feather_1d(n: int, overlap_px: int, device) -> torch.Tensor:
|
|
"""Linear ramp from a small floor->1 over `overlap_px` at each end, 1 in the
|
|
middle. Floor>0 so single-coverage border pixels normalize cleanly."""
|
|
w = torch.ones(n, device=device, dtype=torch.float32)
|
|
if overlap_px > 0:
|
|
ramp = torch.linspace(1.0 / (overlap_px + 1), 1.0, overlap_px, device=device)
|
|
k = min(overlap_px, n)
|
|
w[:k] = torch.minimum(w[:k], ramp[:k])
|
|
w[-k:] = torch.minimum(w[-k:], ramp.flip(0)[:k])
|
|
return w
|
|
|
|
|
|
@torch.no_grad()
|
|
def pid_decode_latent_tiled(net: PidNet, lq_latent: torch.Tensor, *, steps: int = 4,
|
|
sigma: float = 0.0, seed: int = 0, tile: int = 64,
|
|
overlap: int = 16, dtype=torch.bfloat16,
|
|
compile: bool = False, caption_embs: torch.Tensor = None,
|
|
step_cb=None, global_lq: bool = True) -> torch.Tensor:
|
|
"""Tiled SR decode for latents larger than `tile` (memory bound). Decodes
|
|
overlapping latent tiles and feather-blends them in pixel space. Output
|
|
(B,3, h*up, w*up) in [-1,1]; `tile` / `overlap` are in this latent's cells. `step_cb` ticks once per SDE step across all
|
|
tiles (total ticks = steps * count_tiles()).
|
|
|
|
`global_lq` (default True) makes the LQ conditioning tile-invariant: the LQ
|
|
projection — whose ResBlocks carry GroupNorm, which normalizes over the whole
|
|
spatial extent — is run ONCE on the full latent, then each tile is handed its
|
|
sliced token rows. Without it, every tile re-projects its own crop, so its
|
|
GroupNorm sees only tile-local statistics and the output tone drifts vs the
|
|
whole-image decode (the cause of the tile=0 vs tiled color mismatch). The LQ
|
|
projection runs at latent/patch-grid resolution, so this whole-latent pass is
|
|
cheap even when the full pixel decode would OOM. Set False for the legacy
|
|
per-tile behavior (matches the pre-fix shipped color calibration)."""
|
|
device = lq_latent.device
|
|
B, _, Hh, Ww = lq_latent.shape
|
|
up = latent_to_pixel_factor(net) # 32 qwenimage, 64 flux2
|
|
Hout, Wout = Hh * up, Ww * up
|
|
stride = max(1, tile - overlap)
|
|
ys, th = _tile_positions(Hh, tile, stride)
|
|
xs, tw = _tile_positions(Ww, tile, stride)
|
|
ov_px = overlap * up
|
|
|
|
acc = torch.zeros(B, 3, Hout, Wout, device=device, dtype=torch.float32)
|
|
wsum = torch.zeros(1, 1, Hout, Wout, device=device, dtype=torch.float32)
|
|
wy = _feather_1d(th * up, ov_px, device)
|
|
wx = _feather_1d(tw * up, ov_px, device)
|
|
wmask = (wy[:, None] * wx[None, :])[None, None] # (1,1,th*32,tw*32)
|
|
|
|
# All tiles share one fixed output size -> blocks compile once, reuse for every tile.
|
|
runner = get_runner(net, dtype, compile)
|
|
|
|
# Tile-invariant LQ conditioning: project the FULL latent once (GroupNorm sees
|
|
# whole-image stats), then slice each tile's token rows. The patch grid is
|
|
# `r = up // patch_size` cells per latent cell, so a latent tile [yi:yi+th]
|
|
# maps to patch rows [yi*r:(yi+th)*r].
|
|
full_feats_2d = None
|
|
if global_lq:
|
|
r = up // net.patch_size
|
|
full_pH, full_pW = Hh * r, Ww * r
|
|
autocast = (torch.autocast("cuda", dtype=dtype) if device.type == "cuda"
|
|
else torch.autocast("cpu", dtype=dtype))
|
|
with autocast:
|
|
full_feats = net.lq_proj(lq_latent=lq_latent.to(dtype),
|
|
target_pH=full_pH, target_pW=full_pW)
|
|
full_feats_2d = [f.view(B, full_pH, full_pW, -1) for f in full_feats]
|
|
|
|
n = 0
|
|
for yi in ys:
|
|
for xi in xs:
|
|
tile_feats = None
|
|
if full_feats_2d is not None:
|
|
r = up // net.patch_size
|
|
y0, y1, x0, x1 = yi * r, (yi + th) * r, xi * r, (xi + tw) * r
|
|
tile_feats = [f[:, y0:y1, x0:x1, :].reshape(B, (y1 - y0) * (x1 - x0), -1).contiguous()
|
|
for f in full_feats_2d]
|
|
tile_lq = lq_latent[..., yi:yi + th, xi:xi + tw]
|
|
px = pid_decode_latent(net, tile_lq, steps=steps, sigma=sigma, seed=seed + n,
|
|
dtype=dtype, caption_embs=caption_embs,
|
|
_runner=runner, step_cb=step_cb, lq_features=tile_feats)
|
|
py, px_ = yi * up, xi * up
|
|
acc[..., py:py + th * up, px_:px_ + tw * up] += px.float() * wmask
|
|
wsum[..., py:py + th * up, px_:px_ + tw * up] += wmask
|
|
n += 1
|
|
return (acc / wsum.clamp(min=1e-6)).clamp(-1, 1)
|