nirmalpratheep commited on
Commit
cdb5a2f
ยท
verified ยท
1 Parent(s): 27cec6c

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +753 -0
  2. requirements.txt +11 -0
app.py ADDED
@@ -0,0 +1,753 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py โ€” Car Racing Agent ยท Gradio Demo
3
+ OpenEnv Student Challenge 2026 ยท NirmalPratheep
4
+ """
5
+
6
+ import os, sys, tempfile, uuid, traceback
7
+
8
+ _APP_DIR = os.path.dirname(os.path.abspath(__file__))
9
+ # Local layout: app.py lives in app/, repo root is parent.
10
+ # HF Space layout: app.py sits at root alongside game/, env/, training/.
11
+ _ROOT = _APP_DIR if os.path.isdir(os.path.join(_APP_DIR, "game")) else os.path.dirname(_APP_DIR)
12
+ sys.path.insert(0, _ROOT)
13
+ sys.path.insert(1, os.path.join(_ROOT, "training"))
14
+
15
+ os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
16
+ os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
17
+
18
+ import gradio as gr
19
+ import numpy as np
20
+ import torch
21
+ from PIL import Image, ImageDraw, ImageFont
22
+
23
+ from game.rl_splits import TRAIN, _ensure_pygame
24
+ from env.environment import RaceEnvironment
25
+ from env.models import DriveAction
26
+ from train_torchrl import build_policy_and_value
27
+
28
+ _ensure_pygame()
29
+ import pygame
30
+ from game.oval_racer import draw_car, draw_headlights
31
+
32
+ # โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
+ STEP_CHUNK = 20
34
+ MAX_STEPS = 3000
35
+ LAPS_TARGET = 1
36
+ FRAME_W, FRAME_H = 540, 360
37
+ HEADLIGHT_PX = 192
38
+ DEVICE = torch.device("cpu")
39
+
40
+ _CKPT_CANDIDATES = [
41
+ os.path.join(_APP_DIR, "ppo_torchrl_final.pt"),
42
+ os.path.join(_ROOT, "ppo_torchrl_final.pt"),
43
+ os.path.join(_ROOT, "checkpoints", "ppo_torchrl_final.pt"),
44
+ ]
45
+ CKPT_PATH = next((p for p in _CKPT_CANDIDATES if os.path.isfile(p)), None)
46
+ POLICY = None
47
+
48
+
49
+ def _load_policy():
50
+ global POLICY
51
+ if POLICY is not None:
52
+ return POLICY
53
+ if CKPT_PATH is None:
54
+ raise RuntimeError("Checkpoint not found. Put ppo_torchrl_final.pt in app/ or checkpoints/")
55
+ policy, _, _ = build_policy_and_value(DEVICE)
56
+ ckpt = torch.load(CKPT_PATH, map_location="cpu", weights_only=False)
57
+ sd = ckpt.get("policy", ckpt.get("model", {}))
58
+ if any(k.startswith("_orig_mod.") for k in sd):
59
+ sd = {k.replace("_orig_mod.", "", 1): v for k, v in sd.items()}
60
+ policy.load_state_dict(sd)
61
+ policy.eval()
62
+ POLICY = policy
63
+ print(f"โœ“ Policy loaded: {CKPT_PATH}")
64
+ return POLICY
65
+
66
+
67
+ # โ”€โ”€ Rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
68
+
69
+ def _surf_to_pil(surf, size) -> Image.Image:
70
+ small = pygame.transform.scale(surf, size)
71
+ arr = pygame.surfarray.array3d(small).transpose(1, 0, 2)
72
+ return Image.fromarray(arr.astype(np.uint8))
73
+
74
+
75
+ def _game_frame(race_env, trail=None) -> Image.Image:
76
+ ce = race_env._env
77
+ surf = ce.track.surface.copy()
78
+ # Draw path trail before car so car renders on top
79
+ if trail and len(trail) > 1:
80
+ scale_x = FRAME_W / 900
81
+ scale_y = FRAME_H / 600
82
+ for i, (px, py) in enumerate(trail):
83
+ alpha = max(60, int(255 * i / len(trail)))
84
+ r = max(2, int(4 * i / len(trail)))
85
+ color = (255, int(140 * i / len(trail)), 0) # orange fade-in
86
+ pygame.draw.circle(surf, color, (int(px), int(py)), r)
87
+ draw_headlights(surf, ce._x, ce._y, ce._angle)
88
+ draw_car(surf, ce._x, ce._y, ce._angle)
89
+ return _surf_to_pil(surf, (FRAME_W, FRAME_H))
90
+
91
+
92
+ def _headlight_frame(race_env) -> Image.Image:
93
+ img64 = race_env._render_headlight_image()
94
+ return Image.fromarray(img64).resize((HEADLIGHT_PX, HEADLIGHT_PX), Image.NEAREST)
95
+
96
+
97
+ def _placeholder(w, h, text, bg=(15, 17, 26), fg=(80, 90, 120)) -> Image.Image:
98
+ img = Image.new("RGB", (w, h), bg)
99
+ draw = ImageDraw.Draw(img)
100
+ try:
101
+ font = ImageFont.truetype("arial.ttf", 18)
102
+ except Exception:
103
+ font = ImageFont.load_default()
104
+ bb = draw.textbbox((0, 0), text, font=font)
105
+ tw, th = bb[2] - bb[0], bb[3] - bb[1]
106
+ draw.text(((w - tw) // 2, (h - th) // 2), text, fill=fg, font=font)
107
+ return img
108
+
109
+
110
+ def _placeholder_main():
111
+ return _placeholder(FRAME_W, FRAME_H, "โ† Click Reset to load the track")
112
+
113
+ def _placeholder_pov():
114
+ return _placeholder(HEADLIGHT_PX, HEADLIGHT_PX, "POV", fg=(100, 110, 140))
115
+
116
+
117
+ def _agent_action(obs):
118
+ from tensordict import TensorDict
119
+ from torchrl.envs.utils import ExplorationType, set_exploration_type
120
+ policy = _load_policy()
121
+ img = (torch.from_numpy(obs.image.copy())
122
+ .float().div(255.0).permute(2, 0, 1).unsqueeze(0).to(DEVICE))
123
+ scalars = torch.tensor(obs.scalars, dtype=torch.float32, device=DEVICE).unsqueeze(0)
124
+ td = TensorDict({"image": img, "scalars": scalars}, batch_size=[1])
125
+ with set_exploration_type(ExplorationType.MEAN):
126
+ td = policy(td)
127
+ a = td["action"][0].detach().clamp(-1.0, 1.0).cpu().numpy()
128
+ return float(a[0]), float(a[1])
129
+
130
+
131
+ # โ”€โ”€ Status HTML โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
132
+ TRACK_CHOICES = [f"Track {t.level:02d} โ€” {t.name}" for t in TRAIN]
133
+
134
+ def _get_track(label: str):
135
+ lvl = int(label.split(" ")[1])
136
+ return next((t for t in TRAIN if t.level == lvl), TRAIN[0])
137
+
138
+
139
+ def _stat_card(label, value, cls=""):
140
+ return (f"<div class='sc'><div class='sl'>{label}</div>"
141
+ f"<div class='sv {cls}'>{value}</div></div>")
142
+
143
+
144
+ def _status_html(state, error=None):
145
+ if error:
146
+ return (f"<div class='stat-row'>"
147
+ f"<div class='sc err'><div class='sl'>ERROR</div>"
148
+ f"<div class='sv' style='font-size:.7rem;color:#ff6b6b;word-break:break-all'>{error}</div></div>"
149
+ f"</div>")
150
+ if state is None:
151
+ return (f"<div class='stat-row'>"
152
+ + _stat_card("STATUS", "IDLE", "idle")
153
+ + _stat_card("SPEED", "โ€”")
154
+ + _stat_card("LAPS", "โ€”")
155
+ + _stat_card("STEPS", "โ€”")
156
+ + "</div>")
157
+ ce = state["env"]._env
158
+ laps = ce._laps
159
+ speed = ce._speed
160
+ step = state["step"]
161
+ done = state["done"]
162
+ spd = f"{int(abs(speed)/ce.track.max_speed*100)}%"
163
+ if done and laps >= LAPS_TARGET:
164
+ st, cls = "โœ… PASS", "pass"
165
+ elif done:
166
+ st, cls = "๐Ÿ’ฅ CRASH", "fail"
167
+ else:
168
+ st, cls = "โ–ถ RUNNING", "run"
169
+ return (f"<div class='stat-row'>"
170
+ + _stat_card("STATUS", st, cls)
171
+ + _stat_card("SPEED", spd)
172
+ + _stat_card("LAPS", f"{laps}/{LAPS_TARGET}")
173
+ + _stat_card("STEPS", f"{step}")
174
+ + "</div>")
175
+
176
+
177
+ # โ”€โ”€ Callbacks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
178
+
179
+ def reset(track_label):
180
+ try:
181
+ _ensure_pygame()
182
+ track = _get_track(track_label)
183
+ track.build()
184
+ env = RaceEnvironment(track, max_steps=MAX_STEPS, laps_target=LAPS_TARGET, use_image=True)
185
+ obs = env.reset()
186
+ state = {"env": env, "obs": obs, "step": 0, "done": False, "trail": []}
187
+ return state, _game_frame(env), _headlight_frame(env), _status_html(state), None
188
+ except Exception as e:
189
+ traceback.print_exc()
190
+ return None, _placeholder_main(), _placeholder_pov(), _status_html(None, str(e)), None
191
+
192
+
193
+ def step_agent(state):
194
+ """Generator: yields a frame every 4 physics steps so the image streams smoothly."""
195
+ try:
196
+ if state is None:
197
+ yield (state, _placeholder_main(), _placeholder_pov(),
198
+ _status_html(None, "No session โ€” press Reset first"), None)
199
+ return
200
+ if state["done"]:
201
+ yield (state, _game_frame(state["env"], state.get("trail")),
202
+ _headlight_frame(state["env"]), _status_html(state), None)
203
+ return
204
+
205
+ env = state["env"]
206
+ obs = state["obs"]
207
+ trail = state.setdefault("trail", [])
208
+ YIELD_EVERY = 4 # stream a new frame every N physics steps
209
+
210
+ for i in range(STEP_CHUNK):
211
+ if state["done"]:
212
+ break
213
+ accel, steer = _agent_action(obs)
214
+ obs = env.step(DriveAction(accel=accel, steer=steer))
215
+ state["step"] += 1
216
+ trail.append((env._env._x, env._env._y))
217
+ if obs.done:
218
+ state["done"] = True
219
+ # stream intermediate frames
220
+ if (i + 1) % YIELD_EVERY == 0 or state["done"]:
221
+ yield (state,
222
+ _game_frame(env, trail),
223
+ _headlight_frame(env),
224
+ _status_html(state),
225
+ None)
226
+
227
+ state["obs"] = obs
228
+ yield state, _game_frame(env, trail), _headlight_frame(env), _status_html(state), None
229
+
230
+ except Exception as e:
231
+ traceback.print_exc()
232
+ yield state, _placeholder_main(), _placeholder_pov(), _status_html(state, str(e)), None
233
+
234
+
235
+ def auto_drive(track_label):
236
+ """Generator: streams live frames while driving, then yields the final MP4."""
237
+ try:
238
+ import imageio.v3 as iio
239
+ _ensure_pygame()
240
+ track = _get_track(track_label)
241
+ track.build()
242
+ env = RaceEnvironment(track, max_steps=MAX_STEPS, laps_target=LAPS_TARGET, use_image=True)
243
+ obs = env.reset()
244
+ trail = []
245
+ frames = [np.array(_game_frame(env))]
246
+ n = 0
247
+ YIELD_EVERY = 3 # stream a UI update every N steps
248
+
249
+ # Show initial frame
250
+ state = {"env": env, "obs": obs, "step": 0, "done": False, "trail": trail}
251
+ yield state, _game_frame(env, trail), _headlight_frame(env), _status_html(state), None
252
+
253
+ while not obs.done:
254
+ accel, steer = _agent_action(obs)
255
+ obs = env.step(DriveAction(accel=accel, steer=steer))
256
+ n += 1
257
+ trail.append((env._env._x, env._env._y))
258
+
259
+ frame_img = _game_frame(env, trail)
260
+ frames.append(np.array(frame_img))
261
+
262
+ if n % YIELD_EVERY == 0:
263
+ state["step"] = n
264
+ yield (state, frame_img, _headlight_frame(env), _status_html(state), None)
265
+
266
+ # Final state
267
+ ce = env._env
268
+ laps = ce._laps
269
+ crashes = getattr(ce, "_crash_count", 0)
270
+ result = "โœ… PASS" if laps >= LAPS_TARGET and crashes == 0 else "๐Ÿ’ฅ FAIL"
271
+
272
+ vpath = os.path.join(tempfile.gettempdir(), f"race_{uuid.uuid4().hex[:8]}.mp4")
273
+ iio.imwrite(vpath, np.stack(frames), fps=20, codec="libx264", plugin="pyav")
274
+
275
+ state = {"env": env, "obs": obs, "step": n, "done": True, "trail": trail}
276
+ extra = (f"<div class='stat-row'>"
277
+ + _stat_card("RESULT", result, "pass" if "PASS" in result else "fail")
278
+ + _stat_card("LAPS", str(laps))
279
+ + _stat_card("STEPS", str(n))
280
+ + _stat_card("FRAMES", str(len(frames)))
281
+ + "</div>")
282
+ yield state, _game_frame(env, trail), _headlight_frame(env), extra, vpath
283
+
284
+ except Exception as e:
285
+ traceback.print_exc()
286
+ yield None, _placeholder_main(), _placeholder_pov(), _status_html(None, str(e)), None
287
+
288
+
289
+ # โ”€โ”€ CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
290
+ CSS = """
291
+ /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
292
+ Car Racing Agent ยท premium light theme
293
+ palette: warm orange accent on neutral stone background
294
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
295
+
296
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@500;700&display=swap');
297
+
298
+ *, *::before, *::after { box-sizing: border-box; }
299
+
300
+ body, .gradio-container {
301
+ background:
302
+ radial-gradient(1200px 600px at 10% -10%, #ffedd5 0%, transparent 50%),
303
+ radial-gradient(900px 500px at 110% 10%, #fef3c7 0%, transparent 45%),
304
+ linear-gradient(180deg, #fafafa 0%, #f3f4f6 100%) !important;
305
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
306
+ font-size: 13px !important;
307
+ color: #1f2937 !important;
308
+ min-height: 100vh;
309
+ }
310
+ .gradio-container { max-width: 100% !important; padding: 0 !important; }
311
+
312
+ /* strip default Gradio borders โ€” we'll add our own where needed */
313
+ .gr-box, .gr-form, .gr-panel,
314
+ div[class*="component-"], div[data-testid] {
315
+ border: none !important;
316
+ background: transparent !important;
317
+ box-shadow: none !important;
318
+ }
319
+
320
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
321
+ BANNER โ€” hero strip
322
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
323
+ .banner {
324
+ position: relative;
325
+ background:
326
+ linear-gradient(135deg, #ffffff 0%, #fff7ed 100%);
327
+ border-bottom: 1px solid rgba(249, 115, 22, 0.15);
328
+ padding: 22px 32px 20px;
329
+ margin-bottom: 20px;
330
+ overflow: hidden;
331
+ }
332
+ .banner::before {
333
+ content: ""; position: absolute; inset: 0;
334
+ background:
335
+ radial-gradient(500px 200px at 15% 120%, rgba(249,115,22,.18), transparent 70%),
336
+ radial-gradient(400px 180px at 90% -30%, rgba(251,191,36,.22), transparent 70%);
337
+ pointer-events: none;
338
+ }
339
+ .banner-inner { max-width: 1280px; margin: 0 auto; position: relative; z-index: 1; }
340
+ .banner h1 {
341
+ font-size: 1.85rem; font-weight: 900; margin: 0 0 6px;
342
+ letter-spacing: -0.02em;
343
+ background: linear-gradient(92deg, #9a3412 0%, #ea580c 40%, #f97316 65%, #fbbf24 100%);
344
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
345
+ display: inline-block;
346
+ filter: drop-shadow(0 1px 0 rgba(255,255,255,.6));
347
+ }
348
+ .banner .sub {
349
+ color: #57534e; font-size: 0.83rem; margin: 0 0 12px;
350
+ font-weight: 500; line-height: 1.5;
351
+ }
352
+ .banner .sub strong { font-weight: 700; }
353
+ .badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
354
+ .badge {
355
+ background: rgba(255, 255, 255, 0.85);
356
+ border: 1px solid #fed7aa;
357
+ color: #9a3412;
358
+ padding: 4px 11px;
359
+ border-radius: 999px;
360
+ font-size: 0.66rem;
361
+ font-weight: 700;
362
+ letter-spacing: .05em;
363
+ backdrop-filter: blur(6px);
364
+ box-shadow: 0 1px 2px rgba(120, 53, 15, .04);
365
+ transition: transform .12s ease, box-shadow .12s ease;
366
+ }
367
+ .badge:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(120, 53, 15, .10); }
368
+ .badge.green {
369
+ background: rgba(240, 253, 244, .9); border-color: #86efac; color: #166534;
370
+ }
371
+ .badge.hf-badge {
372
+ background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
373
+ border-color: #fbbf24; color: #78350f;
374
+ text-decoration: none; cursor: pointer;
375
+ font-weight: 800;
376
+ }
377
+ .badge.hf-badge:hover { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); }
378
+
379
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
380
+ STAT CARDS
381
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
382
+ .stat-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0; }
383
+ .sc {
384
+ flex: 1; min-width: 64px;
385
+ background: #ffffff;
386
+ border: 1px solid #e7e5e4 !important;
387
+ border-radius: 10px !important;
388
+ padding: 10px 8px;
389
+ text-align: center;
390
+ box-shadow:
391
+ 0 1px 2px rgba(17, 24, 39, .04),
392
+ 0 0 0 1px rgba(255, 255, 255, .6) inset;
393
+ transition: border-color .15s ease, box-shadow .15s ease;
394
+ }
395
+ .sc:hover {
396
+ border-color: #fdba74 !important;
397
+ box-shadow: 0 4px 12px rgba(249, 115, 22, .10);
398
+ }
399
+ .sl {
400
+ font-size: 0.55rem; color: #a8a29e;
401
+ letter-spacing: .14em; text-transform: uppercase;
402
+ margin-bottom: 5px; font-weight: 800;
403
+ }
404
+ .sv {
405
+ font-size: 1.1rem; font-weight: 800;
406
+ color: #1c1917;
407
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
408
+ letter-spacing: -0.01em;
409
+ }
410
+ .sv.pass { color: #15803d; text-shadow: 0 0 18px rgba(34, 197, 94, .25); }
411
+ .sv.fail { color: #dc2626; }
412
+ .sv.run { color: #2563eb; }
413
+ .sv.idle { color: #a8a29e; }
414
+ .sc.err {
415
+ flex: 100%;
416
+ border-color: #fecaca !important;
417
+ background: linear-gradient(135deg, #fef2f2 0%, #fff5f5 100%);
418
+ }
419
+ .sc.err .sv { color: #b91c1c; font-size: .72rem; word-break: break-all; }
420
+
421
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
422
+ IMAGES โ€” track view + agent POV
423
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
424
+ .main-view, .agent-pov {
425
+ position: relative;
426
+ border: 1px solid #e7e5e4 !important;
427
+ border-radius: 14px !important;
428
+ overflow: hidden;
429
+ background: #ffffff;
430
+ box-shadow:
431
+ 0 10px 30px -12px rgba(17, 24, 39, .12),
432
+ 0 0 0 1px rgba(255, 255, 255, .8) inset;
433
+ transition: box-shadow .2s ease, transform .2s ease;
434
+ }
435
+ .main-view:hover {
436
+ box-shadow:
437
+ 0 18px 44px -14px rgba(17, 24, 39, .18),
438
+ 0 0 0 1px rgba(255, 255, 255, .8) inset;
439
+ }
440
+ .main-view img,
441
+ .agent-pov img {
442
+ width: 100% !important; display: block !important;
443
+ border: none !important; border-radius: 0 !important;
444
+ }
445
+ .agent-pov {
446
+ border-color: #fb923c !important;
447
+ box-shadow:
448
+ 0 8px 24px -10px rgba(249, 115, 22, .35),
449
+ 0 0 0 1px rgba(255, 237, 213, .8) inset;
450
+ }
451
+ .agent-pov::after {
452
+ content: "CNN INPUT";
453
+ position: absolute; top: 8px; right: 8px;
454
+ background: rgba(249, 115, 22, .95);
455
+ color: #fff; font-size: 0.55rem;
456
+ letter-spacing: .16em; font-weight: 800;
457
+ padding: 2px 7px; border-radius: 999px;
458
+ box-shadow: 0 2px 6px rgba(249, 115, 22, .35);
459
+ pointer-events: none;
460
+ }
461
+ .agent-pov img { image-rendering: pixelated !important; }
462
+
463
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
464
+ PANEL TITLES (small orange labels above each box)
465
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
466
+ .panel-title {
467
+ display: inline-flex; align-items: center; gap: 6px;
468
+ font-size: 0.6rem; font-weight: 800;
469
+ letter-spacing: .18em; text-transform: uppercase;
470
+ color: #ea580c;
471
+ padding: 3px 10px;
472
+ background: linear-gradient(90deg, #fff7ed 0%, rgba(255,255,255,0) 100%);
473
+ border-left: 3px solid #fb923c;
474
+ border-radius: 2px;
475
+ }
476
+
477
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
478
+ HELP BOX
479
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
480
+ .help-box {
481
+ background:
482
+ linear-gradient(180deg, #ffffff 0%, #fafaf9 100%);
483
+ border: 1px solid #e7e5e4 !important;
484
+ border-radius: 14px !important;
485
+ padding: 14px 16px;
486
+ font-size: 0.78rem; color: #44403c; line-height: 1.65;
487
+ box-shadow:
488
+ 0 4px 14px -6px rgba(17, 24, 39, .08),
489
+ 0 0 0 1px rgba(255, 255, 255, .6) inset;
490
+ }
491
+ .help-box strong { color: #1c1917; font-weight: 700; }
492
+ .help-box .help-title {
493
+ font-size: .7rem; font-weight: 800; letter-spacing: .14em;
494
+ color: #ea580c; text-transform: uppercase; margin-bottom: 10px;
495
+ display: flex; align-items: center; gap: 6px;
496
+ }
497
+ .help-box .step { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 9px; }
498
+ .help-box .num {
499
+ background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
500
+ color: #fff;
501
+ width: 20px; height: 20px; border-radius: 50%;
502
+ display: flex; align-items: center; justify-content: center;
503
+ font-size: 0.65rem; font-weight: 800;
504
+ flex-shrink: 0; margin-top: 1px;
505
+ box-shadow: 0 2px 6px rgba(249, 115, 22, .35);
506
+ }
507
+ .help-box .footer-note {
508
+ margin-top: 10px; padding-top: 10px;
509
+ border-top: 1px dashed #e7e5e4;
510
+ font-size: 0.72rem; color: #78716c;
511
+ }
512
+ .help-box .footer-note .accent { color: #ea580c; font-weight: 700; }
513
+
514
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
515
+ VIDEO PANEL
516
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
517
+ .video-panel {
518
+ border: 1px solid #e7e5e4 !important;
519
+ border-radius: 14px !important;
520
+ overflow: hidden;
521
+ background: #0c0a09;
522
+ box-shadow:
523
+ 0 10px 30px -12px rgba(17, 24, 39, .15),
524
+ 0 0 0 1px rgba(255, 255, 255, .8) inset;
525
+ }
526
+ .video-panel video { width: 100% !important; display: block !important; }
527
+
528
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
529
+ BUTTONS โ€” premium tactile feel
530
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
531
+ button.gr-button, button {
532
+ font-family: 'Inter', sans-serif !important;
533
+ font-size: 0.8rem !important;
534
+ font-weight: 700 !important;
535
+ letter-spacing: .01em !important;
536
+ border-radius: 10px !important;
537
+ transition: transform .08s ease, box-shadow .15s ease, filter .15s ease !important;
538
+ border: 1px solid transparent !important;
539
+ }
540
+ button.gr-button:hover { transform: translateY(-1px); filter: brightness(1.04); }
541
+ button.gr-button:active { transform: translateY(0); }
542
+
543
+ /* primary = orange */
544
+ button.primary, button[variant="primary"], .gr-button-primary {
545
+ background: linear-gradient(135deg, #f97316 0%, #ea580c 100%) !important;
546
+ color: #ffffff !important;
547
+ border-color: #c2410c !important;
548
+ box-shadow:
549
+ 0 4px 12px -2px rgba(249, 115, 22, .35),
550
+ 0 0 0 1px rgba(255, 255, 255, .25) inset !important;
551
+ }
552
+ button.primary:hover { box-shadow: 0 6px 18px -2px rgba(249, 115, 22, .45) !important; }
553
+
554
+ /* secondary = neutral white */
555
+ button.secondary, button[variant="secondary"], .gr-button-secondary {
556
+ background: #ffffff !important;
557
+ color: #1c1917 !important;
558
+ border-color: #e7e5e4 !important;
559
+ box-shadow: 0 1px 2px rgba(17, 24, 39, .05) !important;
560
+ }
561
+ button.secondary:hover { border-color: #fb923c !important; color: #ea580c !important; }
562
+
563
+ /* stop = red */
564
+ button.stop, button[variant="stop"], .gr-button-stop {
565
+ background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%) !important;
566
+ color: #ffffff !important;
567
+ border-color: #15803d !important;
568
+ box-shadow:
569
+ 0 4px 12px -2px rgba(22, 163, 74, .35),
570
+ 0 0 0 1px rgba(255, 255, 255, .25) inset !important;
571
+ }
572
+ button.stop:hover { box-shadow: 0 6px 18px -2px rgba(22, 163, 74, .45) !important; }
573
+
574
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
575
+ LABELS & INPUTS
576
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
577
+ label, .gr-input-label, span[data-testid="block-label"] {
578
+ color: #44403c !important;
579
+ font-size: 0.74rem !important;
580
+ font-weight: 600 !important;
581
+ letter-spacing: .01em !important;
582
+ }
583
+ select, input, textarea {
584
+ font-family: 'Inter', sans-serif !important;
585
+ font-size: 0.82rem !important;
586
+ background: #ffffff !important;
587
+ border: 1px solid #e7e5e4 !important;
588
+ border-radius: 10px !important;
589
+ color: #1c1917 !important;
590
+ transition: border-color .15s ease, box-shadow .15s ease !important;
591
+ }
592
+ select:focus, input:focus, textarea:focus {
593
+ outline: none !important;
594
+ border-color: #fb923c !important;
595
+ box-shadow: 0 0 0 3px rgba(251, 146, 60, .18) !important;
596
+ }
597
+
598
+ /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
599
+ FOOTER
600
+ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */
601
+ .footer-strip {
602
+ margin-top: 24px; padding: 14px 32px 18px;
603
+ border-top: 1px solid #e7e5e4;
604
+ color: #78716c; font-size: 0.72rem;
605
+ text-align: center;
606
+ background: linear-gradient(180deg, rgba(255,255,255,0) 0%, #fafaf9 100%);
607
+ }
608
+ .footer-strip a {
609
+ color: #ea580c; text-decoration: none; font-weight: 700;
610
+ border-bottom: 1px dashed #fdba74;
611
+ }
612
+ .footer-strip a:hover { color: #c2410c; border-bottom-color: #ea580c; }
613
+ """
614
+
615
+ BANNER_HTML = """
616
+ <div class="banner">
617
+ <div class="banner-inner">
618
+ <h1>๐ŸŽ๏ธ Car Racing Agent</h1>
619
+ <p class="sub">
620
+ A PPO agent trained from scratch ยท 10 tracks ยท egocentric vision ยท
621
+ <strong style="color:#15803d">10 / 10 tracks โ€” zero crashes</strong>
622
+ &nbsp;ยท&nbsp; OpenEnv Student Challenge 2026
623
+ </p>
624
+ <div class="badges">
625
+ <span class="badge">OpenEnv API</span>
626
+ <span class="badge">TorchRL PPO</span>
627
+ <span class="badge">Curriculum Learning</span>
628
+ <span class="badge">ImpalaCNN</span>
629
+ <span class="badge">~1.3 M Steps</span>
630
+ <span class="badge green">10 / 10 โœ…</span>
631
+ <a class="badge hf-badge" href="https://huggingface.co/spaces/nirmalpratheep/curriculum-car-racer" target="_blank" rel="noopener">๐Ÿ“ Blog Post</a>
632
+ </div>
633
+ </div>
634
+ </div>
635
+ """
636
+
637
+ HELP_HTML = f"""
638
+ <div class="help-box">
639
+ <div class="help-title">๐ŸŽฎ How to use</div>
640
+ <div class="step">
641
+ <div class="num">1</div>
642
+ <div>Pick any of the <strong>10 curriculum tracks</strong> โ€” from the easy Wide Oval up to the Hairpin and Chicane.</div>
643
+ </div>
644
+ <div class="step">
645
+ <div class="num">2</div>
646
+ <div>Click <strong>Reset</strong> to spawn the agent at the start line. The track view and agent POV appear immediately.</div>
647
+ </div>
648
+ <div class="step">
649
+ <div class="num">3</div>
650
+ <div>Click <strong>Step ร—{STEP_CHUNK}</strong> to advance {STEP_CHUNK} physics steps at a time โ€” watch the path trail build up.</div>
651
+ </div>
652
+ <div class="step">
653
+ <div class="num">4</div>
654
+ <div>Click <strong>Auto-Drive</strong> to run a full lap and generate a <strong>replay video</strong> below.</div>
655
+ </div>
656
+ <div class="footer-note">
657
+ The <span class="accent">orange-bordered image</span> is the actual 64ร—64 input the neural network receives โ€”
658
+ always rotated so the car faces upward.
659
+ </div>
660
+ </div>
661
+ """
662
+
663
+ FOOTER_HTML = """
664
+ <div class="footer-strip">
665
+ Car Racing Agent ยท trained with
666
+ <a href="https://github.com/pytorch/rl" target="_blank">TorchRL</a> PPO ยท
667
+ served via <a href="https://openenv.dev" target="_blank">OpenEnv</a> ยท
668
+ <a href="https://huggingface.co/spaces/nirmalpratheep/Car-Racing-Agent" target="_blank">๐Ÿค— HF Space</a> ยท
669
+ <a href="https://huggingface.co/blog/NirmalPratheep/curriculum-car-racer" target="_blank">๐Ÿ“ Blog</a> ยท
670
+ <a href="https://github.com/NirmalPratheep/curriculum-car-racer" target="_blank">GitHub</a>
671
+ </div>
672
+ """
673
+
674
+ # โ”€โ”€ Build UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
675
+ with gr.Blocks(title="Car Racing Agent โ€” OpenEnv Demo") as demo:
676
+
677
+ gr.HTML(BANNER_HTML)
678
+
679
+ session_state = gr.State(None)
680
+
681
+ with gr.Row(equal_height=False):
682
+
683
+ # โ”€โ”€ Left column โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
684
+ with gr.Column(scale=1, min_width=260):
685
+ gr.HTML("<div class='panel-title' style='margin:4px 0 6px'>Select Track</div>")
686
+ track_dd = gr.Dropdown(
687
+ choices=TRACK_CHOICES, value=TRACK_CHOICES[0],
688
+ label="", interactive=True,
689
+ )
690
+ gr.HTML("<div style='height:10px'></div>")
691
+ reset_btn = gr.Button("๐ŸŽฌ Reset", variant="secondary")
692
+ step_btn = gr.Button(f"โฉ Step ร—{STEP_CHUNK}", variant="primary")
693
+ auto_btn = gr.Button("๐Ÿ Auto-Drive Full Lap", variant="stop")
694
+ gr.HTML("<div style='height:10px'></div>")
695
+ status_out = gr.HTML(_status_html(None))
696
+ gr.HTML("<div style='height:6px'></div>")
697
+ gr.HTML(HELP_HTML)
698
+
699
+ # โ”€โ”€ Right column โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
700
+ with gr.Column(scale=3):
701
+ gr.HTML("<div class='panel-title' style='margin:4px 0 6px'>Live Track View</div>")
702
+ with gr.Row(equal_height=False):
703
+ with gr.Column(scale=3, min_width=300):
704
+ frame_img = gr.Image(
705
+ label="Top-Down Track", type="pil",
706
+ height=360, interactive=False,
707
+ elem_classes=["main-view"],
708
+ value=_placeholder_main(),
709
+ )
710
+ with gr.Column(scale=1, min_width=160):
711
+ headlight_img = gr.Image(
712
+ label="Agent POV โ€” CNN Input (64ร—64)",
713
+ type="pil", height=200, interactive=False,
714
+ elem_classes=["agent-pov"],
715
+ value=_placeholder_pov(),
716
+ )
717
+ gr.HTML("<div style='height:10px'></div>")
718
+ gr.HTML("<div class='panel-title' style='margin:4px 0 6px'>Auto-Drive Replay</div>")
719
+ video_out = gr.Video(
720
+ label="", height=300, show_label=False,
721
+ elem_classes=["video-panel"],
722
+ )
723
+
724
+ gr.HTML(FOOTER_HTML)
725
+
726
+ # โ”€โ”€ Wire callbacks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
727
+ step_event = step_btn.click(
728
+ fn=step_agent,
729
+ inputs=[session_state],
730
+ outputs=[session_state, frame_img, headlight_img, status_out, video_out],
731
+ )
732
+ auto_event = auto_btn.click(
733
+ fn=auto_drive,
734
+ inputs=[track_dd],
735
+ outputs=[session_state, frame_img, headlight_img, status_out, video_out],
736
+ )
737
+ # Reset cancels any running auto-drive or step generator, then resets.
738
+ reset_btn.click(
739
+ fn=reset,
740
+ inputs=[track_dd],
741
+ outputs=[session_state, frame_img, headlight_img, status_out, video_out],
742
+ cancels=[auto_event, step_event],
743
+ )
744
+
745
+
746
+ if __name__ == "__main__":
747
+ demo.queue(default_concurrency_limit=2)
748
+ demo.launch(
749
+ server_name="0.0.0.0",
750
+ server_port=int(os.environ.get("PORT", 7860)),
751
+ css=CSS,
752
+ theme=gr.themes.Soft(primary_hue="orange"),
753
+ )
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.12.0
2
+ torch
3
+ torchrl
4
+ tensordict
5
+ pygame
6
+ numpy
7
+ Pillow
8
+ imageio
9
+ imageio-ffmpeg
10
+ openenv-core
11
+ pydantic