Spaces:
Running
Running
Decouple physics rate from frame rate; Citrus theme via launch()
Browse files
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 =
|
| 85 |
-
POINT_SUBSTEP_CAP =
|
|
|
|
| 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(
|
|
|
|
| 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(
|
| 114 |
-
r = 8 * SC * (0.
|
| 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 =
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
radius=5, fill=(249, 115, 22)) # baseline (orange)
|
| 124 |
|
| 125 |
r = 8 * SC
|
| 126 |
-
d.ellipse([
|
| 127 |
-
env.ball_x * SC + r, env.ball_y * SC + r], fill=(255, 255, 255))
|
| 128 |
|
| 129 |
-
d.text((VW // 2 - 46,
|
| 130 |
-
d.text((VW // 2 + 46,
|
| 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 |
-
|
| 135 |
-
d.text((20, VH - 26),
|
| 136 |
-
|
| 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=
|
| 142 |
return np.asarray(img)
|
| 143 |
|
| 144 |
|
| 145 |
-
def
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
-
def watch_match(opponent_name: str, points_to_win: int =
|
| 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
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 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 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
for _ in range(FPS * 2):
|
| 222 |
-
|
| 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"|
|
| 240 |
-
f"|
|
|
|
|
| 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(
|
| 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=
|
| 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)
|