Files
2026-07-07 13:49:58 +02:00

808 lines
30 KiB
Python

"""Memory-conscious NormalCrafter inference engine.
The engine is intentionally organized as four explicit stages:
1. CLIP image encoding: RGB frames -> semantic frame embeddings.
2. VAE encoding: RGB frames -> spatial RGB latents.
3. UNet inference: RGB latents + CLIP embeddings -> normal-map latents.
4. VAE decoding: normal-map latents -> three-channel normal maps.
The central ownership rule is simple: large, long-lived intermediate videos stay
on CPU. Only the currently active chunk or temporal window is moved to the
inference device. This avoids retaining an entire video on CUDA and makes peak
VRAM mostly independent of total clip length.
"""
from __future__ import annotations
from dataclasses import dataclass
import gc
import inspect
import logging
import threading
from typing import Callable, Literal
import torch
import torch.nn.functional as F
from diffusers import AutoencoderKLTemporalDecoder, EulerDiscreteScheduler
from transformers import CLIPImageProcessor, CLIPVisionModelWithProjection
from .preprocess import FrameSource
from .unet import NormalCrafterUNet
from .windows import Window, build_windows, overlap_length
logger = logging.getLogger(__name__)
# Called after every completed chunk/window. The strings make progress reporting
# descriptive without tying the engine to ComfyUI itself.
ProgressCallback = Callable[[str, int, int], None]
# ``staged`` minimizes VRAM by moving only one model component to the device.
# ``resident`` avoids repeated device transfers but keeps every component loaded.
OffloadMode = Literal["staged", "resident"]
# ``original`` restores the source dimensions after decoding. ``processed`` keeps
# the aspect-preserving, max-resolution-limited dimensions used by the model.
OutputSize = Literal["original", "processed"]
@dataclass(frozen=True, slots=True)
class InferenceConfig:
"""Immutable runtime settings for one inference call.
Keeping these values in a frozen dataclass prevents an inference run from
observing settings that change halfway through execution.
"""
max_resolution: int = 1024
window_size: int = 14
step_size: int = 10
clip_chunk_size: int = 16
vae_encode_chunk_size: int = 7
vae_decode_chunk_size: int = 4
offload_mode: OffloadMode = "staged"
offload_after: bool = True
output_size: OutputSize = "original"
renormalize_normals: bool = True
def validate(self) -> None:
"""Reject combinations that would produce gaps or invalid tensors."""
if self.max_resolution < 64:
raise ValueError("max_resolution must be at least 64")
if self.window_size < 2:
raise ValueError("window_size must be at least 2")
# A step larger than the window would leave uncovered frames between
# windows. A step of zero would never advance.
if not 1 <= self.step_size <= self.window_size:
raise ValueError("step_size must be between 1 and window_size")
for name, value in (
("clip_chunk_size", self.clip_chunk_size),
("vae_encode_chunk_size", self.vae_encode_chunk_size),
("vae_decode_chunk_size", self.vae_decode_chunk_size),
):
if value < 1:
raise ValueError(f"{name} must be positive")
class NormalCrafterModel:
"""Own and execute every model component required for inference.
Ownership guarantees:
* No module-level model globals.
* No duplicate pipeline object pointing at the same modules.
* No CUDA tensor is returned to ComfyUI.
* Intermediate full-video tensors live on CPU.
* A re-entrant lock serializes device moves and inference on this instance.
The object may stay alive in a ComfyUI graph cache, but its resource state is
explicit and controllable through ``offload_to_cpu``.
"""
# These values reproduce the conditioning used by the released pipeline.
# They are inherited from the Stable Video Diffusion interface, even though
# NormalCrafter performs a deterministic one-step prediction.
FPS_CONDITIONING = 7
MOTION_BUCKET_ID = 127
NOISE_AUG_STRENGTH = 0.0
def __init__(
self,
*,
unet: NormalCrafterUNet,
vae: AutoencoderKLTemporalDecoder,
image_encoder: CLIPVisionModelWithProjection,
feature_extractor: CLIPImageProcessor,
scheduler: EulerDiscreteScheduler,
dtype: torch.dtype,
attention_mode: str,
) -> None:
# ``eval`` disables training behavior such as dropout and documents that
# these modules are inference-only in this implementation.
self.unet = unet.eval()
self.vae = vae.eval()
self.image_encoder = image_encoder.eval()
# The feature extractor only provides normalization metadata here; image
# resizing is done directly in Torch to avoid PIL/NumPy round-trips.
self.feature_extractor = feature_extractor
self.scheduler = scheduler
self.dtype = dtype
self.attention_mode = attention_mode
# Device moves are not thread-safe when two graph executions share one
# model instance. The lock also protects scheduler mutation.
self._lock = threading.RLock()
self._configure_attention(attention_mode)
@classmethod
def from_pretrained(
cls,
*,
model_repo: str = "Yanrui95/NormalCrafter",
base_repo: str = "stabilityai/stable-video-diffusion-img2vid-xt",
dtype: torch.dtype = torch.float16,
attention_mode: str = "auto",
local_files_only: bool = False,
) -> "NormalCrafterModel":
"""Load the released weights and assemble one clean model owner.
NormalCrafter supplies its fine-tuned UNet and VAE. The CLIP vision
encoder, preprocessing statistics, and Euler scheduler come from the SVD
base repository used during training.
"""
common = {
"torch_dtype": dtype,
"low_cpu_mem_usage": True,
"local_files_only": local_files_only,
}
logger.info("Loading NormalCrafter UNet from %s", model_repo)
unet = NormalCrafterUNet.from_pretrained(model_repo, subfolder="unet", **common)
logger.info("Loading NormalCrafter VAE from %s", model_repo)
vae = AutoencoderKLTemporalDecoder.from_pretrained(
model_repo,
subfolder="vae",
**common,
)
# Some Hugging Face repos expose an explicit fp16 variant. Try it first
# for half-precision execution, then fall back to the default files.
encoder_kwargs = dict(common)
if dtype in (torch.float16, torch.bfloat16):
encoder_kwargs["variant"] = "fp16"
try:
image_encoder = CLIPVisionModelWithProjection.from_pretrained(
base_repo,
subfolder="image_encoder",
**encoder_kwargs,
)
except (OSError, ValueError, TypeError):
encoder_kwargs.pop("variant", None)
image_encoder = CLIPVisionModelWithProjection.from_pretrained(
base_repo,
subfolder="image_encoder",
**encoder_kwargs,
)
feature_extractor = CLIPImageProcessor.from_pretrained(
base_repo,
subfolder="feature_extractor",
local_files_only=local_files_only,
)
scheduler = EulerDiscreteScheduler.from_pretrained(
base_repo,
subfolder="scheduler",
local_files_only=local_files_only,
)
model = cls(
unet=unet,
vae=vae,
image_encoder=image_encoder,
feature_extractor=feature_extractor,
scheduler=scheduler,
dtype=dtype,
attention_mode=attention_mode,
)
# Loading libraries may choose a device based on environment/config. The
# public lifecycle contract is that a newly loaded model starts on CPU.
model.offload_to_cpu(empty_cache=False)
return model
@property
def modules(self) -> dict[str, torch.nn.Module]:
"""Return the heavy components participating in device management."""
return {
"image_encoder": self.image_encoder,
"vae": self.vae,
"unet": self.unet,
}
def _configure_attention(self, mode: str) -> None:
"""Enable xFormers when available or explicitly requested."""
if mode not in {"auto", "default", "xformers"}:
raise ValueError(f"unsupported attention mode: {mode}")
if mode == "default":
return
try:
self.unet.enable_xformers_memory_efficient_attention()
logger.info("NormalCrafter xFormers attention enabled")
except Exception as exc:
# Explicit requests should fail loudly. ``auto`` is allowed to fall
# back because current PyTorch/Diffusers attention may be sufficient.
if mode == "xformers":
raise RuntimeError("xFormers was requested but could not be enabled") from exc
logger.info("xFormers unavailable; using Diffusers default attention: %s", exc)
@staticmethod
def _device_of(module: torch.nn.Module) -> torch.device:
"""Read a module's current device from its first parameter."""
try:
return next(module.parameters()).device
except StopIteration:
# Defensive fallback for parameterless modules.
return torch.device("cpu")
def _activate(self, name: str, device: torch.device, offload_mode: OffloadMode) -> None:
"""Place model components according to the selected residency policy.
``resident``:
Move all heavy modules to the inference device and leave them there.
``staged``:
Move only the named component to the inference device and force the
others to CPU. After those moves, release now-unused CUDA cache blocks.
"""
if offload_mode == "resident":
for module in self.modules.values():
if self._device_of(module) != device:
module.to(device)
return
for module_name, module in self.modules.items():
target = device if module_name == name else torch.device("cpu")
if self._device_of(module) != target:
module.to(target)
self._release_cuda_cache(device)
@staticmethod
def _release_cuda_cache(device: torch.device) -> None:
"""Release unreachable Python objects and unused CUDA allocator blocks.
``empty_cache`` does not free live tensors. Its usefulness depends on the
preceding ownership discipline: temporary tensors must first go out of
scope or be deleted, and inactive modules must already have moved to CPU.
"""
gc.collect()
if device.type == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
def offload_to_cpu(self, *, empty_cache: bool = True) -> None:
"""Move all owned model weights to CPU and optionally empty CUDA cache."""
for module in self.modules.values():
module.to("cpu")
if empty_cache:
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def infer(
self,
images_bhwc: torch.Tensor,
*,
device: torch.device,
config: InferenceConfig,
progress: ProgressCallback | None = None,
) -> torch.Tensor:
"""Run complete inference and return CPU normal maps in ComfyUI format.
Input shape:
``[frames, height, width, channels]``, values expected in ``[0, 1]``.
Output shape:
``[real_frames, output_height, output_width, 3]`` on CPU, values in
``[0, 1]`` where encoded RGB corresponds to XYZ normal direction.
"""
config.validate()
# FrameSource owns the canonical CPU input and lazily prepares only the
# chunk needed by the current stage.
source = FrameSource(images_bhwc, max_resolution=config.max_resolution)
# The temporal UNet requires at least one full window. Short clips repeat
# their final frame internally; decoding is still limited to real frames.
effective_frames = source.effective_frame_count(config.window_size)
completed = False
# ``torch.inference_mode`` removes autograd bookkeeping more aggressively
# than ``no_grad`` and prevents accidental graph retention.
with self._lock, torch.inference_mode():
try:
# Stage 1: semantic embedding per frame, stored on CPU.
clip_embeddings = self._encode_clip(
source,
effective_frames,
device,
config,
progress,
)
# Stage 2: spatial RGB latent per frame, stored on CPU.
rgb_latents = self._encode_vae(
source,
effective_frames,
device,
config,
progress,
)
# Stage 3: sliding-window temporal prediction, stored on CPU.
normal_latents = self._predict_latents(
rgb_latents,
clip_embeddings,
device,
config,
progress,
)
# These full-video intermediates are no longer needed before the
# decoder stage. Delete references early to reduce host memory.
del rgb_latents, clip_embeddings
# Stage 4: decode only real input frames into final normals.
output = self._decode_normals(
normal_latents,
source,
device,
config,
progress,
)
completed = True
return output
finally:
# On failure we always offload. On success this follows the user's
# setting, allowing ``resident`` mode to remain fast across runs.
if config.offload_after or not completed:
self.offload_to_cpu(empty_cache=True)
def _encode_clip(
self,
source: FrameSource,
frame_count: int,
device: torch.device,
config: InferenceConfig,
progress: ProgressCallback | None,
) -> torch.Tensor:
"""Encode every frame into one CLIP vision embedding on CPU.
Shape flow, typically:
``[chunk, 3, H, W]`` -> ``[chunk, 3, 224, 224]`` ->
``[chunk, 1, embedding_dim]``.
"""
self._activate("image_encoder", device, config.offload_mode)
# Reproduce the CLIP normalization configured by the SVD repository.
# Mean/std remain on CPU because subtraction occurs before device transfer.
mean = torch.tensor(
self.feature_extractor.image_mean,
dtype=torch.float32,
).view(1, 3, 1, 1)
std = torch.tensor(
self.feature_extractor.image_std,
dtype=torch.float32,
).view(1, 3, 1, 1)
output: torch.Tensor | None = None
total = (frame_count + config.clip_chunk_size - 1) // config.clip_chunk_size
for chunk_index, start in enumerate(
range(0, frame_count, config.clip_chunk_size),
start=1,
):
end = min(start + config.clip_chunk_size, frame_count)
# FrameSource resizes/pads lazily and repeats the final real frame for
# synthetic short-clip indices.
frames = source.get_bchw(start, end)
# CLIP vision expects its own fixed spatial resolution.
frames = F.interpolate(
frames,
size=(224, 224),
mode="bicubic",
align_corners=False,
antialias=True,
).clamp_(0.0, 1.0)
# Only the current normalized chunk crosses to the inference device.
pixels = ((frames - mean) / std).to(device=device, dtype=self.dtype)
embeddings = self.image_encoder(pixel_values=pixels).image_embeds
# Diffusers cross-attention expects a sequence dimension. Depending on
# Transformers version, ``image_embeds`` may arrive as rank two.
if embeddings.ndim == 2:
embeddings = embeddings.unsqueeze(1)
# Copy the result back immediately so total video length does not grow
# CUDA residency. Half precision is retained to limit host memory.
embeddings_cpu = embeddings.detach().to(device="cpu", dtype=self.dtype)
# Allocate the final CPU tensor once the runtime embedding shape is
# known, then fill slices instead of accumulating and concatenating.
if output is None:
output = torch.empty(
(frame_count, *embeddings_cpu.shape[1:]),
dtype=self.dtype,
device="cpu",
)
output[start:end].copy_(embeddings_cpu)
# Drop all references to current chunk/device tensors before moving on.
del frames, pixels, embeddings, embeddings_cpu
if progress:
progress("CLIP", chunk_index, total)
assert output is not None
return output
def _encode_vae(
self,
source: FrameSource,
frame_count: int,
device: torch.device,
config: InferenceConfig,
progress: ProgressCallback | None,
) -> torch.Tensor:
"""Encode RGB frames into VAE latents and store them on CPU.
Unlike generative sampling, this path uses the latent distribution mode,
making encoding deterministic and avoiding random sampling noise.
"""
self._activate("vae", device, config.offload_mode)
original_dtype = next(self.vae.parameters()).dtype
# Some Diffusers VAEs declare ``force_upcast`` because selected operations
# are unstable in FP16. The dtype is restored in ``finally`` even on error.
needs_upcast = (
original_dtype == torch.float16
and bool(getattr(self.vae.config, "force_upcast", False))
)
if needs_upcast:
self.vae.to(dtype=torch.float32)
compute_dtype = next(self.vae.parameters()).dtype
output: torch.Tensor | None = None
total = (
frame_count + config.vae_encode_chunk_size - 1
) // config.vae_encode_chunk_size
try:
for chunk_index, start in enumerate(
range(0, frame_count, config.vae_encode_chunk_size),
start=1,
):
end = min(start + config.vae_encode_chunk_size, frame_count)
frames = source.get_bchw(start, end)
# VAE input convention is [-1, 1], whereas ComfyUI uses [0, 1].
frames = (frames * 2.0 - 1.0).to(
device=device,
dtype=compute_dtype,
)
latents = self.vae.encode(frames).latent_dist.mode()
latents_cpu = latents.detach().to(device="cpu", dtype=self.dtype)
# Runtime allocation avoids assumptions about latent channel count
# or spatial scale while still eliminating list + torch.cat churn.
if output is None:
output = torch.empty(
(frame_count, *latents_cpu.shape[1:]),
dtype=self.dtype,
device="cpu",
)
output[start:end].copy_(latents_cpu)
del frames, latents, latents_cpu
if progress:
progress("VAE encode", chunk_index, total)
finally:
if needs_upcast:
self.vae.to(dtype=original_dtype)
assert output is not None
return output
def _added_time_ids(self, *, dtype: torch.dtype, device: torch.device) -> torch.Tensor:
"""Construct the three Stable Video Diffusion conditioning values."""
values = [
self.FPS_CONDITIONING,
self.MOTION_BUCKET_ID,
self.NOISE_AUG_STRENGTH,
]
# Fail explicitly if a different UNet config expects a different added
# conditioning width. Silent shape coercion here would produce bad output.
expected = self.unet.add_embedding.linear_1.in_features
actual = int(self.unet.config.addition_time_embed_dim) * len(values)
if expected != actual:
raise ValueError(
f"UNet added-time embedding mismatch: expected {expected}, constructed {actual}"
)
return torch.tensor([values], dtype=dtype, device=device)
def _predict_latents(
self,
rgb_latents_cpu: torch.Tensor,
clip_embeddings_cpu: torch.Tensor,
device: torch.device,
config: InferenceConfig,
progress: ProgressCallback | None,
) -> torch.Tensor:
"""Predict normal latents over overlapping temporal windows.
Full-video input/output tensors remain on CPU. Each iteration moves only
one window of RGB latents and CLIP embeddings to the inference device.
The released inference path is a deterministic single scheduler step:
normal latents start at zero, RGB latents are concatenated as conditioning,
and the UNet predicts the one-step update.
"""
self._activate("unet", device, config.offload_mode)
frame_count = int(rgb_latents_cpu.shape[0])
windows = build_windows(frame_count, config.window_size, config.step_size)
# The output shape matches the VAE latent shape. It is allocated once and
# overwritten by each anchored window instead of repeatedly concatenated.
output_cpu = torch.empty_like(rgb_latents_cpu, device="cpu")
previous_window: Window | None = None
previous_latents: torch.Tensor | None = None
for index, window in enumerate(windows, start=1):
# RGB latent window: [frames, channels, h, w] ->
# [batch=1, frames, channels, h, w].
image_latents = rgb_latents_cpu[window.start : window.end].to(
device=device,
dtype=self.dtype,
).unsqueeze(0)
# One CLIP embedding is supplied for every video frame.
embeddings = clip_embeddings_cpu[window.start : window.end].to(
device=device,
dtype=self.dtype,
)
# NormalCrafter's prediction state starts from zero rather than random
# diffusion noise. This is why the node intentionally has no seed.
latents = torch.zeros(
(
1,
window.length,
int(self.unet.config.out_channels),
*image_latents.shape[-2:],
),
device=device,
dtype=self.dtype,
)
overlap = overlap_length(previous_window, window)
old_overlap: torch.Tensor | None = None
if overlap and previous_window is not None and previous_latents is not None:
# Determine where the current window begins inside the previous
# window, then seed the overlapping prefix with prior predictions.
previous_offset = window.start - previous_window.start
old_overlap = previous_latents[
:,
previous_offset : previous_offset + overlap,
]
latents[:, :overlap].copy_(old_overlap)
# NormalCrafter uses exactly one Euler scheduler timestep.
self.scheduler.set_timesteps(1, device=device)
timestep = self.scheduler.timesteps[0]
# Scheduler scaling is kept even for one step because it is part of
# the trained SVD inference convention.
model_input = self.scheduler.scale_model_input(latents, timestep)
# Channel concatenation: four predicted-normal channels plus four RGB
# latent conditioning channels -> eight UNet input channels.
model_input = torch.cat((model_input, image_latents), dim=2)
added_time_ids = self._added_time_ids(dtype=self.dtype, device=device)
noise_prediction = self.unet(
model_input,
timestep,
encoder_hidden_states=embeddings,
added_time_ids=added_time_ids,
return_dict=False,
)[0]
predicted = self.scheduler.step(
noise_prediction,
timestep,
latents,
).prev_sample
if old_overlap is not None:
# Crossfade old -> new prediction over the overlap. Endpoint values
# are excluded so neither side receives a fully duplicated weight.
weights = torch.linspace(
1.0,
0.0,
overlap + 2,
device=device,
dtype=self.dtype,
)[1:-1].view(1, overlap, 1, 1, 1)
predicted[:, :overlap] = (
old_overlap * weights
+ predicted[:, :overlap] * (1.0 - weights)
)
# Later windows intentionally overwrite their entire covered region.
# The overlap has already been blended, and the final anchored window
# guarantees coverage through the last frame.
output_cpu[window.start : window.end].copy_(
predicted[0].detach().to("cpu")
)
# Retain only the immediately previous GPU window for overlap seeding.
# This bounds temporal state by ``window_size``, not total clip length.
previous_window = window
previous_latents = predicted.detach()
del image_latents, embeddings, latents, model_input, added_time_ids, noise_prediction
if progress:
progress("UNet", index, len(windows))
return output_cpu
def _decode_normals(
self,
normal_latents_cpu: torch.Tensor,
source: FrameSource,
device: torch.device,
config: InferenceConfig,
progress: ProgressCallback | None,
) -> torch.Tensor:
"""Decode normal latents into final CPU ComfyUI IMAGE tensors."""
self._activate("vae", device, config.offload_mode)
original_dtype = next(self.vae.parameters()).dtype
needs_upcast = (
original_dtype == torch.float16
and bool(getattr(self.vae.config, "force_upcast", False))
)
if needs_upcast:
self.vae.to(dtype=torch.float32)
compute_dtype = next(self.vae.parameters()).dtype
if config.output_size == "original":
out_height, out_width = source.original_height, source.original_width
else:
out_height, out_width = source.resized_height, source.resized_width
# Allocate only the real frame count. Any synthetic padding frames used to
# satisfy a short temporal window are never decoded or returned.
output = torch.empty(
(source.frame_count, out_height, out_width, 3),
dtype=torch.float32,
device="cpu",
)
scaling_factor = float(self.vae.config.scaling_factor)
# Diffusers versions differ in whether temporal decoders accept an
# explicit ``num_frames`` argument. Inspect once and adapt safely.
forward_vae = (
self.vae._orig_mod.forward
if hasattr(self.vae, "_orig_mod")
else self.vae.forward
)
accepts_num_frames = "num_frames" in inspect.signature(forward_vae).parameters
total = (
source.frame_count + config.vae_decode_chunk_size - 1
) // config.vae_decode_chunk_size
try:
for chunk_index, start in enumerate(
range(0, source.frame_count, config.vae_decode_chunk_size),
start=1,
):
end = min(start + config.vae_decode_chunk_size, source.frame_count)
latents = normal_latents_cpu[start:end].to(
device=device,
dtype=compute_dtype,
)
# Undo the latent scaling expected by the VAE decoder.
latents = latents / scaling_factor
decode_kwargs = (
{"num_frames": end - start}
if accepts_num_frames
else {}
)
# Decode, promote to FP32, and transfer to CPU in one expression.
# The chained calls are kept identical to the validated version.
normals = self.vae.decode(latents, **decode_kwargs).sample.float().to("cpu")
# Remove the white spatial padding introduced before encoding.
normals = source.crop_padding(normals)
if (out_height, out_width) != (
source.resized_height,
source.resized_width,
):
normals = F.interpolate(
normals,
size=(out_height, out_width),
mode="bilinear",
align_corners=False,
)
# Decoder output represents XYZ components in [-1, 1].
normals = normals.clamp(-1.0, 1.0)
# Bilinear resizing blends vector components and can reduce vector
# magnitude. Renormalization restores unit-length surface normals.
if config.renormalize_normals:
normals = F.normalize(normals, p=2.0, dim=1, eps=1e-6)
# Encode signed XYZ into display/image range [0, 1].
normals = (normals * 0.5 + 0.5).clamp_(0.0, 1.0)
# Convert BCHW -> BHWC, the layout required by ComfyUI IMAGE.
output[start:end].copy_(
normals.permute(0, 2, 3, 1).contiguous()
)
del latents, normals
if progress:
progress("VAE decode", chunk_index, total)
finally:
if needs_upcast:
self.vae.to(dtype=original_dtype)
return output