[diffusion] refactor: extract LTX2 image encoding from denoising stage (#22976)

This commit is contained in:
Mick
2026-04-17 08:35:15 +08:00
committed by GitHub
parent 2cc52d8326
commit 3d2d57c6cc
5 changed files with 331 additions and 270 deletions
@@ -23,6 +23,7 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages import (
LTX2AVDenoisingStage,
LTX2AVLatentPreparationStage,
LTX2HalveResolutionStage,
LTX2ImageEncodingStage,
LTX2LoRASwitchStage,
LTX2RefinementStage,
LTX2TextConnectorStage,
@@ -175,6 +176,9 @@ def _add_ltx2_stage1_generation_stages(pipeline: ComposedPipelineBase):
transformer=pipeline.get_module("transformer"),
audio_vae=pipeline.get_module("audio_vae"),
),
LTX2ImageEncodingStage(
vae=pipeline.get_module("vae"),
),
LTX2AVDenoisingStage(
transformer=pipeline.get_module("transformer"),
scheduler=pipeline.get_module("scheduler"),
@@ -374,6 +378,12 @@ class LTX2TwoStagePipeline(_BaseLTX2Pipeline):
LTX2LoRASwitchStage(pipeline=self, phase="stage2"),
"ltx2_lora_switch_stage2",
),
(
LTX2ImageEncodingStage(
vae=self.get_module("vae"),
),
"ltx2_image_encoding_stage2",
),
LTX2RefinementStage(
transformer=self.get_module("transformer"),
scheduler=self.get_module("scheduler"),
@@ -46,6 +46,7 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.hunyuan3d_shape import
from sglang.multimodal_gen.runtime.pipelines_core.stages.image_encoding import (
ImageEncodingStage,
ImageVAEEncodingStage,
LTX2ImageEncodingStage,
)
from sglang.multimodal_gen.runtime.pipelines_core.stages.input_validation import (
InputValidationStage,
@@ -91,6 +92,7 @@ __all__ = [
"LTX2AVDecodingStage",
"ImageEncodingStage",
"ImageVAEEncodingStage",
"LTX2ImageEncodingStage",
"TextEncodingStage",
"LTX2TextConnectorStage",
# Hunyuan3D shape stages
@@ -218,9 +218,6 @@ class LTX2RefinementStage(LTX2AVDenoisingStage):
device=batch.audio_latents.device, dtype=torch.float32
)
batch.image_latent = None
batch.ltx2_num_image_tokens = 0
original_scheduler = self.scheduler
original_batch_timesteps = batch.timesteps
original_batch_num_inference_steps = batch.num_inference_steps
@@ -9,7 +9,9 @@ This module contains implementations of image encoding stages for diffusion pipe
import inspect
import numpy as np
import PIL
import PIL.Image
import torch
from diffusers.models.autoencoders.vae import DiagonalGaussianDistribution
from diffusers.models.modeling_outputs import AutoencoderKLOutput
@@ -223,6 +225,316 @@ class ImageEncodingStage(PipelineStage):
return result
class LTX2ImageEncodingStage(PipelineStage):
"""Encode ``batch.image_path`` into packed token latents for LTX-2 TI2V.
Runs before denoising. Populates:
- ``batch.condition_image`` (resized PIL image)
- ``batch.image_latent`` (packed [B, S0, D] token latents)
- ``batch.ltx2_num_image_tokens``
"""
def __init__(self, vae=None, **kwargs) -> None:
super().__init__()
self.vae = vae
self._condition_image_encoder = None
self._condition_image_encoder_dir = None
# -- device management (mirrors ImageVAEEncodingStage) ---------------
def load_model(self):
device = get_local_torch_device()
if self._condition_image_encoder is not None:
self._condition_image_encoder = self._condition_image_encoder.to(device)
else:
self.vae = self.vae.to(device)
def offload_model(self):
if self.server_args.vae_cpu_offload:
self.vae = self.vae.to("cpu")
if self._condition_image_encoder is not None:
self._condition_image_encoder = self._condition_image_encoder.to("cpu")
# -- lazy condition encoder (LTX-2.3) --------------------------------
def _ensure_condition_image_encoder(self, server_args: ServerArgs) -> bool:
"""Load LTX-2.3 condition-encoder weights on first call. Returns True if available."""
import json
import os
arch_config = server_args.pipeline_config.vae_config.arch_config
encoder_subdir = str(getattr(arch_config, "condition_encoder_subdir", ""))
if not encoder_subdir:
return False
vae_model_path = server_args.model_paths["vae"]
encoder_dir = os.path.join(vae_model_path, encoder_subdir)
if (
self._condition_image_encoder is not None
and self._condition_image_encoder_dir == encoder_dir
):
return True
config_path = os.path.join(encoder_dir, "config.json")
weights_path = os.path.join(encoder_dir, "model.safetensors")
if not os.path.exists(config_path) or not os.path.exists(weights_path):
raise ValueError(
f"LTX-2 condition encoder files not found under {encoder_dir}"
)
from safetensors.torch import load_file as safetensors_load_file
from sglang.multimodal_gen.runtime.models.vaes.ltx_2_3_condition_encoder import (
LTX23VideoConditionEncoder,
)
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
self._condition_image_encoder = LTX23VideoConditionEncoder(config)
self._condition_image_encoder.load_state_dict(
safetensors_load_file(weights_path), strict=True
)
self._condition_image_encoder_dir = encoder_dir
return True
# -- image preprocessing ---------------------------------------------
@staticmethod
def _apply_video_codec_compression(
img_array: np.ndarray, crf: int = 33
) -> np.ndarray:
"""Single H.264 frame round-trip to simulate compression artifacts."""
from io import BytesIO
import av
if crf == 0:
return img_array
h, w = img_array.shape[0] // 2 * 2, img_array.shape[1] // 2 * 2
img_array = img_array[:h, :w]
buf = BytesIO()
container = av.open(buf, mode="w", format="mp4")
stream = container.add_stream(
"libx264", rate=1, options={"crf": str(crf), "preset": "veryfast"}
)
stream.height, stream.width = h, w
frame = av.VideoFrame.from_ndarray(img_array, format="rgb24").reformat(
format="yuv420p"
)
container.mux(stream.encode(frame))
container.mux(stream.encode())
container.close()
buf.seek(0)
container = av.open(buf)
decoded = next(container.decode(container.streams.video[0]))
container.close()
return decoded.to_ndarray(format="rgb24")
@staticmethod
def _pil_to_video_tensor(
img: PIL.Image.Image,
*,
width: int,
height: int,
device: torch.device,
dtype: torch.dtype,
) -> torch.Tensor:
"""Scale-to-cover, center-crop, normalize to [1, C, 1, H, W] in [-1, 1]."""
import math
arr = np.array(img).astype(np.uint8)[..., :3]
t = (
torch.from_numpy(arr.astype(np.float32))
.permute(2, 0, 1)
.unsqueeze(0)
.to(device=device)
)
src_h, src_w = t.shape[2], t.shape[3]
scale = max(height / src_h, width / src_w)
new_h, new_w = math.ceil(src_h * scale), math.ceil(src_w * scale)
t = torch.nn.functional.interpolate(
t, size=(new_h, new_w), mode="bilinear", align_corners=False
)
top, left = (new_h - height) // 2, (new_w - width) // 2
t = t[:, :, top : top + height, left : left + width]
return ((t / 127.5 - 1.0).to(dtype=dtype)).unsqueeze(2)
# -- encode paths ----------------------------------------------------
def _vae_encode(
self,
video_condition: torch.Tensor,
server_args: ServerArgs,
generator: torch.Generator | None,
) -> torch.Tensor:
"""VAE encode → sample → per-channel normalize (LTX-2 convention)."""
vae_dtype = PRECISION_TO_TYPE[server_args.pipeline_config.vae_precision]
vae_autocast_enabled = (
vae_dtype != torch.float32
) and not server_args.disable_autocast
with torch.autocast(
device_type=current_platform.device_type,
dtype=vae_dtype,
enabled=vae_autocast_enabled,
):
try:
if server_args.pipeline_config.vae_tiling:
self.vae.enable_tiling()
except Exception:
pass
latent_dist = self.vae.encode(video_condition)
if isinstance(latent_dist, AutoencoderKLOutput):
latent_dist = latent_dist.latent_dist
mode = server_args.pipeline_config.vae_config.encode_sample_mode()
if mode == "argmax":
latent = latent_dist.mode()
elif mode == "sample":
if generator is None:
raise ValueError("Generator must be provided for VAE sampling.")
latent = latent_dist.sample(generator)
else:
raise ValueError(f"Unsupported encode_sample_mode: {mode}")
mean = self.vae.latents_mean.view(1, -1, 1, 1, 1).to(latent)
std = self.vae.latents_std.view(1, -1, 1, 1, 1).to(latent)
return (latent - mean) / std
def _condition_encode(
self, video_condition: torch.Tensor, server_args: ServerArgs
) -> torch.Tensor:
"""LTX-2.3 condition-image encoder path (bypasses VAE)."""
vae_dtype = PRECISION_TO_TYPE[server_args.pipeline_config.vae_precision]
vae_autocast_enabled = (
vae_dtype != torch.float32
) and not server_args.disable_autocast
with torch.autocast(
device_type=current_platform.device_type,
dtype=vae_dtype,
enabled=vae_autocast_enabled,
):
return self._condition_image_encoder(video_condition)
# -- forward ---------------------------------------------------------
@torch.no_grad()
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
if batch.image_path is None:
return batch
if (
batch.image_latent is not None
and int(getattr(batch, "ltx2_num_image_tokens", 0)) > 0
):
# Re-encode if resolution changed (e.g. two-stage upsample between stages)
vae_sf = int(server_args.pipeline_config.vae_scale_factor)
patch = int(server_args.pipeline_config.patch_size)
expected = (int(batch.height) // vae_sf // patch) * (
int(batch.width) // vae_sf // patch
)
if int(batch.image_latent.shape[1]) == expected:
return batch
# Resolution mismatch — clear and re-encode below
batch.image_latent = None
batch.ltx2_num_image_tokens = 0
batch.ltx2_num_image_tokens = 0
batch.image_latent = None
if self.vae is None:
raise ValueError("VAE must be provided for LTX-2 TI2V.")
from sglang.multimodal_gen.runtime.models.vision_utils import load_image
# 1. Load image, apply codec compression, resize for condition_image
image_path = (
batch.image_path[0]
if isinstance(batch.image_path, list)
else batch.image_path
)
img = load_image(image_path)
arr = np.array(img).astype(np.uint8)[..., :3]
arr = self._apply_video_codec_compression(arr, crf=33)
conditioned_img = PIL.Image.fromarray(arr)
batch.condition_image = conditioned_img.resize(
(int(batch.width), int(batch.height)),
resample=PIL.Image.Resampling.BILINEAR,
)
# 2. Load encoder(s) to device, cast to encode_dtype
use_condition_encoder = self._ensure_condition_image_encoder(server_args)
self.load_model()
device = get_local_torch_device()
encode_dtype = batch.latents.dtype
# Cast the active encoder to the latent precision (must match original
# behavior — running in a different dtype shifts the encoded latents).
if use_condition_encoder:
self._condition_image_encoder = self._condition_image_encoder.to(
dtype=encode_dtype
)
else:
self.vae = self.vae.to(dtype=encode_dtype)
video_condition = self._pil_to_video_tensor(
conditioned_img,
width=int(batch.width),
height=int(batch.height),
device=device,
dtype=encode_dtype,
)
# 3. Encode
if use_condition_encoder:
latent = self._condition_encode(video_condition, server_args).to(
dtype=encode_dtype
)
else:
latent = self._vae_encode(video_condition, server_args, batch.generator)
# Restore VAE to its config dtype (shared with decoding stage)
if not use_condition_encoder:
original_dtype = PRECISION_TO_TYPE[
server_args.pipeline_config.vae_precision
]
self.vae = self.vae.to(dtype=original_dtype)
# 4. Pack into token latents and validate
packed = server_args.pipeline_config.maybe_pack_latents(
latent, latent.shape[0], batch
)
if not (isinstance(packed, torch.Tensor) and packed.ndim == 3):
raise ValueError("Expected packed image latents [B, S0, D].")
vae_sf = int(server_args.pipeline_config.vae_scale_factor)
patch = int(server_args.pipeline_config.patch_size)
expected_tokens = (int(batch.height) // vae_sf // patch) * (
int(batch.width) // vae_sf // patch
)
if int(packed.shape[1]) != expected_tokens:
raise ValueError(
f"LTX-2 conditioning token count mismatch: "
f"{packed.shape[1]=} {expected_tokens=}."
)
batch.image_latent = packed
batch.ltx2_num_image_tokens = int(packed.shape[1])
if batch.debug:
logger.info(
"LTX2 TI2V: %d tokens (shape=%s) for %sx%s",
batch.ltx2_num_image_tokens,
tuple(batch.image_latent.shape),
batch.width,
batch.height,
)
self.offload_model()
return batch
class ImageVAEEncodingStage(PipelineStage):
"""
Stage for encoding pixel representations into latent space.
@@ -1,32 +1,13 @@
import copy
import json
import math
import os
from dataclasses import dataclass, field
from io import BytesIO
import av
import numpy as np
import PIL.Image
import torch
from diffusers.models.autoencoders.vae import DiagonalGaussianDistribution
from diffusers.models.modeling_outputs import AutoencoderKLOutput
from safetensors.torch import load_file as safetensors_load_file
from sglang.multimodal_gen.configs.pipeline_configs.ltx_2 import (
is_ltx23_native_variant,
)
from sglang.multimodal_gen.runtime.distributed import get_sp_world_size
from sglang.multimodal_gen.runtime.managers.forward_context import set_forward_context
from sglang.multimodal_gen.runtime.models.vaes.ltx_2_3_condition_encoder import (
LTX23VideoConditionEncoder,
)
from sglang.multimodal_gen.runtime.models.vision_utils import (
load_image,
normalize,
numpy_to_pt,
pil_to_numpy,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
from sglang.multimodal_gen.runtime.pipelines_core.stages.denoising import (
DenoisingContext,
@@ -36,10 +17,8 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.denoising import (
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
StageValidators as V,
)
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.utils import PRECISION_TO_TYPE
logger = init_logger(__name__)
@@ -73,8 +52,6 @@ class LTX2DenoisingStage(DenoisingStage):
super().__init__(
transformer=transformer, scheduler=scheduler, vae=vae, **kwargs
)
self._condition_image_encoder = None
self._condition_image_encoder_dir = None
@staticmethod
def _get_video_latent_num_frames_for_model(
@@ -306,78 +283,13 @@ class LTX2DenoisingStage(DenoisingStage):
)
return cls._ltx2_apply_rescale(cond, pred, rescale_scale)
@staticmethod
def _resize_center_crop(
img: PIL.Image.Image, *, width: int, height: int
) -> PIL.Image.Image:
return img.resize((width, height), resample=PIL.Image.Resampling.BILINEAR)
@staticmethod
def _apply_video_codec_compression(
img_array: np.ndarray, crf: int = 33
) -> np.ndarray:
"""Encode as a single H.264 frame and decode back to simulate compression artifacts."""
if crf == 0:
return img_array
height, width = img_array.shape[0] // 2 * 2, img_array.shape[1] // 2 * 2
img_array = img_array[:height, :width]
buffer = BytesIO()
container = av.open(buffer, mode="w", format="mp4")
stream = container.add_stream(
"libx264", rate=1, options={"crf": str(crf), "preset": "veryfast"}
)
stream.height, stream.width = height, width
frame = av.VideoFrame.from_ndarray(img_array, format="rgb24").reformat(
format="yuv420p"
)
container.mux(stream.encode(frame))
container.mux(stream.encode())
container.close()
buffer.seek(0)
container = av.open(buffer)
decoded = next(container.decode(container.streams.video[0]))
container.close()
return decoded.to_ndarray(format="rgb24")
@staticmethod
def _resize_center_crop_tensor(
img: PIL.Image.Image,
*,
width: int,
height: int,
device: torch.device,
dtype: torch.dtype,
apply_codec_compression: bool = True,
codec_crf: int = 33,
) -> torch.Tensor:
"""Resize, center-crop, and normalize to [1, C, 1, H, W] tensor in [-1, 1]."""
img_array = np.array(img).astype(np.uint8)[..., :3]
if apply_codec_compression:
img_array = LTX2DenoisingStage._apply_video_codec_compression(
img_array, crf=codec_crf
)
tensor = (
torch.from_numpy(img_array.astype(np.float32))
.permute(2, 0, 1)
.unsqueeze(0)
.to(device=device)
)
src_h, src_w = tensor.shape[2], tensor.shape[3]
scale = max(height / src_h, width / src_w)
new_h, new_w = math.ceil(src_h * scale), math.ceil(src_w * scale)
tensor = torch.nn.functional.interpolate(
tensor, size=(new_h, new_w), mode="bilinear", align_corners=False
)
top, left = (new_h - height) // 2, (new_w - width) // 2
tensor = tensor[:, :, top : top + height, left : left + width]
return ((tensor / 127.5 - 1.0).to(dtype=dtype)).unsqueeze(2)
@staticmethod
def _pil_to_normed_tensor(img: PIL.Image.Image) -> torch.Tensor:
# PIL -> numpy [0,1] -> torch [B,C,H,W], then [-1,1]
arr = pil_to_numpy(img)
t = numpy_to_pt(arr)
return normalize(t)
def _preprocess_sp_latents(self, batch: Req, server_args: ServerArgs):
"""LTX-2 TI2V applies image_latent in token space *after* SP sharding,
so the base implementation must not shard it."""
saved = batch.image_latent
batch.image_latent = None
super()._preprocess_sp_latents(batch, server_args)
batch.image_latent = saved
@staticmethod
def _should_apply_ltx2_ti2v(batch: Req) -> bool:
@@ -405,176 +317,6 @@ class LTX2DenoisingStage(DenoisingStage):
) -> bool:
return False
def _get_condition_image_encoder(
self,
server_args: ServerArgs,
*,
device: torch.device,
dtype: torch.dtype,
) -> LTX23VideoConditionEncoder | None:
arch_config = server_args.pipeline_config.vae_config.arch_config
encoder_subdir = str(getattr(arch_config, "condition_encoder_subdir", ""))
if not encoder_subdir:
return None
vae_model_path = server_args.model_paths["vae"]
encoder_dir = os.path.join(vae_model_path, encoder_subdir)
config_path = os.path.join(encoder_dir, "config.json")
weights_path = os.path.join(encoder_dir, "model.safetensors")
if not os.path.exists(config_path) or not os.path.exists(weights_path):
raise ValueError(
f"LTX-2 condition encoder files not found under {encoder_dir}"
)
cached_dir = self._condition_image_encoder_dir
encoder = self._condition_image_encoder
if encoder is None or cached_dir != encoder_dir:
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
encoder = LTX23VideoConditionEncoder(config)
encoder.load_state_dict(safetensors_load_file(weights_path), strict=True)
self._condition_image_encoder = encoder
self._condition_image_encoder_dir = encoder_dir
encoder = encoder.to(device=device, dtype=dtype)
return encoder
def _prepare_ltx2_image_latent(self, batch: Req, server_args: ServerArgs) -> None:
"""Encode `batch.image_path` into packed token latents for LTX-2 TI2V."""
if (
batch.image_latent is not None
and int(getattr(batch, "ltx2_num_image_tokens", 0)) > 0
):
return
batch.ltx2_num_image_tokens = 0
batch.image_latent = None
if batch.image_path is None:
return
if batch.width is None or batch.height is None:
raise ValueError("width/height must be provided for LTX-2 TI2V.")
if self.vae is None:
raise ValueError("VAE must be provided for LTX-2 TI2V.")
image_path = (
batch.image_path[0]
if isinstance(batch.image_path, list)
else batch.image_path
)
img = load_image(image_path)
img_array = np.array(img).astype(np.uint8)[..., :3]
img_array = self._apply_video_codec_compression(img_array, crf=33)
conditioned_img = PIL.Image.fromarray(img_array)
batch.condition_image = self._resize_center_crop(
conditioned_img, width=int(batch.width), height=int(batch.height)
)
latents_device = (
batch.latents.device
if isinstance(batch.latents, torch.Tensor)
else torch.device("cpu")
)
encode_dtype = batch.latents.dtype
original_dtype = PRECISION_TO_TYPE[server_args.pipeline_config.vae_precision]
vae_autocast_enabled = (
original_dtype != torch.float32
) and not server_args.disable_autocast
condition_image_encoder = self._get_condition_image_encoder(
server_args, device=latents_device, dtype=encode_dtype
)
if condition_image_encoder is None:
self.vae = self.vae.to(device=latents_device, dtype=encode_dtype)
video_condition = self._resize_center_crop_tensor(
conditioned_img,
width=int(batch.width),
height=int(batch.height),
device=latents_device,
dtype=encode_dtype,
apply_codec_compression=False,
)
with torch.autocast(
device_type=current_platform.device_type,
dtype=original_dtype,
enabled=vae_autocast_enabled,
):
try:
if (
condition_image_encoder is None
and server_args.pipeline_config.vae_tiling
):
self.vae.enable_tiling()
except Exception:
pass
if not vae_autocast_enabled:
video_condition = video_condition.to(encode_dtype)
if condition_image_encoder is not None:
latent = condition_image_encoder(video_condition)
else:
latent_dist: DiagonalGaussianDistribution = self.vae.encode(
video_condition
)
if isinstance(latent_dist, AutoencoderKLOutput):
latent_dist = latent_dist.latent_dist
if condition_image_encoder is None:
mode = server_args.pipeline_config.vae_config.encode_sample_mode()
if mode == "argmax":
latent = latent_dist.mode()
elif mode == "sample":
if batch.generator is None:
raise ValueError("Generator must be provided for VAE sampling.")
latent = latent_dist.sample(batch.generator)
else:
raise ValueError(f"Unsupported encode_sample_mode: {mode}")
# Per-channel normalization: normalized = (x - mean) / std
mean = self.vae.latents_mean.view(1, -1, 1, 1, 1).to(latent)
std = self.vae.latents_std.view(1, -1, 1, 1, 1).to(latent)
latent = (latent - mean) / std
else:
latent = latent.to(dtype=encode_dtype)
packed = server_args.pipeline_config.maybe_pack_latents(
latent, latent.shape[0], batch
)
if not (isinstance(packed, torch.Tensor) and packed.ndim == 3):
raise ValueError("Expected packed image latents [B, S0, D].")
# Fail-fast token count: must match one latent frame's tokens.
vae_sf = int(server_args.pipeline_config.vae_scale_factor)
patch = int(server_args.pipeline_config.patch_size)
latent_h = int(batch.height) // vae_sf
latent_w = int(batch.width) // vae_sf
expected_tokens = (latent_h // patch) * (latent_w // patch)
if int(packed.shape[1]) != int(expected_tokens):
raise ValueError(
"LTX-2 conditioning token count mismatch: "
f"{int(packed.shape[1])=} {int(expected_tokens)=}."
)
batch.image_latent = packed
batch.ltx2_num_image_tokens = int(packed.shape[1])
if batch.debug:
logger.info(
"LTX2 TI2V conditioning prepared: %d tokens (shape=%s) for %sx%s",
batch.ltx2_num_image_tokens,
tuple(batch.image_latent.shape),
batch.width,
batch.height,
)
if condition_image_encoder is None:
self.vae.to(original_dtype)
if server_args.vae_cpu_offload:
self.vae = self.vae.to("cpu")
if condition_image_encoder is not None:
self._condition_image_encoder = condition_image_encoder.to("cpu")
def _prepare_denoising_loop(
self,
batch: Req,
@@ -602,8 +344,6 @@ class LTX2DenoisingStage(DenoisingStage):
# Video and audio keep separate scheduler state throughout the denoising loop.
ctx.audio_scheduler = copy.deepcopy(self.scheduler)
# Prepare image latents and embeddings for LTX-2 TI2V generation.
self._prepare_ltx2_image_latent(batch, server_args)
do_ti2v = self._should_apply_ltx2_ti2v(batch)
if ctx.use_ltx23_legacy_one_stage: