removing some preprocessors

This commit is contained in:
Maxime Ellerbach
2026-06-17 09:52:23 +00:00
parent abd36f338c
commit d73772b9d0
5 changed files with 68 additions and 68 deletions
@@ -68,7 +68,7 @@ def default_video_dit_config(action_dim: int) -> dict[str, Any]:
"attn_head_dim": 128, "attn_head_dim": 128,
"num_layers": 30, "num_layers": 30,
"eps": 1.0e-6, "eps": 1.0e-6,
"separated_timestep": True, "seperated_timestep": True,
"use_gradient_checkpointing": False, "use_gradient_checkpointing": False,
"video_attention_mask_mode": "first_frame_causal", "video_attention_mask_mode": "first_frame_causal",
"action_conditioned": False, "action_conditioned": False,
@@ -215,7 +215,7 @@ class FastWAMConfig(PreTrainedConfig):
action_dit_config: dict[str, Any] | None = None action_dit_config: dict[str, Any] | None = None
normalization_mapping: dict[str, NormalizationMode] = field( normalization_mapping: dict[str, NormalizationMode] = field(
default_factory=lambda: { default_factory=lambda: {
"VISUAL": NormalizationMode.MEAN_STD, "VISUAL": NormalizationMode.IDENTITY,
"STATE": NormalizationMode.MEAN_STD, "STATE": NormalizationMode.MEAN_STD,
"ACTION": NormalizationMode.MEAN_STD, "ACTION": NormalizationMode.MEAN_STD,
} }
@@ -145,6 +145,26 @@ class FastWAMPolicy(PreTrainedPolicy):
model.to(map_location) model.to(map_location)
return model return model
def _save_pretrained(self, save_directory: Path, state_dict: dict[str, Tensor] | None = None) -> None:
"""Down-cast float tensors to the policy dtype before saving.
FSDP's FULL_STATE_DICT gather returns fp32 master weights, so the default save would
write a fp32 `model.safetensors` (~24 GB) even though FastWAM runs in
`config.torch_dtype` (bf16). That doubles disk/upload and, worse, makes reloading OOM
under FSDP every rank materializes the full fp32 model on GPU before sharding.
Casting float tensors to the configured dtype here halves the checkpoint and keeps
loads within budget; non-float tensors (e.g. integer buffers) pass through unchanged.
The `state_dict is None` path (non-FSDP saves) already holds params at
`config.torch_dtype`, so it needs no cast.
"""
if state_dict is not None:
dtype = _dtype_from_name(self.config.torch_dtype)
state_dict = {
key: (value.to(dtype) if torch.is_floating_point(value) else value)
for key, value in state_dict.items()
}
super()._save_pretrained(save_directory, state_dict)
def get_optim_params(self) -> list[Tensor]: def get_optim_params(self) -> list[Tensor]:
# Return the trainable tensors directly (a single param group). The optimizer # Return the trainable tensors directly (a single param group). The optimizer
# builder wraps these in a param group; returning a bare {"params": [...]} dict # builder wraps these in a param group; returning a bare {"params": [...]} dict
@@ -331,7 +351,9 @@ class FastWAMPolicy(PreTrainedPolicy):
def _debug_tensor_to_pil(image: Tensor): def _debug_tensor_to_pil(image: Tensor):
from PIL import Image from PIL import Image
arr = ((image.detach().float().clamp(-1.0, 1.0) + 1.0) * 127.5).to(torch.uint8) # `real` is the model input in [0, 1] (VISUAL is IDENTITY; the [-1,1] map lives at the VAE
# encode boundary), so map [0, 1] -> [0, 255] for display.
arr = (image.detach().float().clamp(0.0, 1.0) * 255.0).to(torch.uint8)
return Image.fromarray(arr.cpu().permute(1, 2, 0).numpy()) return Image.fromarray(arr.cpu().permute(1, 2, 0).numpy())
@staticmethod @staticmethod
@@ -491,13 +513,29 @@ def batch_device(batch: dict[str, Any]) -> torch.device:
return torch.device("cpu") return torch.device("cpu")
def _resize_frames(frames: Tensor, size: tuple[int, int]) -> Tensor:
"""Resize a frame tensor to `size` (H, W), tolerating a leading temporal/batch stack.
`interpolate` only accepts a single leading batch dim (`[N, C, H, W]`), but FastWAM camera
tensors arrive as `[B, C, H, W]` (live eval) or `[B, T, C, H, W]` (temporal stack), so flatten
any leading dims into the batch, resize, then restore. A no-op when already at `size`.
"""
if tuple(frames.shape[-2:]) == size:
return frames
lead = frames.shape[:-3]
flat = frames.reshape(-1, *frames.shape[-3:])
flat = torch.nn.functional.interpolate(flat, size=size, mode="bilinear", align_corners=False, antialias=True)
return flat.reshape(*lead, *flat.shape[-3:])
def _stack_video_from_images(batch: dict[str, Tensor], config: FastWAMConfig) -> Tensor: def _stack_video_from_images(batch: dict[str, Tensor], config: FastWAMConfig) -> Tensor:
# Exclude the `*_is_pad` companion tensors that delta-timestamp loading adds alongside # Exclude the `*_is_pad` companion tensors that delta-timestamp loading adds alongside
# each camera (shape [B, T]); they share the `observation.images.` prefix but are not frames. # each camera (shape [B, T]); they share the `observation.images.` prefix but are not frames.
image_keys = sorted(k for k in batch if k.startswith("observation.images.") and not k.endswith("_is_pad")) image_keys = sorted(k for k in batch if k.startswith("observation.images.") and not k.endswith("_is_pad"))
if not image_keys: if not image_keys:
raise KeyError("FastWAM batch must contain `video` or `observation.images.*` keys.") raise KeyError("FastWAM batch must contain `video` or `observation.images.*` keys.")
images = [batch[key] for key in image_keys] per_cam = (int(config.image_size[0]), int(config.image_size[1]) // len(image_keys))
images = [_resize_frames(batch[key], per_cam) for key in image_keys]
# Cameras concatenate along width (last dim) in both the single-frame and temporal case. # Cameras concatenate along width (last dim) in both the single-frame and temporal case.
image = torch.cat(images, dim=-1) if len(images) > 1 else images[0] image = torch.cat(images, dim=-1) if len(images) > 1 else images[0]
if image.ndim == 4: if image.ndim == 4:
@@ -530,11 +568,8 @@ def _prepare_infer_image(image: Tensor, config: FastWAMConfig) -> Tensor:
if image.ndim != 4: if image.ndim != 4:
raise ValueError(f"Expected image tensor [B,C,H,W] or [C,H,W], got {tuple(image.shape)}.") raise ValueError(f"Expected image tensor [B,C,H,W] or [C,H,W], got {tuple(image.shape)}.")
target_h, target_w = config.image_size # Resize to the full configured resolution (no-op when the video path already produced it, but
if tuple(image.shape[-2:]) != (target_h, target_w): # also covers a directly-supplied `input_image`). The model owns its input resolution — see
raise ValueError( # `_stack_video_from_images` — so we resize rather than assert on a mismatch.
"FastWAM policy expects preprocessed image tensors with shape " target_h, target_w = int(config.image_size[0]), int(config.image_size[1])
f"[B,C,{target_h},{target_w}], got {tuple(image.shape)}. " return _resize_frames(image, (target_h, target_w))
"Run the FastWAM preprocessor before calling the policy."
)
return image
@@ -1075,6 +1075,10 @@ class FastWAM(torch.nn.Module):
@torch.no_grad() @torch.no_grad()
def _encode_video_latents(self, video_tensor, tiled=False, tile_size=(30, 52), tile_stride=(15, 26)): def _encode_video_latents(self, video_tensor, tiled=False, tile_size=(30, 52), tile_stride=(15, 26)):
# The Wan VAE expects pixels in [-1, 1]; model inputs arrive in [0, 1] (VISUAL is IDENTITY in
# the preprocessor — see configuration_fastwam.normalization_mapping). Map here, at the single
# video-encode boundary, so it is applied exactly once on every path.
video_tensor = video_tensor * 2.0 - 1.0
z = self.vae.encode( z = self.vae.encode(
video_tensor, video_tensor,
device=self.device, device=self.device,
@@ -1094,6 +1098,8 @@ class FastWAM(torch.nn.Module):
raise ValueError( raise ValueError(
f"`input_image` must have shape [1,3,H,W] or [3,H,W], got {tuple(input_image.shape)}" f"`input_image` must have shape [1,3,H,W] or [3,H,W], got {tuple(input_image.shape)}"
) )
# [0, 1] -> [-1, 1] for the Wan VAE (mirrors `_encode_video_latents`); single image-encode boundary.
input_image = input_image * 2.0 - 1.0
image = input_image.to(device=self.device)[0].unsqueeze(1) image = input_image.to(device=self.device)[0].unsqueeze(1)
z = self.vae.encode( z = self.vae.encode(
[image], device=self.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride [image], device=self.device, tiled=tiled, tile_size=tile_size, tile_stride=tile_stride
@@ -24,7 +24,6 @@ from lerobot.processor import (
ActionProcessorStep, ActionProcessorStep,
AddBatchDimensionProcessorStep, AddBatchDimensionProcessorStep,
DeviceProcessorStep, DeviceProcessorStep,
ImageCropResizeProcessorStep,
NormalizerProcessorStep, NormalizerProcessorStep,
PolicyAction, PolicyAction,
PolicyProcessorPipeline, PolicyProcessorPipeline,
@@ -42,39 +41,6 @@ from lerobot.utils.constants import (
from .configuration_fastwam import FastWAMConfig from .configuration_fastwam import FastWAMConfig
@dataclass
@ProcessorStepRegistry.register(name="fastwam_image_crop_resize_processor")
class FastWAMImageCropResizeProcessorStep(ImageCropResizeProcessorStep):
"""`ImageCropResizeProcessorStep` that tolerates a leading temporal/batch stack.
FastWAM loads a per-camera video stack, so image observations arrive as
``[B, T, C, H, W]``. torchvision's crop/resize only accept ``[..., C, H, W]`` with a
single leading batch dim (resize raises on 5-D input), so we flatten any leading
dims into the batch, apply the base 4-D crop/resize, then restore the leading shape.
Crop/resize params and feature-shape bookkeeping are inherited unchanged.
"""
def observation(self, observation: dict) -> dict:
# Delta-timestamp video loading adds `<image_key>_is_pad` boolean masks ([B, T]) that share
# the `observation.images.` prefix but are padding flags, not frames. The base crop/resize
# matches on the `"image"` substring, so set these aside and restore them untouched rather
# than letting it try to resize a mask.
pad_keys = {key: value for key, value in observation.items() if "_is_pad" in key}
leads: dict[str, tuple] = {}
flat_input = {key: value for key, value in observation.items() if key not in pad_keys}
for key, img in list(flat_input.items()):
if "image" in key and torch.is_tensor(img) and img.ndim > 4:
leads[key] = tuple(img.shape[:-3])
flat_input[key] = img.reshape(-1, *img.shape[-3:])
processed = super().observation(flat_input)
out = dict(processed)
for key, lead in leads.items():
im = processed[key]
out[key] = im.reshape(*lead, *im.shape[-3:])
out.update(pad_keys)
return out
@dataclass @dataclass
@ProcessorStepRegistry.register(name="fastwam_action_toggle_processor") @ProcessorStepRegistry.register(name="fastwam_action_toggle_processor")
class FastWAMActionToggleProcessorStep(ActionProcessorStep): class FastWAMActionToggleProcessorStep(ActionProcessorStep):
@@ -124,32 +90,25 @@ def make_fastwam_pre_post_processors(
output processor pipelines discoverable by LeRobot. output processor pipelines discoverable by LeRobot.
""" """
# force visual stats to be mean 0.5 and std 0.5 to map [0, 1] data to [-1, 1] # NOTE: no visual normalization here. VISUAL is IDENTITY (see configuration_fastwam.normalization_mapping)
# — images pass through in [0, 1] and the model maps them to the Wan VAE's [-1, 1] at the encode
# boundary. This is deliberate: `lerobot_train.py` overrides the normalizer stats with
# `dataset.meta.stats` when fine-tuning, and a real dataset's per-channel image std is the tiny
# frame-to-frame brightness variance, which would blow images far outside [-1,1] and saturate them.
# STATE/ACTION still normalize with dataset stats below.
normalization_stats: dict[str, dict[str, Any]] = dict(dataset_stats or {}) normalization_stats: dict[str, dict[str, Any]] = dict(dataset_stats or {})
for key, feature in config.input_features.items():
if feature.type != FeatureType.VISUAL:
continue
channels = int(feature.shape[0])
normalization_stats[key] = {
"mean": torch.full((channels, 1, 1), 0.5, dtype=torch.float32),
"std": torch.full((channels, 1, 1), 0.5, dtype=torch.float32),
}
# resize visual inputs to match model expected input size, if necessary # NOTE: no resize step here. The model is the single authority on input resolution: it resizes
visual_shapes = [ # each camera to the per-camera target (image_size split across cameras) in
feature.shape for feature in config.input_features.values() if feature.type == FeatureType.VISUAL # `_stack_video_from_images` / `_prepare_infer_image`, on every path (train forward, rollout and
] # eval select_action). A preprocessor resize step would be both redundant (the model re-resizes
resize_steps = [] # anyway) and unsafe across fine-tuning: its `resize_size` would be inherited from the base
if visual_shapes: # checkpoint's camera geometry, not this dataset's, making the concatenation N_cameras x too wide.
target_hw = (int(visual_shapes[0][1]), int(visual_shapes[0][2]))
# FastWAM-aware resize: tolerates the leading temporal dim of the video stack.
resize_steps.append(FastWAMImageCropResizeProcessorStep(resize_size=target_hw))
input_steps = [ input_steps = [
RenameObservationsProcessorStep(rename_map={}), RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(), AddBatchDimensionProcessorStep(),
DeviceProcessorStep(device=config.device), DeviceProcessorStep(device=config.device),
*resize_steps,
NormalizerProcessorStep( NormalizerProcessorStep(
features={**config.input_features, **config.output_features}, features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping, norm_map=config.normalization_mapping,
@@ -425,7 +425,7 @@ class WanVideoDiT(WanModel):
has_ref_conv: bool = False, has_ref_conv: bool = False,
add_control_adapter: bool = False, add_control_adapter: bool = False,
in_dim_control_adapter: int = 24, in_dim_control_adapter: int = 24,
separated_timestep: bool = False, seperated_timestep: bool = False,
require_vae_embedding: bool = False, require_vae_embedding: bool = False,
require_clip_embedding: bool = False, require_clip_embedding: bool = False,
fuse_vae_embedding_in_latents: bool = True, fuse_vae_embedding_in_latents: bool = True,
@@ -489,7 +489,7 @@ class WanVideoDiT(WanModel):
self.hidden_dim = hidden_dim self.hidden_dim = hidden_dim
self.attn_head_dim = attn_head_dim self.attn_head_dim = attn_head_dim
self.separated_timestep = separated_timestep self.seperated_timestep = seperated_timestep
self.fuse_vae_embedding_in_latents = fuse_vae_embedding_in_latents self.fuse_vae_embedding_in_latents = fuse_vae_embedding_in_latents
self.video_attention_mask_mode = str(video_attention_mask_mode) self.video_attention_mask_mode = str(video_attention_mask_mode)
self.action_conditioned = action_conditioned self.action_conditioned = action_conditioned
@@ -647,7 +647,7 @@ class WanVideoDiT(WanModel):
) )
tokens_per_frame = (x.shape[3] // patch_h) * (x.shape[4] // patch_w) tokens_per_frame = (x.shape[3] // patch_h) * (x.shape[4] // patch_w)
if not (self.separated_timestep and fuse_vae_embedding_in_latents): if not (self.seperated_timestep and fuse_vae_embedding_in_latents):
raise NotImplementedError( raise NotImplementedError(
"FastWAM currently requires separated timesteps with fused VAE latents." "FastWAM currently requires separated timesteps with fused VAE latents."
) )