| from __future__ import annotations |
|
|
| import argparse |
| import contextlib |
| import json |
| import math |
| import random |
| import subprocess |
| import wave |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| from torch import nn |
| from torch.nn import functional as F |
| from torch.utils.checkpoint import checkpoint |
|
|
| import cv2 |
|
|
| cv2.setNumThreads(1) |
|
|
|
|
| @dataclass(frozen=True) |
| class VideoSpec: |
| width: int |
| height: int |
| fps: float |
| frame_stride: int |
| threshold: int | None |
| frame_count: int |
|
|
| @property |
| def duration(self) -> float: |
| return self.frame_count / self.fps |
|
|
|
|
| @dataclass(frozen=True) |
| class AudioSpec: |
| sample_rate: int |
| sample_count: int |
| channels: int = 1 |
|
|
| @property |
| def duration(self) -> float: |
| return self.sample_count / self.sample_rate |
|
|
|
|
| RenderThreshold = int | str | None |
|
|
|
|
| def safe_name( |
| video_path: Path, |
| width: int, |
| height: int, |
| stride: int, |
| threshold: int | None, |
| max_frames: int | None, |
| ) -> str: |
| threshold_name = "gray" if threshold is None else f"thr{threshold}" |
| frame_name = "full" if max_frames is None else f"n{max_frames}" |
| return f"{video_path.stem}_{width}x{height}_s{stride}_{threshold_name}_{frame_name}" |
|
|
|
|
| def parse_data_threshold(value: str | int | None) -> int | None: |
| if value is None: |
| return None |
| if isinstance(value, int): |
| return None if value < 0 else value |
| text = value.strip().lower() |
| if text in {"none", "gray", "grayscale", "soft", "-1"}: |
| return None |
| threshold = int(text) |
| return None if threshold < 0 else threshold |
|
|
|
|
| def parse_render_threshold(value: str | int | None) -> RenderThreshold: |
| if value is None: |
| return None |
| if isinstance(value, int): |
| return None if value < 0 else value |
| text = value.strip().lower() |
| if text in {"none", "gray", "grayscale", "soft", "-1"}: |
| return None |
| if text in {"auto", "otsu", "adaptive", "calibrated"}: |
| return text |
| threshold = int(text) |
| return None if threshold < 0 else threshold |
|
|
|
|
| def read_source_resolution(video_path: Path) -> tuple[int, int]: |
| capture = cv2.VideoCapture(str(video_path)) |
| if not capture.isOpened(): |
| raise RuntimeError(f"Could not open video: {video_path}") |
| width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) |
| height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) |
| capture.release() |
| if width <= 0 or height <= 0: |
| raise RuntimeError(f"Could not read source resolution from {video_path}") |
| return width, height |
|
|
|
|
| def probe_source_audio(video_path: Path) -> tuple[int, int] | None: |
| command = [ |
| "ffprobe", |
| "-v", |
| "error", |
| "-select_streams", |
| "a:0", |
| "-show_entries", |
| "stream=sample_rate,channels", |
| "-of", |
| "json", |
| str(video_path), |
| ] |
| try: |
| result = subprocess.run(command, check=True, capture_output=True, text=True) |
| except (FileNotFoundError, subprocess.CalledProcessError): |
| return None |
| streams = json.loads(result.stdout or "{}").get("streams", []) |
| if not streams: |
| return None |
| stream = streams[0] |
| sample_rate = int(stream.get("sample_rate") or 0) |
| channels = int(stream.get("channels") or 0) |
| if sample_rate <= 0 or channels <= 0: |
| return None |
| return sample_rate, channels |
|
|
|
|
| def read_video_frames( |
| video_path: Path, |
| width: int, |
| height: int, |
| frame_stride: int, |
| threshold: int | None, |
| max_frames: int | None, |
| ) -> tuple[np.ndarray, float]: |
| capture = cv2.VideoCapture(str(video_path)) |
| if not capture.isOpened(): |
| raise RuntimeError(f"Could not open video: {video_path}") |
|
|
| source_fps = capture.get(cv2.CAP_PROP_FPS) or 30.0 |
| frames: list[np.ndarray] = [] |
| source_index = 0 |
|
|
| while True: |
| ok, frame = capture.read() |
| if not ok: |
| break |
|
|
| if source_index % frame_stride == 0: |
| gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) |
| small = cv2.resize(gray, (width, height), interpolation=cv2.INTER_AREA) |
| if threshold is not None: |
| small = np.where(small >= threshold, 255, 0).astype(np.uint8) |
| frames.append(small) |
| if max_frames is not None and len(frames) >= max_frames: |
| break |
|
|
| source_index += 1 |
|
|
| capture.release() |
|
|
| if not frames: |
| raise RuntimeError("No frames were extracted from the video.") |
|
|
| effective_fps = source_fps / frame_stride |
| return np.stack(frames, axis=0), effective_fps |
|
|
|
|
| def read_audio_samples(video_path: Path, sample_rate: int, channels: int, duration: float) -> np.ndarray: |
| command = [ |
| "ffmpeg", |
| "-v", |
| "error", |
| "-i", |
| str(video_path), |
| "-vn", |
| "-ac", |
| str(channels), |
| "-ar", |
| str(sample_rate), |
| "-t", |
| f"{duration:.6f}", |
| "-f", |
| "f32le", |
| "pipe:1", |
| ] |
| result = subprocess.run(command, check=True, capture_output=True) |
| samples = np.frombuffer(result.stdout, dtype=np.float32).copy() |
| if samples.size == 0: |
| raise RuntimeError(f"No audio was extracted from {video_path}") |
| samples = samples.reshape(-1, channels) |
| return np.clip(samples, -1.0, 1.0) |
|
|
|
|
| def prepare_dataset(args: argparse.Namespace) -> tuple[Path, Path, Path | None, Path | None]: |
| video_path = Path(args.video).resolve() |
| cache_dir = Path(args.cache_dir).resolve() |
| cache_dir.mkdir(parents=True, exist_ok=True) |
|
|
| if getattr(args, "match_source_resolution", False): |
| args.width, args.height = read_source_resolution(video_path) |
|
|
| audio_sample_rate = args.audio_sample_rate |
| audio_channels = args.audio_channels |
| if getattr(args, "match_source_audio", False): |
| probed_audio = probe_source_audio(video_path) |
| if probed_audio is not None: |
| audio_sample_rate, audio_channels = probed_audio |
| else: |
| print("could not probe source audio; using configured audio sample rate/channels") |
| args.audio_sample_rate = audio_sample_rate |
| args.audio_channels = audio_channels |
|
|
| name = safe_name(video_path, args.width, args.height, args.frame_stride, args.threshold, args.max_frames) |
| frames_path = cache_dir / f"{name}.npy" |
| spec_path = cache_dir / f"{name}.json" |
|
|
| if not (frames_path.exists() and spec_path.exists()) or args.force: |
| frames, fps = read_video_frames( |
| video_path=video_path, |
| width=args.width, |
| height=args.height, |
| frame_stride=args.frame_stride, |
| threshold=args.threshold, |
| max_frames=args.max_frames, |
| ) |
| np.save(frames_path, frames) |
|
|
| spec = VideoSpec( |
| width=args.width, |
| height=args.height, |
| fps=fps, |
| frame_stride=args.frame_stride, |
| threshold=args.threshold, |
| frame_count=int(frames.shape[0]), |
| ) |
| spec_path.write_text(json.dumps(asdict(spec), indent=2), encoding="utf-8") |
| print(f"cached {frames.shape[0]} frames at {frames.shape[2]}x{frames.shape[1]} -> {frames_path}") |
|
|
| audio_path = None |
| audio_spec_path = None |
| if getattr(args, "train_audio", True): |
| spec = load_video_spec(spec_path) |
| audio_stem = f"{name}_audio_{audio_sample_rate}hz_{audio_channels}ch" |
| audio_path = cache_dir / f"{audio_stem}.npy" |
| audio_spec_path = cache_dir / f"{audio_stem}.json" |
| if not (audio_path.exists() and audio_spec_path.exists()) or args.force: |
| audio = read_audio_samples(video_path, audio_sample_rate, audio_channels, spec.duration) |
| np.save(audio_path, audio) |
| audio_spec = AudioSpec( |
| sample_rate=audio_sample_rate, |
| sample_count=int(audio.shape[0]), |
| channels=audio_channels, |
| ) |
| audio_spec_path.write_text(json.dumps(asdict(audio_spec), indent=2), encoding="utf-8") |
| print( |
| f"cached {audio.shape[0]} audio samples at " |
| f"{audio_sample_rate} Hz, {audio_channels} channel(s) -> {audio_path}" |
| ) |
|
|
| return frames_path, spec_path, audio_path, audio_spec_path |
|
|
|
|
| class FourierFeatures(nn.Module): |
| def __init__(self, dims: int, bands: int): |
| super().__init__() |
| freq = 2.0 ** torch.arange(bands, dtype=torch.float32) * math.pi |
| self.dims = dims |
| self.register_buffer("freq", freq) |
|
|
| @property |
| def out_dim(self) -> int: |
| return self.dims + self.dims * 2 * int(self.freq.numel()) |
|
|
| def forward(self, coords: torch.Tensor) -> torch.Tensor: |
| encoded = [coords] |
| angles = coords[..., None] * self.freq |
| encoded.append(torch.sin(angles).flatten(-2)) |
| encoded.append(torch.cos(angles).flatten(-2)) |
| return torch.cat(encoded, dim=-1) |
|
|
|
|
| class CoordinateMLP(nn.Module): |
| def __init__(self, dims: int, hidden: int, layers: int, bands: int, out_activation: str | None): |
| super().__init__() |
| self.features = FourierFeatures(dims=dims, bands=bands) |
| blocks: list[nn.Module] = [] |
| in_dim = self.features.out_dim |
| for _ in range(layers): |
| blocks.append(nn.Linear(in_dim, hidden)) |
| blocks.append(nn.SiLU()) |
| in_dim = hidden |
| blocks.append(nn.Linear(in_dim, 1)) |
| self.net = nn.Sequential(*blocks) |
| self.out_activation = out_activation |
|
|
| def forward(self, coords: torch.Tensor) -> torch.Tensor: |
| values = self.net(self.features(coords)).squeeze(-1) |
| if self.out_activation == "tanh": |
| return torch.tanh(values) |
| return values |
|
|
|
|
| class LegacyBadAppleMultiModalNet(nn.Module): |
| def __init__( |
| self, |
| video_hidden: int, |
| video_layers: int, |
| video_bands: int, |
| audio_hidden: int, |
| audio_layers: int, |
| audio_bands: int, |
| train_audio: bool, |
| ): |
| super().__init__() |
| self.video = CoordinateMLP(3, video_hidden, video_layers, video_bands, out_activation=None) |
| self.audio_enabled = train_audio |
| self.audio_channels = 1 |
| self.audio = None |
| if train_audio: |
| self.audio = CoordinateMLP(1, audio_hidden, audio_layers, audio_bands, out_activation="tanh") |
|
|
|
|
| class UnifiedV1BadAppleMultiModalNet(nn.Module): |
| def __init__( |
| self, |
| video_hidden: int, |
| video_layers: int, |
| video_bands: int, |
| audio_hidden: int, |
| audio_layers: int, |
| audio_bands: int, |
| train_audio: bool, |
| audio_channels: int, |
| modality_embedding_dim: int, |
| ): |
| super().__init__() |
| hidden = max(video_hidden, audio_hidden if train_audio else video_hidden) |
| layers = max(video_layers, audio_layers if train_audio else video_layers) |
| self.audio_enabled = train_audio |
| self.audio_channels = max(1, audio_channels) |
| self.video_bands = video_bands |
| self.audio_bands = audio_bands |
| self.modality_embedding_dim = modality_embedding_dim |
|
|
| self.coord_features = FourierFeatures(dims=3, bands=video_bands) |
| self.time_features = FourierFeatures(dims=1, bands=max(video_bands, audio_bands)) |
| self.modality_embedding = nn.Embedding(2, modality_embedding_dim) |
|
|
| trunk_in = self.coord_features.out_dim + self.time_features.out_dim + modality_embedding_dim |
| blocks: list[nn.Module] = [] |
| in_dim = trunk_in |
| for _ in range(layers): |
| blocks.append(nn.Linear(in_dim, hidden)) |
| blocks.append(nn.SiLU()) |
| in_dim = hidden |
| self.trunk = nn.Sequential(*blocks) |
| self.video_head = nn.Linear(hidden, 1) |
| self.audio_head = nn.Linear(hidden, self.audio_channels) if train_audio else None |
|
|
| def _coords3(self, coords: torch.Tensor) -> torch.Tensor: |
| if coords.shape[-1] == 3: |
| return coords |
| if coords.shape[-1] != 1: |
| raise ValueError(f"expected 1D or 3D coordinates, got shape {tuple(coords.shape)}") |
| zeros = torch.zeros((coords.shape[0], 2), device=coords.device, dtype=coords.dtype) |
| return torch.cat((coords, zeros), dim=-1) |
|
|
| def modality_features(self, coords: torch.Tensor, modality_id: int) -> torch.Tensor: |
| coords3 = self._coords3(coords) |
| time = coords3[:, :1] |
| modality = torch.full((coords3.shape[0],), modality_id, device=coords3.device, dtype=torch.long) |
| encoded = torch.cat( |
| ( |
| self.coord_features(coords3), |
| self.time_features(time), |
| self.modality_embedding(modality), |
| ), |
| dim=-1, |
| ) |
| return self.trunk(encoded) |
|
|
| def video(self, coords: torch.Tensor) -> torch.Tensor: |
| return self.video_head(self.modality_features(coords, 0)).squeeze(-1) |
|
|
| def audio(self, coords: torch.Tensor) -> torch.Tensor: |
| if self.audio_head is None: |
| raise RuntimeError("This checkpoint does not contain an audio model.") |
| return torch.tanh(self.audio_head(self.modality_features(coords, 1))) |
|
|
|
|
| class TemporalLatentFeatures(nn.Module): |
| """A shared, linearly interpolated time-memory used by both modalities.""" |
|
|
| def __init__(self, anchors: int, dim: int): |
| super().__init__() |
| self.anchors = anchors |
| self.dim = dim |
| self.embedding = nn.Embedding(anchors, dim) |
| nn.init.normal_(self.embedding.weight, mean=0.0, std=0.02) |
|
|
| def forward(self, time: torch.Tensor) -> torch.Tensor: |
| position = ((time.squeeze(-1) + 1.0) * 0.5 * (self.anchors - 1)).clamp(0, self.anchors - 1) |
| lower = position.floor().long() |
| upper = (lower + 1).clamp(max=self.anchors - 1) |
| fraction = (position - lower.float()).unsqueeze(-1) |
| return torch.lerp(self.embedding(lower), self.embedding(upper), fraction) |
|
|
|
|
| class QATLinear(nn.Linear): |
| """Linear layer with optional per-output-channel INT8 fake quantization.""" |
|
|
| def __init__(self, in_features: int, out_features: int, bias: bool = True): |
| super().__init__(in_features, out_features, bias=bias) |
| self.qat_enabled = False |
|
|
| def forward(self, values: torch.Tensor) -> torch.Tensor: |
| if not self.qat_enabled: |
| return F.linear(values, self.weight, self.bias) |
| max_abs = self.weight.detach().abs().amax(dim=1, keepdim=True).clamp_min(1e-8) |
| scale = max_abs / 127.0 |
| quantized = torch.round(self.weight / scale).clamp(-127, 127) * scale |
| fake_quantized = self.weight + (quantized - self.weight).detach() |
| return F.linear(values, fake_quantized, self.bias) |
|
|
|
|
| def set_qat_enabled(model: nn.Module, enabled: bool) -> None: |
| for module in model.modules(): |
| if isinstance(module, QATLinear): |
| module.qat_enabled = enabled |
|
|
|
|
| class ResidualBlock(nn.Module): |
| def __init__(self, hidden: int): |
| super().__init__() |
| self.norm = nn.LayerNorm(hidden) |
| self.expand = QATLinear(hidden, hidden * 2) |
| self.project = QATLinear(hidden * 2, hidden) |
|
|
| def forward(self, values: torch.Tensor) -> torch.Tensor: |
| residual = values |
| values = self.norm(values) |
| values = F.silu(self.expand(values)) |
| return residual + self.project(values) |
|
|
|
|
| class BadAppleMultiModalNet(nn.Module): |
| """The current unified coordinate field used for new checkpoints.""" |
|
|
| def __init__( |
| self, |
| video_hidden: int, |
| video_layers: int, |
| video_bands: int, |
| audio_hidden: int, |
| audio_layers: int, |
| audio_bands: int, |
| train_audio: bool, |
| audio_channels: int, |
| modality_embedding_dim: int, |
| temporal_latent_anchors: int, |
| temporal_latent_dim: int, |
| pixel_centers: bool = True, |
| gradient_checkpointing: bool = False, |
| ): |
| super().__init__() |
| hidden = max(video_hidden, audio_hidden if train_audio else video_hidden) |
| layers = max(video_layers, audio_layers if train_audio else video_layers) |
| self.audio_enabled = train_audio |
| self.audio_channels = max(1, audio_channels) |
| self.video_bands = video_bands |
| self.audio_bands = audio_bands |
| self.modality_embedding_dim = modality_embedding_dim |
| self.temporal_latent_anchors = temporal_latent_anchors |
| self.temporal_latent_dim = temporal_latent_dim |
| self.pixel_centers = pixel_centers |
| self.gradient_checkpointing = gradient_checkpointing |
|
|
| self.coord_features = FourierFeatures(dims=3, bands=video_bands) |
| self.time_features = FourierFeatures(dims=1, bands=max(video_bands, audio_bands)) |
| self.time_memory = TemporalLatentFeatures(temporal_latent_anchors, temporal_latent_dim) |
| self.modality_embedding = nn.Embedding(2, modality_embedding_dim) |
| trunk_in = ( |
| self.coord_features.out_dim |
| + self.time_features.out_dim |
| + temporal_latent_dim |
| + modality_embedding_dim |
| ) |
| self.input_layer = QATLinear(trunk_in, hidden) |
| self.blocks = nn.ModuleList(ResidualBlock(hidden) for _ in range(max(1, layers - 1))) |
| self.output_norm = nn.LayerNorm(hidden) |
| self.video_head = QATLinear(hidden, 1) |
| self.audio_head = QATLinear(hidden, self.audio_channels) if train_audio else None |
|
|
| def _coords3(self, coords: torch.Tensor) -> torch.Tensor: |
| if coords.shape[-1] == 3: |
| return coords |
| if coords.shape[-1] != 1: |
| raise ValueError(f"expected 1D or 3D coordinates, got shape {tuple(coords.shape)}") |
| zeros = torch.zeros((coords.shape[0], 2), device=coords.device, dtype=coords.dtype) |
| return torch.cat((coords, zeros), dim=-1) |
|
|
| def modality_features(self, coords: torch.Tensor, modality_id: int) -> torch.Tensor: |
| coords3 = self._coords3(coords) |
| time = coords3[:, :1] |
| modality = torch.full((coords3.shape[0],), modality_id, device=coords3.device, dtype=torch.long) |
| encoded = torch.cat( |
| ( |
| self.coord_features(coords3), |
| self.time_features(time), |
| self.time_memory(time), |
| self.modality_embedding(modality), |
| ), |
| dim=-1, |
| ) |
| values = F.silu(self.input_layer(encoded)) |
| for block in self.blocks: |
| if self.gradient_checkpointing and self.training and torch.is_grad_enabled(): |
| values = checkpoint(block, values, use_reentrant=False) |
| else: |
| values = block(values) |
| return self.output_norm(values) |
|
|
| def video(self, coords: torch.Tensor) -> torch.Tensor: |
| return self.video_head(self.modality_features(coords, 0)).squeeze(-1) |
|
|
| def audio(self, coords: torch.Tensor) -> torch.Tensor: |
| if self.audio_head is None: |
| raise RuntimeError("This checkpoint does not contain an audio model.") |
| return torch.tanh(self.audio_head(self.modality_features(coords, 1))) |
|
|
|
|
| def has_audio_model(model: nn.Module) -> bool: |
| if getattr(model, "audio_enabled", False): |
| return True |
| audio = getattr(model, "audio", None) |
| return audio is not None and not callable(audio) |
|
|
|
|
| def predict_video(model: nn.Module, coords: torch.Tensor) -> torch.Tensor: |
| return model.video(coords) |
|
|
|
|
| def predict_audio(model: nn.Module, coords: torch.Tensor) -> torch.Tensor: |
| if not has_audio_model(model): |
| raise RuntimeError("This checkpoint does not contain an audio model.") |
| values = model.audio(coords) |
| if values.dim() == 1: |
| values = values.unsqueeze(-1) |
| return values |
|
|
|
|
| def load_video_spec(spec_path: Path) -> VideoSpec: |
| raw = json.loads(spec_path.read_text(encoding="utf-8")) |
| return VideoSpec(**raw) |
|
|
|
|
| def load_audio_spec(spec_path: Path) -> AudioSpec: |
| raw = json.loads(spec_path.read_text(encoding="utf-8")) |
| return AudioSpec(**raw) |
|
|
|
|
| def choose_device(name: str) -> torch.device: |
| if name == "auto": |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| return torch.device(name) |
|
|
|
|
| def set_seed(seed: int | None) -> None: |
| if seed is None: |
| return |
| random.seed(seed) |
| np.random.seed(seed) |
| torch.manual_seed(seed) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(seed) |
|
|
|
|
| def build_edge_indices(frames: np.ndarray, edge_threshold: int) -> np.ndarray: |
| frame_count, height, width = frames.shape |
| pieces: list[np.ndarray] = [] |
| index_dtype = np.uint32 if frames.size <= np.iinfo(np.uint32).max else np.int64 |
| for frame_index in range(frame_count): |
| frame = np.asarray(frames[frame_index], dtype=np.uint8) |
| edge_mask = np.zeros((height, width), dtype=bool) |
| horizontal = np.abs(frame[:, 1:].astype(np.int16) - frame[:, :-1].astype(np.int16)) >= edge_threshold |
| vertical = np.abs(frame[1:, :].astype(np.int16) - frame[:-1, :].astype(np.int16)) >= edge_threshold |
| edge_mask[:, 1:] |= horizontal |
| edge_mask[:, :-1] |= horizontal |
| edge_mask[1:, :] |= vertical |
| edge_mask[:-1, :] |= vertical |
| yx = np.argwhere(edge_mask) |
| if yx.size: |
| linear = frame_index * height * width + yx[:, 0] * width + yx[:, 1] |
| pieces.append(linear.astype(index_dtype, copy=False)) |
| if not pieces: |
| return np.empty(0, dtype=index_dtype) |
| return np.concatenate(pieces, axis=0) |
|
|
|
|
| def build_motion_frame_weights(frames: np.ndarray, power: float = 0.5) -> np.ndarray: |
| """Favor frames where the silhouette changes without excluding quiet scenes.""" |
| if frames.shape[0] <= 1: |
| return np.ones(1, dtype=np.float32) |
| changes = np.empty(frames.shape[0], dtype=np.float32) |
| previous = np.asarray(frames[0], dtype=np.int16) |
| for frame_index in range(1, frames.shape[0]): |
| current = np.asarray(frames[frame_index], dtype=np.int16) |
| changes[frame_index] = np.abs(current - previous).mean(dtype=np.float64) |
| previous = current |
| changes[0] = changes[1] |
| weights = np.power(changes + 1e-3, power) |
| return (weights / weights.sum()).astype(np.float32) |
|
|
|
|
| def video_white_fraction(frames: np.ndarray) -> float: |
| white_pixels = 0 |
| for frame_index in range(frames.shape[0]): |
| white_pixels += int(np.count_nonzero(frames[frame_index] >= 128)) |
| return white_pixels / max(int(frames.size), 1) |
|
|
|
|
| def video_coords_from_indices( |
| frame_count: int, |
| height: int, |
| width: int, |
| t: torch.Tensor, |
| y: torch.Tensor, |
| x: torch.Tensor, |
| pixel_centers: bool = True, |
| ) -> torch.Tensor: |
| if pixel_centers: |
| x_norm = (x.float() + 0.5) / width |
| y_norm = (y.float() + 0.5) / height |
| else: |
| x_norm = x.float() / max(width - 1, 1) |
| y_norm = y.float() / max(height - 1, 1) |
| coords = torch.stack( |
| ( |
| t.float() / max(frame_count - 1, 1), |
| x_norm, |
| y_norm, |
| ), |
| dim=-1, |
| ) |
| return coords * 2.0 - 1.0 |
|
|
|
|
| def sample_video_batch( |
| frames: np.ndarray, |
| batch_size: int, |
| device: torch.device, |
| edge_indices: np.ndarray | None = None, |
| edge_fraction: float = 0.0, |
| frame_weights: torch.Tensor | None = None, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| frame_count, height, width = frames.shape |
| edge_count = 0 |
| if edge_indices is not None and edge_indices.size > 0 and edge_fraction > 0: |
| edge_count = min(batch_size, int(round(batch_size * edge_fraction))) |
| uniform_count = batch_size - edge_count |
|
|
| parts_t: list[torch.Tensor] = [] |
| parts_y: list[torch.Tensor] = [] |
| parts_x: list[torch.Tensor] = [] |
| if uniform_count > 0: |
| if frame_weights is None: |
| parts_t.append(torch.randint(0, frame_count, (uniform_count,), device=device)) |
| else: |
| parts_t.append(torch.multinomial(frame_weights, uniform_count, replacement=True)) |
| parts_y.append(torch.randint(0, height, (uniform_count,), device=device)) |
| parts_x.append(torch.randint(0, width, (uniform_count,), device=device)) |
| if edge_count > 0 and edge_indices is not None: |
| picked = edge_indices[np.random.randint(0, edge_indices.shape[0], size=edge_count)].astype( |
| np.int64, |
| copy=False, |
| ) |
| frame_pixels = height * width |
| picked_t = picked // frame_pixels |
| picked_remainder = picked % frame_pixels |
| parts_t.append(torch.from_numpy(picked_t).to(device=device, dtype=torch.long)) |
| parts_y.append(torch.from_numpy(picked_remainder // width).to(device=device, dtype=torch.long)) |
| parts_x.append(torch.from_numpy(picked_remainder % width).to(device=device, dtype=torch.long)) |
|
|
| t = torch.cat(parts_t) |
| y = torch.cat(parts_y) |
| x = torch.cat(parts_x) |
|
|
| targets_np = frames[t.cpu().numpy(), y.cpu().numpy(), x.cpu().numpy()] |
| targets = torch.from_numpy(targets_np).to(device=device, dtype=torch.float32) / 255.0 |
| return video_coords_from_indices(frame_count, height, width, t, y, x), targets |
|
|
|
|
| def sample_video_pair_batch( |
| frames: np.ndarray, |
| batch_size: int, |
| device: torch.device, |
| mode: str, |
| frame_weights: torch.Tensor | None = None, |
| ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor] | None: |
| frame_count, height, width = frames.shape |
| if batch_size <= 0: |
| return None |
|
|
| if mode == "temporal": |
| if frame_count < 2: |
| return None |
| if frame_weights is None: |
| t0 = torch.randint(0, frame_count - 1, (batch_size,), device=device) |
| else: |
| t0 = torch.multinomial(frame_weights[:-1], batch_size, replacement=True) |
| t1 = t0 + 1 |
| y0 = torch.randint(0, height, (batch_size,), device=device) |
| x0 = torch.randint(0, width, (batch_size,), device=device) |
| y1 = y0 |
| x1 = x0 |
| elif mode == "spatial": |
| if height < 2 or width < 2: |
| return None |
| t0 = torch.randint(0, frame_count, (batch_size,), device=device) |
| t1 = t0 |
| y0 = torch.randint(0, height, (batch_size,), device=device) |
| x0 = torch.randint(0, width, (batch_size,), device=device) |
| use_x = torch.rand((batch_size,), device=device) < 0.5 |
| x1 = torch.where(use_x, torch.clamp(x0 + 1, max=width - 1), x0) |
| y1 = torch.where(use_x, y0, torch.clamp(y0 + 1, max=height - 1)) |
| else: |
| raise ValueError(f"unknown pair batch mode: {mode}") |
|
|
| target0_np = frames[t0.cpu().numpy(), y0.cpu().numpy(), x0.cpu().numpy()] |
| target1_np = frames[t1.cpu().numpy(), y1.cpu().numpy(), x1.cpu().numpy()] |
| target0 = torch.from_numpy(target0_np).to(device=device, dtype=torch.float32) / 255.0 |
| target1 = torch.from_numpy(target1_np).to(device=device, dtype=torch.float32) / 255.0 |
| coords0 = video_coords_from_indices(frame_count, height, width, t0, y0, x0) |
| coords1 = video_coords_from_indices(frame_count, height, width, t1, y1, x1) |
| return coords0, coords1, target0, target1 |
|
|
|
|
| def sample_audio_batch( |
| audio: np.ndarray, |
| batch_size: int, |
| device: torch.device, |
| chunk_samples: int, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| sample_count = audio.shape[0] |
| if chunk_samples <= 1: |
| indices = torch.randint(0, sample_count, (batch_size,), device=device) |
| else: |
| chunk_count = max(1, batch_size // chunk_samples) |
| max_start = max(1, sample_count - chunk_samples) |
| starts = torch.randint(0, max_start, (chunk_count,), device=device) |
| offsets = torch.arange(chunk_samples, device=device) |
| indices = (starts[:, None] + offsets[None, :]).reshape(-1) |
| targets_np = audio[indices.cpu().numpy()] |
| targets = torch.from_numpy(targets_np).to(device=device, dtype=torch.float32) |
| if targets.dim() == 1: |
| targets = targets.unsqueeze(-1) |
| coords = indices.float().unsqueeze(-1) / max(sample_count - 1, 1) |
| return coords * 2.0 - 1.0, targets |
|
|
|
|
| def audio_stft_loss(pred_chunks: torch.Tensor, target_chunks: torch.Tensor, n_fft: int, hop_length: int) -> torch.Tensor: |
| if pred_chunks.shape[1] < n_fft: |
| return pred_chunks.new_tensor(0.0) |
| pred_chunks = pred_chunks.float() |
| target_chunks = target_chunks.float() |
| pred_signals = pred_chunks.permute(0, 2, 1).reshape(-1, pred_chunks.shape[1]) |
| target_signals = target_chunks.permute(0, 2, 1).reshape(-1, target_chunks.shape[1]) |
| window = torch.hann_window(n_fft, device=pred_chunks.device, dtype=pred_chunks.dtype) |
| pred_spec = torch.stft( |
| pred_signals, |
| n_fft=n_fft, |
| hop_length=hop_length, |
| window=window, |
| return_complex=True, |
| ) |
| target_spec = torch.stft( |
| target_signals, |
| n_fft=n_fft, |
| hop_length=hop_length, |
| window=window, |
| return_complex=True, |
| ) |
| return F.l1_loss(torch.log1p(pred_spec.abs()), torch.log1p(target_spec.abs())) |
|
|
|
|
| def soft_dice_loss(logits: torch.Tensor, targets: torch.Tensor, epsilon: float = 1e-6) -> torch.Tensor: |
| probabilities = torch.sigmoid(logits) |
| intersection = (probabilities * targets).sum() |
| denominator = probabilities.sum() + targets.sum() |
| return 1.0 - (2.0 * intersection + epsilon) / (denominator + epsilon) |
|
|
|
|
| def make_evaluation_batch( |
| frames: np.ndarray, |
| batch_size: int, |
| device: torch.device, |
| seed: int, |
| pixel_centers: bool = True, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """A fixed, reproducible sample for checkpoint selection and calibration.""" |
| frame_count, height, width = frames.shape |
| rng = np.random.default_rng(seed) |
| t_np = rng.integers(0, frame_count, size=batch_size, dtype=np.int64) |
| y_np = rng.integers(0, height, size=batch_size, dtype=np.int64) |
| x_np = rng.integers(0, width, size=batch_size, dtype=np.int64) |
| targets_np = frames[t_np, y_np, x_np] |
| t = torch.from_numpy(t_np).to(device) |
| y = torch.from_numpy(y_np).to(device) |
| x = torch.from_numpy(x_np).to(device) |
| targets = torch.from_numpy(targets_np).to(device=device, dtype=torch.float32) / 255.0 |
| return video_coords_from_indices(frame_count, height, width, t, y, x, pixel_centers=pixel_centers), targets |
|
|
|
|
| @torch.inference_mode() |
| def calibrate_render_threshold( |
| model: nn.Module, |
| frames: np.ndarray, |
| batch_size: int, |
| device: torch.device, |
| gamma: float, |
| contrast: float, |
| seed: int, |
| ) -> tuple[int, float]: |
| """Find one global threshold that maximizes silhouette IoU over fixed pixels.""" |
| was_training = model.training |
| model.eval() |
| coords, targets = make_evaluation_batch( |
| frames, |
| batch_size, |
| device, |
| seed, |
| pixel_centers=getattr(model, "pixel_centers", False), |
| ) |
| probabilities = torch.sigmoid(predict_video(model, coords)).detach().cpu().numpy() |
| adjusted = adjust_image(probabilities, gamma=gamma, contrast=contrast) |
| target_binary = targets.detach().cpu().numpy() >= 0.5 |
| if not target_binary.any(): |
| if was_training: |
| model.train() |
| return 256, 1.0 |
| if target_binary.all(): |
| if was_training: |
| model.train() |
| return 0, 1.0 |
| thresholds = np.arange(1, 256, dtype=np.uint8) |
| scores = [] |
| for threshold in thresholds: |
| prediction = adjusted >= threshold / 255.0 |
| union = np.logical_or(prediction, target_binary).sum() |
| scores.append(float(np.logical_and(prediction, target_binary).sum() / max(union, 1))) |
| best_index = int(np.argmax(scores)) |
| if was_training: |
| model.train() |
| return int(thresholds[best_index]), scores[best_index] |
|
|
|
|
| def resolve_calibrated_threshold( |
| requested: RenderThreshold, |
| model: nn.Module, |
| frames_path: str | Path | None, |
| device: torch.device, |
| batch_size: int, |
| gamma: float, |
| contrast: float, |
| seed: int, |
| ) -> RenderThreshold: |
| if requested != "calibrated": |
| return requested |
| if frames_path is None or not Path(frames_path).exists(): |
| print("could not calibrate threshold because the frame cache is unavailable; using 128") |
| return 128 |
| frames = np.load(Path(frames_path), mmap_mode="r") |
| threshold, iou = calibrate_render_threshold( |
| model=model, |
| frames=frames, |
| batch_size=batch_size, |
| device=device, |
| gamma=gamma, |
| contrast=contrast, |
| seed=seed, |
| ) |
| print(f"calibrated global render threshold={threshold} silhouette_iou={iou:.3f}") |
| return threshold |
|
|
|
|
| def lr_scale_for_step(step: int, args: argparse.Namespace) -> float: |
| warmup_steps = max(0, args.warmup_steps) |
| if warmup_steps > 0 and step <= warmup_steps: |
| return max(args.warmup_start_ratio, step / warmup_steps) |
|
|
| decay_steps = max(1, args.steps - warmup_steps) |
| progress = min(max((step - warmup_steps) / decay_steps, 0.0), 1.0) |
| min_ratio = args.min_lr_ratio |
|
|
| if args.lr_decay == "none": |
| return 1.0 |
| if args.lr_decay == "linear": |
| return min_ratio + (1.0 - min_ratio) * (1.0 - progress) |
| if args.lr_decay == "cosine": |
| cosine = 0.5 * (1.0 + math.cos(math.pi * progress)) |
| return min_ratio + (1.0 - min_ratio) * cosine |
| raise ValueError(f"unknown lr decay: {args.lr_decay}") |
|
|
|
|
| def set_optimizer_lr(optimizer: torch.optim.Optimizer, lr: float) -> None: |
| for group in optimizer.param_groups: |
| group["lr"] = lr |
|
|
|
|
| def frame_coords( |
| frame_index: int, |
| spec: VideoSpec, |
| scale: int, |
| device: torch.device, |
| supersample: int = 1, |
| pixel_centers: bool = True, |
| ) -> torch.Tensor: |
| out_w = spec.width * scale * supersample |
| out_h = spec.height * scale * supersample |
| yy, xx = torch.meshgrid( |
| torch.arange(out_h, device=device), |
| torch.arange(out_w, device=device), |
| indexing="ij", |
| ) |
| t = torch.full_like(xx, frame_index, dtype=torch.float32) |
| if pixel_centers: |
| x_norm = (xx.float() + 0.5) / out_w |
| y_norm = (yy.float() + 0.5) / out_h |
| else: |
| x_norm = xx.float() / max(out_w - 1, 1) |
| y_norm = yy.float() / max(out_h - 1, 1) |
| coords = torch.stack( |
| ( |
| t / max(spec.frame_count - 1, 1), |
| x_norm, |
| y_norm, |
| ), |
| dim=-1, |
| ) |
| return coords.reshape(-1, 3) * 2.0 - 1.0 |
|
|
|
|
| def audio_coords(start_sample: int, sample_count: int, full_count: int, device: torch.device) -> torch.Tensor: |
| indices = torch.arange(start_sample, start_sample + sample_count, device=device, dtype=torch.float32) |
| return (indices / max(full_count - 1, 1)).unsqueeze(-1) * 2.0 - 1.0 |
|
|
|
|
| def adjust_image(image: np.ndarray, gamma: float, contrast: float) -> np.ndarray: |
| adjusted = np.clip((image - 0.5) * contrast + 0.5, 0.0, 1.0) |
| if gamma > 0 and abs(gamma - 1.0) > 1e-6: |
| adjusted = np.power(adjusted, 1.0 / gamma) |
| return adjusted |
|
|
|
|
| def apply_render_threshold( |
| image_u8: np.ndarray, |
| threshold: RenderThreshold, |
| previous_binary: np.ndarray | None, |
| hysteresis: int, |
| ) -> tuple[np.ndarray, np.ndarray | None]: |
| if threshold is None: |
| return image_u8, None |
|
|
| threshold_value: float | None = None |
| if threshold == "auto" or threshold == "otsu": |
| threshold_value, binary = cv2.threshold(image_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) |
| elif threshold == "adaptive": |
| block_size = max(3, (min(image_u8.shape) // 12) | 1) |
| binary = cv2.adaptiveThreshold( |
| image_u8, |
| 255, |
| cv2.ADAPTIVE_THRESH_GAUSSIAN_C, |
| cv2.THRESH_BINARY, |
| block_size, |
| 2, |
| ) |
| else: |
| threshold_value = float(threshold) |
| binary = np.where(image_u8 >= int(threshold), 255, 0).astype(np.uint8) |
|
|
| if previous_binary is not None and threshold_value is not None and hysteresis > 0: |
| distance = np.abs(image_u8.astype(np.int16) - int(round(threshold_value))) |
| ambiguous = distance <= hysteresis |
| binary[ambiguous] = previous_binary[ambiguous] |
| return binary.astype(np.uint8), binary.astype(np.uint8) |
|
|
|
|
| def render_video_frame( |
| model: nn.Module, |
| spec: VideoSpec, |
| frame_index: int, |
| device: torch.device, |
| scale: int, |
| supersample: int, |
| render_batch: int, |
| threshold: RenderThreshold, |
| gamma: float, |
| contrast: float, |
| previous_binary: np.ndarray | None, |
| threshold_hysteresis: int, |
| ) -> tuple[np.ndarray, np.ndarray | None]: |
| sample_scale = scale * supersample |
| sample_w = spec.width * sample_scale |
| sample_h = spec.height * sample_scale |
| out_w = spec.width * scale |
| out_h = spec.height * scale |
|
|
| coords = frame_coords( |
| frame_index, |
| spec, |
| scale, |
| device, |
| supersample=supersample, |
| pixel_centers=getattr(model, "pixel_centers", False), |
| ) |
| chunks = [] |
| for chunk in coords.split(render_batch): |
| chunks.append(torch.sigmoid(predict_video(model, chunk)).detach().cpu()) |
| image = torch.cat(chunks).reshape(sample_h, sample_w).numpy() |
| if supersample > 1: |
| image = cv2.resize(image, (out_w, out_h), interpolation=cv2.INTER_AREA) |
| image = adjust_image(image, gamma=gamma, contrast=contrast) |
| image_u8 = (image * 255.0).clip(0, 255).astype(np.uint8) |
| return apply_render_threshold(image_u8, threshold, previous_binary, threshold_hysteresis) |
|
|
|
|
| @torch.inference_mode() |
| def render_contact_sheet( |
| model: BadAppleMultiModalNet, |
| spec: VideoSpec, |
| output_path: Path, |
| device: torch.device, |
| tiles: int, |
| scale: int, |
| render_batch: int, |
| threshold: RenderThreshold, |
| ) -> Path: |
| was_training = model.training |
| model.eval() |
|
|
| indices = np.linspace(0, spec.frame_count - 1, tiles, dtype=int) |
| tile_w = spec.width * scale |
| tile_h = spec.height * scale |
| canvas = np.zeros((tile_h, tile_w * tiles), dtype=np.uint8) |
|
|
| for column, frame_index in enumerate(indices): |
| coords = frame_coords( |
| int(frame_index), |
| spec, |
| scale, |
| device, |
| pixel_centers=getattr(model, "pixel_centers", False), |
| ) |
| chunks = [] |
| for chunk in coords.split(render_batch): |
| chunks.append(torch.sigmoid(predict_video(model, chunk)).detach().cpu()) |
| image = torch.cat(chunks).reshape(tile_h, tile_w).numpy() |
| image_u8 = (image * 255.0).clip(0, 255).astype(np.uint8) |
| image_u8, _ = apply_render_threshold(image_u8, threshold, None, 0) |
| canvas[:, column * tile_w : (column + 1) * tile_w] = image_u8 |
|
|
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| cv2.imwrite(str(output_path), canvas) |
|
|
| if was_training: |
| model.train() |
| return output_path |
|
|
|
|
| @torch.inference_mode() |
| def render_audio_wav( |
| model: BadAppleMultiModalNet, |
| audio_spec: AudioSpec, |
| output_path: Path, |
| device: torch.device, |
| render_batch: int, |
| smooth_samples: int, |
| normalize: bool, |
| peak: float, |
| fade_ms: float, |
| start_seconds: float = 0.0, |
| duration_seconds: float | None = None, |
| ) -> Path: |
| if not has_audio_model(model): |
| raise RuntimeError("This checkpoint does not contain an audio model.") |
|
|
| was_training = model.training |
| model.eval() |
| start_sample = int(start_seconds * audio_spec.sample_rate) |
| if duration_seconds is None: |
| sample_count = audio_spec.sample_count - start_sample |
| else: |
| sample_count = int(duration_seconds * audio_spec.sample_rate) |
| sample_count = max(0, min(sample_count, audio_spec.sample_count - start_sample)) |
|
|
| pieces = [] |
| for start in range(0, sample_count, render_batch): |
| count = min(render_batch, sample_count - start) |
| coords = audio_coords(start_sample + start, count, audio_spec.sample_count, device) |
| pieces.append(predict_audio(model, coords).detach().cpu()) |
|
|
| audio = torch.cat(pieces).numpy() if pieces else np.zeros((0, audio_spec.channels), dtype=np.float32) |
| if audio.ndim == 1: |
| audio = audio[:, None] |
| if smooth_samples > 1 and audio.size >= smooth_samples: |
| kernel = np.ones(smooth_samples, dtype=np.float32) / smooth_samples |
| for channel in range(audio.shape[1]): |
| audio[:, channel] = np.convolve(audio[:, channel], kernel, mode="same") |
| if fade_ms > 0 and audio.shape[0] > 0: |
| fade_samples = min(audio.shape[0] // 2, int(audio_spec.sample_rate * fade_ms / 1000.0)) |
| if fade_samples > 0: |
| fade = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32)[:, None] |
| audio[:fade_samples] *= fade |
| audio[-fade_samples:] *= fade[::-1] |
| if normalize and audio.size > 0: |
| max_abs = float(np.max(np.abs(audio))) |
| if max_abs > 1e-8: |
| audio = audio / max_abs * min(max(peak, 0.0), 1.0) |
| audio_i16 = (np.clip(audio, -1.0, 1.0) * 32767.0).astype(np.int16) |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| with wave.open(str(output_path), "wb") as wav: |
| wav.setnchannels(audio.shape[1] if audio.ndim == 2 else audio_spec.channels) |
| wav.setsampwidth(2) |
| wav.setframerate(audio_spec.sample_rate) |
| wav.writeframes(audio_i16.tobytes()) |
|
|
| if was_training: |
| model.train() |
| return output_path |
|
|
|
|
| @torch.inference_mode() |
| def render_video_file( |
| model: BadAppleMultiModalNet, |
| spec: VideoSpec, |
| output_path: Path, |
| device: torch.device, |
| scale: int, |
| render_batch: int, |
| threshold: RenderThreshold, |
| render_log_every: int, |
| encoder: str, |
| crf: int, |
| preset: str, |
| tune: str, |
| supersample: int, |
| gamma: float, |
| contrast: float, |
| threshold_hysteresis: int, |
| start_frame: int = 0, |
| frame_count: int | None = None, |
| ) -> Path: |
| was_training = model.training |
| model.eval() |
| frame_count = spec.frame_count - start_frame if frame_count is None else frame_count |
| frame_count = max(0, min(frame_count, spec.frame_count - start_frame)) |
|
|
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| out_w = spec.width * scale |
| out_h = spec.height * scale |
| writer = None |
| ffmpeg_process: subprocess.Popen[bytes] | None = None |
| if encoder == "opencv": |
| writer = cv2.VideoWriter( |
| str(output_path), |
| cv2.VideoWriter_fourcc(*"mp4v"), |
| spec.fps, |
| (out_w, out_h), |
| isColor=False, |
| ) |
| if not writer.isOpened(): |
| raise RuntimeError(f"Could not open video writer: {output_path}") |
| elif encoder == "ffmpeg": |
| command = [ |
| "ffmpeg", |
| "-y", |
| "-v", |
| "error", |
| "-f", |
| "rawvideo", |
| "-pix_fmt", |
| "gray", |
| "-s", |
| f"{out_w}x{out_h}", |
| "-r", |
| f"{spec.fps:.6f}", |
| "-i", |
| "pipe:0", |
| "-an", |
| "-c:v", |
| "libx264", |
| "-preset", |
| preset, |
| "-crf", |
| str(crf), |
| "-tune", |
| tune, |
| "-pix_fmt", |
| "yuv420p", |
| "-movflags", |
| "+faststart", |
| str(output_path), |
| ] |
| ffmpeg_process = subprocess.Popen(command, stdin=subprocess.PIPE) |
| else: |
| raise ValueError(f"unknown video encoder: {encoder}") |
|
|
| print(f"rendering {frame_count} frames at {out_w}x{out_h} -> {output_path}") |
| previous_binary: np.ndarray | None = None |
| for offset in range(frame_count): |
| frame_index = start_frame + offset |
| image_u8, previous_binary = render_video_frame( |
| model=model, |
| spec=spec, |
| frame_index=frame_index, |
| device=device, |
| scale=scale, |
| supersample=supersample, |
| render_batch=render_batch, |
| threshold=threshold, |
| gamma=gamma, |
| contrast=contrast, |
| previous_binary=previous_binary, |
| threshold_hysteresis=threshold_hysteresis, |
| ) |
| if writer is not None: |
| writer.write(image_u8) |
| elif ffmpeg_process is not None and ffmpeg_process.stdin is not None: |
| ffmpeg_process.stdin.write(image_u8.tobytes()) |
|
|
| if offset == 0 or (offset + 1) % render_log_every == 0 or offset + 1 == frame_count: |
| print(f"rendered {offset + 1:>5}/{frame_count}") |
|
|
| if writer is not None: |
| writer.release() |
| if ffmpeg_process is not None: |
| if ffmpeg_process.stdin is not None: |
| ffmpeg_process.stdin.close() |
| return_code = ffmpeg_process.wait() |
| if return_code != 0: |
| raise RuntimeError(f"ffmpeg video encoding failed with exit code {return_code}") |
| if was_training: |
| model.train() |
| return output_path |
|
|
|
|
| def mux_wav_audio( |
| rendered_video: Path, |
| audio_wav: Path, |
| output_video: Path, |
| copy_video: bool, |
| audio_bitrate: str, |
| ) -> None: |
| command = [ |
| "ffmpeg", |
| "-y", |
| "-v", |
| "error", |
| "-i", |
| str(rendered_video), |
| "-i", |
| str(audio_wav), |
| "-map", |
| "0:v:0", |
| "-map", |
| "1:a:0", |
| "-c:v", |
| "copy" if copy_video else "libx264", |
| "-c:a", |
| "aac", |
| "-b:a", |
| audio_bitrate, |
| "-movflags", |
| "+faststart", |
| str(output_video), |
| ] |
| subprocess.run(command, check=True) |
|
|
|
|
| def mux_source_audio( |
| rendered_video: Path, |
| source_video: Path, |
| output_video: Path, |
| copy_video: bool, |
| duration_seconds: float | None, |
| ) -> None: |
| command = [ |
| "ffmpeg", |
| "-y", |
| "-v", |
| "error", |
| "-i", |
| str(rendered_video), |
| "-i", |
| str(source_video), |
| "-map", |
| "0:v:0", |
| "-map", |
| "1:a:0", |
| "-c:v", |
| "copy" if copy_video else "libx264", |
| "-c:a", |
| "copy", |
| ] |
| if duration_seconds is not None: |
| command.extend(["-t", f"{duration_seconds:.6f}"]) |
| command.extend(["-movflags", "+faststart"]) |
| command.append(str(output_video)) |
| subprocess.run(command, check=True) |
|
|
|
|
| def build_model_from_args(args: argparse.Namespace) -> BadAppleMultiModalNet: |
| return BadAppleMultiModalNet( |
| video_hidden=args.hidden, |
| video_layers=args.layers, |
| video_bands=args.bands, |
| audio_hidden=args.audio_hidden, |
| audio_layers=args.audio_layers, |
| audio_bands=args.audio_bands, |
| train_audio=args.train_audio, |
| audio_channels=args.audio_channels, |
| modality_embedding_dim=args.modality_embedding_dim, |
| temporal_latent_anchors=args.temporal_latent_anchors, |
| temporal_latent_dim=args.temporal_latent_dim, |
| pixel_centers=True, |
| gradient_checkpointing=args.gradient_checkpointing, |
| ) |
|
|
|
|
| def build_model_from_checkpoint(ckpt: dict) -> nn.Module: |
| if ckpt.get("architecture") in {"unified-v2", "unified-v3", "unified-v3-int8"}: |
| audio_spec = ckpt.get("audio_spec") |
| audio_channels = ckpt.get("audio_channels") |
| if audio_channels is None and audio_spec is not None: |
| audio_channels = audio_spec.get("channels", 1) |
| return BadAppleMultiModalNet( |
| video_hidden=ckpt["video_hidden"], |
| video_layers=ckpt["video_layers"], |
| video_bands=ckpt["video_bands"], |
| audio_hidden=ckpt["audio_hidden"], |
| audio_layers=ckpt["audio_layers"], |
| audio_bands=ckpt["audio_bands"], |
| train_audio=audio_spec is not None, |
| audio_channels=audio_channels or 1, |
| modality_embedding_dim=ckpt.get("modality_embedding_dim", 8), |
| temporal_latent_anchors=ckpt.get("temporal_latent_anchors", 512), |
| temporal_latent_dim=ckpt.get("temporal_latent_dim", 32), |
| pixel_centers=ckpt.get("pixel_centers", False), |
| gradient_checkpointing=False, |
| ) |
| if ckpt.get("architecture") != "unified": |
| return LegacyBadAppleMultiModalNet( |
| video_hidden=ckpt["video_hidden"], |
| video_layers=ckpt["video_layers"], |
| video_bands=ckpt["video_bands"], |
| audio_hidden=ckpt["audio_hidden"], |
| audio_layers=ckpt["audio_layers"], |
| audio_bands=ckpt["audio_bands"], |
| train_audio=ckpt["audio_spec"] is not None, |
| ) |
|
|
| audio_spec = ckpt.get("audio_spec") |
| audio_channels = ckpt.get("audio_channels") |
| if audio_channels is None and audio_spec is not None: |
| audio_channels = audio_spec.get("channels", 1) |
| return UnifiedV1BadAppleMultiModalNet( |
| video_hidden=ckpt["video_hidden"], |
| video_layers=ckpt["video_layers"], |
| video_bands=ckpt["video_bands"], |
| audio_hidden=ckpt["audio_hidden"], |
| audio_layers=ckpt["audio_layers"], |
| audio_bands=ckpt["audio_bands"], |
| train_audio=ckpt["audio_spec"] is not None, |
| audio_channels=audio_channels or 1, |
| modality_embedding_dim=ckpt.get("modality_embedding_dim", 8), |
| ) |
|
|
|
|
| def autocast_context(device: torch.device, enabled: bool) -> contextlib.AbstractContextManager: |
| if enabled and device.type == "cuda": |
| return torch.autocast(device_type=device.type) |
| return contextlib.nullcontext() |
|
|
|
|
| def make_grad_scaler(use_amp: bool) -> torch.amp.GradScaler: |
| if hasattr(torch, "amp") and hasattr(torch.amp, "GradScaler"): |
| return torch.amp.GradScaler("cuda", enabled=use_amp) |
| return torch.cuda.amp.GradScaler(enabled=use_amp) |
|
|
|
|
| def state_dict_to_cpu(state_dict: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: |
| return {key: value.detach().cpu() for key, value in state_dict.items()} |
|
|
|
|
| def pack_int8_state_dict(model: nn.Module, state_dict: dict[str, torch.Tensor]) -> dict: |
| linear_weights = { |
| f"{name}.weight" if name else "weight" |
| for name, module in model.named_modules() |
| if isinstance(module, QATLinear) |
| } |
| packed: dict[str, object] = {} |
| for key, value in state_dict.items(): |
| tensor = value.detach().cpu() |
| if key in linear_weights: |
| max_abs = tensor.float().abs().amax(dim=1).clamp_min(1e-8) |
| scale = max_abs / 127.0 |
| quantized = torch.round(tensor.float() / scale[:, None]).clamp(-127, 127).to(torch.int8) |
| packed[key] = {"int8": quantized, "scale": scale.to(torch.float16)} |
| elif tensor.is_floating_point(): |
| max_abs = float(tensor.float().abs().max()) if tensor.numel() else 0.0 |
| packed[key] = tensor.float() if max_abs > 65000.0 else tensor.to(torch.float16) |
| else: |
| packed[key] = tensor |
| return packed |
|
|
|
|
| def unpack_int8_state_dict(packed: dict) -> dict[str, torch.Tensor]: |
| unpacked: dict[str, torch.Tensor] = {} |
| for key, value in packed.items(): |
| if isinstance(value, dict) and "int8" in value: |
| unpacked[key] = value["int8"].float() * value["scale"].float()[:, None] |
| elif isinstance(value, torch.Tensor) and value.is_floating_point(): |
| unpacked[key] = value.float() |
| else: |
| unpacked[key] = value |
| return unpacked |
|
|
|
|
| def checkpoint_model_state(ckpt: dict, use_ema: bool) -> tuple[dict[str, torch.Tensor], str]: |
| if ckpt.get("quantized_model") is not None: |
| return unpack_int8_state_dict(ckpt["quantized_model"]), "quantized_model" |
| state_key = "ema_model" if use_ema and ckpt.get("ema_model") is not None else "model" |
| return ckpt[state_key], state_key |
|
|
|
|
| def init_ema_state(model: nn.Module) -> dict[str, torch.Tensor]: |
| return {key: value.detach().clone() for key, value in model.state_dict().items()} |
|
|
|
|
| @torch.no_grad() |
| def update_ema_state(model: nn.Module, ema_state: dict[str, torch.Tensor], decay: float) -> None: |
| current = model.state_dict() |
| for key, value in current.items(): |
| if value.is_floating_point(): |
| ema_state[key].mul_(decay).add_(value.detach(), alpha=1.0 - decay) |
| else: |
| ema_state[key].copy_(value) |
|
|
|
|
| def checkpoint_payload( |
| model: nn.Module, |
| args: argparse.Namespace, |
| video_spec: VideoSpec, |
| audio_spec: AudioSpec | None, |
| frames_path: Path, |
| audio_path: Path | None, |
| ema_state: dict[str, torch.Tensor] | None, |
| step: int, |
| best_val_loss: float | None, |
| ) -> dict: |
| return { |
| "architecture": "unified-v3", |
| "model": state_dict_to_cpu(model.state_dict()), |
| "ema_model": state_dict_to_cpu(ema_state) if ema_state is not None else None, |
| "step": step, |
| "best_val_loss": best_val_loss, |
| "video_hidden": args.hidden, |
| "video_layers": args.layers, |
| "video_bands": args.bands, |
| "audio_hidden": args.audio_hidden, |
| "audio_layers": args.audio_layers, |
| "audio_bands": args.audio_bands, |
| "audio_channels": audio_spec.channels if audio_spec is not None else args.audio_channels, |
| "modality_embedding_dim": args.modality_embedding_dim, |
| "temporal_latent_anchors": args.temporal_latent_anchors, |
| "temporal_latent_dim": args.temporal_latent_dim, |
| "pixel_centers": True, |
| "video_spec": asdict(video_spec), |
| "audio_spec": asdict(audio_spec) if audio_spec is not None else None, |
| "frames_cache": str(frames_path), |
| "audio_cache": str(audio_path) if audio_path is not None else None, |
| "source_video": str(Path(args.video).resolve()), |
| } |
|
|
|
|
| def save_checkpoint(path: Path, payload: dict) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| tmp_path = path.with_name(f"{path.name}.tmp") |
| torch.save(payload, tmp_path) |
| tmp_path.replace(path) |
|
|
|
|
| @torch.inference_mode() |
| def evaluate_video_quality( |
| model: nn.Module, |
| coords: torch.Tensor, |
| targets: torch.Tensor, |
| pos_weight: torch.Tensor | None, |
| ) -> tuple[float, float, float]: |
| was_training = model.training |
| model.eval() |
| logits = predict_video(model, coords) |
| loss = F.binary_cross_entropy_with_logits(logits, targets, pos_weight=pos_weight) |
| pred = (torch.sigmoid(logits) >= 0.5).float() |
| acc = (pred == (targets >= 0.5)).float().mean() |
| target_binary = targets >= 0.5 |
| pred_binary = pred >= 0.5 |
| intersection = torch.logical_and(pred_binary, target_binary).sum() |
| union = torch.logical_or(pred_binary, target_binary).sum() |
| iou = intersection.float() / union.clamp_min(1).float() |
| if was_training: |
| model.train() |
| return float(loss.item()), float(acc.item()), float(iou.item()) |
|
|
|
|
| def train(args: argparse.Namespace) -> Path: |
| set_seed(args.seed) |
| frames_path, spec_path, audio_path, audio_spec_path = prepare_dataset(args) |
| frames = np.load(frames_path, mmap_mode="r") |
| video_spec = load_video_spec(spec_path) |
| audio = np.load(audio_path, mmap_mode="r") if audio_path is not None else None |
| audio_spec = load_audio_spec(audio_spec_path) if audio_spec_path is not None else None |
|
|
| device = choose_device(args.device) |
| model = build_model_from_args(args).to(device) |
| if args.resume is not None: |
| resumed = torch.load(Path(args.resume).resolve(), map_location="cpu") |
| state_key = "ema_model" if args.resume_ema and resumed.get("ema_model") is not None else "model" |
| model.load_state_dict(resumed[state_key]) |
| print(f"resumed model weights from {args.resume} ({state_key})") |
| optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay) |
| use_amp = args.amp and device.type == "cuda" |
| scaler = make_grad_scaler(use_amp) |
| ema_state = init_ema_state(model) if args.ema_decay > 0 else None |
| video_pos_weight = None |
| if args.balanced_video_loss: |
| white_fraction = video_white_fraction(frames) |
| if 1e-4 < white_fraction < 1.0 - 1e-4: |
| video_pos_weight = torch.tensor((1.0 - white_fraction) / white_fraction, device=device) |
| print(f"balanced video loss: white_fraction={white_fraction:.4f} pos_weight={video_pos_weight.item():.2f}") |
| else: |
| print(f"balanced video loss disabled: white_fraction={white_fraction:.4f} has only one class") |
|
|
| edge_indices = None |
| if args.edge_sample_fraction > 0: |
| edge_indices = build_edge_indices(frames, args.edge_threshold) |
| print( |
| f"edge sampler: {edge_indices.shape[0]:,} candidate edge pixels " |
| f"({edge_indices.nbytes / (1024 ** 2):.1f} MiB)" |
| ) |
|
|
| motion_weights = None |
| if args.motion_sample_fraction > 0: |
| motion_weights = torch.from_numpy(build_motion_frame_weights(frames)).to(device) |
| uniform_weight = torch.full_like(motion_weights, 1.0 / motion_weights.numel()) |
| motion_weights = torch.lerp(uniform_weight, motion_weights, args.motion_sample_fraction) |
| print(f"motion sampler: enabled at {args.motion_sample_fraction:.0%} strength") |
|
|
| evaluation_coords, evaluation_targets = make_evaluation_batch( |
| frames, |
| args.val_batch_size, |
| device, |
| args.seed + 17, |
| ) |
|
|
| print( |
| f"training on {device}: {video_spec.frame_count} frames, " |
| f"{video_spec.width}x{video_spec.height}, {sum(p.numel() for p in model.parameters()):,} parameters" |
| ) |
| print( |
| f"batches: video={args.batch_size:,} pairs={args.video_pair_batch_size:,} " |
| f"audio={args.audio_batch_size:,} checkpointing={args.gradient_checkpointing}" |
| ) |
| if audio_spec is not None: |
| print( |
| f"audio target: {audio_spec.sample_count} samples at " |
| f"{audio_spec.sample_rate} Hz, {audio_spec.channels} channel(s)" |
| ) |
| print( |
| f"lr schedule: base={args.lr:g} warmup={args.warmup_steps} " |
| f"decay={args.lr_decay} min_ratio={args.min_lr_ratio:g}" |
| ) |
|
|
| output_dir = Path(args.output_dir).resolve() |
| output_dir.mkdir(parents=True, exist_ok=True) |
| photo_dir = output_dir / "photo_samples" |
| video_dir = output_dir / "video_samples" |
| audio_dir = output_dir / "audio_samples" |
| ckpt_path = output_dir / f"{frames_path.stem}_multimodal.pt" |
| best_ckpt_path = output_dir / f"{frames_path.stem}_multimodal_best.pt" |
| best_val_loss: float | None = None |
| best_iou = -1.0 |
|
|
| model.train() |
| for step in range(1, args.steps + 1): |
| current_lr = args.lr * lr_scale_for_step(step, args) |
| set_optimizer_lr(optimizer, current_lr) |
|
|
| video_coords, video_targets = sample_video_batch( |
| frames, |
| args.batch_size, |
| device, |
| edge_indices=edge_indices, |
| edge_fraction=args.edge_sample_fraction, |
| frame_weights=motion_weights, |
| ) |
| audio_loss = None |
| video_temporal_loss = None |
| video_spatial_loss = None |
| align_loss = None |
|
|
| optimizer.zero_grad(set_to_none=True) |
| with autocast_context(device, use_amp): |
| video_logits = predict_video(model, video_coords) |
| video_loss = F.binary_cross_entropy_with_logits(video_logits, video_targets, pos_weight=video_pos_weight) |
| loss = video_loss |
| if args.video_dice_loss_weight > 0: |
| loss = loss + args.video_dice_loss_weight * soft_dice_loss(video_logits, video_targets) |
|
|
| if args.video_temporal_loss_weight > 0: |
| temporal_batch = sample_video_pair_batch( |
| frames, |
| args.video_pair_batch_size, |
| device, |
| mode="temporal", |
| frame_weights=motion_weights, |
| ) |
| if temporal_batch is not None: |
| coords0, coords1, target0, target1 = temporal_batch |
| pred_delta = torch.sigmoid(predict_video(model, coords1)) - torch.sigmoid( |
| predict_video(model, coords0) |
| ) |
| target_delta = target1 - target0 |
| video_temporal_loss = F.mse_loss(pred_delta, target_delta) |
| loss = loss + args.video_temporal_loss_weight * video_temporal_loss |
|
|
| if args.video_spatial_loss_weight > 0: |
| spatial_batch = sample_video_pair_batch( |
| frames, |
| args.video_pair_batch_size, |
| device, |
| mode="spatial", |
| ) |
| if spatial_batch is not None: |
| coords0, coords1, target0, target1 = spatial_batch |
| pred_delta = torch.sigmoid(predict_video(model, coords1)) - torch.sigmoid( |
| predict_video(model, coords0) |
| ) |
| target_delta = target1 - target0 |
| video_spatial_loss = F.mse_loss(pred_delta, target_delta) |
| loss = loss + args.video_spatial_loss_weight * video_spatial_loss |
|
|
| train_audio_this_step = ( |
| audio is not None |
| and has_audio_model(model) |
| and step >= args.audio_start_step |
| and args.audio_loss_weight > 0 |
| ) |
| if train_audio_this_step: |
| audio_coords_batch, audio_targets = sample_audio_batch( |
| audio, |
| args.audio_batch_size, |
| device, |
| args.audio_chunk_samples, |
| ) |
| audio_pred = predict_audio(model, audio_coords_batch) |
| audio_loss = F.mse_loss(audio_pred, audio_targets) |
| if args.audio_chunk_samples > 1: |
| usable = (audio_pred.shape[0] // args.audio_chunk_samples) * args.audio_chunk_samples |
| pred_chunks = audio_pred[:usable].reshape(-1, args.audio_chunk_samples, audio_pred.shape[-1]) |
| target_chunks = audio_targets[:usable].reshape(-1, args.audio_chunk_samples, audio_targets.shape[-1]) |
| if args.audio_derivative_loss_weight > 0: |
| pred_delta = pred_chunks[:, 1:] - pred_chunks[:, :-1] |
| target_delta = target_chunks[:, 1:] - target_chunks[:, :-1] |
| audio_loss = audio_loss + args.audio_derivative_loss_weight * F.mse_loss( |
| pred_delta, |
| target_delta, |
| ) |
| if args.audio_stft_loss_weight > 0 and usable > 0: |
| audio_loss = audio_loss + args.audio_stft_loss_weight * audio_stft_loss( |
| pred_chunks, |
| target_chunks, |
| n_fft=args.audio_stft_n_fft, |
| hop_length=args.audio_stft_hop_length, |
| ) |
| audio_ramp = min(1.0, (step - args.audio_start_step + 1) / args.audio_loss_ramp_steps) |
| loss = loss + args.audio_loss_weight * audio_ramp * audio_loss |
|
|
| if ( |
| args.cross_modal_loss_weight > 0 |
| and audio is not None |
| and has_audio_model(model) |
| and hasattr(model, "modality_features") |
| ): |
| times = torch.rand((args.cross_modal_batch_size, 1), device=device) * 2.0 - 1.0 |
| zeros = torch.zeros((args.cross_modal_batch_size, 2), device=device) |
| video_features = model.modality_features(torch.cat((times, zeros), dim=-1), 0) |
| audio_features = model.modality_features(times, 1) |
| align_loss = F.mse_loss(video_features, audio_features) |
| loss = loss + args.cross_modal_loss_weight * align_loss |
|
|
| scaler.scale(loss).backward() |
| if args.grad_clip > 0: |
| scaler.unscale_(optimizer) |
| torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) |
| scaler.step(optimizer) |
| scaler.update() |
| if ema_state is not None: |
| update_ema_state(model, ema_state, args.ema_decay) |
|
|
| if step == 1 or step % args.log_every == 0 or step == args.steps: |
| with torch.no_grad(): |
| pred = (torch.sigmoid(video_logits) >= 0.5).float() |
| acc = (pred == (video_targets >= 0.5)).float().mean().item() |
| audio_text = "" if audio_loss is None else f" audio_loss={audio_loss.item():.5f}" |
| temporal_text = "" if video_temporal_loss is None else f" temporal={video_temporal_loss.item():.5f}" |
| spatial_text = "" if video_spatial_loss is None else f" spatial={video_spatial_loss.item():.5f}" |
| align_text = "" if align_loss is None else f" align={align_loss.item():.5f}" |
| print( |
| f"step {step:>6}/{args.steps} lr={current_lr:.3g} loss={loss.item():.5f} " |
| f"video_loss={video_loss.item():.5f}{audio_text}{temporal_text}" |
| f"{spatial_text}{align_text} pixel_acc={acc:.3f}" |
| ) |
|
|
| if args.val_every > 0 and (step == 1 or step % args.val_every == 0 or step == args.steps): |
| val_loss, val_acc, val_iou = evaluate_video_quality( |
| model, |
| evaluation_coords, |
| evaluation_targets, |
| video_pos_weight, |
| ) |
| print(f"evaluation video_loss={val_loss:.5f} pixel_acc={val_acc:.3f} silhouette_iou={val_iou:.3f}") |
| if val_iou > best_iou or (math.isclose(val_iou, best_iou) and (best_val_loss is None or val_loss < best_val_loss)): |
| best_val_loss = val_loss |
| best_iou = val_iou |
| save_checkpoint( |
| best_ckpt_path, |
| checkpoint_payload( |
| model=model, |
| args=args, |
| video_spec=video_spec, |
| audio_spec=audio_spec, |
| frames_path=frames_path, |
| audio_path=audio_path, |
| ema_state=ema_state, |
| step=step, |
| best_val_loss=best_val_loss, |
| ), |
| ) |
| print(f"saved best checkpoint -> {best_ckpt_path}") |
|
|
| if args.photo_sample_every > 0 and (step % args.photo_sample_every == 0 or step == args.steps): |
| sample_path = photo_dir / f"{frames_path.stem}_step_{step:06d}.png" |
| photo_threshold = args.photo_sample_threshold |
| if photo_threshold == "calibrated": |
| photo_threshold, photo_iou = calibrate_render_threshold( |
| model, frames, args.val_batch_size, device, args.render_gamma, args.render_contrast, args.seed + 17 |
| ) |
| print(f"calibrated photo threshold={photo_threshold} silhouette_iou={photo_iou:.3f}") |
| render_contact_sheet( |
| model=model, |
| spec=video_spec, |
| output_path=sample_path, |
| device=device, |
| tiles=args.photo_sample_tiles, |
| scale=args.photo_sample_scale, |
| render_batch=args.render_batch, |
| threshold=photo_threshold, |
| ) |
| print(f"photo sample -> {sample_path}") |
|
|
| if args.video_sample_every > 0 and (step % args.video_sample_every == 0 or step == args.steps): |
| sample_name = f"{frames_path.stem}_step_{step:06d}" |
| raw_video = video_dir / f"{sample_name}_raw.mp4" |
| final_video = video_dir / f"{sample_name}.mp4" |
| if args.video_sample_seconds <= 0: |
| sample_frames = video_spec.frame_count |
| else: |
| sample_frames = min(video_spec.frame_count, int(args.video_sample_seconds * video_spec.fps)) |
| video_threshold = args.video_sample_threshold |
| if video_threshold == "calibrated": |
| video_threshold, video_iou = calibrate_render_threshold( |
| model, frames, args.val_batch_size, device, args.render_gamma, args.render_contrast, args.seed + 17 |
| ) |
| print(f"calibrated video threshold={video_threshold} silhouette_iou={video_iou:.3f}") |
| render_video_file( |
| model=model, |
| spec=video_spec, |
| output_path=raw_video, |
| device=device, |
| scale=args.video_sample_scale, |
| render_batch=args.render_batch, |
| threshold=video_threshold, |
| render_log_every=args.render_log_every, |
| encoder=args.video_encoder, |
| crf=args.video_crf, |
| preset=args.video_preset, |
| tune=args.video_tune, |
| supersample=args.video_sample_supersample, |
| gamma=args.render_gamma, |
| contrast=args.render_contrast, |
| threshold_hysteresis=args.render_threshold_hysteresis, |
| start_frame=0, |
| frame_count=sample_frames, |
| ) |
| if audio_spec is not None and has_audio_model(model) and step >= args.audio_start_step: |
| audio_wav = audio_dir / f"{sample_name}.wav" |
| render_audio_wav( |
| model=model, |
| audio_spec=audio_spec, |
| output_path=audio_wav, |
| device=device, |
| render_batch=args.audio_render_batch, |
| smooth_samples=args.audio_render_smooth_samples, |
| normalize=args.audio_normalize, |
| peak=args.audio_peak, |
| fade_ms=args.audio_fade_ms, |
| duration_seconds=sample_frames / video_spec.fps, |
| ) |
| mux_wav_audio( |
| raw_video, |
| audio_wav, |
| final_video, |
| copy_video=args.mux_copy_video, |
| audio_bitrate=args.audio_bitrate, |
| ) |
| print(f"video sample with generated audio -> {final_video}") |
| else: |
| print(f"video sample -> {raw_video}") |
|
|
| save_checkpoint( |
| ckpt_path, |
| checkpoint_payload( |
| model=model, |
| args=args, |
| video_spec=video_spec, |
| audio_spec=audio_spec, |
| frames_path=frames_path, |
| audio_path=audio_path, |
| ema_state=ema_state, |
| step=args.steps, |
| best_val_loss=best_val_loss, |
| ), |
| ) |
| print(f"saved checkpoint -> {ckpt_path}") |
|
|
| if args.render: |
| render_from_checkpoint(ckpt_path, args) |
|
|
| return ckpt_path |
|
|
|
|
| def distilled_checkpoint_payload( |
| model: nn.Module, |
| args: argparse.Namespace, |
| teacher_path: Path, |
| teacher_ckpt: dict, |
| video_spec: VideoSpec, |
| audio_spec: AudioSpec | None, |
| frames_path: Path, |
| audio_path: Path | None, |
| step: int, |
| best_score: float | None, |
| quantized: bool, |
| ) -> dict: |
| payload = checkpoint_payload( |
| model=model, |
| args=args, |
| video_spec=video_spec, |
| audio_spec=audio_spec, |
| frames_path=frames_path, |
| audio_path=audio_path, |
| ema_state=None, |
| step=step, |
| best_val_loss=best_score, |
| ) |
| payload["distilled_from"] = str(teacher_path) |
| payload["teacher_step"] = teacher_ckpt.get("step") |
| payload["qat"] = bool(args.qat) |
| if quantized: |
| payload["architecture"] = "unified-v3-int8" |
| payload["quantized_model"] = pack_int8_state_dict(model, model.state_dict()) |
| payload.pop("model", None) |
| payload.pop("ema_model", None) |
| return payload |
|
|
|
|
| @torch.inference_mode() |
| def evaluate_distilled_model( |
| student: nn.Module, |
| teacher: nn.Module, |
| video_coords: torch.Tensor, |
| video_targets: torch.Tensor, |
| audio_coords_batch: torch.Tensor | None, |
| audio_weight: float, |
| ) -> tuple[float, float, float, float | None]: |
| was_training = student.training |
| student.eval() |
| teacher_video = torch.sigmoid(predict_video(teacher, video_coords)) |
| student_video = torch.sigmoid(predict_video(student, video_coords)) |
| video_mse = F.mse_loss(student_video, teacher_video) |
| student_binary = student_video >= 0.5 |
| teacher_binary = teacher_video >= 0.5 |
| agreement = (student_binary == teacher_binary).float().mean() |
| target_binary = video_targets >= 0.5 |
| intersection = torch.logical_and(student_binary, target_binary).sum() |
| union = torch.logical_or(student_binary, target_binary).sum() |
| iou = intersection.float() / union.clamp_min(1).float() |
| audio_mse = None |
| score = video_mse |
| if audio_coords_batch is not None and has_audio_model(student) and has_audio_model(teacher): |
| teacher_audio = predict_audio(teacher, audio_coords_batch) |
| student_audio = predict_audio(student, audio_coords_batch) |
| audio_mse = F.mse_loss(student_audio, teacher_audio) |
| score = score + audio_weight * audio_mse |
| if was_training: |
| student.train() |
| return ( |
| float(score.item()), |
| float(agreement.item()), |
| float(iou.item()), |
| None if audio_mse is None else float(audio_mse.item()), |
| ) |
|
|
|
|
| def distill(args: argparse.Namespace) -> Path: |
| set_seed(args.seed) |
| teacher_path = Path(args.teacher).resolve() |
| teacher_ckpt = torch.load(teacher_path, map_location="cpu") |
| video_spec = VideoSpec(**teacher_ckpt["video_spec"]) |
| audio_spec = AudioSpec(**teacher_ckpt["audio_spec"]) if teacher_ckpt.get("audio_spec") is not None else None |
| frames_path = Path(teacher_ckpt["frames_cache"]) |
| audio_path = Path(teacher_ckpt["audio_cache"]) if teacher_ckpt.get("audio_cache") else None |
| if not frames_path.exists(): |
| raise FileNotFoundError(f"Teacher frame cache is unavailable: {frames_path}") |
| if audio_spec is not None and (audio_path is None or not audio_path.exists()): |
| raise FileNotFoundError(f"Teacher audio cache is unavailable: {audio_path}") |
|
|
| frames = np.load(frames_path, mmap_mode="r") |
| audio = np.load(audio_path, mmap_mode="r") if audio_path is not None else None |
| args.video = teacher_ckpt.get("source_video", args.video) |
| args.train_audio = audio_spec is not None |
| args.audio_channels = audio_spec.channels if audio_spec is not None else 1 |
|
|
| device = choose_device(args.device) |
| teacher = build_model_from_checkpoint(teacher_ckpt).to(device) |
| teacher_state, teacher_state_name = checkpoint_model_state(teacher_ckpt, use_ema=True) |
| teacher.load_state_dict(teacher_state) |
| teacher.eval() |
| for parameter in teacher.parameters(): |
| parameter.requires_grad_(False) |
|
|
| student = build_model_from_args(args).to(device) |
| optimizer = torch.optim.AdamW(student.parameters(), lr=args.lr, weight_decay=args.weight_decay) |
| use_amp = args.amp and device.type == "cuda" |
| scaler = make_grad_scaler(use_amp) |
| qat_start_step = args.qat_start_step or max(1, int(args.steps * 0.8)) |
|
|
| edge_indices = None |
| if args.edge_sample_fraction > 0: |
| edge_indices = build_edge_indices(frames, args.edge_threshold) |
| motion_weights = None |
| if args.motion_sample_fraction > 0: |
| motion_weights = torch.from_numpy(build_motion_frame_weights(frames)).to(device) |
| uniform_weight = torch.full_like(motion_weights, 1.0 / motion_weights.numel()) |
| motion_weights = torch.lerp(uniform_weight, motion_weights, args.motion_sample_fraction) |
|
|
| evaluation_coords, evaluation_targets = make_evaluation_batch( |
| frames, |
| args.val_batch_size, |
| device, |
| args.seed + 29, |
| ) |
| evaluation_audio_coords = None |
| if audio is not None: |
| evaluation_audio_coords, _ = sample_audio_batch( |
| audio, |
| args.audio_val_batch_size, |
| device, |
| chunk_samples=1, |
| ) |
|
|
| output_dir = Path(args.output_dir).resolve() |
| output_dir.mkdir(parents=True, exist_ok=True) |
| base_name = teacher_path.stem.removesuffix("_multimodal_best").removesuffix("_multimodal") |
| latest_path = output_dir / f"{base_name}_distilled_latest.pt" |
| best_path = output_dir / f"{base_name}_distilled_best.pt" |
| int8_path = output_dir / f"{base_name}_distilled_int8.pt" |
| final_int8_path = output_dir / f"{base_name}_distilled_int8_final.pt" |
| best_score: float | None = None |
| best_qat_score: float | None = None |
|
|
| student_params = sum(parameter.numel() for parameter in student.parameters()) |
| teacher_params = sum(parameter.numel() for parameter in teacher.parameters()) |
| print( |
| f"distilling {teacher_state_name} on {device}: teacher={teacher_params:,} params " |
| f"student={student_params:,} params ({student_params / teacher_params:.1%})" |
| ) |
| print(f"QAT starts at step {qat_start_step:,}; INT8 target={int8_path}") |
|
|
| student.train() |
| qat_active = False |
| for step in range(1, args.steps + 1): |
| if args.qat and not qat_active and step >= qat_start_step: |
| qat_active = True |
| set_qat_enabled(student, True) |
| print(f"enabled INT8 fake quantization at step {step:,}") |
|
|
| current_lr = args.lr * lr_scale_for_step(step, args) |
| set_optimizer_lr(optimizer, current_lr) |
| video_coords, video_targets = sample_video_batch( |
| frames, |
| args.batch_size, |
| device, |
| edge_indices=edge_indices, |
| edge_fraction=args.edge_sample_fraction, |
| frame_weights=motion_weights, |
| ) |
|
|
| optimizer.zero_grad(set_to_none=True) |
| with torch.no_grad(), autocast_context(device, use_amp): |
| teacher_video_logits = predict_video(teacher, video_coords) |
| with autocast_context(device, use_amp): |
| student_video_logits = predict_video(student, video_coords) |
| temperature = args.distill_temperature |
| teacher_soft = torch.sigmoid(teacher_video_logits / temperature) |
| soft_video_loss = F.binary_cross_entropy_with_logits( |
| student_video_logits / temperature, |
| teacher_soft, |
| ) * (temperature ** 2) |
| hard_video_loss = F.binary_cross_entropy_with_logits(student_video_logits, video_targets) |
| video_loss = (1.0 - args.hard_target_weight) * soft_video_loss + args.hard_target_weight * hard_video_loss |
| loss = video_loss |
|
|
| audio_loss = None |
| if audio is not None and has_audio_model(student): |
| audio_coords_batch, audio_targets = sample_audio_batch( |
| audio, |
| args.audio_batch_size, |
| device, |
| args.audio_chunk_samples, |
| ) |
| with torch.no_grad(): |
| teacher_audio = predict_audio(teacher, audio_coords_batch) |
| student_audio = predict_audio(student, audio_coords_batch) |
| soft_audio_loss = F.mse_loss(student_audio, teacher_audio) |
| hard_audio_loss = F.mse_loss(student_audio, audio_targets) |
| audio_loss = ( |
| (1.0 - args.hard_target_weight) * soft_audio_loss |
| + args.hard_target_weight * hard_audio_loss |
| ) |
| usable = (student_audio.shape[0] // args.audio_chunk_samples) * args.audio_chunk_samples |
| if usable > 0 and args.audio_chunk_samples > 1: |
| student_chunks = student_audio[:usable].reshape(-1, args.audio_chunk_samples, student_audio.shape[-1]) |
| teacher_chunks = teacher_audio[:usable].reshape(-1, args.audio_chunk_samples, teacher_audio.shape[-1]) |
| if args.audio_derivative_loss_weight > 0: |
| student_delta = student_chunks[:, 1:] - student_chunks[:, :-1] |
| teacher_delta = teacher_chunks[:, 1:] - teacher_chunks[:, :-1] |
| audio_loss = audio_loss + args.audio_derivative_loss_weight * F.mse_loss( |
| student_delta, |
| teacher_delta, |
| ) |
| if args.audio_stft_loss_weight > 0: |
| audio_loss = audio_loss + args.audio_stft_loss_weight * audio_stft_loss( |
| student_chunks, |
| teacher_chunks, |
| args.audio_stft_n_fft, |
| args.audio_stft_hop_length, |
| ) |
| loss = loss + args.audio_loss_weight * audio_loss |
|
|
| scaler.scale(loss).backward() |
| if args.grad_clip > 0: |
| scaler.unscale_(optimizer) |
| torch.nn.utils.clip_grad_norm_(student.parameters(), args.grad_clip) |
| scaler.step(optimizer) |
| scaler.update() |
|
|
| if step == 1 or step % args.log_every == 0 or step == args.steps: |
| audio_text = "" if audio_loss is None else f" audio={audio_loss.item():.5f}" |
| print( |
| f"distill {step:>6}/{args.steps} lr={current_lr:.3g} loss={loss.item():.5f} " |
| f"video={video_loss.item():.5f}{audio_text} qat={qat_active}" |
| ) |
|
|
| if args.val_every > 0 and (step % args.val_every == 0 or step == args.steps): |
| score, agreement, iou, audio_mse = evaluate_distilled_model( |
| student, |
| teacher, |
| evaluation_coords, |
| evaluation_targets, |
| evaluation_audio_coords, |
| args.audio_loss_weight, |
| ) |
| audio_text = "" if audio_mse is None else f" audio_teacher_mse={audio_mse:.6f}" |
| print( |
| f"distill evaluation score={score:.6f} teacher_agreement={agreement:.4f} " |
| f"silhouette_iou={iou:.4f}{audio_text}" |
| ) |
| payload = distilled_checkpoint_payload( |
| student, |
| args, |
| teacher_path, |
| teacher_ckpt, |
| video_spec, |
| audio_spec, |
| frames_path, |
| audio_path, |
| step, |
| score, |
| quantized=False, |
| ) |
| save_checkpoint(latest_path, payload) |
| if best_score is None or score < best_score: |
| best_score = score |
| save_checkpoint(best_path, payload) |
| print(f"saved best distilled checkpoint -> {best_path}") |
| if qat_active and (best_qat_score is None or score < best_qat_score): |
| best_qat_score = score |
| int8_payload = distilled_checkpoint_payload( |
| student, |
| args, |
| teacher_path, |
| teacher_ckpt, |
| video_spec, |
| audio_spec, |
| frames_path, |
| audio_path, |
| step, |
| score, |
| quantized=True, |
| ) |
| save_checkpoint(int8_path, int8_payload) |
| print(f"saved INT8 distilled checkpoint -> {int8_path}") |
| student.train() |
|
|
| final_payload = distilled_checkpoint_payload( |
| student, |
| args, |
| teacher_path, |
| teacher_ckpt, |
| video_spec, |
| audio_spec, |
| frames_path, |
| audio_path, |
| args.steps, |
| best_score, |
| quantized=args.qat, |
| ) |
| final_path = final_int8_path if args.qat else latest_path |
| save_checkpoint(final_path, final_payload) |
| print(f"saved distilled model -> {final_path} ({final_path.stat().st_size / (1024 ** 2):.2f} MiB)") |
| selected_path = int8_path if args.qat and int8_path.exists() else final_path |
| if args.render: |
| render_from_checkpoint(selected_path, args) |
| return selected_path |
|
|
|
|
| @torch.inference_mode() |
| def render_from_checkpoint(ckpt_path: Path, args: argparse.Namespace) -> Path: |
| ckpt = torch.load(ckpt_path, map_location="cpu") |
| video_spec = VideoSpec(**ckpt["video_spec"]) |
| audio_spec = AudioSpec(**ckpt["audio_spec"]) if ckpt["audio_spec"] is not None else None |
| device = choose_device(args.device) |
| model = build_model_from_checkpoint(ckpt).to(device) |
| model_state, state_key = checkpoint_model_state(ckpt, args.use_ema) |
| model.load_state_dict(model_state) |
| model.eval() |
|
|
| render_threshold = resolve_calibrated_threshold( |
| requested=args.render_threshold, |
| model=model, |
| frames_path=ckpt.get("frames_cache"), |
| device=device, |
| batch_size=args.threshold_calibration_batch, |
| gamma=args.render_gamma, |
| contrast=args.render_contrast, |
| seed=1234, |
| ) |
|
|
| output_dir = Path(args.output_dir).resolve() |
| output_dir.mkdir(parents=True, exist_ok=True) |
| raw_video = output_dir / f"{ckpt_path.stem}_nn_raw.mp4" |
| final_video = output_dir / f"{ckpt_path.stem}_nn.mp4" |
|
|
| render_video_file( |
| model=model, |
| spec=video_spec, |
| output_path=raw_video, |
| device=device, |
| scale=args.render_scale, |
| render_batch=args.render_batch, |
| threshold=render_threshold, |
| render_log_every=args.render_log_every, |
| encoder=args.video_encoder, |
| crf=args.video_crf, |
| preset=args.video_preset, |
| tune=args.video_tune, |
| supersample=args.render_supersample, |
| gamma=args.render_gamma, |
| contrast=args.render_contrast, |
| threshold_hysteresis=args.render_threshold_hysteresis, |
| ) |
|
|
| if args.audio_source == "source": |
| source_video = Path(ckpt.get("source_video") or args.video).resolve() |
| mux_source_audio( |
| raw_video, |
| source_video, |
| final_video, |
| copy_video=args.mux_copy_video, |
| duration_seconds=video_spec.duration, |
| ) |
| print(f"saved video with source audio -> {final_video}") |
| return final_video |
|
|
| if args.audio_source == "generated" and audio_spec is not None and has_audio_model(model): |
| audio_wav = output_dir / f"{ckpt_path.stem}_nn.wav" |
| render_audio_wav( |
| model=model, |
| audio_spec=audio_spec, |
| output_path=audio_wav, |
| device=device, |
| render_batch=args.audio_render_batch, |
| smooth_samples=args.audio_render_smooth_samples, |
| normalize=args.audio_normalize, |
| peak=args.audio_peak, |
| fade_ms=args.audio_fade_ms, |
| duration_seconds=video_spec.duration, |
| ) |
| mux_wav_audio( |
| raw_video, |
| audio_wav, |
| final_video, |
| copy_video=args.mux_copy_video, |
| audio_bitrate=args.audio_bitrate, |
| ) |
| print(f"saved video with generated audio -> {final_video}") |
| return final_video |
|
|
| print(f"saved video -> {raw_video}") |
| return raw_video |
|
|
|
|
| def make_preview(args: argparse.Namespace) -> Path: |
| ckpt_path = Path(args.checkpoint).resolve() |
| ckpt = torch.load(ckpt_path, map_location="cpu") |
| video_spec = VideoSpec(**ckpt["video_spec"]) |
| device = choose_device(args.device) |
| model = build_model_from_checkpoint(ckpt).to(device) |
| model_state, state_key = checkpoint_model_state(ckpt, args.use_ema) |
| model.load_state_dict(model_state) |
| model.eval() |
|
|
| preview_threshold = resolve_calibrated_threshold( |
| requested=args.render_threshold, |
| model=model, |
| frames_path=ckpt.get("frames_cache"), |
| device=device, |
| batch_size=args.threshold_calibration_batch, |
| gamma=1.0, |
| contrast=1.0, |
| seed=1234, |
| ) |
|
|
| output_dir = Path(args.output_dir).resolve() |
| output_dir.mkdir(parents=True, exist_ok=True) |
| preview_path = output_dir / f"{ckpt_path.stem}_preview.png" |
|
|
| render_contact_sheet( |
| model=model, |
| spec=video_spec, |
| output_path=preview_path, |
| device=device, |
| tiles=args.tiles, |
| scale=args.render_scale, |
| render_batch=args.render_batch, |
| threshold=preview_threshold, |
| ) |
| print(f"saved preview -> {preview_path}") |
| return preview_path |
|
|
|
|
| def add_common_args(parser: argparse.ArgumentParser) -> None: |
| parser.add_argument("--video", default="Bad_Apple.mp4", help="Source video path.") |
| parser.add_argument("--output-dir", default="outputs", help="Directory for checkpoints and renders.") |
| parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or a PyTorch device like cuda:0.") |
|
|
|
|
| def add_data_args(parser: argparse.ArgumentParser) -> None: |
| parser.add_argument("--cache-dir", default="cache") |
| parser.add_argument("--width", type=int, default=160) |
| parser.add_argument("--height", type=int, default=120) |
| parser.add_argument("--match-source-resolution", action="store_true") |
| parser.add_argument("--frame-stride", type=int, default=1, help="Use every Nth source frame.") |
| parser.add_argument( |
| "--threshold", |
| type=parse_data_threshold, |
| default=None, |
| help="Training target threshold. Use none/gray/-1 to keep soft grayscale targets.", |
| ) |
| parser.add_argument("--max-frames", type=int, default=None) |
| parser.add_argument("--force", action="store_true") |
| parser.add_argument("--audio-sample-rate", type=int, default=16000) |
| parser.add_argument("--audio-channels", type=int, default=2) |
| parser.add_argument("--match-source-audio", action="store_true") |
| parser.add_argument("--train-audio", action=argparse.BooleanOptionalAction, default=True) |
|
|
|
|
| def add_render_args(parser: argparse.ArgumentParser) -> None: |
| parser.add_argument("--low-memory", action="store_true", help="Use smaller training/render chunks.") |
| parser.add_argument("--render-scale", type=int, default=3) |
| parser.add_argument("--render-batch", type=int, default=65536) |
| parser.add_argument("--render-log-every", type=int, default=100) |
| parser.add_argument( |
| "--render-threshold", |
| type=parse_render_threshold, |
| default="calibrated", |
| help="Output threshold: calibrated, auto, adaptive, integer, or none/gray/-1.", |
| ) |
| parser.add_argument("--threshold-calibration-batch", type=int, default=65536) |
| parser.add_argument("--render-supersample", type=int, default=1) |
| parser.add_argument("--render-gamma", type=float, default=1.0) |
| parser.add_argument("--render-contrast", type=float, default=1.0) |
| parser.add_argument("--render-threshold-hysteresis", type=int, default=4) |
| parser.add_argument("--video-encoder", choices=("ffmpeg", "opencv"), default="ffmpeg") |
| parser.add_argument("--video-crf", type=int, default=16) |
| parser.add_argument("--video-preset", default="slow") |
| parser.add_argument("--video-tune", default="animation") |
| parser.add_argument("--mux-copy-video", action=argparse.BooleanOptionalAction, default=True) |
| parser.add_argument("--generated-audio", action=argparse.BooleanOptionalAction, default=True) |
| parser.add_argument("--audio-source", choices=("generated", "source", "none"), default=None) |
| parser.add_argument("--audio-bitrate", default="192k") |
| parser.add_argument("--audio-render-batch", type=int, default=65536) |
| parser.add_argument("--audio-render-smooth-samples", type=int, default=5, help="Moving-average smoothing for generated audio. Use 1 to disable.") |
| parser.add_argument("--audio-normalize", action=argparse.BooleanOptionalAction, default=True) |
| parser.add_argument("--audio-peak", type=float, default=0.95) |
| parser.add_argument("--audio-fade-ms", type=float, default=5.0) |
| parser.add_argument("--use-ema", action=argparse.BooleanOptionalAction, default=True) |
|
|
|
|
| def build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser(description="Train a tiny multimodal coordinate net to reconstruct Bad Apple.") |
| subparsers = parser.add_subparsers(dest="command", required=True) |
|
|
| prep = subparsers.add_parser("prepare", help="Extract and cache small video frames and audio.") |
| add_common_args(prep) |
| add_data_args(prep) |
|
|
| train_parser = subparsers.add_parser("train", help="Train the multimodal video+audio model.") |
| add_common_args(train_parser) |
| add_data_args(train_parser) |
| train_parser.add_argument("--hidden", type=int, default=256) |
| train_parser.add_argument("--layers", type=int, default=6) |
| train_parser.add_argument("--bands", type=int, default=13) |
| train_parser.add_argument("--audio-hidden", type=int, default=256) |
| train_parser.add_argument("--audio-layers", type=int, default=6) |
| train_parser.add_argument("--audio-bands", type=int, default=22) |
| train_parser.add_argument("--modality-embedding-dim", type=int, default=8) |
| train_parser.add_argument("--temporal-latent-anchors", type=int, default=512) |
| train_parser.add_argument("--temporal-latent-dim", type=int, default=32) |
| train_parser.add_argument("--gradient-checkpointing", action=argparse.BooleanOptionalAction, default=True) |
| train_parser.add_argument("--steps", type=int, default=100000) |
| train_parser.add_argument("--batch-size", type=int, default=32768) |
| train_parser.add_argument("--audio-batch-size", type=int, default=16384) |
| train_parser.add_argument("--audio-loss-weight", type=float, default=0.15) |
| train_parser.add_argument("--audio-start-step", type=int, default=5000) |
| train_parser.add_argument("--audio-loss-ramp-steps", type=int, default=5000) |
| train_parser.add_argument("--audio-chunk-samples", type=int, default=2048, help="Train audio on contiguous chunks. Use 1 for random isolated samples.") |
| train_parser.add_argument("--audio-derivative-loss-weight", type=float, default=0.5, help="Extra continuity loss for generated audio chunks.") |
| train_parser.add_argument("--audio-stft-loss-weight", type=float, default=0.02) |
| train_parser.add_argument("--audio-stft-n-fft", type=int, default=512) |
| train_parser.add_argument("--audio-stft-hop-length", type=int, default=128) |
| train_parser.add_argument("--cross-modal-loss-weight", type=float, default=0.0) |
| train_parser.add_argument("--cross-modal-batch-size", type=int, default=1024) |
| train_parser.add_argument("--video-temporal-loss-weight", type=float, default=0.1) |
| train_parser.add_argument("--video-spatial-loss-weight", type=float, default=0.05) |
| train_parser.add_argument("--video-dice-loss-weight", type=float, default=0.1) |
| train_parser.add_argument("--video-pair-batch-size", type=int, default=8192) |
| train_parser.add_argument("--edge-sample-fraction", type=float, default=0.35) |
| train_parser.add_argument("--edge-threshold", type=int, default=24) |
| train_parser.add_argument("--motion-sample-fraction", type=float, default=0.35) |
| train_parser.add_argument("--balanced-video-loss", action=argparse.BooleanOptionalAction, default=True) |
| train_parser.add_argument("--lr", type=float, default=2e-3) |
| train_parser.add_argument("--warmup-steps", type=int, default=1000) |
| train_parser.add_argument("--warmup-start-ratio", type=float, default=0.05) |
| train_parser.add_argument("--lr-decay", choices=("cosine", "linear", "none"), default="cosine") |
| train_parser.add_argument("--min-lr-ratio", type=float, default=0.05) |
| train_parser.add_argument("--weight-decay", type=float, default=1e-4) |
| train_parser.add_argument("--grad-clip", type=float, default=1.0) |
| train_parser.add_argument("--ema-decay", type=float, default=0.995) |
| train_parser.add_argument("--val-every", type=int, default=1000) |
| train_parser.add_argument("--val-batch-size", type=int, default=16384) |
| train_parser.add_argument("--amp", action=argparse.BooleanOptionalAction, default=True) |
| train_parser.add_argument("--seed", type=int, default=1234) |
| train_parser.add_argument("--resume", default=None) |
| train_parser.add_argument("--resume-ema", action=argparse.BooleanOptionalAction, default=True) |
| train_parser.add_argument("--log-every", type=int, default=250) |
| train_parser.add_argument("--render", action="store_true") |
| add_render_args(train_parser) |
| train_parser.add_argument("--photo-sample-every", type=int, default=500, help="Write PNG samples every N steps. Use 0 to disable.") |
| train_parser.add_argument("--photo-sample-tiles", type=int, default=8) |
| train_parser.add_argument("--photo-sample-scale", type=int, default=2) |
| train_parser.add_argument( |
| "--photo-sample-threshold", |
| type=parse_render_threshold, |
| default="auto", |
| help="Set to none/gray/-1 to keep grayscale photo samples.", |
| ) |
| train_parser.add_argument("--video-sample-every", type=int, default=1000, help="Write MP4 samples every N steps. Use 0 to disable.") |
| train_parser.add_argument("--video-sample-seconds", type=float, default=4.0, help="Length of each training MP4 sample. Use 0 for full video samples.") |
| train_parser.add_argument("--video-sample-scale", type=int, default=2) |
| train_parser.add_argument("--video-sample-supersample", type=int, default=1) |
| train_parser.add_argument( |
| "--video-sample-threshold", |
| type=parse_render_threshold, |
| default="auto", |
| help="Set to none/gray/-1 to keep grayscale video samples.", |
| ) |
| train_parser.add_argument("--sample-every", type=int, default=None, help=argparse.SUPPRESS) |
|
|
| distill_parser = subparsers.add_parser("distill", help="Distill a checkpoint into a QAT INT8 student.") |
| add_common_args(distill_parser) |
| distill_parser.set_defaults(output_dir="outputs/distilled") |
| distill_parser.add_argument("teacher", help="Teacher checkpoint path.") |
| distill_parser.add_argument("--hidden", type=int, default=192) |
| distill_parser.add_argument("--layers", type=int, default=4) |
| distill_parser.add_argument("--bands", type=int, default=11) |
| distill_parser.add_argument("--audio-hidden", type=int, default=192) |
| distill_parser.add_argument("--audio-layers", type=int, default=4) |
| distill_parser.add_argument("--audio-bands", type=int, default=18) |
| distill_parser.add_argument("--modality-embedding-dim", type=int, default=8) |
| distill_parser.add_argument("--temporal-latent-anchors", type=int, default=384) |
| distill_parser.add_argument("--temporal-latent-dim", type=int, default=24) |
| distill_parser.add_argument("--gradient-checkpointing", action=argparse.BooleanOptionalAction, default=True) |
| distill_parser.add_argument("--steps", type=int, default=30000) |
| distill_parser.add_argument("--batch-size", type=int, default=8192) |
| distill_parser.add_argument("--audio-batch-size", type=int, default=4096) |
| distill_parser.add_argument("--audio-val-batch-size", type=int, default=4096) |
| distill_parser.add_argument("--audio-chunk-samples", type=int, default=2048) |
| distill_parser.add_argument("--audio-loss-weight", type=float, default=1.0) |
| distill_parser.add_argument("--audio-derivative-loss-weight", type=float, default=0.5) |
| distill_parser.add_argument("--audio-stft-loss-weight", type=float, default=0.02) |
| distill_parser.add_argument("--audio-stft-n-fft", type=int, default=512) |
| distill_parser.add_argument("--audio-stft-hop-length", type=int, default=128) |
| distill_parser.add_argument("--hard-target-weight", type=float, default=0.05) |
| distill_parser.add_argument("--distill-temperature", type=float, default=2.0) |
| distill_parser.add_argument("--qat", action=argparse.BooleanOptionalAction, default=True) |
| distill_parser.add_argument("--qat-start-step", type=int, default=None) |
| distill_parser.add_argument("--edge-sample-fraction", type=float, default=0.35) |
| distill_parser.add_argument("--edge-threshold", type=int, default=24) |
| distill_parser.add_argument("--motion-sample-fraction", type=float, default=0.35) |
| distill_parser.add_argument("--lr", type=float, default=1e-3) |
| distill_parser.add_argument("--warmup-steps", type=int, default=500) |
| distill_parser.add_argument("--warmup-start-ratio", type=float, default=0.05) |
| distill_parser.add_argument("--lr-decay", choices=("cosine", "linear", "none"), default="cosine") |
| distill_parser.add_argument("--min-lr-ratio", type=float, default=0.05) |
| distill_parser.add_argument("--weight-decay", type=float, default=1e-4) |
| distill_parser.add_argument("--grad-clip", type=float, default=1.0) |
| distill_parser.add_argument("--val-every", type=int, default=500) |
| distill_parser.add_argument("--val-batch-size", type=int, default=4096) |
| distill_parser.add_argument("--log-every", type=int, default=100) |
| distill_parser.add_argument("--amp", action=argparse.BooleanOptionalAction, default=True) |
| distill_parser.add_argument("--seed", type=int, default=1234) |
| distill_parser.add_argument("--render", action="store_true") |
| add_render_args(distill_parser) |
|
|
| render = subparsers.add_parser("render", help="Render a trained checkpoint back to MP4.") |
| add_common_args(render) |
| render.add_argument("checkpoint") |
| add_render_args(render) |
|
|
| preview = subparsers.add_parser("preview", help="Make a still contact sheet from a trained checkpoint.") |
| add_common_args(preview) |
| preview.add_argument("checkpoint") |
| preview.add_argument("--tiles", type=int, default=8) |
| preview.add_argument("--render-scale", type=int, default=2) |
| preview.add_argument("--render-batch", type=int, default=65536) |
| preview.add_argument("--low-memory", action="store_true", help="Use smaller render chunks.") |
| preview.add_argument( |
| "--render-threshold", |
| type=parse_render_threshold, |
| default="calibrated", |
| help="Output threshold: calibrated, auto, adaptive, integer, or none/gray/-1.", |
| ) |
| preview.add_argument("--threshold-calibration-batch", type=int, default=65536) |
| preview.add_argument("--use-ema", action=argparse.BooleanOptionalAction, default=True) |
|
|
| return parser |
|
|
|
|
| def normalize_args(args: argparse.Namespace) -> argparse.Namespace: |
| if hasattr(args, "threshold"): |
| args.threshold = parse_data_threshold(args.threshold) |
| for name in ("render_threshold", "photo_sample_threshold", "video_sample_threshold"): |
| if hasattr(args, name): |
| setattr(args, name, parse_render_threshold(getattr(args, name))) |
|
|
| if getattr(args, "sample_every", None) is not None: |
| args.photo_sample_every = args.sample_every |
| if getattr(args, "low_memory", False): |
| for name, limit in ( |
| ("batch_size", 8192), |
| ("audio_batch_size", 4096), |
| ("audio_val_batch_size", 4096), |
| ("video_pair_batch_size", 2048), |
| ("cross_modal_batch_size", 256), |
| ("val_batch_size", 4096), |
| ("render_batch", 16384), |
| ("threshold_calibration_batch", 16384), |
| ("audio_render_batch", 16384), |
| ): |
| if hasattr(args, name): |
| setattr(args, name, min(getattr(args, name), limit)) |
| if hasattr(args, "audio_source") and args.audio_source is None: |
| args.audio_source = "generated" if getattr(args, "generated_audio", True) else "none" |
| if hasattr(args, "audio_channels"): |
| args.audio_channels = max(1, args.audio_channels) |
| if hasattr(args, "audio_chunk_samples"): |
| args.audio_chunk_samples = max(1, args.audio_chunk_samples) |
| if hasattr(args, "audio_val_batch_size"): |
| args.audio_val_batch_size = max(1, args.audio_val_batch_size) |
| if hasattr(args, "audio_render_smooth_samples"): |
| args.audio_render_smooth_samples = max(1, args.audio_render_smooth_samples) |
| if hasattr(args, "audio_start_step"): |
| args.audio_start_step = max(1, args.audio_start_step) |
| if hasattr(args, "audio_loss_ramp_steps"): |
| args.audio_loss_ramp_steps = max(1, args.audio_loss_ramp_steps) |
| if hasattr(args, "audio_stft_n_fft"): |
| args.audio_stft_n_fft = max(16, args.audio_stft_n_fft) |
| if hasattr(args, "audio_stft_hop_length"): |
| args.audio_stft_hop_length = max(1, args.audio_stft_hop_length) |
| if hasattr(args, "edge_sample_fraction"): |
| args.edge_sample_fraction = min(max(args.edge_sample_fraction, 0.0), 1.0) |
| if hasattr(args, "motion_sample_fraction"): |
| args.motion_sample_fraction = min(max(args.motion_sample_fraction, 0.0), 1.0) |
| if hasattr(args, "edge_threshold"): |
| args.edge_threshold = max(1, args.edge_threshold) |
| if hasattr(args, "cross_modal_batch_size"): |
| args.cross_modal_batch_size = max(1, args.cross_modal_batch_size) |
| if hasattr(args, "video_pair_batch_size"): |
| args.video_pair_batch_size = max(1, args.video_pair_batch_size) |
| if hasattr(args, "val_batch_size"): |
| args.val_batch_size = max(1, args.val_batch_size) |
| if hasattr(args, "threshold_calibration_batch"): |
| args.threshold_calibration_batch = max(1, args.threshold_calibration_batch) |
| if hasattr(args, "temporal_latent_anchors"): |
| args.temporal_latent_anchors = max(2, args.temporal_latent_anchors) |
| if hasattr(args, "temporal_latent_dim"): |
| args.temporal_latent_dim = max(1, args.temporal_latent_dim) |
| if hasattr(args, "video_dice_loss_weight"): |
| args.video_dice_loss_weight = max(0.0, args.video_dice_loss_weight) |
| if hasattr(args, "ema_decay"): |
| args.ema_decay = min(max(args.ema_decay, 0.0), 0.999999) |
| if hasattr(args, "grad_clip"): |
| args.grad_clip = max(0.0, args.grad_clip) |
| if hasattr(args, "render_supersample"): |
| args.render_supersample = max(1, args.render_supersample) |
| if hasattr(args, "video_sample_supersample"): |
| args.video_sample_supersample = max(1, args.video_sample_supersample) |
| if hasattr(args, "render_gamma"): |
| args.render_gamma = max(1e-3, args.render_gamma) |
| if hasattr(args, "render_contrast"): |
| args.render_contrast = max(0.0, args.render_contrast) |
| if hasattr(args, "render_threshold_hysteresis"): |
| args.render_threshold_hysteresis = max(0, args.render_threshold_hysteresis) |
| if hasattr(args, "video_crf"): |
| args.video_crf = min(max(args.video_crf, 0), 51) |
| if hasattr(args, "audio_peak"): |
| args.audio_peak = min(max(args.audio_peak, 0.0), 1.0) |
| if hasattr(args, "audio_fade_ms"): |
| args.audio_fade_ms = max(0.0, args.audio_fade_ms) |
| if hasattr(args, "min_lr_ratio"): |
| args.min_lr_ratio = min(max(args.min_lr_ratio, 0.0), 1.0) |
| if hasattr(args, "warmup_start_ratio"): |
| args.warmup_start_ratio = min(max(args.warmup_start_ratio, 0.0), 1.0) |
| if hasattr(args, "hard_target_weight"): |
| args.hard_target_weight = min(max(args.hard_target_weight, 0.0), 1.0) |
| if hasattr(args, "distill_temperature"): |
| args.distill_temperature = max(args.distill_temperature, 1e-3) |
| if hasattr(args, "qat_start_step") and args.qat_start_step is not None: |
| args.qat_start_step = max(1, args.qat_start_step) |
| return args |
|
|
|
|
| def main() -> None: |
| parser = build_parser() |
| args = normalize_args(parser.parse_args()) |
|
|
| if args.command == "prepare": |
| prepare_dataset(args) |
| elif args.command == "train": |
| train(args) |
| elif args.command == "distill": |
| distill(args) |
| elif args.command == "render": |
| render_from_checkpoint(Path(args.checkpoint).resolve(), args) |
| elif args.command == "preview": |
| make_preview(args) |
| else: |
| parser.error(f"unknown command: {args.command}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|