Files
Davids048andClaude Fable 5.1 a5aa64ab6b [refactor]: decide every config value before the config freezes, and drop the from_pretrained keywords
VideoGenerator.from_pretrained(model_path, config) takes the same nested
settings as from_config; the 24 flat keywords, from_pretrained_kwargs_to_config,
FROM_PRETRAINED_KWARGS, and every flat_field in the schema are deleted.
nvfp4_fa4 is the typed field engine.attention.nvfp4_fa4, applied by a
resolution step. 65 call sites and the docs use the nested form; the three
kwargs golden cases are config cases with byte-identical results.

Every value is decided during resolution; no runtime code calls
with_override. The device offload policy (unified memory, MPS, layerwise
conflicts, lazy module load) runs as resolution steps in the main process
(fastvideo/api/device_policy.py), and the worker runs on the config it
receives. The LTX-2 refine defaults from model_index.json and the MiniMax-H3
schedule from fastvideo_inference.json are resolution steps
(fastvideo/api/checkpoint_defaults.py) that read local checkpoints or download
only those files; the H3 pipeline validates the schedule against the loaded
schedulers but no longer writes it. The preprocessing entry point passes the
downloaded local path as run state instead of a config override. Teacher and
critic models load with override_transformer_cls_name as a load argument.
The test isolation turns the device policy off, as it blocks downloads, so
that the goldens do not depend on the machine.

Against the 3f6893a0 goldens, every field still matches except boundary_ratio
and ltx2_vae_tiling (DESIGN section 9) and the fields that no longer exist.
The launcher and trainer YAML verifications report 0 unexplained differences.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 07:14:31 +00:00

1672 lines
64 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Validation callback.
All configuration is read from the YAML ``callbacks.validation``
section. The pipeline class is resolved from
``pipeline_target``.
"""
from __future__ import annotations
import contextlib
import gc
import json
import os
import time
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING
import numpy as np
import torch
import torchvision
from einops import rearrange
from torch.utils.data import DataLoader
from fastvideo.api.inference_resolution import resolve_inference_config
from fastvideo.api.overrides import apply_overrides
from fastvideo.api.resolution import ResolvedGeneratorConfig
from fastvideo.api.sampling_param import SamplingParam
from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.dataset.validation_dataset import (
ValidationDataset, )
from fastvideo.distributed import (
get_sp_group,
get_world_group,
)
from fastvideo.logger import init_logger
from fastvideo.pipelines import ForwardBatch
from fastvideo.train.callbacks.callback import Callback
from fastvideo.train.utils.instantiate import resolve_target
from fastvideo.train.utils.moduleloader import (
build_inference_resolved_config, )
from fastvideo.train.utils.validation_media import write_validation_mp4
from fastvideo.training.trackers import DummyTracker
from fastvideo.utils import pixels_to_uint8, shallow_asdict
if TYPE_CHECKING:
from fastvideo.train.methods.base import TrainingMethod
logger = init_logger(__name__)
@dataclass(slots=True)
class _ValidationStepResult:
"""Keep decoded media aligned with the prompt and conditions that produced it."""
videos: list[list[np.ndarray]]
captions: list[str]
audio_waveforms: list[torch.Tensor | np.ndarray | None] = field(default_factory=list)
audio_sample_rates: list[int | None] = field(default_factory=list)
overlay_videos: list[list[np.ndarray]] = field(default_factory=list)
overlay_captions: list[str] = field(default_factory=list)
ref_videos: list[str | None] = field(default_factory=list)
actions: list[dict[str, Any] | None] = field(default_factory=list)
mouse_pitch_signs: list[int | None] = field(default_factory=list)
@dataclass(slots=True)
class _ValidationMetricStats:
sums: dict[str, float] = field(default_factory=dict)
counts: dict[str, float] = field(default_factory=dict)
per_video: list[dict[str, Any]] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
@dataclass(slots=True)
class _SavedValidationVideos:
"""Identify verified MP4 files and their positions in the generated batch."""
filenames: list[str] = field(default_factory=list)
indices: list[int] = field(default_factory=list)
audio_video_count: int = 0
@dataclass(slots=True)
class _ValidationMetricsConfig:
enabled: bool = False
names: list[str] = field(default_factory=list)
device: str = "cuda"
calibration_path: str | None = None
mouse_pitch_sign: int = 1
skip_missing_deps: bool = True
strict: bool = False
unload_after_validation: bool = True
loader_threads: int = 1
prefetch_factor: int = 2
log_prefix: str = "metrics/validation"
DEFAULT_VALIDATION_VBENCH_METRICS = [
"vbench.imaging_quality",
"vbench.aesthetic_quality",
"vbench.temporal_flickering",
"vbench.motion_smoothness",
"vbench.subject_consistency",
"vbench.background_consistency",
"vbench.dynamic_degree",
]
SYNTHETIC_OPTICAL_FLOW_METRIC = "optical_flow.synthetic_optical_flow"
SYNTHETIC_OPTICAL_FLOW_LOG_KEYS = (
"mf_epe_mean",
"mf_angle_err_mean",
"mf_cosine_mean",
"mf_mag_ratio_mean",
"pixel_epe_mean_mean",
"px_angle_rmse_mean",
"fl_all_mean",
"foe_dist_mean",
"flow_kl_2d_mean",
)
def _dotted_leaves(mapping: Mapping[str, Any], prefix: str = "") -> dict[str, Any]:
"""Return ``{dotted.path: value}`` for every value of a nested mapping that is not a non-empty mapping."""
leaves: dict[str, Any] = {}
for key, value in mapping.items():
path = f"{prefix}{key}"
if isinstance(value, Mapping) and value:
leaves.update(_dotted_leaves(value, f"{path}."))
else:
leaves[path] = value
return leaves
class ValidationCallback(Callback):
"""Generic validation callback driven entirely by YAML
config.
Works with any pipeline that follows the
``PipelineCls.from_pretrained(...)`` + ``pipeline.forward()``
contract.
"""
def __init__(
self,
*,
pipeline_target: str,
dataset_file: str,
every_steps: int = 100,
run_at_start: bool = True,
sampling_steps: list[int] | None = None,
guidance_scale: float | None = None,
num_frames: int | None = None,
num_videos_per_prompt: int = 1,
use_validation_media_conditioning: bool = True,
output_dir: str | None = None,
sampling_timesteps: list[int] | None = None,
overlay_actions: bool = False,
keyboard_value_scale: float = 1.0,
offload_training_state: bool = False,
unload_pipeline_after_validation: bool = False,
attn_qat_infer: bool = False,
**generator_overrides: Any,
) -> None:
"""Configure validation cadence, generation parameters, and pipeline loading.
``run_at_start`` controls the pre-training baseline event.
``use_validation_media_conditioning`` lets text-to-video recipes use
captions from a dataset that also contains source-media paths. The
remaining keys are nested ``GeneratorConfig`` fields, such as
``engine.offload.vae``, that override the validation pipeline's config.
"""
self.pipeline_target = str(pipeline_target)
self.dataset_file = str(dataset_file)
self.every_steps = int(every_steps)
self.run_at_start = self._coerce_bool(run_at_start)
self.sampling_steps = ([int(s) for s in sampling_steps] if sampling_steps else [40])
self.guidance_scale = (float(guidance_scale) if guidance_scale is not None else None)
self.num_frames = (int(num_frames) if num_frames is not None else None)
self.num_videos_per_prompt = int(num_videos_per_prompt)
if self.num_videos_per_prompt <= 0:
raise ValueError("callbacks.validation.num_videos_per_prompt must be positive")
self.use_validation_media_conditioning = self._coerce_bool(use_validation_media_conditioning)
self.output_dir = (str(output_dir) if output_dir is not None else None)
self.sampling_timesteps = ([int(s) for s in sampling_timesteps] if sampling_timesteps is not None else None)
self.overlay_actions = self._coerce_bool(overlay_actions)
# Validation-only action amplification for world model; training keeps raw action values.
self.keyboard_value_scale = float(keyboard_value_scale)
metrics_config = generator_overrides.pop("metrics", None)
self.metrics_config = self._parse_metrics_config(metrics_config)
self.offload_training_state = self._coerce_bool(offload_training_state)
self.unload_pipeline_after_validation = self._coerce_bool(unload_pipeline_after_validation)
self.attn_qat_infer = self._coerce_bool(attn_qat_infer)
self.generator_overrides = dict(generator_overrides)
# Set after on_train_start.
self._pipeline: Any | None = None
self._pipeline_key: tuple[Any, ...] | None = None
self._sampling_param: SamplingParam | None = None
self._metric_evaluator: Any | None = None
self.tracker: Any = DummyTracker()
self.validation_random_generator: (torch.Generator | None) = None
self.seed: int = 0
@staticmethod
def _parse_metrics_config(config: Any) -> _ValidationMetricsConfig:
if config is None or config is False:
return _ValidationMetricsConfig(enabled=False)
if config is True:
return _ValidationMetricsConfig(
enabled=True,
names=list(DEFAULT_VALIDATION_VBENCH_METRICS),
)
if isinstance(config, str):
return _ValidationMetricsConfig(
enabled=True,
names=[config],
)
if isinstance(config, list | tuple):
return _ValidationMetricsConfig(
enabled=bool(config),
names=[str(name) for name in config],
)
if not isinstance(config, dict):
raise TypeError("callbacks.validation.metrics must be a bool, string, list, or mapping")
enabled = bool(config.get("enabled", True))
raw_names = config.get("names", config.get("metrics", None))
if raw_names is None:
names = list(DEFAULT_VALIDATION_VBENCH_METRICS)
elif isinstance(raw_names, str):
names = [raw_names]
else:
names = [str(name) for name in raw_names]
return _ValidationMetricsConfig(
enabled=enabled and bool(names),
names=names,
device=str(config.get("device", "cuda")),
calibration_path=(str(config["calibration_path"]) if config.get("calibration_path") is not None else None),
mouse_pitch_sign=int(config.get("mouse_pitch_sign", 1)),
skip_missing_deps=bool(config.get("skip_missing_deps", True)),
strict=bool(config.get("strict", False)),
unload_after_validation=bool(config.get("unload_after_validation", True)),
loader_threads=int(config.get("loader_threads", 1)),
prefetch_factor=int(config.get("prefetch_factor", 2)),
log_prefix=str(config.get("log_prefix", "metrics/validation")),
)
@staticmethod
def _coerce_bool(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
# ----------------------------------------------------------
# Callback hooks
# ----------------------------------------------------------
def _adopt_training_denoising_ladder(
self,
method: TrainingMethod,
) -> None:
"""Keep validation on the timestep ladder the method trains against.
``sampling_steps`` only sets ``num_inference_steps``, which the
scheduler turns into an N-point sigma grid -- N-1 forwards on its own
spacing, not the trained ladder. Validation reaches the trained
operating point only when ``sampling_timesteps`` repeats
``method.dmd_denoising_steps`` exactly, so inherit it by default and
refuse to run when the two disagree.
"""
method_config = getattr(method, "method_config", None)
if not isinstance(method_config, dict):
return
raw = method_config.get("dmd_denoising_steps")
if not isinstance(raw, list) or not raw:
return
trained = [int(s) for s in raw]
if self.sampling_timesteps is None:
self.sampling_timesteps = trained
logger.info(
"validation: inheriting the trained denoising ladder %s "
"(%d forwards)",
trained,
len(trained),
)
return
if self.sampling_timesteps != trained:
raise ValueError("callbacks.validation.sampling_timesteps "
f"{self.sampling_timesteps} disagrees with the trained ladder "
f"{trained} (method.dmd_denoising_steps). Validation would "
"sample off the operating point the student is trained for. "
"Drop the override to inherit the ladder, or align the two.")
def on_train_start(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
self.method = method
tc = self.training_config
self._adopt_training_denoising_ladder(method)
self.world_group = get_world_group()
self.sp_group = get_sp_group()
self.global_rank = self.world_group.rank
self.rank_in_sp_group = (self.sp_group.rank_in_group)
self.sp_world_size = self.sp_group.world_size
seed = tc.data.seed
if seed is None:
raise ValueError("training.data.seed must be set "
"for validation")
self.seed = int(seed)
self.validation_random_generator = (torch.Generator(device="cpu").manual_seed(self.seed))
tracker = getattr(method, "tracker", None)
if tracker is not None:
self.tracker = tracker
def on_validation_begin(
self,
method: TrainingMethod,
iteration: int = 0,
) -> None:
"""Run the optional step-zero baseline and each scheduled validation event."""
if self.every_steps <= 0:
return
# Step zero measures the checkpoint before the first optimizer update.
if iteration == 0 and not self.run_at_start:
return
if iteration % self.every_steps != 0:
return
self._run_validation(method, iteration)
# ----------------------------------------------------------
# Core validation logic
# ----------------------------------------------------------
def _run_validation(
self,
method: TrainingMethod,
step: int,
) -> None:
transformer = method.student.transformer
try:
with self._validation_memory_context(
method,
validation_transformer=transformer,
):
# Look for an EMA callback to temporarily swap
# EMA weights during validation.
ema_cb = self._find_ema_callback()
ctx = ema_cb.ema_context(transformer) if ema_cb is not None else contextlib.nullcontext(transformer)
with ctx as t, self._attn_qat_infer_context(t):
self._run_validation_inner(
method,
step,
t,
)
finally:
if self.unload_pipeline_after_validation:
self._clear_pipeline_cache()
@contextlib.contextmanager
def _attn_qat_infer_context(self, transformer: torch.nn.Module):
if not self.attn_qat_infer:
yield
return
from fastvideo.attention.backends.attn_qat_infer import (AttnQatInferImpl, is_attn_qat_infer_available)
from fastvideo.attention.backends.attn_qat_train import (
AttnQatTrainImpl, )
from fastvideo.platforms import AttentionBackendEnum
layers = [
module for module in transformer.modules()
if isinstance(getattr(module, "attn_impl", None), AttnQatTrainImpl)
]
if not layers:
raise RuntimeError("attn_qat_infer validation requested, but the transformer has no ATTN_QAT_TRAIN layers")
if not is_attn_qat_infer_available():
from fastvideo.attention.backends.attn_qat_infer import (
attn_qat_infer_receipt, )
raise RuntimeError("attn_qat_infer validation requested but no ATTN_QAT_INFER kernel serves "
f"this device ({attn_qat_infer_receipt()}). Set "
"callbacks.validation.attn_qat_infer=false to validate with ATTN_QAT_TRAIN.")
previous = [(layer, layer.attn_impl, layer.backend) for layer in layers]
try:
for layer, impl, _ in previous:
layer.attn_impl = AttnQatInferImpl(
num_heads=layer.num_heads,
head_size=layer.head_size,
num_kv_heads=layer.num_kv_heads,
causal=impl.causal,
softmax_scale=layer.softmax_scale,
)
layer.backend = AttentionBackendEnum.ATTN_QAT_INFER
logger.info("Enabled ATTN_QAT_INFER for %d validation attention layers.", len(layers))
yield
finally:
for layer, impl, backend in previous:
layer.attn_impl = impl
layer.backend = backend
@contextlib.contextmanager
def _validation_memory_context(
self,
method: TrainingMethod,
*,
validation_transformer: torch.nn.Module,
):
if not self.offload_training_state:
yield
return
optimizer_tensor_records: list[tuple[Any, Any, torch.device]] = []
module_records: list[tuple[str, torch.nn.Module, torch.device]] = []
try:
self._offload_optimizer_states_to_cpu(
method,
optimizer_tensor_records,
)
self._offload_inactive_role_modules_to_cpu(
method,
validation_transformer=validation_transformer,
module_records=module_records,
)
self._empty_cuda_cache()
yield
finally:
self._restore_inactive_role_modules(module_records)
self._restore_optimizer_states(optimizer_tensor_records)
self._empty_cuda_cache()
def _offload_optimizer_states_to_cpu(
self,
method: TrainingMethod,
records: list[tuple[Any, Any, torch.device]],
) -> None:
optimizers = getattr(method, "_optimizer_dict", {})
if not optimizers:
return
moved = 0
for optimizer in optimizers.values():
state = getattr(optimizer, "state", None)
if not isinstance(state, dict):
continue
for param_state in state.values():
moved += self._offload_tensor_container_to_cpu(
param_state,
records,
)
if moved:
logger.info(
"Offloaded %d optimizer state tensors to CPU for validation.",
moved,
)
def _offload_tensor_container_to_cpu(
self,
obj: Any,
records: list[tuple[Any, Any, torch.device]],
) -> int:
moved = 0
if isinstance(obj, dict):
for key, value in list(obj.items()):
if torch.is_tensor(value) and value.device.type == "cuda":
records.append((obj, key, value.device))
obj[key] = value.detach().cpu()
moved += 1
else:
moved += self._offload_tensor_container_to_cpu(value, records)
return moved
if isinstance(obj, list):
for idx, value in enumerate(list(obj)):
if torch.is_tensor(value) and value.device.type == "cuda":
records.append((obj, idx, value.device))
obj[idx] = value.detach().cpu()
moved += 1
else:
moved += self._offload_tensor_container_to_cpu(value, records)
return moved
def _restore_optimizer_states(
self,
records: list[tuple[Any, Any, torch.device]],
) -> None:
for container, key, device in reversed(records):
value = container[key]
if torch.is_tensor(value):
container[key] = value.to(device=device)
if records:
logger.info(
"Restored %d optimizer state tensors after validation.",
len(records),
)
def _offload_inactive_role_modules_to_cpu(
self,
method: TrainingMethod,
*,
validation_transformer: torch.nn.Module,
module_records: list[tuple[str, torch.nn.Module, torch.device]],
) -> None:
role_models = getattr(method, "_role_models", {})
if not isinstance(role_models, dict):
return
for role, model in role_models.items():
module = getattr(model, "transformer", None)
if not isinstance(module, torch.nn.Module):
continue
if module is validation_transformer:
continue
device = self._first_cuda_tensor_device(module)
if device is None:
continue
try:
module.to("cpu")
except Exception as exc:
logger.warning(
"Could not offload role %r transformer to CPU before validation: %s",
role,
exc,
)
continue
module_records.append((str(role), module, device))
logger.info(
"Offloaded role %r transformer from %s to CPU for validation.",
role,
device,
)
def _restore_inactive_role_modules(
self,
module_records: list[tuple[str, torch.nn.Module, torch.device]],
) -> None:
for role, module, device in reversed(module_records):
module.to(device)
logger.info(
"Restored role %r transformer to %s after validation.",
role,
device,
)
@staticmethod
def _first_cuda_tensor_device(module: torch.nn.Module) -> torch.device | None:
for tensor in list(module.parameters(recurse=True)) + list(module.buffers(recurse=True)):
device = getattr(tensor, "device", None)
if isinstance(device, torch.device) and device.type == "cuda":
return device
return None
def _clear_pipeline_cache(self) -> None:
self._pipeline = None
self._pipeline_key = None
self._empty_cuda_cache()
@staticmethod
def _empty_cuda_cache() -> None:
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _find_ema_callback(self) -> Any | None:
"""Find the EMA callback in the callback dict."""
from fastvideo.train.callbacks.ema import (
EMACallback, )
cb_dict = getattr(self, "_callback_dict", None)
if cb_dict is not None:
for cb in cb_dict._callbacks.values():
if isinstance(cb, EMACallback):
return cb
return None
def _run_validation_inner(
self,
method: TrainingMethod,
step: int,
transformer: torch.nn.Module,
) -> None:
"""Generate one event and gather sequence-parallel group outputs for logging.
Each sequence-parallel group leader saves its local media. Global rank
zero gathers the leaders' paths and statistics into one tracker event.
"""
tc = self.training_config
was_training = bool(getattr(transformer, "training", False))
output_dir = (self.output_dir or tc.checkpoint.output_dir)
try:
transformer.eval()
num_sp_groups = (self.world_group.world_size // self.sp_group.world_size)
sp = self._get_sampling_param()
for num_inference_steps in self.sampling_steps:
validation_started_at = time.perf_counter()
result = self._run_validation_for_steps(
num_inference_steps,
transformer=transformer,
)
# Every rank participates in sequence-parallel inference, but
# only the group leader retains decoded media for saving.
if self.rank_in_sp_group != 0:
continue
os.makedirs(
output_dir,
exist_ok=True,
)
local_videos = self._save_validation_videos(
result.videos,
output_dir=output_dir,
step=step,
num_inference_steps=num_inference_steps,
fps=sp.fps,
audio_waveforms=result.audio_waveforms,
audio_sample_rates=result.audio_sample_rates,
)
local_overlay_videos = self._save_validation_videos(
result.overlay_videos,
output_dir=output_dir,
step=step,
num_inference_steps=num_inference_steps,
fps=sp.fps,
suffix="_overlay",
)
local_video_filenames = local_videos.filenames
local_overlay_video_filenames = local_overlay_videos.filenames
local_captions = self._select_by_indices(
result.captions,
local_videos.indices,
)
local_overlay_captions = self._select_by_indices(
result.overlay_captions,
local_overlay_videos.indices,
)
local_ref_videos = self._select_by_indices(
result.ref_videos,
local_videos.indices,
)
local_actions = self._select_by_indices(
result.actions,
local_videos.indices,
)
local_mouse_pitch_signs = self._select_by_indices(
result.mouse_pitch_signs,
local_videos.indices,
)
local_metric_stats = self._evaluate_validation_metrics(
video_filenames=local_video_filenames,
captions=local_captions,
ref_videos=local_ref_videos,
actions=local_actions,
mouse_pitch_signs=local_mouse_pitch_signs,
fps=sp.fps,
output_dir=output_dir,
step=step,
num_inference_steps=num_inference_steps,
)
if self.global_rank == 0:
all_video_filenames = list(local_video_filenames)
all_overlay_video_filenames = list(local_overlay_video_filenames)
all_captions = list(local_captions)
all_overlay_captions = list(local_overlay_captions)
all_audio_video_count = local_videos.audio_video_count
all_metric_stats = local_metric_stats
for sp_idx in range(1, num_sp_groups):
# Sequence-parallel group leaders occupy the first
# global rank in each contiguous group.
src = (sp_idx * self.sp_world_size)
recv_v = (self.world_group.recv_object(src=src))
recv_c = (self.world_group.recv_object(src=src))
recv_ov = (self.world_group.recv_object(src=src))
recv_oc = (self.world_group.recv_object(src=src))
recv_m = (self.world_group.recv_object(src=src))
recv_audio_video_count = (self.world_group.recv_object(src=src))
all_video_filenames.extend(recv_v)
all_overlay_video_filenames.extend(recv_ov)
all_captions.extend(recv_c)
all_overlay_captions.extend(recv_oc)
all_audio_video_count += int(recv_audio_video_count)
self._merge_metric_stats(
all_metric_stats,
recv_m,
)
self._log_validation_metrics(
all_metric_stats,
step=step,
)
# Media and completion counts share one tracker event so
# artifacts and verification data remain aligned.
self._log_validation_video_artifacts(
all_video_filenames,
all_captions,
key=f"validation_videos_{num_inference_steps}_steps",
step=step,
fps=sp.fps,
scalar_metrics={
f"validation/{num_inference_steps}_steps_video_count":
float(len(all_video_filenames)),
f"validation/{num_inference_steps}_steps_duration_sec":
(time.perf_counter() - validation_started_at),
f"validation/{num_inference_steps}_steps_audio_video_count":
float(all_audio_video_count),
},
)
if all_overlay_video_filenames:
self._log_validation_video_artifacts(
all_overlay_video_filenames,
all_overlay_captions,
key=(f"validation_videos_{num_inference_steps}"
f"_steps_overlay"),
step=step,
fps=sp.fps,
)
else:
self.world_group.send_object(
local_video_filenames,
dst=0,
)
self.world_group.send_object(
local_captions,
dst=0,
)
self.world_group.send_object(
local_overlay_video_filenames,
dst=0,
)
self.world_group.send_object(
local_overlay_captions,
dst=0,
)
self.world_group.send_object(
local_metric_stats,
dst=0,
)
self.world_group.send_object(
local_videos.audio_video_count,
dst=0,
)
finally:
if was_training:
transformer.train()
self._release_metric_evaluator()
def _save_validation_videos(
self,
videos: list[list[np.ndarray]],
*,
output_dir: str,
step: int,
num_inference_steps: int,
fps: int,
suffix: str = "",
audio_waveforms: list[torch.Tensor | np.ndarray | None] | None = None,
audio_sample_rates: list[int | None] | None = None,
) -> _SavedValidationVideos:
"""Save aligned validation media and skip artifacts that fail encoding.
Audio and sample-rate metadata must pass the alignment checks before
encoding. Media writer failures are logged and omitted so validation
artifact creation does not interrupt the training loop.
"""
if (audio_waveforms is None) != (audio_sample_rates is None):
raise ValueError("Validation audio waveforms and sample rates must be provided together.")
if audio_waveforms is not None and len(audio_waveforms) != len(videos):
raise ValueError("Validation audio waveform count must match the video count.")
if audio_sample_rates is not None and len(audio_sample_rates) != len(videos):
raise ValueError("Validation audio sample-rate count must match the video count.")
saved = _SavedValidationVideos()
for i, video in enumerate(videos):
audio = audio_waveforms[i] if audio_waveforms is not None else None
audio_sample_rate = audio_sample_rates[i] if audio_sample_rates is not None else None
if (audio is None) != (audio_sample_rate is None):
raise ValueError("Each validation waveform must have one aligned sample rate.")
fname = os.path.join(
output_dir,
f"validation_step_{step}"
f"_inference_steps_{num_inference_steps}"
f"_rank_{self.global_rank}"
f"_video_{i}{suffix}.mp4",
)
try:
write_validation_mp4(
fname,
video,
fps=fps,
audio=audio,
audio_sample_rate=audio_sample_rate,
)
except Exception as exc:
# Validation media is diagnostic output, so one failed write
# must not terminate training or prevent later artifact writes.
logger.exception(
"Failed to save validation media %s on rank %s; skipping artifact: %s",
fname,
self.global_rank,
exc,
)
with contextlib.suppress(OSError):
os.remove(fname)
continue
saved.filenames.append(fname)
saved.indices.append(i)
if audio is not None:
# The media writer verifies requested streams before returning.
saved.audio_video_count += 1
return saved
@staticmethod
def _select_by_indices(
values: list[Any],
indices: list[int],
) -> list[Any]:
return [values[i] for i in indices if i < len(values)]
def _log_validation_video_artifacts(
self,
video_filenames: list[str],
captions: list[str],
*,
key: str,
step: int,
fps: int,
scalar_metrics: dict[str, float] | None = None,
) -> None:
"""Log validation media and its scalar verification data at one step."""
video_logs = []
for fname, cap in zip(
video_filenames,
captions,
strict=True,
):
art = self.tracker.video(
fname,
caption=cap,
fps=fps,
)
if art is not None:
video_logs.append(art)
if video_logs:
artifacts: dict[str, Any] = {key: video_logs}
if scalar_metrics:
artifacts.update(scalar_metrics)
self.tracker.log_artifacts(
artifacts,
step,
)
# ----------------------------------------------------------
# Metric evaluation
# ----------------------------------------------------------
def _metric_device(self) -> str:
device = self.metrics_config.device
if device == "cuda" and torch.cuda.is_available():
local_rank = int(getattr(self.world_group, "local_rank", 0))
return f"cuda:{local_rank}"
return device
def _get_metric_evaluator(self) -> Any:
if self._metric_evaluator is not None:
return self._metric_evaluator
from fastvideo.eval import Evaluator
cfg = self.metrics_config
self._metric_evaluator = Evaluator(
metrics=cfg.names,
device=self._metric_device(),
num_gpus=1,
loader_threads=cfg.loader_threads,
prefetch_factor=cfg.prefetch_factor,
pre_upload=False,
skip_missing_deps=cfg.skip_missing_deps,
)
logger.info(
"Initialized validation metrics: %s",
", ".join(self._metric_evaluator.metric_names),
)
return self._metric_evaluator
def _release_metric_evaluator(self) -> None:
if self._metric_evaluator is None:
return
if self.metrics_config.unload_after_validation:
self._metric_evaluator.unload()
self._metric_evaluator = None
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _evaluate_validation_metrics(
self,
*,
video_filenames: list[str],
captions: list[str],
ref_videos: list[str | None],
actions: list[dict[str, Any] | None],
mouse_pitch_signs: list[int | None],
fps: int,
output_dir: str,
step: int,
num_inference_steps: int,
) -> _ValidationMetricStats:
stats = _ValidationMetricStats()
if not self.metrics_config.enabled or not video_filenames:
return stats
try:
evaluator = self._get_metric_evaluator()
from fastvideo.eval import samples_from
samples = samples_from(
video=video_filenames,
reference=self._available_paths(ref_videos),
text_prompts=captions,
fps=float(fps),
extras=self._validation_metric_extras(
video_filenames=video_filenames,
actions=actions,
mouse_pitch_signs=mouse_pitch_signs,
),
)
results = evaluator.evaluate(samples=samples)
for filename, metric_results in zip(
video_filenames,
results,
strict=True,
):
row: dict[str, Any] = {"path": filename}
self._accumulate_metric_results(
stats,
row,
metric_results,
)
stats.per_video.append(row)
for metric_name, metric_result in results.corpus.items():
row = {"path": "<corpus>"}
self._accumulate_metric_results(
stats,
row,
{metric_name: metric_result},
)
stats.per_video.append(row)
except Exception as exc:
message = ("Validation metric evaluation failed on rank "
f"{self.global_rank}: {exc}")
logger.exception(message)
stats.errors.append(message)
if self.metrics_config.strict:
raise
finally:
if self._metric_evaluator is not None:
self._metric_evaluator.release_cuda_memory()
self._write_metric_summary(
stats,
output_dir=output_dir,
step=step,
num_inference_steps=num_inference_steps,
)
return stats
@staticmethod
def _accumulate_metric_results(
stats: _ValidationMetricStats,
row: dict[str, Any],
metric_results: dict[str, Any],
) -> None:
for metric_name, metric_result in metric_results.items():
details = getattr(metric_result, "details", {}) or {}
if metric_name == SYNTHETIC_OPTICAL_FLOW_METRIC:
if not isinstance(details, dict):
continue
for detail_name in SYNTHETIC_OPTICAL_FLOW_LOG_KEYS:
key = f"{metric_name}.{detail_name}"
ValidationCallback._accumulate_scalar(
stats,
row,
key,
details.get(detail_name),
)
continue
score = getattr(metric_result, "score", None)
ValidationCallback._accumulate_scalar(
stats,
row,
metric_name,
score,
)
if not isinstance(details, dict):
continue
for detail_name, value in details.items():
key = f"{metric_name}.{detail_name}"
ValidationCallback._accumulate_scalar(
stats,
row,
key,
value,
)
@staticmethod
def _accumulate_scalar(
stats: _ValidationMetricStats,
row: dict[str, Any],
key: str,
value: Any,
) -> None:
if not isinstance(value, float | int | np.floating | np.integer):
return
value_float = float(value)
if not np.isfinite(value_float):
return
row[key] = value_float
stats.sums[key] = stats.sums.get(key, 0.0) + value_float
stats.counts[key] = stats.counts.get(key, 0.0) + 1.0
@staticmethod
def _merge_metric_stats(
dst: _ValidationMetricStats,
src: _ValidationMetricStats,
) -> None:
for key, value in src.sums.items():
dst.sums[key] = dst.sums.get(key, 0.0) + float(value)
for key, value in src.counts.items():
dst.counts[key] = dst.counts.get(key, 0.0) + float(value)
dst.per_video.extend(src.per_video)
dst.errors.extend(src.errors)
def _log_validation_metrics(
self,
stats: _ValidationMetricStats,
*,
step: int,
) -> None:
if stats.errors:
message = "Validation metric evaluation failed:\n" + "\n".join(stats.errors)
if self.metrics_config.strict:
raise RuntimeError(message)
logger.warning(message)
logs: dict[str, float] = {}
for key, metric_sum in stats.sums.items():
metric_count = stats.counts.get(key, 0.0)
if metric_count <= 0:
continue
value = float(metric_sum / metric_count)
if not np.isfinite(value):
continue
logs[self._metric_log_name(key)] = value
if logs:
self.tracker.log(
logs,
step,
)
def _metric_log_name(
self,
key: str,
) -> str:
return f"{self.metrics_config.log_prefix}/{key.replace('.', '/')}"
def _write_metric_summary(
self,
stats: _ValidationMetricStats,
*,
output_dir: str,
step: int,
num_inference_steps: int,
) -> None:
if not self.metrics_config.enabled:
return
summary_dir = os.path.join(
output_dir,
"eval",
f"step_{step}",
)
os.makedirs(
summary_dir,
exist_ok=True,
)
metrics = {
key: float(stats.sums[key] / stats.counts[key])
for key in sorted(stats.sums) if stats.counts.get(key, 0.0) > 0
}
payload = {
"step": int(step),
"num_inference_steps": int(num_inference_steps),
"rank": int(self.global_rank),
"metrics": metrics,
"per_video": stats.per_video,
"errors": stats.errors,
}
path = os.path.join(
summary_dir,
f"inference_steps_{num_inference_steps}_rank_{self.global_rank}.json",
)
with open(
path,
"w",
encoding="utf-8",
) as f:
json.dump(
payload,
f,
indent=2,
)
def _validation_metric_extras(
self,
*,
video_filenames: list[str],
actions: list[dict[str, Any] | None],
mouse_pitch_signs: list[int | None],
) -> list[dict[str, Any]]:
extras: list[dict[str, Any]] = []
for i, _filename in enumerate(video_filenames):
extra: dict[str, Any] = {}
action = actions[i] if i < len(actions) else None
if action is not None:
extra["actions"] = action
if self.metrics_config.calibration_path is not None:
extra["calibration"] = self.metrics_config.calibration_path
mouse_pitch_sign = mouse_pitch_signs[i] if i < len(mouse_pitch_signs) else None
extra["mouse_pitch_sign"] = int(mouse_pitch_sign or self.metrics_config.mouse_pitch_sign)
extras.append(extra)
return extras
@staticmethod
def _available_paths(paths: list[str | None]) -> list[str] | None:
if not paths or any(path is None for path in paths):
return None
return [str(path) for path in paths]
@staticmethod
def _validation_actions(validation_batch: dict[str, Any]) -> dict[str, Any] | None:
keyboard = validation_batch.get("keyboard_cond")
mouse = validation_batch.get("mouse_cond")
if keyboard is not None and mouse is not None:
return {
"keyboard": np.asarray(keyboard),
"mouse": np.asarray(mouse),
}
action_path = validation_batch.get("action_path")
if not isinstance(action_path, str) or not os.path.isfile(action_path):
return None
try:
actions_obj = np.load(action_path, allow_pickle=True)
if isinstance(actions_obj, np.ndarray) and actions_obj.dtype == object:
actions_obj = actions_obj.item()
except Exception as exc:
logger.warning(
"Failed to load validation action file %s for metrics: %s",
action_path,
exc,
)
return None
if not isinstance(actions_obj, dict):
return None
keyboard = actions_obj.get("keyboard")
mouse = actions_obj.get("mouse")
if keyboard is None or mouse is None:
return None
return {
"keyboard": np.asarray(keyboard),
"mouse": np.asarray(mouse),
}
def _validation_mouse_pitch_sign(
self,
validation_batch: dict[str, Any],
) -> int:
if validation_batch.get("mouse_pitch_sign") is not None:
return int(validation_batch["mouse_pitch_sign"])
flipped = validation_batch.get("mouse_pitch_flipped")
if isinstance(flipped, str):
flipped = flipped.lower() in {"1", "true", "yes", "y"}
if bool(flipped):
return -1
return int(self.metrics_config.mouse_pitch_sign)
# ----------------------------------------------------------
# Pipeline management
# ----------------------------------------------------------
def _get_sampling_param(self) -> SamplingParam:
if self._sampling_param is None:
self._sampling_param = (SamplingParam.from_pretrained(self._pipeline_model_path()))
return self._sampling_param
def _pipeline_model_path(self) -> str:
tc = self.training_config
pipeline_config = getattr(tc, "pipeline_config", None)
model_path = getattr(pipeline_config, "_fastvideo_train_model_path", None)
return str(model_path or tc.model_path)
def _validation_pipeline_config(
self,
transformer: torch.nn.Module,
loaded_config: Any,
) -> Any:
"""Copy the training pipeline config for the validation forwards.
The copy takes the runtime attention window of ``transformer`` and the
encoder widths of ``loaded_config``, the validation pipeline's loaded
model definition.
"""
tc = self.training_config
pipeline_config = (deepcopy(tc.pipeline_config) if tc.pipeline_config is not None else PipelineConfig())
self._sync_runtime_dit_arch_config(
pipeline_config,
transformer,
)
self._keep_loaded_encoder_widths(
pipeline_config,
loaded_config,
)
return pipeline_config
@staticmethod
def _keep_loaded_encoder_widths(
validation_config: Any,
loaded_config: Any,
) -> None:
"""Carry the loader-populated encoder widths onto ``validation_config``.
Validation reaches the stages through a pipeline config that never
passes through ``ModelConfig.update_model_arch``: the deep copy
``_validation_pipeline_config`` makes of the training-side config. It
still holds the encoder dataclass defaults for whatever the checkpoint
would have supplied.
Stages read ``hidden_size`` only when they have to synthesise an
embedding instead of measuring one: HunyuanVideo 1.5 sizes its
zero-length ByT5 placeholder from it, so the generic ``T5ArchConfig``
default of 512 collides with the checkpoint's real width of 1472.
Only ``hidden_size`` is copied. Training owns the rest: model plugins
set ``text_len`` from ``text_encoder_max_lengths`` to size the parquet
text padding, and ``tokenizer_kwargs`` carry run-specific settings, so
both keep their training values. A falsy width means the loader never
populated that encoder, so there is nothing to carry over.
"""
loaded_encoders = getattr(loaded_config, "text_encoder_configs", None)
validation_encoders = getattr(
validation_config,
"text_encoder_configs",
None,
)
if not loaded_encoders or not validation_encoders:
return
for validation_encoder, loaded_encoder in zip(
validation_encoders,
loaded_encoders,
strict=False,
):
hidden_size = getattr(
getattr(loaded_encoder, "arch_config", None),
"hidden_size",
None,
)
arch_config = getattr(validation_encoder, "arch_config", None)
if hidden_size and arch_config is not None:
arch_config.hidden_size = hidden_size
@staticmethod
def _sync_runtime_dit_arch_config(
pipeline_config: Any,
transformer: torch.nn.Module,
) -> None:
dit_config = getattr(
pipeline_config,
"dit_config",
None,
)
arch_config = getattr(
dit_config,
"arch_config",
None,
)
if arch_config is None:
return
for name in ("local_attn_size", "sink_size"):
if not hasattr(arch_config, name) or not hasattr(transformer, name):
continue
setattr(
arch_config,
name,
getattr(transformer, name),
)
def _get_pipeline(
self,
*,
transformer: torch.nn.Module,
) -> Any:
key = (id(transformer), )
if (self._pipeline is not None and self._pipeline_key == key):
return self._pipeline
PipelineCls = resolve_target(self.pipeline_target)
loaded_modules: dict[str, Any] = {"transformer": transformer}
# Distillation methods build the flow-match scheduler their few-step DMD
# sampler needs; inject it so the pipeline doesn't fall back to UniPC.
method_scheduler = getattr(self.method, "_sf_scheduler", None)
if method_scheduler is not None:
loaded_modules["scheduler"] = method_scheduler
# The pipeline class comes from a YAML target, so static analysis cannot
# infer the dynamically resolved ``from_pretrained`` class method.
self._pipeline = PipelineCls.from_pretrained( # type: ignore[attr-defined]
self._pipeline_model_path(),
resolved_config=resolve_inference_config(self._pipeline_generator_config()),
loaded_modules=loaded_modules,
)
self._pipeline_key = key
return self._pipeline
def _pipeline_generator_config(self) -> dict[str, Any]:
"""Build the ``GeneratorConfig`` mapping of the validation pipeline.
It has the training parallel layout, keeps the DiT on the device, takes
``flow_shift`` from the training pipeline config, and applies
``generator_overrides`` last.
"""
tc = self.training_config
raw: dict[str, Any] = {
"model_path": self._pipeline_model_path(),
"engine": {
"num_gpus": tc.distributed.num_gpus,
"parallelism": {
"tp_size": tc.distributed.tp_size,
"sp_size": tc.distributed.sp_size,
},
"offload": {
"dit": False,
"dit_layerwise": False,
"pin_cpu_memory": tc.distributed.pin_cpu_memory,
},
},
}
flow_shift = getattr(
tc.pipeline_config,
"flow_shift",
None,
)
if flow_shift is not None:
raw["pipeline"] = {"flow_shift": float(flow_shift)}
return apply_overrides(raw, _dotted_leaves(self.generator_overrides))
def _validation_forward_config(
self,
pipeline: Any,
transformer: torch.nn.Module,
) -> ResolvedGeneratorConfig:
"""Build the resolved inference config that the validation forwards pass to the pipeline stages.
Its model definition is ``_validation_pipeline_config``. When that
definition has no DMD denoising steps, ``sampling_timesteps`` become
``pipeline.dmd_denoising_steps``, which the causal and DMD denoising
stages read.
"""
tc = self.training_config
validation_pipeline_config = self._validation_pipeline_config(
transformer,
pipeline.resolved_config.pipeline_config,
)
dmd_denoising_steps = None
if (self.sampling_timesteps is not None
and getattr(validation_pipeline_config, "dmd_denoising_steps", None) is None):
dmd_denoising_steps = [int(s) for s in self.sampling_timesteps]
forward_config = build_inference_resolved_config(
tc,
model_path=tc.model_path,
pipeline_config=validation_pipeline_config,
dmd_denoising_steps=dmd_denoising_steps,
)
dit_config = forward_config.pipeline_config.dit_config
logger.info(
"Validation forward config: local_attn_size=%s sink_size=%s boundary_ratio=%s",
getattr(dit_config.arch_config, "local_attn_size", None),
getattr(dit_config.arch_config, "sink_size", None),
getattr(dit_config, "boundary_ratio", None),
)
return forward_config
# ----------------------------------------------------------
# Batch preparation
# ----------------------------------------------------------
def _prepare_validation_batch(
self,
sampling_param: SamplingParam,
validation_batch: dict[str, Any],
num_inference_steps: int,
) -> ForwardBatch:
"""Build a pipeline batch with the recipe's output and conditioning policy."""
tc = self.training_config
sampling_param.prompt = validation_batch["prompt"]
sampling_param.height = tc.data.num_height
sampling_param.width = tc.data.num_width
sampling_param.num_inference_steps = int(num_inference_steps)
sampling_param.data_type = "video"
if self.guidance_scale is not None:
sampling_param.guidance_scale = float(self.guidance_scale)
sampling_param.seed = self.seed
# Output multiplicity belongs in SamplingParam so pipeline stages
# allocate the same batch dimension that validation expects to log.
sampling_param.num_videos_per_prompt = self.num_videos_per_prompt
# SamplingParam is cached across records; clearing the path prevents a
# prior record's image or video from conditioning a later prompt.
sampling_param.image_path = None
if self.use_validation_media_conditioning:
# Image-to-video pipelines use an image or the first frame of a validation video.
img_path = (validation_batch.get("image_path") or validation_batch.get("video_path"))
if img_path is not None and (img_path.startswith("http") or os.path.isfile(img_path)):
sampling_param.image_path = img_path
temporal_compression_factor = int(
tc.pipeline_config.vae_config.arch_config.temporal_compression_ratio # type: ignore[union-attr]
)
default_num_frames = ((tc.data.num_latent_t - 1) * temporal_compression_factor + 1)
if self.num_frames is not None:
sampling_param.num_frames = int(self.num_frames)
else:
sampling_param.num_frames = int(default_num_frames)
latents_size = [
(sampling_param.num_frames - 1) // 4 + 1,
sampling_param.height // 8,
sampling_param.width // 8,
]
n_tokens = (latents_size[0] * latents_size[1] * latents_size[2])
sampling_timesteps_tensor = (torch.tensor(
[int(s) for s in self.sampling_timesteps],
dtype=torch.long,
) if self.sampling_timesteps is not None else None)
batch = ForwardBatch(
**shallow_asdict(sampling_param),
generator=self.validation_random_generator,
n_tokens=n_tokens,
eta=0.0,
VSA_sparsity=tc.vsa_sparsity,
timesteps=sampling_timesteps_tensor,
)
# shallow_asdict(sampling_param) copies list-typed fields by
# reference. sampling_param is cached and reused across every
# validation sample/step, so without this reset every ForwardBatch
# would share (and keep appending to) the same
# prompt_attention_mask/negative_attention_mask list forever --
# index [0] would then hold the *first-ever* validation sample's
# mask instead of the current one, mismatching prompt_embeds.
batch.prompt_attention_mask = []
batch.negative_attention_mask = []
# Conditionally set I2V fields.
if ("image" in validation_batch and validation_batch["image"] is not None):
batch.pil_image = validation_batch["image"]
self._attach_action_conditions(
batch,
validation_batch,
sampling_param.num_frames,
)
return batch
def _attach_action_conditions(
self,
batch: ForwardBatch,
validation_batch: dict[str, Any],
num_frames: int,
) -> None:
for name in ("keyboard_cond", "mouse_cond"):
value = validation_batch.get(name)
if value is None:
continue
array = np.asarray(value)
if array.size == 0:
continue
array = array[:num_frames]
tensor = torch.as_tensor(
array,
dtype=torch.bfloat16,
)
student = getattr(self.method, "student", None)
prepare_action = getattr(student, "prepare_validation_action_condition", None)
if prepare_action is not None:
tensor = prepare_action(
tensor,
name=name,
keyboard_value_scale=self.keyboard_value_scale,
)
tensor = tensor.unsqueeze(0)
setattr(
batch,
name,
tensor,
)
# ----------------------------------------------------------
# Validation loop
# ----------------------------------------------------------
def _run_validation_for_steps(
self,
num_inference_steps: int,
*,
transformer: torch.nn.Module,
) -> _ValidationStepResult:
"""Generate the prompts assigned to one sequence-parallel group.
``ValidationDataset`` pads the prompt count to the data-parallel degree
and assigns an equal prompt shard to each sequence-parallel group. All
ranks in a group execute each forward pass; the group leader retains
decoded media.
"""
pipeline = self._get_pipeline(transformer=transformer, )
sampling_param = self._get_sampling_param()
dataset = ValidationDataset(self.dataset_file)
dataloader = DataLoader(
dataset,
batch_size=None,
num_workers=0,
)
forward_config = self._validation_forward_config(pipeline, transformer)
videos: list[list[np.ndarray]] = []
audio_waveforms: list[torch.Tensor | np.ndarray | None] = []
audio_sample_rates: list[int | None] = []
overlay_videos: list[list[np.ndarray]] = []
captions: list[str] = []
overlay_captions: list[str] = []
ref_videos: list[str | None] = []
actions: list[dict[str, Any] | None] = []
mouse_pitch_signs: list[int | None] = []
for validation_batch in dataloader:
batch = self._prepare_validation_batch(
sampling_param,
validation_batch,
num_inference_steps,
)
assert (batch.prompt is not None and isinstance(batch.prompt, str))
ref_video = validation_batch.get("ref_video")
action = self._validation_actions(validation_batch)
with torch.no_grad():
output_batch = pipeline.forward(
batch,
forward_config,
)
samples = output_batch.output.cpu()
if self.rank_in_sp_group != 0:
continue
# Append metadata only on the group leader so every list position
# describes the same decoded video throughout save and logging.
output_audio = output_batch.extra.get("audio")
output_audio_sample_rate = output_batch.extra.get("audio_sample_rate")
if (output_audio is None) != (output_audio_sample_rate is None):
raise ValueError("Validation pipeline outputs must provide audio and its sample rate together.")
if output_audio is not None and not isinstance(output_audio,
np.ndarray) and not torch.is_tensor(output_audio):
raise TypeError("Validation pipeline audio must be a torch.Tensor or numpy.ndarray; "
f"got {type(output_audio).__name__}.")
if torch.is_tensor(output_audio):
# The returned validation result stays on CPU so MP4 encoding
# does not retain a generation tensor on the GPU.
output_audio = output_audio.detach().cpu()
video = rearrange(
samples,
"b c t h w -> t b c h w",
)
frames: list[np.ndarray] = []
for x in video:
x = torchvision.utils.make_grid(
x,
nrow=6,
)
x = (x.transpose(0, 1).transpose(1, 2).squeeze(-1))
frames.append(pixels_to_uint8(x).numpy())
videos.append(frames)
captions.append(batch.prompt)
audio_waveforms.append(output_audio)
audio_sample_rates.append(int(output_audio_sample_rate) if output_audio_sample_rate is not None else None)
ref_videos.append(ref_video if isinstance(ref_video, str) else None)
actions.append(action)
mouse_pitch_signs.append(self._validation_mouse_pitch_sign(validation_batch))
if self.overlay_actions:
overlay_frames = self._post_process_validation_frames(
frames,
action=action,
)
if overlay_frames is not None:
overlay_videos.append(overlay_frames)
overlay_captions.append(batch.prompt)
return _ValidationStepResult(
videos=videos,
captions=captions,
audio_waveforms=audio_waveforms,
audio_sample_rates=audio_sample_rates,
overlay_videos=overlay_videos,
overlay_captions=overlay_captions,
ref_videos=ref_videos,
actions=actions,
mouse_pitch_signs=mouse_pitch_signs,
)
def _post_process_validation_frames(
self,
frames: list[np.ndarray],
*,
action: dict[str, Any] | None,
) -> list[np.ndarray] | None:
student = getattr(self.method, "student", None)
post_process = getattr(student, "post_process_validation_frames", None)
if post_process is None:
return None
return post_process(
frames,
action=action,
)
# ----------------------------------------------------------
# State management
# ----------------------------------------------------------
def state_dict(self) -> dict[str, Any]:
state: dict[str, Any] = {}
if self.validation_random_generator is not None:
state["validation_rng"] = (self.validation_random_generator.get_state())
return state
def load_state_dict(
self,
state_dict: dict[str, Any],
) -> None:
rng_state = state_dict.get("validation_rng")
if (rng_state is not None and self.validation_random_generator is not None):
self.validation_random_generator.set_state(rng_state)