kerzgrr commited on
Commit
21fd264
·
verified ·
1 Parent(s): 0dfd563

Document download + run commands; ship live_infer.py

Browse files
Files changed (2) hide show
  1. README.md +65 -49
  2. live_infer.py +303 -0
README.md CHANGED
@@ -1,49 +1,65 @@
1
- ---
2
- license: apache-2.0
3
- library_name: pytorch
4
- pipeline_tag: other
5
- tags:
6
- - world-model
7
- - diffusion
8
- - pong
9
- - video
10
- - causal
11
- - game
12
- ---
13
-
14
- # Diffusion Pong
15
-
16
- A small action-conditioned world model that learned to keep a Pong match going. You hold **W** / **S**, it invents the next frame.
17
-
18
- ![demo](demo.gif)
19
-
20
- 128×128 at 6 FPS with a 12-frame latent history. Codec is a frozen SDXL VAE (`madebyollin/sdxl-vae-fp16-fix`).
21
-
22
- ## Play it
23
-
24
- From the livediffusion source tree (after `pip install -e .`):
25
-
26
- ```bash
27
- python scripts/live_infer.py
28
- ```
29
-
30
- First run pulls this repo into the HF cache. Needs a CUDA GPU with BF16.
31
-
32
- ```bash
33
- # optional knobs
34
- python scripts/live_infer.py --steps 2 --window-scale 7 --seed 42
35
- ```
36
-
37
- Controls: **W** up, **S** down. Click the window once so it has focus. Clicking the canvas drops a yellow ball into the latent history for a few frames if you want to poke it.
38
-
39
- ## What's in the files
40
-
41
- | file | what |
42
- |---|---|
43
- | `ema.safetensors` | playable weights |
44
- | `config.json` | video / model / codec settings used at train time |
45
- | `demo.gif` | live rollout clip |
46
-
47
- Action vector is `[W, S]` as floats in `{0,1}`. Hold both and it treats that as neutral.
48
-
49
- If something breaks, it's probably the VAE download or a GPU that can't do BF16.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: pytorch
4
+ pipeline_tag: other
5
+ tags:
6
+ - world-model
7
+ - diffusion
8
+ - pong
9
+ - video
10
+ - causal
11
+ - game
12
+ ---
13
+
14
+ # Diffusion Pong
15
+
16
+ A small action-conditioned world model that learned to keep a Pong match going. You hold **W** / **S**, it invents the next frame.
17
+
18
+ ![demo](demo.gif)
19
+
20
+ 128×128 at 6 FPS with a 12-frame latent history. Codec is a frozen SDXL VAE (`madebyollin/sdxl-vae-fp16-fix`). Needs a CUDA GPU with BF16.
21
+
22
+ ## Play it
23
+
24
+ Install the livediffusion package from this project's source tree, then grab the play script + weights from the Hub.
25
+
26
+ ```bash
27
+ # from the livediffusion repo root
28
+ pip install -e .
29
+ pip install -U "huggingface_hub[cli]"
30
+ ```
31
+
32
+ Download the script (and optionally the weights into a local folder):
33
+
34
+ ```bash
35
+ # play script
36
+ hf download kerzgrr/diffusionpong live_infer.py --local-dir scripts
37
+
38
+ # weights + config (optional — the script can also pull these on its own)
39
+ hf download kerzgrr/diffusionpong --local-dir checkpoints/diffusionpong --include "ema.safetensors" --include "config.json"
40
+ ```
41
+
42
+ Run:
43
+
44
+ ```bash
45
+ # downloads into the HF cache automatically if you skip --local-dir
46
+ python scripts/live_infer.py --steps 2
47
+
48
+ # or point at a folder you already downloaded
49
+ python scripts/live_infer.py --repo kerzgrr/diffusionpong --local-dir checkpoints/diffusionpong --steps 2 --window-scale 7 --seed 42
50
+ ```
51
+
52
+ Controls: **W** up, **S** down. Click the window once so it has focus. Clicking the canvas drops a yellow ball into the latent history for a few frames if you want to poke it.
53
+
54
+ ## What's in the files
55
+
56
+ | file | what |
57
+ |---|---|
58
+ | `ema.safetensors` | playable weights |
59
+ | `config.json` | video / model / codec settings used at train time |
60
+ | `live_infer.py` | live play script (same as `scripts/live_infer.py`) |
61
+ | `demo.gif` | live rollout clip |
62
+
63
+ Action vector is `[W, S]` as floats in `{0,1}`. Hold both and it treats that as neutral.
64
+
65
+ If something breaks, it's probably the VAE download or a GPU that can't do BF16.
live_infer.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Play Diffusion Pong live. Downloads kerzgrr/diffusionpong on first run.
3
+
4
+ Requires: a BF16 CUDA GPU, this repo installed (`pip install -e .`), and
5
+ `huggingface_hub` (already a project dependency).
6
+
7
+ Controls:
8
+ W / S — move left paddle up / down
9
+ click — inject a yellow ball for a few frames (debug)
10
+ close window or Ctrl+C to quit
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import random
17
+ import sys
18
+ import time
19
+ import tkinter as tk
20
+ from collections import deque
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ import torch
25
+ from huggingface_hub import hf_hub_download, snapshot_download
26
+ from PIL import Image, ImageDraw, ImageTk
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parents[1]
29
+ SRC = REPO_ROOT / "src"
30
+ if SRC.is_dir() and str(SRC) not in sys.path:
31
+ sys.path.insert(0, str(SRC))
32
+
33
+ from livediffusion.checkpoint import load_model_weights
34
+ from livediffusion.codec import load_codec
35
+ from livediffusion.config import load_config
36
+ from livediffusion.meanflow import sample_next_latent
37
+ from livediffusion.model import CausalLatentVideoDiT
38
+ from livediffusion.simulator import PongSimulation
39
+
40
+ DEFAULT_REPO = "kerzgrr/diffusionpong"
41
+
42
+
43
+ def _parse_args() -> argparse.Namespace:
44
+ parser = argparse.ArgumentParser(description="Live play Diffusion Pong from the Hub.")
45
+ parser.add_argument("--repo", default=DEFAULT_REPO, help="Hugging Face model repo id")
46
+ parser.add_argument("--revision", default="main")
47
+ parser.add_argument(
48
+ "--local-dir",
49
+ default=None,
50
+ help="Optional folder to download weights into (default: HF cache).",
51
+ )
52
+ parser.add_argument("--steps", type=int, default=2)
53
+ parser.add_argument("--frames", type=int, default=0, help="0 = run until you close the window")
54
+ parser.add_argument("--seed", type=int, default=None)
55
+ parser.add_argument("--window-scale", type=int, default=6)
56
+ parser.add_argument("--fps-cap", type=float, default=0.0, help="Optional display cap; 0 = uncapped")
57
+ return parser.parse_args()
58
+
59
+
60
+ def _download_checkpoint(repo: str, revision: str, local_dir: str | None) -> Path:
61
+ if local_dir:
62
+ root = Path(
63
+ snapshot_download(
64
+ repo_id=repo,
65
+ revision=revision,
66
+ local_dir=local_dir,
67
+ allow_patterns=["ema.safetensors", "model.safetensors", "config.json"],
68
+ )
69
+ )
70
+ return root
71
+ config_path = Path(
72
+ hf_hub_download(repo_id=repo, filename="config.json", revision=revision)
73
+ )
74
+ root = config_path.parent
75
+ for name in ("ema.safetensors", "model.safetensors"):
76
+ try:
77
+ hf_hub_download(repo_id=repo, filename=name, revision=revision)
78
+ except Exception:
79
+ if name == "ema.safetensors":
80
+ raise
81
+ return root
82
+
83
+
84
+ def _frames_to_tensor(frames: np.ndarray, device: torch.device) -> torch.Tensor:
85
+ tensor = torch.from_numpy(frames.copy()).permute(0, 3, 1, 2).float()
86
+ return tensor.div_(127.5).sub_(1.0).unsqueeze(0).to(device, non_blocking=True)
87
+
88
+
89
+ def _tensor_to_image(frame: torch.Tensor) -> Image.Image:
90
+ array = frame.float().clamp(-1.0, 1.0).add(1.0).mul(127.5)
91
+ array = array.byte().permute(1, 2, 0).cpu().numpy()
92
+ return Image.fromarray(array, mode="RGB")
93
+
94
+
95
+ def _inject_ball(image: Image.Image, position: tuple[float, float]) -> Image.Image:
96
+ image = image.copy()
97
+ draw = ImageDraw.Draw(image)
98
+ radius = max(3.0, min(image.width, image.height) * 0.025)
99
+ x, y = position
100
+ draw.ellipse(
101
+ (x - radius, y - radius, x + radius, y + radius),
102
+ fill=(255, 209, 72),
103
+ outline=(255, 247, 196),
104
+ width=max(1, round(radius * 0.25)),
105
+ )
106
+ return image
107
+
108
+
109
+ def _image_to_video_tensor(image: Image.Image, device: torch.device) -> torch.Tensor:
110
+ array = np.asarray(image, dtype=np.uint8).copy()
111
+ tensor = torch.from_numpy(array).permute(2, 0, 1).float()
112
+ return (
113
+ tensor.div_(127.5)
114
+ .sub_(1.0)
115
+ .reshape(1, 1, 3, image.height, image.width)
116
+ .to(device, non_blocking=True)
117
+ )
118
+
119
+
120
+ class LiveWindow:
121
+ def __init__(self, scale: int) -> None:
122
+ self.scale = max(1, scale)
123
+ self.is_open = True
124
+ self.root = tk.Tk()
125
+ self.root.title("Diffusion Pong")
126
+ self.label = tk.Label(self.root, background="black")
127
+ self.label.pack()
128
+ self.photo: ImageTk.PhotoImage | None = None
129
+ self.generated_width = 0
130
+ self.generated_height = 0
131
+ self.pending_click: tuple[float, float] | None = None
132
+ self.pressed: set[str] = set()
133
+ self.label.bind("<Button-1>", self._on_click)
134
+ self.root.bind("<KeyPress>", self._on_key_press)
135
+ self.root.bind("<KeyRelease>", self._on_key_release)
136
+ self.root.protocol("WM_DELETE_WINDOW", self.close)
137
+ self.root.focus_force()
138
+
139
+ def _on_key_press(self, event: tk.Event) -> None:
140
+ key = str(event.keysym).lower()
141
+ if key in {"w", "s"}:
142
+ self.pressed.add(key)
143
+
144
+ def _on_key_release(self, event: tk.Event) -> None:
145
+ self.pressed.discard(str(event.keysym).lower())
146
+
147
+ def action(self) -> tuple[float, float]:
148
+ if {"w", "s"}.issubset(self.pressed):
149
+ return (0.0, 0.0)
150
+ return (float("w" in self.pressed), float("s" in self.pressed))
151
+
152
+ def _on_click(self, event: tk.Event) -> None:
153
+ if self.generated_width <= 0 or self.generated_height <= 0:
154
+ return
155
+ x = (float(event.x) / self.scale) % self.generated_width
156
+ y = min(max(float(event.y) / self.scale, 0.0), self.generated_height - 1.0)
157
+ self.pending_click = (x, y)
158
+
159
+ def consume_click(self) -> tuple[float, float] | None:
160
+ click = self.pending_click
161
+ self.pending_click = None
162
+ return click
163
+
164
+ def close(self) -> None:
165
+ if self.is_open:
166
+ self.is_open = False
167
+ self.root.destroy()
168
+
169
+ def show(self, image: Image.Image, frame_index: int, latency_ms: float) -> bool:
170
+ if not self.is_open:
171
+ return False
172
+ self.generated_width, self.generated_height = image.size
173
+ display = image.resize(
174
+ (image.width * self.scale, image.height * self.scale),
175
+ Image.Resampling.NEAREST,
176
+ )
177
+ self.photo = ImageTk.PhotoImage(display)
178
+ self.label.configure(image=self.photo)
179
+ held = "+".join(sorted(self.pressed)).upper()
180
+ held_text = f" [{held}]" if held else ""
181
+ self.root.title(
182
+ f"Diffusion Pong — frame {frame_index + 1} {latency_ms:.0f} ms{held_text}"
183
+ )
184
+ try:
185
+ self.root.update_idletasks()
186
+ self.root.update()
187
+ except tk.TclError:
188
+ self.is_open = False
189
+ return self.is_open
190
+
191
+
192
+ @torch.inference_mode()
193
+ def main() -> None:
194
+ args = _parse_args()
195
+ if not torch.cuda.is_available() or not torch.cuda.is_bf16_supported():
196
+ raise SystemExit("Need a CUDA GPU with BF16 support.")
197
+
198
+ print(f"Downloading {args.repo} …")
199
+ checkpoint = _download_checkpoint(args.repo, args.revision, args.local_dir)
200
+ config_path = checkpoint / "config.json"
201
+ if not config_path.exists():
202
+ raise SystemExit(f"Missing config.json in {checkpoint}")
203
+ config = load_config(config_path)
204
+ device = torch.device("cuda")
205
+ torch.set_float32_matmul_precision("high")
206
+
207
+ seed = args.seed if args.seed is not None else random.SystemRandom().randrange(2**31)
208
+ simulation = PongSimulation.from_seed(config.video, seed)
209
+ for _ in range(random.Random(seed).randint(0, 400)):
210
+ simulation.step()
211
+ history_rgb = _frames_to_tensor(
212
+ simulation.sample_frames(config.video.history_frames),
213
+ device,
214
+ )
215
+
216
+ print("Loading VAE + DiT …")
217
+ codec = load_codec(config.codec, device)
218
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
219
+ history = deque(
220
+ (
221
+ codec.encode(history_rgb)[:, index]
222
+ for index in range(config.video.history_frames)
223
+ ),
224
+ maxlen=config.video.history_frames,
225
+ )
226
+ model = (
227
+ CausalLatentVideoDiT(
228
+ config.model,
229
+ latent_channels=codec.channels,
230
+ history_frames=codec.latent_frame_count(config.video.history_frames),
231
+ )
232
+ .to(device)
233
+ .eval()
234
+ )
235
+ load_model_weights(checkpoint, model, use_ema=True)
236
+
237
+ steps = max(1, args.steps)
238
+ # Teacher-stage checkpoint: use instantaneous velocity sampling.
239
+ instantaneous = True
240
+ frame_limit = args.frames if args.frames > 0 else 10_000_000
241
+ window = LiveWindow(args.window_scale)
242
+ generator = torch.Generator(device=device).manual_seed(seed + 7)
243
+ inject_pos: tuple[float, float] | None = None
244
+ inject_frames = 0
245
+ print("Ready. Click the window, then hold W/S.")
246
+
247
+ for frame_index in range(frame_limit):
248
+ click = window.consume_click()
249
+ if click is not None:
250
+ inject_pos = click
251
+ inject_frames = 3
252
+
253
+ history_tensor = torch.stack(tuple(history), dim=1)
254
+ action = torch.tensor(
255
+ [window.action()],
256
+ device=device,
257
+ dtype=torch.float32,
258
+ ).unsqueeze(0)
259
+
260
+ torch.cuda.synchronize()
261
+ started = time.perf_counter()
262
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
263
+ memory_cache = model.build_memory_cache(history_tensor)
264
+ prediction = sample_next_latent(
265
+ model,
266
+ history_tensor,
267
+ steps,
268
+ actions=action,
269
+ generator=generator,
270
+ window_frames=1,
271
+ memory_cache=memory_cache,
272
+ instantaneous=instantaneous,
273
+ )
274
+ decoded = codec.decode(prediction)
275
+ torch.cuda.synchronize()
276
+ latency_ms = (time.perf_counter() - started) * 1_000
277
+
278
+ image = _tensor_to_image(decoded[0, 0])
279
+ frame_latent = prediction[:, 0]
280
+ if inject_pos is not None and inject_frames > 0:
281
+ image = _inject_ball(image, inject_pos)
282
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
283
+ frame_latent = codec.encode(_image_to_video_tensor(image, device))[:, 0]
284
+ inject_frames -= 1
285
+ if inject_frames == 0:
286
+ inject_pos = None
287
+
288
+ history.append(frame_latent)
289
+ if not window.show(image, frame_index, latency_ms):
290
+ break
291
+ if args.fps_cap > 0:
292
+ target = 1.0 / args.fps_cap
293
+ elapsed = time.perf_counter() - started
294
+ if elapsed < target:
295
+ time.sleep(target - elapsed)
296
+
297
+ if window.is_open:
298
+ window.close()
299
+ print("Done.")
300
+
301
+
302
+ if __name__ == "__main__":
303
+ main()