kerzgrr commited on
Commit
892e8a7
·
verified ·
1 Parent(s): c9cc580

Action-conditioned Breakout EMA step 500 + standalone inference

Browse files
Files changed (5) hide show
  1. README.md +59 -0
  2. config.json +108 -0
  3. ema.safetensors +3 -0
  4. live_infer.py +1368 -0
  5. preview.png +0 -0
README.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: pytorch
4
+ pipeline_tag: other
5
+ tags:
6
+ - world-model
7
+ - diffusion
8
+ - breakout
9
+ - video
10
+ - causal
11
+ - game
12
+ ---
13
+
14
+ # Diffusion Breakout
15
+
16
+ An action-conditioned Breakout world model. Hold **A / D** (or the arrow keys) and the model generates the next frame.
17
+
18
+ ![validation preview](preview.png)
19
+
20
+ 128x128 at 6 FPS with 12 frames of latent history. The model has about 70M parameters and uses the frozen SDXL VAE from `madebyollin/sdxl-vae-fp16-fix`.
21
+
22
+ ## Run it
23
+
24
+ Requires a CUDA GPU with BF16 support.
25
+
26
+ ```bash
27
+ pip install torch numpy pillow safetensors huggingface_hub diffusers
28
+
29
+ hf download kerzgrr/diffusionbreakout live_infer.py --local-dir .
30
+ python live_infer.py --steps 1
31
+ ```
32
+
33
+ To keep the weights in a local folder:
34
+
35
+ ```bash
36
+ hf download kerzgrr/diffusionbreakout \
37
+ --local-dir checkpoints/diffusionbreakout \
38
+ --include "ema.safetensors" \
39
+ --include "config.json" \
40
+ --include "live_infer.py"
41
+
42
+ python checkpoints/diffusionbreakout/live_infer.py \
43
+ --local-dir checkpoints/diffusionbreakout \
44
+ --steps 1 \
45
+ --window-scale 6
46
+ ```
47
+
48
+ Click the window for focus. **A / Left** moves left; **D / Right** moves right.
49
+
50
+ ## Files
51
+
52
+ | file | contents |
53
+ |---|---|
54
+ | `ema.safetensors` | action-conditioned EMA weights |
55
+ | `config.json` | model, video, codec, and training settings |
56
+ | `live_infer.py` | single-file live inference |
57
+ | `preview.png` | validation strip |
58
+
59
+ Actions are encoded as `[left, right]`.
config.json ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "codec": {
3
+ "enable_slicing": true,
4
+ "enable_tiling": false,
5
+ "frame_batch_size": 256,
6
+ "kind": "frame_vae",
7
+ "latent_channels": 4,
8
+ "model_id": "madebyollin/sdxl-vae-fp16-fix",
9
+ "revision": "main",
10
+ "sample_posterior": false,
11
+ "spatial_compression": 8,
12
+ "subfolder": "",
13
+ "temporal_compression": 1
14
+ },
15
+ "data": {
16
+ "batch_size": 128,
17
+ "data_path": "",
18
+ "dataset_kind": "breakout",
19
+ "frame_stride": 1,
20
+ "hf_repo_id": "",
21
+ "hf_revision": "main",
22
+ "latent_cache_path": "",
23
+ "latent_prefetch_batches": 8,
24
+ "network_clip_prefetch": 2,
25
+ "num_workers": 8,
26
+ "persistent_workers": true,
27
+ "pin_memory": true,
28
+ "prefetch_batches": 2,
29
+ "sample_shuffle": 64,
30
+ "shard_shuffle": 256,
31
+ "train_samples": 500000,
32
+ "validation_samples": 1024,
33
+ "windows_per_video": 4
34
+ },
35
+ "inference": {
36
+ "frames": 120,
37
+ "steps": 1
38
+ },
39
+ "model": {
40
+ "action_dim": 2,
41
+ "context_dim": 0,
42
+ "depth": 12,
43
+ "fine_history_frames": 2,
44
+ "fine_memory_patch_size": 2,
45
+ "generation_frames": 1,
46
+ "hidden_size": 416,
47
+ "memory_depth": 3,
48
+ "memory_patch_size": 4,
49
+ "mlp_ratio": 4.0,
50
+ "num_heads": 8,
51
+ "qk_norm": true,
52
+ "target_patch_size": 2
53
+ },
54
+ "schema_version": 3,
55
+ "simulation": {
56
+ "max_shapes": 2,
57
+ "max_size": 6.0,
58
+ "max_speed": 20.0,
59
+ "min_shapes": 1,
60
+ "min_size": 3.0,
61
+ "min_speed": 8.0,
62
+ "supersample": 2,
63
+ "warmup_frames": 8
64
+ },
65
+ "training": {
66
+ "activation_checkpointing": true,
67
+ "critic_learning_rate": 0.0001,
68
+ "distribution_matching_start_step": 30000,
69
+ "distribution_matching_weight": 0.05,
70
+ "ema_decay": 0.9999,
71
+ "epochs": 100,
72
+ "flow_matching_ratio": 0.25,
73
+ "full_interval_ratio": 0.25,
74
+ "gradient_accumulation_steps": 1,
75
+ "history_noise_std": 0.01,
76
+ "keep_checkpoints": 5,
77
+ "learning_rate": 0.0003,
78
+ "log_every": 20,
79
+ "max_grad_norm": 1.0,
80
+ "min_learning_rate": 1e-05,
81
+ "on_policy_max_horizon": 4,
82
+ "on_policy_ratio": 0.15,
83
+ "on_policy_sampling_steps": 4,
84
+ "on_policy_start_step": 15000,
85
+ "output_dir": "runs/breakout-action-world-model",
86
+ "reward_loss_weight": 0.0,
87
+ "save_every": 50,
88
+ "seed": 2026,
89
+ "teacher_endpoint_ratio": 0.1,
90
+ "teacher_integration_steps": 8,
91
+ "teacher_logit_mean": 1.0,
92
+ "teacher_logit_std": 1.0,
93
+ "teacher_steps": 10000,
94
+ "teacher_x0_weight": 0.25,
95
+ "terminal_loss_weight": 0.0,
96
+ "validate_every": 50,
97
+ "validation_batches": 1,
98
+ "warmup_steps": 500,
99
+ "weight_decay": 0.0
100
+ },
101
+ "video": {
102
+ "fps": 6.0,
103
+ "future_frames": 5,
104
+ "height": 128,
105
+ "history_frames": 12,
106
+ "width": 128
107
+ }
108
+ }
ema.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d9f2a9ab8aa958c532fc5f010d0f429e49762803a4203269296e7ae48fdf8191
3
+ size 281086680
live_infer.py ADDED
@@ -0,0 +1,1368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run Diffusion Breakout.
3
+
4
+ Deps:
5
+ pip install torch numpy pillow safetensors huggingface_hub diffusers
6
+
7
+ Run:
8
+ python live_infer.py
9
+ python live_infer.py --steps 1 --window-scale 7 --seed 42
10
+
11
+ Controls: A/D or arrow keys. Click window for focus.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import math
18
+ import random
19
+ import time
20
+ import tkinter as tk
21
+ from collections import deque
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import torch
27
+ import torch.nn.functional as F
28
+ from diffusers import AutoencoderKL
29
+ from huggingface_hub import hf_hub_download
30
+ from PIL import Image, ImageDraw, ImageTk
31
+ from safetensors.torch import load_file
32
+ from torch import nn
33
+ from torch.utils.checkpoint import checkpoint
34
+
35
+ DEFAULT_REPO = "kerzgrr/diffusionbreakout"
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class VideoConfig:
40
+ height: int = 128
41
+ width: int = 128
42
+ fps: float = 6.0
43
+ history_frames: int = 12
44
+ future_frames: int = 5
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class ModelConfig:
49
+ target_patch_size: int = 2
50
+ memory_patch_size: int = 4
51
+ fine_memory_patch_size: int = 2
52
+ fine_history_frames: int = 2
53
+ hidden_size: int = 384
54
+ depth: int = 12
55
+ memory_depth: int = 3
56
+ num_heads: int = 8
57
+ mlp_ratio: float = 4.0
58
+ qk_norm: bool = True
59
+ generation_frames: int = 1
60
+ action_dim: int = 2
61
+ context_dim: int = 0
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class CodecSettings:
66
+ model_id: str = "madebyollin/sdxl-vae-fp16-fix"
67
+ subfolder: str = ""
68
+ revision: str = "main"
69
+ latent_channels: int = 4
70
+ spatial_compression: int = 8
71
+ frame_batch_size: int = 256
72
+ sample_posterior: bool = False
73
+ enable_slicing: bool = True
74
+ enable_tiling: bool = False
75
+ _BREAKOUT_BRICK_COLORS = (
76
+ (220, 72, 88),
77
+ (230, 140, 54),
78
+ (236, 196, 72),
79
+ (72, 186, 120),
80
+ (72, 148, 220),
81
+ (156, 102, 220),
82
+ )
83
+
84
+
85
+ class BreakoutSimulation:
86
+ """Classic Breakout with continuous physics and a scripted bottom paddle."""
87
+
88
+ def __init__(self, video_config: VideoConfig, seed: int) -> None:
89
+ self.video_config = video_config
90
+ self.rng = np.random.default_rng(seed)
91
+ self.dt = 1.0 / video_config.fps
92
+ self.paddle_width = max(18.0, video_config.width * 0.18)
93
+ self.paddle_height = max(4.0, video_config.height * 0.03)
94
+ self.ball_radius = max(3.0, min(video_config.width, video_config.height) * 0.025)
95
+ self.paddle_y = video_config.height * 0.90
96
+ self.brick_rows = 5
97
+ self.brick_cols = 8
98
+ self.brick_top = video_config.height * 0.14
99
+ self.brick_gap = 1.5
100
+ usable_width = video_config.width - 10.0
101
+ self.brick_width = (usable_width - self.brick_gap * (self.brick_cols - 1)) / self.brick_cols
102
+ self.brick_height = max(5.0, video_config.height * 0.045)
103
+ self.paddle_x = video_config.width / 2
104
+ self.score = 0
105
+ self.elapsed = 0.0
106
+ self.aim_bias = float(self.rng.uniform(-8.0, 8.0))
107
+ self.reaction_delay = int(self.rng.integers(0, 4))
108
+ self.randomness = float(self.rng.uniform(0.10, 0.28))
109
+ self._observed_ball_x: deque[float] = deque(
110
+ [video_config.width / 2] * (self.reaction_delay + 1),
111
+ maxlen=self.reaction_delay + 1,
112
+ )
113
+ self._held_action = 0
114
+ self._action_hold_frames = 0
115
+ self.ball_position = np.zeros(2, dtype=np.float32)
116
+ self.ball_velocity = np.zeros(2, dtype=np.float32)
117
+ self.bricks = np.ones((self.brick_rows, self.brick_cols), dtype=bool)
118
+ # Punch a few random holes so early frames aren't always a full wall.
119
+ missing = int(self.rng.integers(0, 6))
120
+ for _ in range(missing):
121
+ row = int(self.rng.integers(0, self.brick_rows))
122
+ col = int(self.rng.integers(0, self.brick_cols))
123
+ self.bricks[row, col] = False
124
+ self._serve()
125
+ for _ in range(10):
126
+ self.step()
127
+
128
+ @classmethod
129
+ def from_seed(
130
+ cls,
131
+ video_config: VideoConfig,
132
+ seed: int,
133
+ *,
134
+ at_start: bool = False,
135
+ ) -> BreakoutSimulation:
136
+ simulation = cls(video_config, seed)
137
+ if at_start:
138
+ simulation.reset_to_start()
139
+ return simulation
140
+
141
+ def reset_to_start(self) -> None:
142
+ """Reset to a fresh serve against a full brick wall (for inference)."""
143
+ self.paddle_x = self.video_config.width / 2
144
+ self.score = 0
145
+ self.elapsed = 0.0
146
+ self._held_action = 0
147
+ self._action_hold_frames = 0
148
+ self._observed_ball_x.clear()
149
+ self._observed_ball_x.extend(
150
+ [self.video_config.width / 2] * (self.reaction_delay + 1)
151
+ )
152
+ self.bricks[:] = True
153
+ self._serve()
154
+
155
+ def _brick_rect(self, row: int, col: int) -> tuple[float, float, float, float]:
156
+ x0 = 5.0 + col * (self.brick_width + self.brick_gap)
157
+ y0 = self.brick_top + row * (self.brick_height + self.brick_gap)
158
+ return x0, y0, x0 + self.brick_width, y0 + self.brick_height
159
+
160
+ def _serve(self) -> None:
161
+ angle = float(self.rng.uniform(-0.65, 0.65))
162
+ speed = float(self.rng.uniform(48.0, 64.0))
163
+ self.ball_position[:] = (
164
+ self.paddle_x + float(self.rng.uniform(-6.0, 6.0)),
165
+ self.paddle_y - self.paddle_height - self.ball_radius - 2.0,
166
+ )
167
+ self.ball_velocity[:] = (
168
+ speed * math.sin(angle),
169
+ -speed * math.cos(angle),
170
+ )
171
+
172
+ def _reset_bricks(self) -> None:
173
+ self.bricks[:] = True
174
+
175
+ def _move_paddle(self, dt: float, action: int | None) -> None:
176
+ width = float(self.video_config.width)
177
+ paddle_speed = width * 0.85
178
+ if action is None:
179
+ target = float(self.ball_position[0]) + self.aim_bias
180
+ delta = max(-paddle_speed * dt, min(paddle_speed * dt, target - self.paddle_x))
181
+ else:
182
+ delta = float(max(-1, min(1, action))) * paddle_speed * dt
183
+ self.paddle_x += delta
184
+ half = self.paddle_width / 2
185
+ self.paddle_x = max(half + 2.0, min(width - half - 2.0, self.paddle_x))
186
+
187
+ def _collide_bricks(self) -> None:
188
+ x, y = map(float, self.ball_position)
189
+ r = self.ball_radius
190
+ for row in range(self.brick_rows):
191
+ for col in range(self.brick_cols):
192
+ if not self.bricks[row, col]:
193
+ continue
194
+ x0, y0, x1, y1 = self._brick_rect(row, col)
195
+ closest_x = max(x0, min(x, x1))
196
+ closest_y = max(y0, min(y, y1))
197
+ dx = x - closest_x
198
+ dy = y - closest_y
199
+ if dx * dx + dy * dy > r * r:
200
+ continue
201
+ self.bricks[row, col] = False
202
+ self.score += 1
203
+ # Reflect on the dominant penetration axis.
204
+ if abs(dx) > abs(dy):
205
+ self.ball_velocity[0] = -self.ball_velocity[0]
206
+ self.ball_position[0] = x0 - r if dx < 0 else x1 + r
207
+ else:
208
+ self.ball_velocity[1] = -self.ball_velocity[1]
209
+ self.ball_position[1] = y0 - r if dy < 0 else y1 + r
210
+ if not self.bricks.any():
211
+ self._reset_bricks()
212
+ return
213
+
214
+ def step(self, action: int | None = None) -> None:
215
+ width = float(self.video_config.width)
216
+ height = float(self.video_config.height)
217
+ substeps = 4
218
+ sub_dt = self.dt / substeps
219
+ for _ in range(substeps):
220
+ self._move_paddle(sub_dt, action)
221
+ self.ball_position += self.ball_velocity * sub_dt
222
+ x, y = map(float, self.ball_position)
223
+ if x - self.ball_radius < 2:
224
+ self.ball_position[0] = 2 + self.ball_radius
225
+ self.ball_velocity[0] = abs(self.ball_velocity[0])
226
+ elif x + self.ball_radius > width - 2:
227
+ self.ball_position[0] = width - 2 - self.ball_radius
228
+ self.ball_velocity[0] = -abs(self.ball_velocity[0])
229
+ if y - self.ball_radius < 2:
230
+ self.ball_position[1] = 2 + self.ball_radius
231
+ self.ball_velocity[1] = abs(self.ball_velocity[1])
232
+ paddle_top = self.paddle_y - self.paddle_height / 2
233
+ hitting_paddle = (
234
+ self.ball_velocity[1] > 0
235
+ and y + self.ball_radius >= paddle_top
236
+ and y - self.ball_radius <= self.paddle_y + self.paddle_height / 2
237
+ and abs(x - self.paddle_x) <= self.paddle_width / 2 + self.ball_radius
238
+ )
239
+ if hitting_paddle:
240
+ self.ball_position[1] = paddle_top - self.ball_radius
241
+ self.ball_velocity[1] = -abs(self.ball_velocity[1]) * 1.01
242
+ self.ball_velocity[0] += (x - self.paddle_x) * 1.35
243
+ speed = float(np.linalg.norm(self.ball_velocity))
244
+ target = float(np.clip(speed, 46.0, 78.0))
245
+ if speed > 1e-5:
246
+ self.ball_velocity *= target / speed
247
+ self._collide_bricks()
248
+ if self.ball_position[1] > height + self.ball_radius * 2:
249
+ self._serve()
250
+ self.elapsed += sub_dt
251
+
252
+ def render(self) -> np.ndarray:
253
+ scale = 2
254
+ width = self.video_config.width
255
+ height = self.video_config.height
256
+ image = Image.new("RGB", (width * scale, height * scale), (6, 8, 18))
257
+ draw = ImageDraw.Draw(image)
258
+ draw.rectangle((0, 0, width * scale, 3 * scale), fill=(36, 48, 72))
259
+ for row in range(self.brick_rows):
260
+ color = _BREAKOUT_BRICK_COLORS[row % len(_BREAKOUT_BRICK_COLORS)]
261
+ for col in range(self.brick_cols):
262
+ if not self.bricks[row, col]:
263
+ continue
264
+ x0, y0, x1, y1 = self._brick_rect(row, col)
265
+ draw.rounded_rectangle(
266
+ (x0 * scale, y0 * scale, x1 * scale, y1 * scale),
267
+ radius=max(1, round(scale)),
268
+ fill=color,
269
+ )
270
+ draw.rounded_rectangle(
271
+ (
272
+ (self.paddle_x - self.paddle_width / 2) * scale,
273
+ (self.paddle_y - self.paddle_height / 2) * scale,
274
+ (self.paddle_x + self.paddle_width / 2) * scale,
275
+ (self.paddle_y + self.paddle_height / 2) * scale,
276
+ ),
277
+ radius=2 * scale,
278
+ fill=(230, 238, 245),
279
+ )
280
+ ball_x, ball_y = self.ball_position * scale
281
+ radius = self.ball_radius * scale
282
+ draw.ellipse(
283
+ (ball_x - radius, ball_y - radius, ball_x + radius, ball_y + radius),
284
+ fill=(255, 209, 72),
285
+ )
286
+ image = image.resize((width, height), Image.Resampling.LANCZOS)
287
+ return np.asarray(image, dtype=np.uint8)
288
+
289
+ def next_frame(self) -> np.ndarray:
290
+ frame = self.render()
291
+ self.step()
292
+ return frame
293
+
294
+ def sample_frames(self, count: int) -> np.ndarray:
295
+ return np.stack([self.next_frame() for _ in range(count)])
296
+
297
+ def _scripted_action(self) -> int:
298
+ self._observed_ball_x.append(float(self.ball_position[0]))
299
+ if self._action_hold_frames > 0:
300
+ self._action_hold_frames -= 1
301
+ return self._held_action
302
+ self._action_hold_frames = int(self.rng.integers(1, 5))
303
+ random_value = float(self.rng.random())
304
+ delayed_target = self._observed_ball_x[0] + self.aim_bias
305
+ error = delayed_target - self.paddle_x
306
+ ideal_action = -1 if error < -4 else (1 if error > 4 else 0)
307
+ if random_value < self.randomness * 0.35:
308
+ action = 0
309
+ elif random_value < self.randomness * 0.75:
310
+ action = int(self.rng.choice((-1, 0, 1)))
311
+ elif random_value < self.randomness:
312
+ action = -ideal_action if ideal_action != 0 else int(self.rng.choice((-1, 1)))
313
+ else:
314
+ action = ideal_action
315
+ self._held_action = action
316
+ return action
317
+
318
+ def sample_gameplay(self, count: int) -> tuple[np.ndarray, np.ndarray]:
319
+ frames: list[np.ndarray] = []
320
+ actions: list[tuple[float, float]] = []
321
+ for _ in range(count):
322
+ frames.append(self.render())
323
+ action = self._scripted_action()
324
+ # [A/left, D/right] — same layout we'll use for action fine-tuning later.
325
+ actions.append((float(action < 0), float(action > 0)))
326
+ self.step(action)
327
+ return np.stack(frames), np.asarray(actions, dtype=np.float32)
328
+
329
+
330
+ class FrameAutoencoderCodec(nn.Module):
331
+ def __init__(self, config: CodecSettings, device: torch.device, dtype: torch.dtype) -> None:
332
+ super().__init__()
333
+ load_kwargs: dict[str, object] = {
334
+ "revision": config.revision,
335
+ "torch_dtype": dtype,
336
+ "low_cpu_mem_usage": True,
337
+ }
338
+ if config.subfolder:
339
+ load_kwargs["subfolder"] = config.subfolder
340
+ try:
341
+ self.vae = AutoencoderKL.from_pretrained(
342
+ config.model_id, local_files_only=True, **load_kwargs
343
+ )
344
+ except OSError:
345
+ self.vae = AutoencoderKL.from_pretrained(config.model_id, **load_kwargs)
346
+ self.vae.to(device=device).eval()
347
+ self.vae.to(memory_format=torch.channels_last)
348
+ self.vae.requires_grad_(False)
349
+ if config.enable_slicing:
350
+ self.vae.enable_slicing()
351
+ if config.enable_tiling:
352
+ self.vae.enable_tiling()
353
+ self.channels = int(self.vae.config.latent_channels)
354
+ self.spatial_compression = 2 ** (len(self.vae.config.block_out_channels) - 1)
355
+ self.temporal_compression = 1
356
+ self.frame_batch_size = config.frame_batch_size
357
+ self.sample_posterior = config.sample_posterior
358
+ self.dtype = dtype
359
+ self.scaling_factor = float(self.vae.config.scaling_factor)
360
+ shift_factor = getattr(self.vae.config, "shift_factor", None)
361
+ self.shift_factor = float(shift_factor) if shift_factor is not None else 0.0
362
+
363
+ def latent_frame_count(self, frame_count: int) -> int:
364
+ return frame_count
365
+
366
+ @torch.no_grad()
367
+ def encode(self, frames: torch.Tensor) -> torch.Tensor:
368
+ batch, frame_count, channels, height, width = frames.shape
369
+ flat_frames = frames.reshape(batch * frame_count, channels, height, width)
370
+ encoded: list[torch.Tensor] = []
371
+ for frame_batch in flat_frames.split(self.frame_batch_size):
372
+ frame_batch = frame_batch.to(self.dtype).contiguous(
373
+ memory_format=torch.channels_last
374
+ )
375
+ posterior = self.vae.encode(frame_batch).latent_dist
376
+ latents = posterior.sample() if self.sample_posterior else posterior.mode()
377
+ encoded.append((latents - self.shift_factor) * self.scaling_factor)
378
+ merged = torch.cat(encoded)
379
+ return merged.reshape(
380
+ batch,
381
+ frame_count,
382
+ self.channels,
383
+ height // self.spatial_compression,
384
+ width // self.spatial_compression,
385
+ )
386
+
387
+ @torch.no_grad()
388
+ def decode(self, latents: torch.Tensor) -> torch.Tensor:
389
+ batch, frame_count, channels, height, width = latents.shape
390
+ flat_latents = latents.reshape(batch * frame_count, channels, height, width)
391
+ decoded: list[torch.Tensor] = []
392
+ for latent_batch in flat_latents.split(self.frame_batch_size):
393
+ denormalized = latent_batch / self.scaling_factor + self.shift_factor
394
+ denormalized = denormalized.to(self.dtype).contiguous(
395
+ memory_format=torch.channels_last
396
+ )
397
+ decoded.append(self.vae.decode(denormalized).sample)
398
+ merged = torch.cat(decoded)
399
+ return merged.reshape(
400
+ batch,
401
+ frame_count,
402
+ 3,
403
+ height * self.spatial_compression,
404
+ width * self.spatial_compression,
405
+ ).clamp(-1.0, 1.0)
406
+ @dataclass(slots=True)
407
+ class PrefixCache:
408
+ key_values: tuple[tuple[torch.Tensor, torch.Tensor], ...]
409
+ pooled: torch.Tensor
410
+
411
+
412
+ MemoryCache = PrefixCache
413
+
414
+
415
+ def _video_positions(
416
+ frames: int,
417
+ height: int,
418
+ width: int,
419
+ time_offset: int,
420
+ device: torch.device,
421
+ ) -> torch.Tensor:
422
+ time = torch.arange(time_offset, time_offset + frames, device=device)
423
+ y = torch.arange(height, device=device)
424
+ x = torch.arange(width, device=device)
425
+ grid = torch.meshgrid(time, y, x, indexing="ij")
426
+ return torch.stack(grid, dim=-1).reshape(-1, 3).float()
427
+
428
+
429
+ class Rotary3D(nn.Module):
430
+ def __init__(self, head_size: int, base: float = 10_000.0) -> None:
431
+ super().__init__()
432
+ self.rotary_size = (head_size // 6) * 6
433
+ self.axis_size = self.rotary_size // 3
434
+ frequencies = torch.exp(
435
+ -math.log(base)
436
+ * torch.arange(self.axis_size // 2, dtype=torch.float32)
437
+ / (self.axis_size // 2)
438
+ )
439
+ self.register_buffer("frequencies", frequencies, persistent=False)
440
+
441
+ @staticmethod
442
+ def _rotate_half(inputs: torch.Tensor) -> torch.Tensor:
443
+ pairs = inputs.reshape(*inputs.shape[:-1], -1, 2)
444
+ first, second = pairs.unbind(dim=-1)
445
+ return torch.stack((-second, first), dim=-1).flatten(-2)
446
+
447
+ def forward(self, inputs: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
448
+ angles = torch.cat(
449
+ [positions[:, axis, None] * self.frequencies[None] for axis in range(3)],
450
+ dim=-1,
451
+ )
452
+ cosine = angles.cos().repeat_interleave(2, dim=-1)[None, None]
453
+ sine = angles.sin().repeat_interleave(2, dim=-1)[None, None]
454
+ rotary, remainder = inputs.split(
455
+ (self.rotary_size, inputs.shape[-1] - self.rotary_size),
456
+ dim=-1,
457
+ )
458
+ rotated = rotary * cosine.to(rotary.dtype)
459
+ rotated = rotated + self._rotate_half(rotary) * sine.to(rotary.dtype)
460
+ return torch.cat((rotated, remainder), dim=-1)
461
+
462
+
463
+ class ScalarEmbedding(nn.Module):
464
+ def __init__(self, hidden_size: int) -> None:
465
+ super().__init__()
466
+ self.hidden_size = hidden_size
467
+ self.mlp = nn.Sequential(
468
+ nn.Linear(hidden_size, hidden_size),
469
+ nn.SiLU(),
470
+ nn.Linear(hidden_size, hidden_size),
471
+ )
472
+
473
+ def forward(self, value: torch.Tensor) -> torch.Tensor:
474
+ value = value.reshape(-1).float()
475
+ half = self.hidden_size // 2
476
+ frequencies = torch.exp(
477
+ -math.log(10_000.0)
478
+ * torch.arange(half, device=value.device, dtype=torch.float32)
479
+ / half
480
+ )
481
+ angles = value[:, None] * frequencies[None] * 1_000.0
482
+ embedding = torch.cat((angles.cos(), angles.sin()), dim=-1)
483
+ return self.mlp(embedding.to(dtype=self.mlp[0].weight.dtype))
484
+
485
+
486
+ class SwiGLU(nn.Module):
487
+ def __init__(self, hidden_size: int, ratio: float) -> None:
488
+ super().__init__()
489
+ inner_size = int(hidden_size * ratio * 2 / 3)
490
+ inner_size = ((inner_size + 63) // 64) * 64
491
+ self.input = nn.Linear(hidden_size, inner_size * 2, bias=False)
492
+ self.output = nn.Linear(inner_size, hidden_size, bias=False)
493
+
494
+ def forward(self, inputs: torch.Tensor) -> torch.Tensor:
495
+ values, gates = self.input(inputs).chunk(2, dim=-1)
496
+ return self.output(values * F.silu(gates))
497
+
498
+
499
+ class SelfAttention(nn.Module):
500
+ def __init__(self, hidden_size: int, num_heads: int, qk_norm: bool) -> None:
501
+ super().__init__()
502
+ self.num_heads = num_heads
503
+ self.head_size = hidden_size // num_heads
504
+ self.qkv = nn.Linear(hidden_size, hidden_size * 3, bias=False)
505
+ self.output = nn.Linear(hidden_size, hidden_size, bias=False)
506
+ self.q_norm = (
507
+ nn.RMSNorm(self.head_size, eps=1e-6)
508
+ if qk_norm
509
+ else nn.Identity()
510
+ )
511
+ self.k_norm = (
512
+ nn.RMSNorm(self.head_size, eps=1e-6)
513
+ if qk_norm
514
+ else nn.Identity()
515
+ )
516
+ self.rotary = Rotary3D(self.head_size)
517
+
518
+ def forward(self, inputs: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
519
+ batch, tokens, channels = inputs.shape
520
+ query, key, value = self.qkv(inputs).reshape(
521
+ batch,
522
+ tokens,
523
+ 3,
524
+ self.num_heads,
525
+ self.head_size,
526
+ ).unbind(dim=2)
527
+ query = self.rotary(self.q_norm(query).transpose(1, 2), positions)
528
+ key = self.rotary(self.k_norm(key).transpose(1, 2), positions)
529
+ value = value.transpose(1, 2)
530
+ attended = F.scaled_dot_product_attention(query, key, value)
531
+ return self.output(attended.transpose(1, 2).reshape(batch, tokens, channels))
532
+
533
+
534
+ class PrefixAttention(nn.Module):
535
+ """Target attention over the joint clean-prefix and noisy-target sequence."""
536
+
537
+ def __init__(self, hidden_size: int, num_heads: int, qk_norm: bool) -> None:
538
+ super().__init__()
539
+ self.num_heads = num_heads
540
+ self.head_size = hidden_size // num_heads
541
+ self.target_qkv = nn.Linear(hidden_size, hidden_size * 3, bias=False)
542
+ self.prefix_key_value = nn.Linear(hidden_size, hidden_size * 2, bias=False)
543
+ self.output = nn.Linear(hidden_size, hidden_size, bias=False)
544
+ self.q_norm = (
545
+ nn.RMSNorm(self.head_size, eps=1e-6)
546
+ if qk_norm
547
+ else nn.Identity()
548
+ )
549
+ self.k_norm = (
550
+ nn.RMSNorm(self.head_size, eps=1e-6)
551
+ if qk_norm
552
+ else nn.Identity()
553
+ )
554
+ self.rotary = Rotary3D(self.head_size)
555
+
556
+ def project_prefix(
557
+ self,
558
+ prefix: torch.Tensor,
559
+ positions: torch.Tensor,
560
+ ) -> tuple[torch.Tensor, torch.Tensor]:
561
+ batch, tokens, _ = prefix.shape
562
+ key, value = self.prefix_key_value(prefix).reshape(
563
+ batch,
564
+ tokens,
565
+ 2,
566
+ self.num_heads,
567
+ self.head_size,
568
+ ).unbind(dim=2)
569
+ key = self.rotary(self.k_norm(key).transpose(1, 2), positions)
570
+ return key, value.transpose(1, 2)
571
+
572
+ def forward_cached(
573
+ self,
574
+ target: torch.Tensor,
575
+ target_positions: torch.Tensor,
576
+ prefix_key_value: tuple[torch.Tensor, torch.Tensor],
577
+ ) -> torch.Tensor:
578
+ batch, tokens, channels = target.shape
579
+ query, key, value = self.target_qkv(target).reshape(
580
+ batch,
581
+ tokens,
582
+ 3,
583
+ self.num_heads,
584
+ self.head_size,
585
+ ).unbind(dim=2)
586
+ query = self.rotary(self.q_norm(query).transpose(1, 2), target_positions)
587
+ key = self.rotary(self.k_norm(key).transpose(1, 2), target_positions)
588
+ value = value.transpose(1, 2)
589
+ prefix_key, prefix_value = prefix_key_value
590
+ key = torch.cat((prefix_key, key), dim=2)
591
+ value = torch.cat((prefix_value, value), dim=2)
592
+ attended = F.scaled_dot_product_attention(query, key, value)
593
+ return self.output(attended.transpose(1, 2).reshape(batch, tokens, channels))
594
+
595
+
596
+ class PrefixDiTBlock(nn.Module):
597
+ def __init__(self, config: ModelConfig) -> None:
598
+ super().__init__()
599
+ hidden_size = config.hidden_size
600
+ self.prefix_norm1 = nn.RMSNorm(hidden_size, eps=1e-6)
601
+ self.prefix_attention = SelfAttention(
602
+ hidden_size,
603
+ config.num_heads,
604
+ config.qk_norm,
605
+ )
606
+ self.prefix_norm2 = nn.RMSNorm(hidden_size, eps=1e-6)
607
+ self.prefix_mlp = SwiGLU(hidden_size, config.mlp_ratio)
608
+ self.prefix_projection_norm = nn.RMSNorm(hidden_size, eps=1e-6)
609
+ self.target_norm1 = nn.RMSNorm(
610
+ hidden_size,
611
+ eps=1e-6,
612
+ elementwise_affine=False,
613
+ )
614
+ self.target_attention = PrefixAttention(
615
+ hidden_size,
616
+ config.num_heads,
617
+ config.qk_norm,
618
+ )
619
+ self.target_norm2 = nn.RMSNorm(
620
+ hidden_size,
621
+ eps=1e-6,
622
+ elementwise_affine=False,
623
+ )
624
+ self.target_mlp = SwiGLU(hidden_size, config.mlp_ratio)
625
+ self.modulation = nn.Linear(hidden_size, hidden_size * 6)
626
+
627
+ @staticmethod
628
+ def _modulate(
629
+ inputs: torch.Tensor,
630
+ shift: torch.Tensor,
631
+ scale: torch.Tensor,
632
+ ) -> torch.Tensor:
633
+ return inputs * (1.0 + scale[:, None]) + shift[:, None]
634
+
635
+ def advance_prefix(
636
+ self,
637
+ prefix: torch.Tensor,
638
+ positions: torch.Tensor,
639
+ ) -> torch.Tensor:
640
+ prefix = prefix + self.prefix_attention(self.prefix_norm1(prefix), positions)
641
+ return prefix + self.prefix_mlp(self.prefix_norm2(prefix))
642
+
643
+ def prefix_key_value(
644
+ self,
645
+ prefix: torch.Tensor,
646
+ positions: torch.Tensor,
647
+ ) -> tuple[torch.Tensor, torch.Tensor]:
648
+ return self.target_attention.project_prefix(
649
+ self.prefix_projection_norm(prefix),
650
+ positions,
651
+ )
652
+
653
+ def advance_target(
654
+ self,
655
+ target: torch.Tensor,
656
+ condition: torch.Tensor,
657
+ target_positions: torch.Tensor,
658
+ prefix_key_value: tuple[torch.Tensor, torch.Tensor],
659
+ ) -> torch.Tensor:
660
+ shift1, scale1, gate1, shift2, scale2, gate2 = self.modulation(
661
+ F.silu(condition)
662
+ ).chunk(6, dim=-1)
663
+ normalized = self._modulate(self.target_norm1(target), shift1, scale1)
664
+ target = target + gate1[:, None] * self.target_attention.forward_cached(
665
+ normalized,
666
+ target_positions,
667
+ prefix_key_value,
668
+ )
669
+ normalized = self._modulate(self.target_norm2(target), shift2, scale2)
670
+ return target + gate2[:, None] * self.target_mlp(normalized)
671
+
672
+ def forward(
673
+ self,
674
+ prefix: torch.Tensor,
675
+ target: torch.Tensor,
676
+ condition: torch.Tensor,
677
+ prefix_positions: torch.Tensor,
678
+ target_positions: torch.Tensor,
679
+ ) -> tuple[torch.Tensor, torch.Tensor]:
680
+ prefix = self.advance_prefix(prefix, prefix_positions)
681
+ key_value = self.prefix_key_value(prefix, prefix_positions)
682
+ target = self.advance_target(
683
+ target,
684
+ condition,
685
+ target_positions,
686
+ key_value,
687
+ )
688
+ return prefix, target
689
+
690
+
691
+ class CausalLatentVideoDiT(nn.Module):
692
+ """Multiscale clean-prefix/noisy-suffix latent video transformer."""
693
+
694
+ def __init__(
695
+ self,
696
+ model_config: ModelConfig,
697
+ latent_channels: int,
698
+ history_frames: int = 12,
699
+ ) -> None:
700
+ super().__init__()
701
+ self.model_config = model_config
702
+ self.latent_channels = latent_channels
703
+ self.history_frames = history_frames
704
+ self.gradient_checkpointing = False
705
+ hidden_size = model_config.hidden_size
706
+ target_patch = model_config.target_patch_size
707
+ coarse_patch = model_config.memory_patch_size
708
+ fine_patch = model_config.fine_memory_patch_size
709
+ self.target_patch_embed = nn.Conv3d(
710
+ latent_channels,
711
+ hidden_size,
712
+ kernel_size=(1, target_patch, target_patch),
713
+ stride=(1, target_patch, target_patch),
714
+ )
715
+ self.coarse_prefix_embed = nn.Conv3d(
716
+ latent_channels,
717
+ hidden_size,
718
+ kernel_size=(1, coarse_patch, coarse_patch),
719
+ stride=(1, coarse_patch, coarse_patch),
720
+ )
721
+ self.fine_prefix_embed = nn.Conv3d(
722
+ latent_channels,
723
+ hidden_size,
724
+ kernel_size=(1, fine_patch, fine_patch),
725
+ stride=(1, fine_patch, fine_patch),
726
+ )
727
+ self.motion_prefix_embed = nn.Conv3d(
728
+ latent_channels,
729
+ hidden_size,
730
+ kernel_size=(1, fine_patch, fine_patch),
731
+ stride=(1, fine_patch, fine_patch),
732
+ )
733
+ self.coarse_scale_embedding = nn.Parameter(torch.zeros(1, 1, hidden_size))
734
+ self.fine_scale_embedding = nn.Parameter(torch.zeros(1, 1, hidden_size))
735
+ self.t_embedding = ScalarEmbedding(hidden_size)
736
+ self.r_embedding = ScalarEmbedding(hidden_size)
737
+ self.delta_embedding = ScalarEmbedding(hidden_size)
738
+ self.time_projection = nn.Sequential(
739
+ nn.Linear(hidden_size * 3, hidden_size),
740
+ nn.SiLU(),
741
+ nn.Linear(hidden_size, hidden_size),
742
+ )
743
+ self.prefix_condition = nn.Linear(hidden_size, hidden_size)
744
+ self.action_projection = (
745
+ nn.Sequential(
746
+ nn.Linear(model_config.action_dim, hidden_size),
747
+ nn.SiLU(),
748
+ nn.Linear(hidden_size, hidden_size),
749
+ )
750
+ if model_config.action_dim > 0
751
+ else None
752
+ )
753
+ self.context_projection = (
754
+ nn.Linear(model_config.context_dim, hidden_size, bias=False)
755
+ if model_config.context_dim > 0
756
+ else None
757
+ )
758
+ self.blocks = nn.ModuleList(
759
+ PrefixDiTBlock(model_config) for _ in range(model_config.depth)
760
+ )
761
+ self.output_norm = nn.RMSNorm(
762
+ hidden_size,
763
+ eps=1e-6,
764
+ elementwise_affine=False,
765
+ )
766
+ self.output_modulation = nn.Linear(hidden_size, hidden_size * 2)
767
+ self.output_projection = nn.Linear(
768
+ hidden_size,
769
+ target_patch * target_patch * latent_channels,
770
+ )
771
+ self.outcome_norm = nn.RMSNorm(hidden_size, eps=1e-6)
772
+ self.reward_head = nn.Linear(hidden_size, 1)
773
+ self.terminal_head = nn.Linear(hidden_size, 1)
774
+ self.reset_parameters()
775
+
776
+ def enable_activation_checkpointing(self, enabled: bool = True) -> None:
777
+ self.gradient_checkpointing = enabled
778
+
779
+ def reset_parameters(self) -> None:
780
+ for module in self.modules():
781
+ if isinstance(module, nn.Linear):
782
+ nn.init.xavier_uniform_(module.weight)
783
+ if module.bias is not None:
784
+ nn.init.zeros_(module.bias)
785
+ for embedding in (
786
+ self.target_patch_embed,
787
+ self.coarse_prefix_embed,
788
+ self.fine_prefix_embed,
789
+ self.motion_prefix_embed,
790
+ ):
791
+ nn.init.normal_(embedding.weight, std=0.02)
792
+ nn.init.normal_(self.coarse_scale_embedding, std=0.02)
793
+ nn.init.normal_(self.fine_scale_embedding, std=0.02)
794
+ for block in self.blocks:
795
+ nn.init.zeros_(block.modulation.weight)
796
+ nn.init.zeros_(block.modulation.bias)
797
+ nn.init.zeros_(self.output_modulation.weight)
798
+ nn.init.zeros_(self.output_modulation.bias)
799
+ nn.init.zeros_(self.output_projection.weight)
800
+ nn.init.zeros_(self.output_projection.bias)
801
+ nn.init.zeros_(self.reward_head.weight)
802
+ nn.init.zeros_(self.reward_head.bias)
803
+ nn.init.zeros_(self.terminal_head.weight)
804
+ nn.init.zeros_(self.terminal_head.bias)
805
+
806
+ @staticmethod
807
+ def _patch(
808
+ embedding: nn.Conv3d,
809
+ video: torch.Tensor,
810
+ time_offset: int,
811
+ ) -> tuple[torch.Tensor, torch.Tensor]:
812
+ tokens = embedding(video.transpose(1, 2))
813
+ _, _, frames, height, width = tokens.shape
814
+ positions = _video_positions(
815
+ frames,
816
+ height,
817
+ width,
818
+ time_offset,
819
+ video.device,
820
+ )
821
+ return tokens.flatten(2).transpose(1, 2), positions
822
+
823
+ def _encode_prefix(
824
+ self,
825
+ history: torch.Tensor,
826
+ context: torch.Tensor | None = None,
827
+ ) -> tuple[torch.Tensor, torch.Tensor]:
828
+ if history.shape[1] != self.history_frames:
829
+ raise ValueError(
830
+ f"Expected {self.history_frames} history frames, got {history.shape[1]}."
831
+ )
832
+ coarse, coarse_positions = self._patch(
833
+ self.coarse_prefix_embed,
834
+ history,
835
+ 0,
836
+ )
837
+ fine_frames = self.model_config.fine_history_frames
838
+ fine_history = history[:, -fine_frames:]
839
+ previous = history[:, -fine_frames - 1 : -1]
840
+ if previous.shape[1] != fine_frames:
841
+ previous = torch.cat((fine_history[:, :1], fine_history[:, :-1]), dim=1)
842
+ motion = fine_history - previous
843
+ fine, fine_positions = self._patch(
844
+ self.fine_prefix_embed,
845
+ fine_history,
846
+ self.history_frames - fine_frames,
847
+ )
848
+ motion_tokens, _ = self._patch(
849
+ self.motion_prefix_embed,
850
+ motion,
851
+ self.history_frames - fine_frames,
852
+ )
853
+ coarse = coarse + self.coarse_scale_embedding
854
+ fine = fine + motion_tokens + self.fine_scale_embedding
855
+ prefix = torch.cat((coarse, fine), dim=1)
856
+ positions = torch.cat((coarse_positions, fine_positions), dim=0)
857
+ if self.context_projection is not None and context is not None:
858
+ projected = self.context_projection(context)
859
+ prefix = torch.cat((prefix, projected), dim=1)
860
+ positions = torch.cat(
861
+ (
862
+ positions,
863
+ torch.zeros(projected.shape[1], 3, device=history.device),
864
+ ),
865
+ dim=0,
866
+ )
867
+ return prefix, positions
868
+
869
+ def _condition(
870
+ self,
871
+ prefix: torch.Tensor,
872
+ r: torch.Tensor,
873
+ t: torch.Tensor,
874
+ ) -> torch.Tensor:
875
+ time = self.time_projection(
876
+ torch.cat(
877
+ (
878
+ self.t_embedding(t),
879
+ self.r_embedding(r),
880
+ self.delta_embedding(t - r),
881
+ ),
882
+ dim=-1,
883
+ )
884
+ )
885
+ return time + self.prefix_condition(prefix.mean(dim=1))
886
+
887
+ def build_memory_cache(
888
+ self,
889
+ history: torch.Tensor,
890
+ context: torch.Tensor | None = None,
891
+ ) -> PrefixCache:
892
+ prefix, positions = self._encode_prefix(history, context)
893
+ pooled = self.prefix_condition(prefix.mean(dim=1))
894
+ key_values: list[tuple[torch.Tensor, torch.Tensor]] = []
895
+ for block in self.blocks:
896
+ prefix = block.advance_prefix(prefix, positions)
897
+ key_values.append(block.prefix_key_value(prefix, positions))
898
+ return PrefixCache(tuple(key_values), pooled)
899
+
900
+ def predict_outcomes(
901
+ self,
902
+ history: torch.Tensor,
903
+ actions: torch.Tensor | None = None,
904
+ frame_count: int = 1,
905
+ memory_cache: PrefixCache | None = None,
906
+ ) -> tuple[torch.Tensor, torch.Tensor]:
907
+ if memory_cache is None:
908
+ prefix, _ = self._encode_prefix(history)
909
+ pooled = self.prefix_condition(prefix.mean(dim=1))
910
+ else:
911
+ pooled = memory_cache.pooled
912
+ hidden = pooled[:, None].expand(-1, frame_count, -1)
913
+ if self.action_projection is not None:
914
+ if actions is None or actions.shape[1] != frame_count:
915
+ raise ValueError("Outcome actions must match frame_count.")
916
+ hidden = hidden + self.action_projection(actions)
917
+ hidden = self.outcome_norm(hidden)
918
+ return self.reward_head(hidden).squeeze(-1), self.terminal_head(hidden).squeeze(-1)
919
+
920
+ def _unpatchify(
921
+ self,
922
+ patches: torch.Tensor,
923
+ frames: int,
924
+ height: int,
925
+ width: int,
926
+ ) -> torch.Tensor:
927
+ batch = patches.shape[0]
928
+ patch = self.model_config.target_patch_size
929
+ patches = patches.reshape(
930
+ batch,
931
+ frames,
932
+ height,
933
+ width,
934
+ patch,
935
+ patch,
936
+ self.latent_channels,
937
+ )
938
+ return patches.permute(0, 1, 6, 2, 4, 3, 5).reshape(
939
+ batch,
940
+ frames,
941
+ self.latent_channels,
942
+ height * patch,
943
+ width * patch,
944
+ )
945
+
946
+ def forward(
947
+ self,
948
+ noisy_target: torch.Tensor,
949
+ history: torch.Tensor,
950
+ r: torch.Tensor,
951
+ t: torch.Tensor,
952
+ actions: torch.Tensor | None = None,
953
+ context: torch.Tensor | None = None,
954
+ memory_cache: PrefixCache | None = None,
955
+ ) -> torch.Tensor:
956
+ target_volume = self.target_patch_embed(noisy_target.transpose(1, 2))
957
+ batch, _, frames, height, width = target_volume.shape
958
+ target = target_volume.flatten(2).transpose(1, 2)
959
+ target_positions = _video_positions(
960
+ frames,
961
+ height,
962
+ width,
963
+ self.history_frames,
964
+ noisy_target.device,
965
+ )
966
+ if memory_cache is None:
967
+ prefix, prefix_positions = self._encode_prefix(history, context)
968
+ condition = self._condition(prefix, r, t)
969
+ else:
970
+ prefix = None
971
+ prefix_positions = None
972
+ condition = self.time_projection(
973
+ torch.cat(
974
+ (
975
+ self.t_embedding(t),
976
+ self.r_embedding(r),
977
+ self.delta_embedding(t - r),
978
+ ),
979
+ dim=-1,
980
+ )
981
+ ) + memory_cache.pooled
982
+ if self.action_projection is not None:
983
+ if actions is None or actions.shape[:2] != (batch, frames):
984
+ raise ValueError("Actions must match [batch, target_frames, action_dim].")
985
+ action_embedding = self.action_projection(actions)
986
+ target = target + action_embedding.repeat_interleave(height * width, dim=1)
987
+ condition = condition + action_embedding.mean(dim=1)
988
+ for block_index, block in enumerate(self.blocks):
989
+ if memory_cache is None:
990
+ if prefix is None or prefix_positions is None:
991
+ raise RuntimeError("Prefix state is unavailable.")
992
+ if self.training and self.gradient_checkpointing:
993
+ prefix, target = checkpoint(
994
+ block,
995
+ prefix,
996
+ target,
997
+ condition,
998
+ prefix_positions,
999
+ target_positions,
1000
+ use_reentrant=False,
1001
+ )
1002
+ else:
1003
+ prefix, target = block(
1004
+ prefix,
1005
+ target,
1006
+ condition,
1007
+ prefix_positions,
1008
+ target_positions,
1009
+ )
1010
+ else:
1011
+ target = block.advance_target(
1012
+ target,
1013
+ condition,
1014
+ target_positions,
1015
+ memory_cache.key_values[block_index],
1016
+ )
1017
+ shift, scale = self.output_modulation(F.silu(condition)).chunk(2, dim=-1)
1018
+ target = self.output_norm(target) * (1.0 + scale[:, None]) + shift[:, None]
1019
+ return self._unpatchify(
1020
+ self.output_projection(target),
1021
+ frames,
1022
+ height,
1023
+ width,
1024
+ )
1025
+
1026
+
1027
+ CausalVideoDiT = CausalLatentVideoDiT
1028
+ @torch.inference_mode()
1029
+ def sample_next_latent(
1030
+ model: nn.Module,
1031
+ history: torch.Tensor,
1032
+ steps: int = 2,
1033
+ actions: torch.Tensor | None = None,
1034
+ generator: torch.Generator | None = None,
1035
+ window_frames: int = 1,
1036
+ memory_cache: object | None = None,
1037
+ instantaneous: bool = False,
1038
+ ) -> torch.Tensor:
1039
+ if steps < 1:
1040
+ raise ValueError("steps must be positive.")
1041
+ batch, _, channels, height, width = history.shape
1042
+ state = torch.randn(
1043
+ (batch, window_frames, channels, height, width),
1044
+ device=history.device,
1045
+ dtype=history.dtype,
1046
+ generator=generator,
1047
+ )
1048
+ boundaries = torch.linspace(1.0, 0.0, steps + 1, device=history.device)
1049
+ for index in range(steps):
1050
+ t_value = boundaries[index]
1051
+ r_value = boundaries[index + 1]
1052
+ t = t_value.expand(batch)
1053
+ r = r_value.expand(batch)
1054
+ model_r = t if instantaneous else r
1055
+ average_velocity = model(
1056
+ state,
1057
+ history,
1058
+ model_r,
1059
+ t,
1060
+ actions=actions,
1061
+ memory_cache=memory_cache,
1062
+ )
1063
+ state = state - (t_value - r_value).to(state.dtype) * average_velocity
1064
+ return state
1065
+
1066
+ def _parse_args() -> argparse.Namespace:
1067
+ parser = argparse.ArgumentParser(description="Diffusion Breakout.")
1068
+ parser.add_argument("--repo", default=DEFAULT_REPO)
1069
+ parser.add_argument("--revision", default="main")
1070
+ parser.add_argument("--local-dir", default=None)
1071
+ parser.add_argument("--steps", type=int, default=1)
1072
+ parser.add_argument("--frames", type=int, default=0, help="0 = until window closed")
1073
+ parser.add_argument("--seed", type=int, default=None)
1074
+ parser.add_argument("--window-scale", type=int, default=6)
1075
+ parser.add_argument("--fps-cap", type=float, default=0.0)
1076
+ return parser.parse_args()
1077
+
1078
+
1079
+ def _download(repo: str, revision: str, local_dir: str | None) -> Path:
1080
+ if local_dir:
1081
+ root = Path(local_dir)
1082
+ root.mkdir(parents=True, exist_ok=True)
1083
+ for name in ("config.json", "ema.safetensors"):
1084
+ hf_hub_download(
1085
+ repo_id=repo,
1086
+ filename=name,
1087
+ revision=revision,
1088
+ local_dir=str(root),
1089
+ )
1090
+ return root
1091
+ config_path = Path(
1092
+ hf_hub_download(repo_id=repo, filename="config.json", revision=revision)
1093
+ )
1094
+ hf_hub_download(repo_id=repo, filename="ema.safetensors", revision=revision)
1095
+ return config_path.parent
1096
+
1097
+
1098
+ def _load_settings(config_path: Path) -> tuple[VideoConfig, ModelConfig, CodecSettings]:
1099
+ raw = json.loads(config_path.read_text(encoding="utf-8"))
1100
+ video = VideoConfig(**{key: raw["video"][key] for key in VideoConfig.__dataclass_fields__})
1101
+ model = ModelConfig(**{key: raw["model"][key] for key in ModelConfig.__dataclass_fields__})
1102
+ codec_raw = raw["codec"]
1103
+ codec = CodecSettings(
1104
+ model_id=codec_raw.get("model_id", "madebyollin/sdxl-vae-fp16-fix"),
1105
+ subfolder=codec_raw.get("subfolder", "") or "",
1106
+ revision=codec_raw.get("revision", "main"),
1107
+ latent_channels=int(codec_raw.get("latent_channels", 4)),
1108
+ spatial_compression=int(codec_raw.get("spatial_compression", 8)),
1109
+ frame_batch_size=int(codec_raw.get("frame_batch_size", 256)),
1110
+ sample_posterior=bool(codec_raw.get("sample_posterior", False)),
1111
+ enable_slicing=bool(codec_raw.get("enable_slicing", True)),
1112
+ enable_tiling=bool(codec_raw.get("enable_tiling", False)),
1113
+ )
1114
+ return video, model, codec
1115
+
1116
+
1117
+ def _frames_to_tensor(frames: np.ndarray, device: torch.device) -> torch.Tensor:
1118
+ tensor = torch.from_numpy(frames.copy()).permute(0, 3, 1, 2).float()
1119
+ return tensor.div_(127.5).sub_(1.0).unsqueeze(0).to(device, non_blocking=True)
1120
+
1121
+
1122
+ def _tensor_to_image(frame: torch.Tensor) -> Image.Image:
1123
+ array = frame.float().clamp(-1.0, 1.0).add(1.0).mul(127.5)
1124
+ return Image.fromarray(array.byte().permute(1, 2, 0).cpu().numpy(), mode="RGB")
1125
+
1126
+
1127
+ def _inject_ball(image: Image.Image, position: tuple[float, float]) -> Image.Image:
1128
+ image = image.copy()
1129
+ draw = ImageDraw.Draw(image)
1130
+ radius = max(3.0, min(image.width, image.height) * 0.025)
1131
+ x, y = position
1132
+ draw.ellipse(
1133
+ (x - radius, y - radius, x + radius, y + radius),
1134
+ fill=(255, 209, 72),
1135
+ outline=(255, 247, 196),
1136
+ width=max(1, round(radius * 0.25)),
1137
+ )
1138
+ return image
1139
+
1140
+
1141
+ def _rgb_image_to_frame_tensor(image: Image.Image, device: torch.device) -> torch.Tensor:
1142
+ array = np.asarray(image, dtype=np.uint8).copy()
1143
+ return (
1144
+ torch.from_numpy(array)
1145
+ .permute(2, 0, 1)
1146
+ .float()
1147
+ .div_(127.5)
1148
+ .sub_(1.0)
1149
+ .to(device, non_blocking=True)
1150
+ )
1151
+
1152
+
1153
+ def _inject_ball_into_history(
1154
+ history: deque[torch.Tensor],
1155
+ codec: FrameAutoencoderCodec,
1156
+ position: tuple[float, float],
1157
+ ) -> Image.Image:
1158
+ history_tensor = torch.stack(tuple(history), dim=1)
1159
+ device = history_tensor.device
1160
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
1161
+ decoded = codec.decode(history_tensor)
1162
+ painted_images: list[Image.Image] = []
1163
+ painted_frames: list[torch.Tensor] = []
1164
+ for index in range(decoded.shape[1]):
1165
+ image = _inject_ball(_tensor_to_image(decoded[0, index]), position)
1166
+ painted_images.append(image)
1167
+ painted_frames.append(_rgb_image_to_frame_tensor(image, device))
1168
+ rgb = torch.stack(painted_frames, dim=0).unsqueeze(0)
1169
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
1170
+ latents = codec.encode(rgb)
1171
+ history.clear()
1172
+ history.extend(latents[:, index] for index in range(latents.shape[1]))
1173
+ return painted_images[-1]
1174
+
1175
+
1176
+ def _encode_ball_frame(
1177
+ image: Image.Image,
1178
+ position: tuple[float, float],
1179
+ codec: FrameAutoencoderCodec,
1180
+ device: torch.device,
1181
+ ) -> tuple[Image.Image, torch.Tensor]:
1182
+ painted = _inject_ball(image, position)
1183
+ rgb = _rgb_image_to_frame_tensor(painted, device).reshape(
1184
+ 1, 1, 3, painted.height, painted.width
1185
+ )
1186
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
1187
+ latent = codec.encode(rgb)[:, 0]
1188
+ return painted, latent
1189
+
1190
+
1191
+ class LiveWindow:
1192
+ def __init__(self, scale: int) -> None:
1193
+ self.scale = max(1, scale)
1194
+ self.is_open = True
1195
+ self.root = tk.Tk()
1196
+ self.root.title("Diffusion Breakout")
1197
+ self.label = tk.Label(self.root, background="black")
1198
+ self.label.pack()
1199
+ self.photo: ImageTk.PhotoImage | None = None
1200
+ self.generated_width = 0
1201
+ self.generated_height = 0
1202
+ self.pending_click: tuple[float, float] | None = None
1203
+ self.pressed: set[str] = set()
1204
+ self.label.bind("<Button-1>", self._on_click)
1205
+ self.root.bind("<KeyPress>", self._on_key_press)
1206
+ self.root.bind("<KeyRelease>", self._on_key_release)
1207
+ self.root.protocol("WM_DELETE_WINDOW", self.close)
1208
+ self.root.focus_force()
1209
+
1210
+ def _on_key_press(self, event: tk.Event) -> None:
1211
+ key = str(event.keysym).lower()
1212
+ if key in {"w", "s", "a", "d", "left", "right"}:
1213
+ self.pressed.add(key)
1214
+
1215
+ def _on_key_release(self, event: tk.Event) -> None:
1216
+ self.pressed.discard(str(event.keysym).lower())
1217
+
1218
+ def action(self) -> tuple[float, float]:
1219
+ negative = bool(self.pressed & {"w", "a", "left"})
1220
+ positive = bool(self.pressed & {"s", "d", "right"})
1221
+ if negative and positive:
1222
+ return (0.0, 0.0)
1223
+ return (float(negative), float(positive))
1224
+
1225
+ def _on_click(self, event: tk.Event) -> None:
1226
+ if self.generated_width <= 0:
1227
+ return
1228
+ x = (float(event.x) / self.scale) % self.generated_width
1229
+ y = min(max(float(event.y) / self.scale, 0.0), self.generated_height - 1.0)
1230
+ self.pending_click = (x, y)
1231
+
1232
+ def consume_click(self) -> tuple[float, float] | None:
1233
+ click = self.pending_click
1234
+ self.pending_click = None
1235
+ return click
1236
+
1237
+ def close(self) -> None:
1238
+ if self.is_open:
1239
+ self.is_open = False
1240
+ self.root.destroy()
1241
+
1242
+ def show(self, image: Image.Image, frame_index: int, latency_ms: float) -> bool:
1243
+ if not self.is_open:
1244
+ return False
1245
+ self.generated_width, self.generated_height = image.size
1246
+ display = image.resize(
1247
+ (image.width * self.scale, image.height * self.scale),
1248
+ Image.Resampling.NEAREST,
1249
+ )
1250
+ self.photo = ImageTk.PhotoImage(display)
1251
+ self.label.configure(image=self.photo)
1252
+ held = "+".join(sorted(self.pressed)).upper()
1253
+ held_text = f" [{held}]" if held else ""
1254
+ self.root.title(
1255
+ f"Diffusion Breakout — frame {frame_index + 1} {latency_ms:.0f} ms{held_text}"
1256
+ )
1257
+ try:
1258
+ self.root.update_idletasks()
1259
+ self.root.update()
1260
+ except tk.TclError:
1261
+ self.is_open = False
1262
+ return self.is_open
1263
+
1264
+
1265
+ @torch.inference_mode()
1266
+ def main() -> None:
1267
+ args = _parse_args()
1268
+ if not torch.cuda.is_available() or not torch.cuda.is_bf16_supported():
1269
+ raise SystemExit("Need a CUDA GPU with BF16 support.")
1270
+
1271
+ print(f"Downloading {args.repo} …")
1272
+ checkpoint = _download(args.repo, args.revision, args.local_dir)
1273
+ video_cfg, model_cfg, codec_cfg = _load_settings(checkpoint / "config.json")
1274
+ weights = checkpoint / "ema.safetensors"
1275
+ if not weights.exists():
1276
+ raise SystemExit(f"Missing {weights}")
1277
+
1278
+ device = torch.device("cuda")
1279
+ torch.set_float32_matmul_precision("high")
1280
+ seed = args.seed if args.seed is not None else random.SystemRandom().randrange(2**31)
1281
+ simulation = BreakoutSimulation.from_seed(video_cfg, seed, at_start=True)
1282
+ history_rgb = _frames_to_tensor(
1283
+ simulation.sample_frames(video_cfg.history_frames),
1284
+ device,
1285
+ )
1286
+
1287
+ print("Loading VAE + DiT …")
1288
+ codec = FrameAutoencoderCodec(codec_cfg, device, torch.bfloat16)
1289
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
1290
+ latents = codec.encode(history_rgb)
1291
+ history: deque[torch.Tensor] = deque(
1292
+ (latents[:, index] for index in range(video_cfg.history_frames)),
1293
+ maxlen=video_cfg.history_frames,
1294
+ )
1295
+ model = (
1296
+ CausalLatentVideoDiT(
1297
+ model_cfg,
1298
+ latent_channels=codec.channels,
1299
+ history_frames=codec.latent_frame_count(video_cfg.history_frames),
1300
+ )
1301
+ .to(device)
1302
+ .eval()
1303
+ )
1304
+ model.load_state_dict(load_file(str(weights)), strict=True)
1305
+
1306
+ steps = max(1, args.steps)
1307
+ frame_limit = args.frames if args.frames > 0 else 10_000_000
1308
+ window = LiveWindow(args.window_scale)
1309
+ generator = torch.Generator(device=device).manual_seed(seed + 7)
1310
+ injected_position: tuple[float, float] | None = None
1311
+ force_ball_frames = 0
1312
+ print("Ready. Click the window, then hold A/D or arrow keys.")
1313
+
1314
+ for frame_index in range(frame_limit):
1315
+ click = window.consume_click()
1316
+ if click is not None:
1317
+ preview = _inject_ball_into_history(history, codec, click)
1318
+ injected_position = click
1319
+ force_ball_frames = video_cfg.history_frames
1320
+ if not window.show(preview, frame_index, 0.0):
1321
+ break
1322
+ history_tensor = torch.stack(tuple(history), dim=1)
1323
+ action = torch.tensor(
1324
+ [window.action()],
1325
+ device=device,
1326
+ dtype=torch.float32,
1327
+ ).unsqueeze(0)
1328
+ torch.cuda.synchronize()
1329
+ started = time.perf_counter()
1330
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
1331
+ memory_cache = model.build_memory_cache(history_tensor)
1332
+ prediction = sample_next_latent(
1333
+ model,
1334
+ history_tensor,
1335
+ steps,
1336
+ actions=action if model_cfg.action_dim > 0 else None,
1337
+ generator=generator,
1338
+ window_frames=1,
1339
+ memory_cache=memory_cache,
1340
+ instantaneous=True,
1341
+ )
1342
+ decoded = codec.decode(prediction)
1343
+ torch.cuda.synchronize()
1344
+ latency_ms = (time.perf_counter() - started) * 1000
1345
+ image = _tensor_to_image(decoded[0, 0])
1346
+ frame_latent = prediction[:, 0]
1347
+ if injected_position is not None and force_ball_frames > 0:
1348
+ image, frame_latent = _encode_ball_frame(
1349
+ image, injected_position, codec, device
1350
+ )
1351
+ force_ball_frames -= 1
1352
+ if force_ball_frames == 0:
1353
+ injected_position = None
1354
+ history.append(frame_latent)
1355
+ if not window.show(image, frame_index, latency_ms):
1356
+ break
1357
+ if args.fps_cap > 0:
1358
+ target = 1.0 / args.fps_cap
1359
+ elapsed = time.perf_counter() - started
1360
+ if elapsed < target:
1361
+ time.sleep(target - elapsed)
1362
+ if window.is_open:
1363
+ window.close()
1364
+ print("Done.")
1365
+
1366
+
1367
+ if __name__ == "__main__":
1368
+ main()
preview.png ADDED