multimodalart HF Staff commited on
Commit
0d88e1f
·
verified ·
1 Parent(s): 13eb3cb

Decouple physics rate from frame rate; Citrus theme via launch()

Browse files
Files changed (1) hide show
  1. app.py +103 -83
app.py CHANGED
@@ -81,8 +81,9 @@ GAME_IFRAME = (
81
  SC = 0.8 # render scale: 800x500 table -> 640x400 video
82
  VW, VH = int(800 * SC), int(500 * SC)
83
  FPS = 40 # one frame per physics sub-step == real time
84
- MAX_FRAMES = 1100 # hard cap on video length (~27 s)
85
- POINT_SUBSTEP_CAP = 420 # a single point is called a draw after this
 
86
  MAX_POINTS = 12 # safety net against endless draw sequences
87
 
88
 
@@ -98,9 +99,11 @@ def _font(size: int):
98
 
99
 
100
  F_BIG, F_MED, F_SMALL = _font(34), _font(15), _font(13)
 
101
 
102
 
103
- def _render_frame(env, score_a, score_b, opp_name, trail, banner=""):
 
104
  img = Image.new("RGB", (VW, VH), (15, 22, 38))
105
  d = ImageDraw.Draw(img)
106
 
@@ -110,73 +113,93 @@ def _render_frame(env, score_a, score_b, opp_name, trail, banner=""):
110
 
111
  for i, (tx, ty) in enumerate(trail):
112
  a = (i + 1) / (len(trail) + 1)
113
- c = int(60 + 150 * a)
114
- r = 8 * SC * (0.35 + 0.6 * a)
115
  d.ellipse([tx * SC - r, ty * SC - r, tx * SC + r, ty * SC + r], fill=(c, c, c))
116
 
117
  pw = 14 * SC
118
- eh = env.ego_paddle_h * SC / 2
119
- oh = env.opp_paddle_h * SC / 2
120
- d.rounded_rectangle([2, env.ego_y * SC - eh, 2 + pw, env.ego_y * SC + eh],
121
- radius=5, fill=(79, 140, 255)) # Mr. Pong (blue)
122
- d.rounded_rectangle([VW - pw - 2, env.opp_y * SC - oh, VW - 2, env.opp_y * SC + oh],
123
- radius=5, fill=(249, 115, 22)) # baseline (orange)
124
 
125
  r = 8 * SC
126
- d.ellipse([env.ball_x * SC - r, env.ball_y * SC - r,
127
- env.ball_x * SC + r, env.ball_y * SC + r], fill=(255, 255, 255))
128
 
129
- d.text((VW // 2 - 46, 16), str(score_a), font=F_BIG, fill=(79, 140, 255), anchor="ma")
130
- d.text((VW // 2 + 46, 16), str(score_b), font=F_BIG, fill=(249, 115, 22), anchor="ma")
131
  d.text((20, 20), "MR. PONG", font=F_MED, fill=(120, 150, 200))
132
  d.text((VW - 20, 20), opp_name.upper(), font=F_MED, fill=(190, 130, 80), anchor="ra")
133
-
134
- speed = math.hypot(env.ball_vx, env.ball_vy)
135
- d.text((20, VH - 26),
136
- f"rally {env.rally_count} hits ball {speed:4.1f} px/f", font=F_SMALL,
137
- fill=(120, 135, 160))
138
 
139
  if banner:
140
  d.rectangle([0, VH // 2 - 34, VW, VH // 2 + 34], fill=(11, 15, 25))
141
- d.text((VW // 2, VH // 2), banner, font=F_BIG, fill=(249, 115, 22), anchor="mm")
142
  return np.asarray(img)
143
 
144
 
145
- def _play_point(env, opponent, frames, opp_name, score_a, score_b, serve_dir, record):
146
- """Run one point sub-step by sub-step; returns the winner string."""
147
- env.reset(serve_direction=serve_dir)
148
- trail = []
149
- ai_act = 0
150
- opp_act = 0
151
- for i in range(POINT_SUBSTEP_CAP):
152
- # Both policies are re-queried every `frame_skip` sub-steps, exactly as
153
- # StandalonePongEnv.step() does in the author's simulate() loop.
154
- if i % env.phys.frame_skip == 0:
155
- ai_act = policy_action(env.get_ego_observation())
156
- opp_act = opponent.act(env)
157
- trail.append((env.ball_x, env.ball_y))
158
- if len(trail) > 8:
159
- trail.pop(0)
160
- done, info = env.physics_substep(ego_action=ai_act, opp_action=opp_act)
161
- if record and len(frames) < MAX_FRAMES:
162
- frames.append(_render_frame(env, score_a, score_b, opp_name, trail))
163
- if done:
164
- return info.get("winner")
165
- return "draw"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
 
168
- def watch_match(opponent_name: str, points_to_win: int = 3,
169
  randomize_seed: bool = True, seed: int = 0):
170
- """Simulate and render a Mr. Pong match against a baseline opponent.
171
 
172
  Args:
173
- opponent_name: which scripted baseline to face (from the model card).
174
  points_to_win: number of points needed to take the match.
175
  randomize_seed: draw a fresh random seed for this match.
176
  seed: RNG seed used when randomize_seed is off.
177
 
178
  Returns:
179
- An MP4 of the match, a markdown scoreline, and the seed actually used.
180
  """
181
  import imageio.v2 as imageio
182
 
@@ -187,48 +210,44 @@ def watch_match(opponent_name: str, points_to_win: int = 3,
187
  random.seed(seed)
188
  np.random.seed(seed % (2**32))
189
 
190
- points_to_win = int(points_to_win)
191
- opp_cls = OPPONENTS[opponent_name]
192
- opponent = opp_cls(rng=rng)
193
-
194
  env = StandalonePongEnv(PhysicsConfig(), seed=seed)
195
- frames = []
196
- score_a = score_b = 0
197
- serve_dir = 1
198
- rallies = []
199
- t0 = time.perf_counter()
200
 
201
- while (score_a < points_to_win and score_b < points_to_win
202
- and len(rallies) < MAX_POINTS):
203
- winner = _play_point(env, opponent, frames, opponent_name,
204
- score_a, score_b, serve_dir,
205
- record=len(frames) < MAX_FRAMES)
206
- rallies.append(env.rally_count)
207
- if winner == "ego":
208
- score_a += 1
209
- serve_dir = 1
210
- elif winner == "opponent":
211
- score_b += 1
212
- serve_dir = -1
213
- else:
214
- serve_dir = -serve_dir
215
- if len(frames) >= MAX_FRAMES:
216
- break
217
 
218
- banner = ("MR. PONG WINS %d-%d" % (score_a, score_b) if score_a > score_b
219
- else ("%s WINS %d-%d" % (opponent_name.upper(), score_b, score_a)
220
- if score_b > score_a else "TIME LIMIT"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  for _ in range(FPS * 2):
222
- if len(frames) < MAX_FRAMES + FPS * 2:
223
- frames.append(_render_frame(env, score_a, score_b, opponent_name,
224
- [], banner=banner))
225
 
226
- sim_s = time.perf_counter() - t0
227
  out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
228
  with imageio.get_writer(out, fps=FPS, codec="libx264", quality=7,
229
  macro_block_size=1, pixelformat="yuv420p") as w:
230
  for f in frames:
231
  w.append_data(f)
 
232
 
233
  stats = (
234
  f"### Mr. Pong **{score_a} – {score_b}** {opponent_name}\n"
@@ -236,8 +255,9 @@ def watch_match(opponent_name: str, points_to_win: int = 3,
236
  f"| Points played | {len(rallies)} |\n"
237
  f"| Longest rally | {max(rallies)} hits |\n"
238
  f"| Average rally | {np.mean(rallies):.1f} hits |\n"
239
- f"| Video length | {len(frames) / FPS:.1f} s ({len(frames)} frames) |\n"
240
- f"| Simulation time | {sim_s:.2f} s |\n"
 
241
  f"| Seed | `{seed}` |\n"
242
  )
243
  return out, stats, seed
@@ -317,7 +337,7 @@ It wins ~87% of its matches against the strongest scripted baseline it was train
317
  in real time. Or watch the real PyTorch checkpoint take on the model-card opponents.
318
  """
319
 
320
- with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Mr. Pong") as demo:
321
  with gr.Column(elem_id="col-container"):
322
  gr.Markdown(INTRO)
323
 
@@ -341,7 +361,7 @@ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Mr. Pong") as demo:
341
  height=430, format="mp4")
342
  stats_out = gr.Markdown()
343
  with gr.Accordion("Advanced settings", open=False):
344
- points = gr.Slider(1, 5, value=3, step=1, label="Points to win")
345
  rand_seed = gr.Checkbox(value=True, label="Randomize seed")
346
  seed_num = gr.Number(value=0, precision=0, label="Seed")
347
  gr.Examples(
@@ -391,4 +411,4 @@ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Mr. Pong") as demo:
391
  )
392
 
393
  if __name__ == "__main__":
394
- demo.launch(mcp_server=True)
 
81
  SC = 0.8 # render scale: 800x500 table -> 640x400 video
82
  VW, VH = int(800 * SC), int(500 * SC)
83
  FPS = 40 # one frame per physics sub-step == real time
84
+ MAX_FRAMES = 1600 # hard cap on rendered frames (40 s at 40 fps)
85
+ POINT_SUBSTEP_CAP = 4500 # == the author's 1500-step rally limit
86
+ MATCH_SUBSTEP_CAP = 4800 # keeps playback speed at 3x or below
87
  MAX_POINTS = 12 # safety net against endless draw sequences
88
 
89
 
 
99
 
100
 
101
  F_BIG, F_MED, F_SMALL = _font(34), _font(15), _font(13)
102
+ BLUE, ORANGE = (79, 140, 255), (249, 115, 22)
103
 
104
 
105
+ def _render_frame(state, opp_name, trail, speed_label, banner=""):
106
+ bx, by, ego_y, opp_y, ego_h, opp_h, rally, sa, sb, bspeed = state
107
  img = Image.new("RGB", (VW, VH), (15, 22, 38))
108
  d = ImageDraw.Draw(img)
109
 
 
113
 
114
  for i, (tx, ty) in enumerate(trail):
115
  a = (i + 1) / (len(trail) + 1)
116
+ c = int(45 + 150 * a)
117
+ r = 8 * SC * (0.3 + 0.65 * a)
118
  d.ellipse([tx * SC - r, ty * SC - r, tx * SC + r, ty * SC + r], fill=(c, c, c))
119
 
120
  pw = 14 * SC
121
+ eh, oh = ego_h * SC / 2, opp_h * SC / 2
122
+ d.rounded_rectangle([2, ego_y * SC - eh, 2 + pw, ego_y * SC + eh],
123
+ radius=5, fill=BLUE) # Mr. Pong
124
+ d.rounded_rectangle([VW - pw - 2, opp_y * SC - oh, VW - 2, opp_y * SC + oh],
125
+ radius=5, fill=ORANGE) # scripted baseline
 
126
 
127
  r = 8 * SC
128
+ d.ellipse([bx * SC - r, by * SC - r, bx * SC + r, by * SC + r], fill=(255, 255, 255))
 
129
 
130
+ d.text((VW // 2 - 46, 14), str(sa), font=F_BIG, fill=BLUE, anchor="ma")
131
+ d.text((VW // 2 + 46, 14), str(sb), font=F_BIG, fill=ORANGE, anchor="ma")
132
  d.text((20, 20), "MR. PONG", font=F_MED, fill=(120, 150, 200))
133
  d.text((VW - 20, 20), opp_name.upper(), font=F_MED, fill=(190, 130, 80), anchor="ra")
134
+ d.text((20, VH - 26), f"rally {rally} hits ball {bspeed:4.1f} px/f",
135
+ font=F_SMALL, fill=(120, 135, 160))
136
+ d.text((VW - 20, VH - 26), speed_label, font=F_SMALL, fill=(120, 135, 160),
137
+ anchor="ra")
 
138
 
139
  if banner:
140
  d.rectangle([0, VH // 2 - 34, VW, VH // 2 + 34], fill=(11, 15, 25))
141
+ d.text((VW // 2, VH // 2), banner, font=F_BIG, fill=ORANGE, anchor="mm")
142
  return np.asarray(img)
143
 
144
 
145
+ def _snapshot(env, sa, sb):
146
+ return (env.ball_x, env.ball_y, env.ego_y, env.opp_y,
147
+ env.ego_paddle_h, env.opp_paddle_h, env.rally_count, sa, sb,
148
+ math.hypot(env.ball_vx, env.ball_vy))
149
+
150
+
151
+ def _simulate_match(env, opponent, points_to_win, budget):
152
+ """Play a match sub-step by sub-step, recording every state for later replay."""
153
+ states, rallies = [], []
154
+ score_a = score_b = 0
155
+ serve_dir = 1
156
+
157
+ while (score_a < points_to_win and score_b < points_to_win
158
+ and len(rallies) < MAX_POINTS and len(states) < budget):
159
+ env.reset(serve_direction=serve_dir)
160
+ ai_act = opp_act = 0
161
+ winner = "draw"
162
+ for i in range(POINT_SUBSTEP_CAP):
163
+ # Both sides re-decide every `frame_skip` sub-steps, exactly as
164
+ # StandalonePongEnv.step() does in the author's simulate() loop.
165
+ if i % env.phys.frame_skip == 0:
166
+ ai_act = policy_action(env.get_ego_observation())
167
+ opp_act = opponent.act(env)
168
+ done, info = env.physics_substep(ego_action=ai_act, opp_action=opp_act)
169
+ states.append(_snapshot(env, score_a, score_b))
170
+ if done:
171
+ winner = info.get("winner")
172
+ break
173
+ if len(states) >= budget:
174
+ break
175
+
176
+ rallies.append(env.rally_count)
177
+ if winner == "ego":
178
+ score_a += 1
179
+ serve_dir = 1
180
+ elif winner == "opponent":
181
+ score_b += 1
182
+ serve_dir = -1
183
+ else:
184
+ serve_dir = -serve_dir
185
+ # keep the final state of the point on screen for a beat
186
+ states.extend([_snapshot(env, score_a, score_b)] * env.phys.frame_skip * 8)
187
+
188
+ return states, rallies, score_a, score_b
189
 
190
 
191
+ def watch_match(opponent_name: str, points_to_win: int = 2,
192
  randomize_seed: bool = True, seed: int = 0):
193
+ """Simulate and render a Mr. Pong match against a scripted baseline opponent.
194
 
195
  Args:
196
+ opponent_name: which baseline from the model card to face.
197
  points_to_win: number of points needed to take the match.
198
  randomize_seed: draw a fresh random seed for this match.
199
  seed: RNG seed used when randomize_seed is off.
200
 
201
  Returns:
202
+ An MP4 replay of the match, a markdown scoreline, and the seed used.
203
  """
204
  import imageio.v2 as imageio
205
 
 
210
  random.seed(seed)
211
  np.random.seed(seed % (2**32))
212
 
213
+ points_to_win = max(1, int(points_to_win))
214
+ opponent = OPPONENTS[opponent_name](rng=rng)
 
 
215
  env = StandalonePongEnv(PhysicsConfig(), seed=seed)
 
 
 
 
 
216
 
217
+ t0 = time.perf_counter()
218
+ states, rallies, score_a, score_b = _simulate_match(
219
+ env, opponent, points_to_win, MATCH_SUBSTEP_CAP)
220
+ sim_s = time.perf_counter() - t0
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
+ # Physics runs at 40 sub-steps/s; drop every n-th state so the clip fits the
223
+ # frame budget, and say so on screen.
224
+ stride = max(1, math.ceil(len(states) / MAX_FRAMES))
225
+ speed_label = "real time" if stride == 1 else f"{stride}x speed"
226
+
227
+ if score_a > score_b:
228
+ banner = f"MR. PONG WINS {score_a}-{score_b}"
229
+ elif score_b > score_a:
230
+ banner = f"{opponent_name.upper()} WINS {score_b}-{score_a}"
231
+ else:
232
+ banner = f"{score_a}-{score_b} — TIME LIMIT"
233
+
234
+ t1 = time.perf_counter()
235
+ frames, trail = [], []
236
+ for s in states[::stride]:
237
+ trail.append((s[0], s[1]))
238
+ if len(trail) > 8:
239
+ trail.pop(0)
240
+ frames.append(_render_frame(s, opponent_name, trail, speed_label))
241
+ last = states[-1]
242
  for _ in range(FPS * 2):
243
+ frames.append(_render_frame(last, opponent_name, [], speed_label, banner=banner))
 
 
244
 
 
245
  out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
246
  with imageio.get_writer(out, fps=FPS, codec="libx264", quality=7,
247
  macro_block_size=1, pixelformat="yuv420p") as w:
248
  for f in frames:
249
  w.append_data(f)
250
+ render_s = time.perf_counter() - t1
251
 
252
  stats = (
253
  f"### Mr. Pong **{score_a} – {score_b}** {opponent_name}\n"
 
255
  f"| Points played | {len(rallies)} |\n"
256
  f"| Longest rally | {max(rallies)} hits |\n"
257
  f"| Average rally | {np.mean(rallies):.1f} hits |\n"
258
+ f"| Game time simulated | {len(states) / FPS:.1f} s |\n"
259
+ f"| Playback | {speed_label} ({len(frames) / FPS:.1f} s clip) |\n"
260
+ f"| Simulate / render | {sim_s:.2f} s / {render_s:.2f} s |\n"
261
  f"| Seed | `{seed}` |\n"
262
  )
263
  return out, stats, seed
 
337
  in real time. Or watch the real PyTorch checkpoint take on the model-card opponents.
338
  """
339
 
340
+ with gr.Blocks(title="Mr. Pong") as demo:
341
  with gr.Column(elem_id="col-container"):
342
  gr.Markdown(INTRO)
343
 
 
361
  height=430, format="mp4")
362
  stats_out = gr.Markdown()
363
  with gr.Accordion("Advanced settings", open=False):
364
+ points = gr.Slider(1, 5, value=2, step=1, label="Points to win")
365
  rand_seed = gr.Checkbox(value=True, label="Randomize seed")
366
  seed_num = gr.Number(value=0, precision=0, label="Seed")
367
  gr.Examples(
 
411
  )
412
 
413
  if __name__ == "__main__":
414
+ demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)