Files
2026-05-25 18:53:02 +03:00

1246 lines
44 KiB
Python

"""
ComfyUI-PiD
Experimental ComfyUI nodes for NVIDIA PiD (Pixel Diffusion Decoder).
PiD is not a native ComfyUI VAE object. NVIDIA's inference path needs:
latent + native decoder/baseline image + sigma + caption -> PiD -> final image
So the decode node accepts a LATENT plus either a matching ComfyUI VAE or a
pre-decoded baseline IMAGE, then returns IMAGE.
"""
from __future__ import annotations
import contextlib
import gc
import os
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import torch
try:
import comfy.model_management as model_management
except Exception: # pragma: no cover - only available inside ComfyUI
model_management = None
try:
import folder_paths # ComfyUI model path helper
except Exception: # pragma: no cover - only available inside ComfyUI
folder_paths = None
NODE_DIR = Path(__file__).resolve().parent
# Expected layout: ComfyUI/custom_nodes/ComfyUI-PiD-ZImage/pid_decode.py
COMFYUI_DIR = NODE_DIR.parent.parent
VENDOR_DIR = NODE_DIR / "vendor"
DEFAULT_PID_DIR = VENDOR_DIR / "PiD"
PID_REPO_URL = "https://github.com/nv-tlabs/PiD.git"
PID_ZIP_URL = "https://github.com/nv-tlabs/PiD/archive/refs/heads/main.zip"
HF_REPO_ID = "nvidia/PiD"
AE_REL_PATH = "checkpoints/ae.safetensors"
AE_FILENAME = "ae.safetensors"
@dataclass(frozen=True)
class PiDCheckpoint:
experiment: str
relpath: str
scale: int
@dataclass(frozen=True)
class PiDBackbone:
label: str
registry_key: str
latent_channels: int
default_scale: int
latent_downscale: int
ckpt_types: Tuple[str, ...]
aux_files: Tuple[str, ...] = ()
needs_flux_ae: bool = False
# Mirrors NVIDIA's official pid/_src/inference/checkpoint_registry.py.
PID_CKPTS: Dict[Tuple[str, str], PiDCheckpoint] = {
("flux", "2k"): PiDCheckpoint(
experiment="PiD_res2k_sr4x_official_flux_distill_4step",
relpath="checkpoints/PiD_res2k_sr4x_official_flux_distill_4step/model_ema_bf16.pth",
scale=4,
),
("flux2", "2k"): PiDCheckpoint(
experiment="PiD_res2k_sr4x_official_flux2_distill_4step",
relpath="checkpoints/PiD_res2k_sr4x_official_flux2_distill_4step/model_ema_bf16.pth",
scale=4,
),
("sd3", "2k"): PiDCheckpoint(
experiment="PiD_res2k_sr4x_official_sd3_distill_4step",
relpath="checkpoints/PiD_res2k_sr4x_official_sd3_distill_4step/model_ema_bf16.pth",
scale=4,
),
("zimage", "2k"): PiDCheckpoint(
experiment="PiD_res2k_sr4x_official_flux_distill_4step",
relpath="checkpoints/PiD_res2k_sr4x_official_flux_distill_4step/model_ema_bf16.pth",
scale=4,
),
("rae", "2k"): PiDCheckpoint(
experiment="PiD_res2k_sr4x_official_dinov2_distill_4step",
relpath="checkpoints/PiD_res2k_sr4x_official_dinov2_distill_4step/model_ema_bf16.pth",
scale=4,
),
("scale_rae", "2k"): PiDCheckpoint(
experiment="PiD_res2k_sr8x_official_siglip_distill_4step",
relpath="checkpoints/PiD_res2k_sr8x_official_siglip_distill_4step/model_ema_bf16.pth",
scale=8,
),
("flux", "2kto4k"): PiDCheckpoint(
experiment="PiD_res2kto4k_sr4x_official_flux_distill_4step",
relpath="checkpoints/PiD_res2kto4k_sr4x_official_flux_distill_4step/model_ema_bf16.pth",
scale=4,
),
("flux2", "2kto4k"): PiDCheckpoint(
experiment="PiD_res2kto4k_sr4x_official_flux2_distill_4step",
relpath="checkpoints/PiD_res2kto4k_sr4x_official_flux2_distill_4step/model_ema_bf16.pth",
scale=4,
),
("sd3", "2kto4k"): PiDCheckpoint(
experiment="PiD_res2kto4k_sr4x_official_sd3_distill_4step",
relpath="checkpoints/PiD_res2kto4k_sr4x_official_sd3_distill_4step/model_ema_bf16.pth",
scale=4,
),
("zimage", "2kto4k"): PiDCheckpoint(
experiment="PiD_res2kto4k_sr4x_official_flux_distill_4step",
relpath="checkpoints/PiD_res2kto4k_sr4x_official_flux_distill_4step/model_ema_bf16.pth",
scale=4,
),
}
PID_BACKBONES: Dict[str, PiDBackbone] = {
"zimage": PiDBackbone("Z-Image", "zimage", 16, 4, 8, ("2k", "2kto4k"), needs_flux_ae=True),
"flux": PiDBackbone("Flux", "flux", 16, 4, 8, ("2k", "2kto4k"), needs_flux_ae=True),
"flux2": PiDBackbone(
"Flux2",
"flux2",
128,
4,
16,
("2k", "2kto4k"),
aux_files=("checkpoints/flux2_ae.safetensors",),
),
"sd3": PiDBackbone(
"SD3",
"sd3",
16,
4,
8,
("2k", "2kto4k"),
aux_files=("checkpoints/sd3_vae/vae/diffusion_pytorch_model.safetensors",),
),
"dinov2": PiDBackbone(
"DINOv2 RAE",
"rae",
768,
4,
16,
("2k",),
aux_files=(
"checkpoints/rae/decoders/dinov2/wReg_base/ViTXL_n08_i512/model.pt",
"checkpoints/rae/stats/dinov2/wReg_base/imagenet1k_512/stat.pt",
),
),
"siglip": PiDBackbone(
"SigLIP Scale-RAE",
"scale_rae",
1152,
8,
16,
("2k",),
aux_files=(
"checkpoints/scale_rae/decoder/XL_decoder_config.json",
"checkpoints/scale_rae/decoder/siglip2_sop14_i224_web73M_ganw3_decXL.pt",
),
),
}
BACKBONE_CHOICES = list(PID_BACKBONES.keys())
SEQUENTIAL_OFFLOAD_CHOICES = ["disabled", "sequential_blocks", "sequential_blocks_aggressive"]
_MODEL_CACHE: Dict[Tuple[str, str, str, str, str, bool, str], object] = {}
class PiDNodeError(RuntimeError):
pass
@contextlib.contextmanager
def _pushd(path: Path):
old = Path.cwd()
os.chdir(str(path))
try:
yield
finally:
os.chdir(str(old))
def _python_executable() -> str:
return sys.executable or "python"
def _run(cmd: List[str], cwd: Optional[Path] = None) -> str:
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
if proc.returncode != 0:
raise PiDNodeError("Command failed:\n" + " ".join(cmd) + "\n\n" + proc.stdout)
return proc.stdout.strip()
def _free_cuda_memory(aggressive: bool = False) -> None:
"""Best-effort cleanup for running PiD after a large ComfyUI model.
ComfyUI often keeps the Z-Image UNet/TE/VAE staged for dynamic VRAM. PiD is
another 1.36B parameter model, so on 16GB cards it helps to decode the native
baseline image first, then release Comfy's loaded models before PiD inference.
"""
try:
gc.collect()
except Exception:
pass
if model_management is not None:
if aggressive:
for name in ("unload_all_models", "unload_model_clones"):
fn = getattr(model_management, name, None)
if callable(fn):
try:
fn()
except TypeError:
try:
fn(None)
except Exception:
pass
except Exception:
pass
try:
model_management.soft_empty_cache()
except Exception:
pass
if torch.cuda.is_available():
try:
torch.cuda.synchronize()
except Exception:
pass
try:
torch.cuda.empty_cache()
except Exception:
pass
if aggressive:
try:
torch.cuda.ipc_collect()
except Exception:
pass
def _vram_total_gb() -> float:
if not torch.cuda.is_available():
return 0.0
try:
props = torch.cuda.get_device_properties(torch.cuda.current_device())
return float(props.total_memory) / (1024 ** 3)
except Exception:
return 0.0
def _format_pid_runtime_error(exc: BaseException, infer_image_size: Tuple[int, int], ckpt_type: str, scale: int) -> PiDNodeError:
msg = str(exc)
lower = msg.lower()
h, w = int(infer_image_size[0]), int(infer_image_size[1])
advice = (
f"PiD inference failed while generating {w}x{h} with pid_ckpt_type={ckpt_type!r}, scale={scale}.\n\n"
"Most common cause: VRAM/allocator pressure. For a 16GB GPU, 1024 base + scale=4 "
"means roughly 4096x4096 PiD output, which is usually too heavy inside ComfyUI while "
"large generation models were just used.\n\n"
"Try these first:\n"
" 1. Set PiD scale=1 or scale=2 for 1024 base images.\n"
" 2. For the 2k PiD checkpoint, use a 512x512 base latent with scale=4.\n"
" 3. Keep unload_comfy_before_pid=true and aggressive_cleanup=true.\n"
" 4. Restart ComfyUI after a CUDA allocator crash.\n"
" 5. If your launcher supports it, try disabling cudaMallocAsync / CUDA malloc async.\n\n"
f"Original error: {msg}"
)
if "out of memory" in lower or "cudamallocasync" in lower or "internal assert" in lower or "cuda" in lower:
return PiDNodeError(advice)
return PiDNodeError(f"PiD inference failed. Original error: {msg}")
def _resolve_pid_dir(pid_source_dir: str = "") -> Path:
# Priority: explicit UI path -> environment variable -> bundled vendor path.
if pid_source_dir and pid_source_dir.strip():
return Path(pid_source_dir.strip()).expanduser().resolve()
env_path = os.environ.get("PID_REPO_DIR") or os.environ.get("COMFYUI_PID_REPO_DIR")
if env_path:
return Path(env_path).expanduser().resolve()
return DEFAULT_PID_DIR.resolve()
def _pid_is_present(pid_dir: Path) -> bool:
return (pid_dir / "pid" / "_src" / "utils" / "model_loader.py").is_file()
def _ensure_pid_source(pid_dir: Path, allow_download: bool = True) -> List[str]:
messages: List[str] = []
if _pid_is_present(pid_dir):
if str(pid_dir) not in sys.path:
sys.path.insert(0, str(pid_dir))
messages.append(f"PiD source found: {pid_dir}")
return messages
if not allow_download:
raise PiDNodeError(
f"PiD source was not found at {pid_dir}.\n"
"Set auto_download=true, or set PID_REPO_DIR / COMFYUI_PID_REPO_DIR "
"to your local PiD checkout."
)
pid_dir.parent.mkdir(parents=True, exist_ok=True)
git = shutil.which("git")
if git:
messages.append(f"Cloning PiD source into {pid_dir} ...")
_run([git, "clone", "--depth", "1", PID_REPO_URL, str(pid_dir)])
else:
messages.append("git not found; downloading PiD main.zip instead ...")
zip_path = pid_dir.parent / "PiD-main.zip"
urllib.request.urlretrieve(PID_ZIP_URL, zip_path)
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(pid_dir.parent)
extracted = pid_dir.parent / "PiD-main"
if pid_dir.exists():
shutil.rmtree(pid_dir)
extracted.rename(pid_dir)
zip_path.unlink(missing_ok=True)
if not _pid_is_present(pid_dir):
raise PiDNodeError(f"PiD source download finished but expected files were not found under {pid_dir}")
if str(pid_dir) not in sys.path:
sys.path.insert(0, str(pid_dir))
messages.append("PiD source is ready.")
return messages
def _ensure_hf_download_available() -> None:
try:
import huggingface_hub # noqa: F401
except Exception as exc:
raise PiDNodeError(
"Missing dependency: huggingface_hub.\n"
"Install this custom node's requirements, or manually run:\n"
f"{_python_executable()} -m pip install \"huggingface-hub>=1.0\""
) from exc
def _candidate_comfy_ae_paths() -> List[Path]:
"""Return likely local Flux/Z-Image AE VAE paths from ComfyUI.
NVIDIA PiD's own config instantiates a Flux VAE encoder/tokenizer and expects
./checkpoints/ae.safetensors relative to the PiD repository root. Most ComfyUI
Z-Image workflows already have the exact same file under ComfyUI/models/vae/.
"""
candidates: List[Path] = []
# Ask ComfyUI's model registry first. This respects extra_model_paths.yaml.
if folder_paths is not None:
try:
get_full_path = getattr(folder_paths, "get_full_path", None)
if get_full_path is not None:
p = get_full_path("vae", AE_FILENAME)
if p:
candidates.append(Path(p))
except Exception:
pass
try:
get_full_path_or_raise = getattr(folder_paths, "get_full_path_or_raise", None)
if get_full_path_or_raise is not None:
p = get_full_path_or_raise("vae", AE_FILENAME)
if p:
candidates.append(Path(p))
except Exception:
pass
try:
for name in folder_paths.get_filename_list("vae"):
if Path(name).name.lower() == AE_FILENAME:
try:
p = folder_paths.get_full_path("vae", name)
if p:
candidates.append(Path(p))
except Exception:
pass
except Exception:
pass
# Fallback to standard ComfyUI folder locations.
vae_dir = COMFYUI_DIR / "models" / "vae"
candidates.append(vae_dir / AE_FILENAME)
try:
candidates.extend(vae_dir.glob(f"**/{AE_FILENAME}"))
except Exception:
pass
# Deduplicate while preserving order.
seen = set()
out: List[Path] = []
for c in candidates:
try:
cr = c.expanduser().resolve()
except Exception:
continue
if cr in seen:
continue
seen.add(cr)
out.append(cr)
return out
def _copy_local_ae_to_pid(pid_dir: Path) -> Optional[str]:
target = pid_dir / AE_REL_PATH
if target.is_file():
return f"Flux AE VAE already exists: {target}"
for src in _candidate_comfy_ae_paths():
if src.is_file():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
return f"Copied Flux AE VAE from {src} to {target}"
return None
def _download_ae_safetensors(pid_dir: Path) -> str:
_ensure_hf_download_available()
from huggingface_hub import hf_hub_download
target = pid_dir / AE_REL_PATH
if target.is_file():
return f"Flux AE VAE already exists: {target}"
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
pid_dir.mkdir(parents=True, exist_ok=True)
hf_hub_download(
repo_id=HF_REPO_ID,
filename=AE_REL_PATH,
local_dir=str(pid_dir),
token=token,
)
if not target.is_file():
raise PiDNodeError(f"Hugging Face download finished but Flux AE VAE is missing: {target}")
return f"Downloaded Flux AE VAE: {target}"
def _ensure_ae_safetensors(pid_dir: Path, allow_download: bool = True) -> Path:
"""Ensure PiD's required ./checkpoints/ae.safetensors exists."""
target = pid_dir / AE_REL_PATH
if target.is_file():
return target
copied = _copy_local_ae_to_pid(pid_dir)
if copied and target.is_file():
return target
if allow_download:
try:
_download_ae_safetensors(pid_dir)
return target
except Exception as exc:
local_candidates = "\n".join(f" - {p}" for p in _candidate_comfy_ae_paths()) or " - none found"
raise PiDNodeError(
"PiD requires Flux's AE VAE at:\n"
f" {target}\n\n"
"I could not find/copy it from ComfyUI and could not download it automatically.\n"
"Manual fix:\n"
f" 1. Make sure {AE_FILENAME} exists in ComfyUI/models/vae/\n"
f" 2. Copy it to {target}\n\n"
"Local paths checked:\n"
f"{local_candidates}\n\n"
f"Original download/copy error: {exc}"
) from exc
local_candidates = "\n".join(f" - {p}" for p in _candidate_comfy_ae_paths()) or " - none found"
raise PiDNodeError(
"PiD requires Flux's AE VAE at:\n"
f" {target}\n\n"
"Set auto_download=true, or manually copy:\n"
f" ComfyUI/models/vae/{AE_FILENAME}\n"
"to:\n"
f" {target}\n\n"
"Local paths checked:\n"
f"{local_candidates}"
)
def _download_hf_file(pid_dir: Path, relpath: str) -> str:
_ensure_hf_download_available()
from huggingface_hub import hf_hub_download
target = pid_dir / relpath
if target.is_file():
return f"Asset already exists: {target}"
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
pid_dir.mkdir(parents=True, exist_ok=True)
hf_hub_download(
repo_id=HF_REPO_ID,
filename=relpath,
local_dir=str(pid_dir),
token=token,
)
if not target.is_file():
raise PiDNodeError(f"Hugging Face download finished but asset is missing: {target}")
return f"Downloaded: {target}"
def _checkpoint_for(backbone: str, ckpt_type: str) -> PiDCheckpoint:
if backbone not in PID_BACKBONES:
raise PiDNodeError(f"Unknown backbone={backbone!r}; expected one of {BACKBONE_CHOICES}")
backbone_info = PID_BACKBONES[backbone]
if ckpt_type not in backbone_info.ckpt_types:
supported = ", ".join(backbone_info.ckpt_types)
raise PiDNodeError(
f"{backbone_info.label} does not ship a {ckpt_type!r} checkpoint in NVIDIA's registry. "
f"Supported pid_ckpt_type values: {supported}."
)
try:
return PID_CKPTS[(backbone_info.registry_key, ckpt_type)]
except KeyError as exc:
raise PiDNodeError(f"No PiD checkpoint registered for backbone={backbone!r}, pid_ckpt_type={ckpt_type!r}") from exc
def _ensure_checkpoint(pid_dir: Path, backbone: str, ckpt_type: str, allow_download: bool = True) -> Path:
ckpt = _checkpoint_for(backbone, ckpt_type)
ckpt_path = pid_dir / ckpt.relpath
if ckpt_path.is_file():
return ckpt_path
if not allow_download:
raise PiDNodeError(
f"PiD checkpoint is missing: {ckpt_path}\n"
"Set auto_download=true or download the matching file from nvidia/PiD."
)
_download_hf_file(pid_dir, ckpt.relpath)
return ckpt_path
def _ensure_backbone_assets(pid_dir: Path, backbone: str, allow_download: bool = True) -> None:
info = PID_BACKBONES[backbone]
if info.needs_flux_ae:
_ensure_ae_safetensors(pid_dir, allow_download=allow_download)
missing = [relpath for relpath in info.aux_files if not (pid_dir / relpath).is_file()]
if not missing:
return
if not allow_download:
missing_text = "\n".join(f" - {pid_dir / relpath}" for relpath in missing)
raise PiDNodeError(
f"{info.label} PiD support needs these extra asset files:\n"
f"{missing_text}\n\n"
"Set auto_download=true or download them from nvidia/PiD."
)
for relpath in missing:
_download_hf_file(pid_dir, relpath)
def _import_pid_loader(pid_dir: Path):
if str(pid_dir) not in sys.path:
sys.path.insert(0, str(pid_dir))
try:
from pid._src.utils.model_loader import load_model_from_checkpoint
except Exception as exc:
raise PiDNodeError(
"Could not import NVIDIA PiD. Usually this means PiD dependencies are not installed.\n\n"
"Install the packages in requirements.txt, then restart ComfyUI.\n\n"
f"Original import error: {exc}"
) from exc
return load_model_from_checkpoint
def _load_pid_model(
pid_dir: Path,
backbone: str,
ckpt_type: str,
checkpoint_path: Path,
dtype_choice: str,
load_ema_to_reg: bool = False,
):
experiment = _checkpoint_for(backbone, ckpt_type).experiment
config_file = "pid/_src/configs/pid/config.py"
cache_key = (
str(pid_dir),
backbone,
ckpt_type,
str(checkpoint_path),
experiment,
bool(load_ema_to_reg),
dtype_choice,
)
if cache_key in _MODEL_CACHE:
return _MODEL_CACHE[cache_key]
if not torch.cuda.is_available():
raise PiDNodeError("PiD's official model loader instantiates the decoder on CUDA. CUDA GPU is required.")
load_model_from_checkpoint = _import_pid_loader(pid_dir)
# PiD's config helper expects config_file to be relative to the PiD repo root.
with _pushd(pid_dir):
model, _config = load_model_from_checkpoint(
experiment_name=experiment,
checkpoint_path=str(checkpoint_path),
config_file=config_file,
enable_fsdp=False,
experiment_opts=[],
strict=False,
load_ema_to_reg=load_ema_to_reg,
)
model.eval()
_MODEL_CACHE[cache_key] = model
return model
def _remove_model_from_cache(model: object) -> None:
"""Drop a specific PiD model from the private cache by object identity."""
for key, cached_model in list(_MODEL_CACHE.items()):
if cached_model is model:
_MODEL_CACHE.pop(key, None)
def _unload_pid_model(model: object, aggressive: bool = True) -> None:
"""Move PiD off CUDA and clear the private cache after a decode run."""
_remove_model_from_cache(model)
try:
to = getattr(model, "to", None)
if callable(to):
to("cpu")
except Exception:
pass
_free_cuda_memory(aggressive=aggressive)
def _pid_lq_condition_type(model: object) -> str:
config = getattr(model, "config", None)
return str(getattr(config, "lq_condition_type", "latent") or "latent").strip().lower()
def _pid_uses_lq_image(model: object) -> bool:
return _pid_lq_condition_type(model) in ("image", "image_latent")
def _pid_uses_lq_latent(model: object) -> bool:
return _pid_lq_condition_type(model) in ("latent", "image_latent")
def _move_module_like_to_cpu(obj: object) -> None:
if obj is None:
return
nested_model = getattr(obj, "model", None)
nested_nested_model = getattr(nested_model, "model", None)
for target in (obj, nested_model, nested_nested_model):
if target is None:
continue
try:
to = getattr(target, "to", None)
if callable(to):
to("cpu")
except Exception:
pass
def _offload_unused_pid_conditioners(model: object, include_vae: bool = True) -> None:
_move_module_like_to_cpu(getattr(model, "text_encoder", None))
null_caption = getattr(model, "_null_caption_embs", None)
if isinstance(null_caption, torch.Tensor):
try:
setattr(model, "_null_caption_embs", null_caption.cpu())
except Exception:
pass
if include_vae:
_move_module_like_to_cpu(getattr(model, "vae_encoder", None))
_free_cuda_memory(aggressive=True)
class _LazyLQFeatureList:
"""Compute PiD LQ projection heads just-in-time instead of keeping all heads."""
def __init__(self, tokens: torch.Tensor, output_heads, pit_head):
self.tokens = tokens
self.output_heads = output_heads
self.pit_head = pit_head
def __len__(self) -> int:
return len(self.output_heads) + (1 if self.pit_head is not None else 0)
def __getitem__(self, idx: int) -> torch.Tensor:
length = len(self)
if idx < 0:
idx += length
if idx < 0 or idx >= length:
raise IndexError(idx)
if idx < len(self.output_heads):
return self.output_heads[idx](self.tokens)
if self.pit_head is None:
raise IndexError(idx)
return self.pit_head(self.tokens)
def _enable_lazy_lq_projection(model: object) -> None:
"""Patch PiD's LQ projector so 4K runs do not cache every block feature."""
net = getattr(model, "net", None)
lq_proj = getattr(net, "lq_proj", None)
if lq_proj is None or getattr(lq_proj, "_comfy_pid_lazy_lq", False):
return
if getattr(net, "_cp_group", None) is not None:
return
needed = (
"image_conv",
"latent_proj",
"merge",
"output_heads",
"pit_head",
"_align_image_to_patch_grid",
"_align_latent_to_patch_grid",
)
if any(not hasattr(lq_proj, name) for name in needed):
return
def lazy_forward(
lq_video_or_image=None,
lq_latent=None,
target_pH: int = 0,
target_pW: int = 0,
):
if target_pH <= 0 or target_pW <= 0:
raise AssertionError("Must provide target_pH and target_pW")
features = []
if lq_proj.image_conv is not None and lq_video_or_image is not None:
features.append(lq_proj._align_image_to_patch_grid(lq_video_or_image, target_pH, target_pW))
if lq_proj.latent_proj is not None and lq_latent is not None:
features.append(lq_proj._align_latent_to_patch_grid(lq_latent, target_pH, target_pW))
if len(features) == 2 and lq_proj.merge is not None:
merged = lq_proj.merge(torch.cat(features, dim=1))
elif len(features) == 1:
merged = features[0]
else:
ref = lq_video_or_image if lq_video_or_image is not None else lq_latent
if ref is None:
raise PiDNodeError("PiD low-VRAM LQ projection received neither LQ image nor latent.")
b, device, dtype = ref.shape[0], ref.device, ref.dtype
hidden_dim = int(getattr(lq_proj, "hidden_dim", 0) or getattr(lq_proj, "out_dim", 0))
tokens = torch.zeros(
b,
int(target_pH) * int(target_pW),
hidden_dim,
device=device,
dtype=dtype,
)
return _LazyLQFeatureList(tokens, lq_proj.output_heads, lq_proj.pit_head)
tokens = merged.flatten(2).transpose(1, 2)
return _LazyLQFeatureList(tokens, lq_proj.output_heads, lq_proj.pit_head)
lq_proj.forward = lazy_forward
lq_proj._comfy_pid_lazy_lq = True
def _generate_samples_low_vram(model: object, data_batch: dict, **kwargs):
"""Run PiD with text/VAE offload and lazy LQ conditioning."""
_enable_lazy_lq_projection(model)
batch = dict(data_batch)
condition_type = _pid_lq_condition_type(model)
if condition_type == "latent":
batch.pop("LQ_video_or_image", None)
elif condition_type == "image":
batch.pop("LQ_latent", None)
original_encode = getattr(model, "_encode_text_raw", None)
if not callable(original_encode):
return model.generate_samples_from_batch(batch, **kwargs)
def encode_then_offload(captions):
text_encoder = getattr(model, "text_encoder", None)
try:
to = getattr(text_encoder, "to", None)
if callable(to):
to("cuda")
except Exception:
pass
result = original_encode(captions)
_offload_unused_pid_conditioners(model, include_vae="LQ_latent" in batch)
return result
try:
setattr(model, "_encode_text_raw", encode_then_offload)
return model.generate_samples_from_batch(batch, **kwargs)
finally:
try:
setattr(model, "_encode_text_raw", original_encode)
except Exception:
pass
def _make_pid_data_batch(
model: object,
caption: str,
sigma: float,
latent_cpu: torch.Tensor,
baseline_cpu: Optional[torch.Tensor],
device: str,
) -> dict:
# Free heavyweight PiD conditioners before moving LQ inputs onto CUDA. The
# text encoder is moved back only for caption encoding inside the sampler.
_offload_unused_pid_conditioners(model, include_vae=_pid_uses_lq_latent(model))
batch = int(latent_cpu.shape[0])
data_batch = {
getattr(model.config, "input_caption_key", "caption"): [caption or ""] * batch,
"degrade_sigma": torch.full((batch,), float(sigma), device=device, dtype=torch.float32),
}
if _pid_uses_lq_latent(model):
data_batch["LQ_latent"] = latent_cpu.to(device=device, dtype=torch.bfloat16)
if _pid_uses_lq_image(model):
if baseline_cpu is None:
raise PiDNodeError(
"This PiD checkpoint requires LQ image conditioning. Connect baseline_image or a matching VAE."
)
data_batch["LQ_video_or_image"] = (baseline_cpu.to(device=device, dtype=torch.bfloat16) * 2.0) - 1.0
return data_batch
def _infer_baseline_size_from_latent(samples: torch.Tensor, backbone: str) -> Tuple[int, int]:
info = PID_BACKBONES.get(backbone, PID_BACKBONES["zimage"])
return int(samples.shape[-2]) * int(info.latent_downscale), int(samples.shape[-1]) * int(info.latent_downscale)
def _baseline_cpu_and_size(
samples: torch.Tensor,
backbone: str,
vae=None,
baseline_image=None,
) -> Tuple[Optional[torch.Tensor], Tuple[int, int]]:
if baseline_image is None and vae is None:
return None, _infer_baseline_size_from_latent(samples, backbone)
if baseline_image is None:
baseline_01 = _decode_baseline_with_comfy_vae(vae, samples, backbone)
else:
baseline_01 = _comfy_image_to_bchw_01(baseline_image)
if baseline_01.shape[0] != samples.shape[0]:
raise PiDNodeError(
f"Batch mismatch: latent batch={samples.shape[0]}, baseline batch={baseline_01.shape[0]}"
)
baseline_cpu = baseline_01.detach().to("cpu").contiguous()
return baseline_cpu, (int(baseline_cpu.shape[-2]), int(baseline_cpu.shape[-1]))
class _SequentialBlockOffloader:
"""Best-effort sequential CPU offload for PiD's largest DiT block stack.
This keeps the non-block parts of PiD on CUDA, moves the detected transformer
blocks to CPU, and transfers one block to CUDA only for its own forward call.
It is intentionally conservative and opt-in because it is much slower than
keeping all blocks resident on the GPU.
"""
def __init__(self, model, mode: str, device: str = "cuda"):
self.model = model
self.mode = str(mode or "disabled").strip().lower()
self.device = device
self.aggressive = self.mode == "sequential_blocks_aggressive"
self.handles = []
self.blocks = []
self.container_name = ""
if self.mode == "disabled":
return
if self.mode not in SEQUENTIAL_OFFLOAD_CHOICES:
raise PiDNodeError(
f"Unknown sequential_offload={self.mode!r}; expected one of {SEQUENTIAL_OFFLOAD_CHOICES}"
)
self.blocks, self.container_name = self._find_largest_block_stack(model)
if not self.blocks:
raise PiDNodeError(
"Sequential block offload could not find a transformer/DiT block stack in the loaded PiD model. "
"Set sequential_offload=disabled for this model."
)
self._install()
@staticmethod
def _module_param_count(module) -> int:
try:
return sum(int(p.numel()) for p in module.parameters(recurse=True))
except Exception:
return 0
@staticmethod
def _children(module):
try:
return list(module.children())
except Exception:
return []
def _find_largest_block_stack(self, model):
candidates = []
for name, module in model.named_modules():
children = self._children(module)
if len(children) < 4:
continue
cls_name = module.__class__.__name__.lower()
name_l = str(name).lower()
child_classes = " ".join(c.__class__.__name__.lower() for c in children[:8])
looks_like_blocks = (
"blocks" in name_l
or "block" in cls_name
or "block" in child_classes
or "transformer" in name_l
or "dit" in name_l
)
if not looks_like_blocks:
continue
child_param_counts = [self._module_param_count(c) for c in children]
total_params = sum(child_param_counts)
# Avoid tiny helper containers; a real PiD block stack is large.
if total_params < 10_000_000:
continue
candidates.append((total_params, name or module.__class__.__name__, children))
if not candidates:
return [], ""
candidates.sort(key=lambda item: item[0], reverse=True)
_total, name, blocks = candidates[0]
return blocks, name
def _install(self) -> None:
# Move selected blocks to CPU before the sample loop starts. The hooks
# bring each block back only for its own forward pass.
for block in self.blocks:
try:
block.to("cpu")
except Exception:
pass
_free_cuda_memory(aggressive=self.aggressive)
for block in self.blocks:
self.handles.append(block.register_forward_pre_hook(self._pre_forward))
self.handles.append(block.register_forward_hook(self._post_forward))
def _pre_forward(self, module, inputs):
try:
module.to(self.device)
except Exception as exc:
raise PiDNodeError(f"Sequential block offload failed moving a PiD block to CUDA: {exc}") from exc
return None
def _post_forward(self, module, inputs, output):
try:
module.to("cpu")
except Exception:
pass
if self.aggressive:
_free_cuda_memory(aggressive=True)
return output
def cleanup(self) -> None:
for handle in self.handles:
try:
handle.remove()
except Exception:
pass
self.handles.clear()
try:
gc.collect()
except Exception:
pass
if torch.cuda.is_available():
try:
torch.cuda.empty_cache()
except Exception:
pass
def _latent_samples(latent: dict) -> torch.Tensor:
if not isinstance(latent, dict) or "samples" not in latent:
raise PiDNodeError("Expected a ComfyUI LATENT dict containing key 'samples'.")
samples = latent["samples"]
if getattr(samples, "is_nested", False):
samples = samples.unbind()[0]
if samples.ndim != 4:
raise PiDNodeError(f"Expected latent samples as [B,C,H,W], got shape {list(samples.shape)}")
return samples
def _latent_pid_sigma(latent: dict, fallback: float) -> float:
if isinstance(latent, dict) and "pid_sigma" in latent and abs(float(fallback)) < 1e-12:
try:
return float(latent["pid_sigma"])
except Exception:
pass
return float(fallback)
def _comfy_image_to_bchw_01(image: torch.Tensor) -> torch.Tensor:
# Comfy IMAGE is [B,H,W,C] float 0..1.
if image.ndim != 4 or image.shape[-1] not in (1, 3, 4):
raise PiDNodeError(f"Expected ComfyUI IMAGE [B,H,W,C], got shape {list(image.shape)}")
if image.shape[-1] == 4:
image = image[..., :3]
if image.shape[-1] == 1:
image = image.repeat(1, 1, 1, 3)
return image.permute(0, 3, 1, 2).contiguous().clamp(0, 1)
def _bchw_neg1_to_comfy_image(image: torch.Tensor) -> torch.Tensor:
# PiD output is normally [B,C,H,W] in [-1,1]. Some PiD builds return a
# single-frame video tensor [B,C,1,H,W] or [B,1,C,H,W]. Convert all of
# those to Comfy IMAGE [B,H,W,C] in [0,1].
if image.ndim == 5:
# Observed from PiD/Z-Image: [B, C, T, H, W] with T=1.
if image.shape[2] == 1 and image.shape[1] in (1, 3, 4):
image = image[:, :, 0, :, :]
# Defensive support for [B, T, C, H, W] with T=1.
elif image.shape[1] == 1 and image.shape[2] in (1, 3, 4):
image = image[:, 0, :, :, :]
else:
raise PiDNodeError(
"Expected PiD output [B,C,H,W], [B,C,1,H,W], or [B,1,C,H,W], "
f"got shape {list(image.shape)}"
)
if image.ndim == 3:
image = image.unsqueeze(0)
if image.ndim != 4:
raise PiDNodeError(f"Expected PiD output [B,C,H,W], got shape {list(image.shape)}")
if image.shape[1] == 4:
image = image[:, :3, :, :]
if image.shape[1] == 1:
image = image.repeat(1, 3, 1, 1)
if image.shape[1] != 3:
raise PiDNodeError(f"Expected 1/3/4 output channels, got shape {list(image.shape)}")
image = image.float().clamp(-1, 1)
image = (image + 1.0) * 0.5
return image.permute(0, 2, 3, 1).contiguous().cpu().clamp(0, 1)
def _normalize_pid_samples(samples):
# Official code uses samples[0]; support tensor, list[tensor], or tuple returns.
if isinstance(samples, torch.Tensor):
return samples
if isinstance(samples, (list, tuple)):
if len(samples) == 1 and isinstance(samples[0], torch.Tensor) and samples[0].ndim == 4:
return samples[0]
tensors = [s for s in samples if isinstance(s, torch.Tensor)]
if len(tensors) == 1:
return tensors[0]
if tensors and all(t.ndim == 3 for t in tensors):
return torch.stack(tensors, dim=0)
raise PiDNodeError(f"Unsupported PiD output type: {type(samples)}")
def _decode_baseline_with_comfy_vae(vae, samples: torch.Tensor, backbone: str) -> torch.Tensor:
try:
image = vae.decode(samples)
except Exception as exc:
label = PID_BACKBONES.get(backbone, PID_BACKBONES["zimage"]).label
raise PiDNodeError(
f"ComfyUI VAE decode failed. Make sure the connected VAE can decode {label} "
"latents, or connect a pre-decoded baseline_image instead."
) from exc
return _comfy_image_to_bchw_01(image)
class PiDDecode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latent": ("LATENT",),
"caption": ("STRING", {"forceInput": True}),
# Use ComfyUI's canonical combo syntax. The older
# ("COMBO", {"options": ...}) form can cause the frontend to
# rebuild widget_values with the first combo dropped after tab
# switches / node refreshes.
"backbone": (BACKBONE_CHOICES, {"default": "zimage"}),
"pid_ckpt_type": (["2k", "2kto4k"], {"default": "2k"}),
"pid_steps": ("INT", {"default": 4, "min": 1, "max": 64, "step": 1}),
"scale": ("INT", {"default": 0, "min": 0, "max": 8, "step": 1}),
"cfg_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 20.0, "step": 0.1}),
"sigma": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1000.0, "step": 0.001}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1}),
"auto_download": ("BOOLEAN", {"default": True}),
"unload_comfy_before_pid": ("BOOLEAN", {"default": True}),
"aggressive_cleanup": ("BOOLEAN", {"default": True}),
},
"optional": {
"vae": ("VAE",),
"pid_source_dir": ("STRING", {"default": "", "multiline": False}),
"sequential_offload": (SEQUENTIAL_OFFLOAD_CHOICES, {"default": "disabled"}),
"baseline_image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "decode"
CATEGORY = "PiD"
def decode(
self,
latent,
caption: str,
backbone: str,
pid_ckpt_type: str,
pid_steps: int,
scale: int,
cfg_scale: float,
sigma: float,
seed: int,
auto_download: bool,
unload_comfy_before_pid: bool = True,
aggressive_cleanup: bool = True,
vae=None,
pid_source_dir: str = "",
sequential_offload: str = "disabled",
baseline_image=None,
):
backbone = str(backbone).strip()
pid_ckpt_type = str(pid_ckpt_type).strip()
sequential_offload = str(sequential_offload or "disabled").strip().lower()
if sequential_offload not in SEQUENTIAL_OFFLOAD_CHOICES:
raise PiDNodeError(
f"Unknown sequential_offload={sequential_offload!r}; expected one of {SEQUENTIAL_OFFLOAD_CHOICES}"
)
if backbone not in PID_BACKBONES:
raise PiDNodeError(f"Unknown backbone={backbone!r}; expected one of {BACKBONE_CHOICES}")
backbone_info = PID_BACKBONES[backbone]
ckpt = _checkpoint_for(backbone, pid_ckpt_type)
if int(scale) <= 0:
scale = int(ckpt.scale or backbone_info.default_scale)
pid_dir = _resolve_pid_dir(pid_source_dir)
_ensure_pid_source(pid_dir, allow_download=bool(auto_download))
checkpoint_path = _ensure_checkpoint(pid_dir, backbone, pid_ckpt_type, allow_download=bool(auto_download))
_ensure_backbone_assets(pid_dir, backbone, allow_download=bool(auto_download))
samples = _latent_samples(latent)
sigma = _latent_pid_sigma(latent, sigma)
if samples.shape[1] != backbone_info.latent_channels:
raise PiDNodeError(
f"{backbone_info.label} PiD expects {backbone_info.latent_channels}-channel latents. "
f"Got {samples.shape[1]} channels."
)
# Keep only CPU copies before unloading Z-Image / CLIP / VAE.
# This prevents upstream CUDA tensors or a connected ComfyUI VAE object from
# keeping VRAM alive during the PiD-only stage.
samples_cpu = samples.detach().to("cpu").contiguous()
baseline_cpu, baseline_size = _baseline_cpu_and_size(
samples,
backbone,
vae=vae,
baseline_image=baseline_image,
)
del samples
vae = None
baseline_image = None
latent = None
# Decode or receive the low-res baseline first, then release Comfy's
# Z-Image/TE/VAE models before loading/running PiD.
if unload_comfy_before_pid:
_free_cuda_memory(aggressive=bool(aggressive_cleanup))
if not torch.cuda.is_available():
raise PiDNodeError("CUDA GPU is required for PiD.")
model = _load_pid_model(
pid_dir=pid_dir,
backbone=backbone,
ckpt_type=pid_ckpt_type,
checkpoint_path=checkpoint_path,
dtype_choice="bf16",
load_ema_to_reg=False,
)
device = "cuda"
caption = caption or ""
data_batch = _make_pid_data_batch(model, caption, float(sigma), samples_cpu, baseline_cpu, device)
del samples_cpu
del baseline_cpu
h, w = baseline_size
infer_image_size = (int(h) * int(scale), int(w) * int(scale))
if model_management is not None:
try:
model_management.throw_exception_if_processing_interrupted()
except Exception:
pass
_free_cuda_memory(aggressive=bool(aggressive_cleanup))
offloader = None
if sequential_offload != "disabled":
offloader = _SequentialBlockOffloader(model, sequential_offload, device=device)
try:
with torch.inference_mode():
out = _generate_samples_low_vram(
model,
data_batch,
cfg_scale=float(cfg_scale),
num_steps=int(pid_steps),
seed=int(seed),
shift=None,
image_size=infer_image_size,
)
except Exception as exc:
# After a CUDA allocator/internal-assert failure, the process may be in a bad state.
# Still try to free memory and return a useful error to the ComfyUI UI.
if offloader is not None:
offloader.cleanup()
offloader = None
del data_batch
_unload_pid_model(model, aggressive=True)
del model
raise _format_pid_runtime_error(exc, infer_image_size, f"{backbone}/{pid_ckpt_type}", int(scale)) from exc
out = _normalize_pid_samples(out)
image = _bchw_neg1_to_comfy_image(out)
del out
del data_batch
if offloader is not None:
offloader.cleanup()
offloader = None
_unload_pid_model(model, aggressive=bool(aggressive_cleanup))
del model
return (image,)
NODE_CLASS_MAPPINGS = {
"PiDDecode": PiDDecode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PiDDecode": "PiD Decode",
}