HaptalAI commited on
Commit
f734d80
Β·
verified Β·
1 Parent(s): 8b90a2a

Initial RoboGen v1

Browse files
README.md CHANGED
@@ -1,13 +1,26 @@
1
  ---
2
- title: Robogen
3
- emoji: πŸ“š
4
- colorFrom: gray
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.15.1
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: RoboGen
3
+ emoji: πŸ“Š
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.29.0
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # RoboGen β€” Synthetic Robotics Dataset Generator
13
+
14
+ **Physics-accurate LeRobot-format datasets for SO-100, SO-101, and Koch arms.**
15
+
16
+ Generate synthetic datasets with physically-plausible trajectories, automatic quality
17
+ scoring against HaptalAI's misalignment benchmark, and one-click download.
18
+
19
+ ## Features
20
+ - Cubic spline trajectories with analytical velocity derivatives (not random walks)
21
+ - Three failure modes: grasp slip, velocity spike, torque saturation
22
+ - Quality scoring via calibrated MAD z-score thresholds (community-benchmarked)
23
+ - Downloadable zip: parquet dataset + auto-generated README
24
+
25
+ ## Contact
26
+ aarav@haptal.ai
__pycache__/airtable.cpython-310.pyc ADDED
Binary file (2.06 kB). View file
 
__pycache__/airtable.cpython-311.pyc ADDED
Binary file (3 kB). View file
 
__pycache__/generator.cpython-310.pyc ADDED
Binary file (20.4 kB). View file
 
__pycache__/generator.cpython-311.pyc ADDED
Binary file (36.3 kB). View file
 
__pycache__/readme_gen.cpython-310.pyc ADDED
Binary file (7.24 kB). View file
 
__pycache__/readme_gen.cpython-311.pyc ADDED
Binary file (9.31 kB). View file
 
airtable.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ airtable.py β€” email gate logging to Airtable.
3
+
4
+ Required env vars:
5
+ AIRTABLE_BASE_ID β€” e.g. "appXXXXXXXXXXXXXX"
6
+ AIRTABLE_API_KEY β€” personal access token ("pat...")
7
+
8
+ Table name is hardcoded as "RoboGen Leads". Change TABLE_NAME below if needed.
9
+ Failures are swallowed β€” caller receives a (success: bool, message: str) tuple
10
+ and must allow the download even if logging fails.
11
+ """
12
+
13
+ import os
14
+ import json
15
+ import datetime
16
+ from typing import Tuple, Optional
17
+
18
+ try:
19
+ import requests
20
+ _REQUESTS_OK = True
21
+ except ImportError:
22
+ _REQUESTS_OK = False
23
+
24
+ TABLE_NAME = "RoboGen Leads"
25
+
26
+ _BASE_ID = os.environ.get("AIRTABLE_BASE_ID", "")
27
+ _API_KEY = os.environ.get("AIRTABLE_API_KEY", "")
28
+
29
+
30
+ def log_email(
31
+ email: str,
32
+ robot: str,
33
+ task: str,
34
+ n_episodes: int,
35
+ quality_score: float,
36
+ band: str,
37
+ ) -> Tuple[bool, str]:
38
+ """
39
+ POST one record to Airtable.
40
+
41
+ Returns (True, "Logged") on success, (False, reason) on failure.
42
+ Caller MUST still enable the download on failure.
43
+ """
44
+ if not _REQUESTS_OK:
45
+ return False, "requests library not installed"
46
+
47
+ if not _BASE_ID or not _API_KEY:
48
+ return False, "AIRTABLE_BASE_ID / AIRTABLE_API_KEY not set"
49
+
50
+ endpoint = f"https://api.airtable.com/v0/{_BASE_ID}/{TABLE_NAME.replace(' ', '%20')}"
51
+ headers = {
52
+ "Authorization": f"Bearer {_API_KEY}",
53
+ "Content-Type": "application/json",
54
+ }
55
+ payload = {
56
+ "fields": {
57
+ "Email": email.strip(),
58
+ "Robot": robot,
59
+ "Task": task,
60
+ "Episodes": n_episodes,
61
+ "Quality Score": quality_score,
62
+ "Band": band,
63
+ "Timestamp": datetime.datetime.utcnow().isoformat() + "Z",
64
+ }
65
+ }
66
+
67
+ try:
68
+ resp = requests.post(endpoint, headers=headers, json=payload, timeout=6)
69
+ if resp.status_code in (200, 201):
70
+ return True, "Logged"
71
+ return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
72
+ except Exception as exc:
73
+ return False, str(exc)
app.py ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RoboGen β€” HaptalAI Synthetic Robotics Dataset Generator
3
+ Gradio Space: HaptalAI/robogen
4
+
5
+ Step-by-step UI:
6
+ 1 β†’ Robot selection (card buttons)
7
+ 2 β†’ Task selection (dropdown)
8
+ 3 β†’ Parameter configuration (sliders + checkboxes with tooltips)
9
+ 4 β†’ Generate (progress bar)
10
+ 5 β†’ Results dashboard (quality score, band, failure breakdown)
11
+ 6 β†’ Email gate β†’ Download zip (parquet + README)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import sys
18
+ import io
19
+ import json
20
+ import zipfile
21
+ import tempfile
22
+ import traceback
23
+ from typing import Optional, Dict, List
24
+
25
+ # Allow running as python space/app.py from repo root
26
+ sys.path.insert(0, os.path.dirname(__file__))
27
+
28
+ import gradio as gr
29
+ import pandas as pd
30
+ import numpy as np
31
+
32
+ from generator import (
33
+ generate_dataset,
34
+ score_dataset,
35
+ annotate_quality_scores,
36
+ TASKS_BY_ROBOT,
37
+ ROBOT_CONFIG,
38
+ FAILURE_TYPES,
39
+ )
40
+ from readme_gen import generate_readme
41
+ from airtable import log_email
42
+
43
+ # ── Load CSS ──────────────────────────────────────────────────────────────────
44
+
45
+ _CSS_PATH = os.path.join(os.path.dirname(__file__), "style.css")
46
+ with open(_CSS_PATH) as _f:
47
+ _CSS = _f.read()
48
+
49
+ # ── Robot display config ──────────────────────────────────────────────────────
50
+
51
+ ROBOT_ICONS = {
52
+ "SO-100": "SO-100",
53
+ "SO-101": "SO-101",
54
+ "Koch": "Koch",
55
+ }
56
+
57
+ ROBOT_DESCRIPTIONS = {
58
+ "SO-100": "Low-cost 6-DOF arm, community favourite",
59
+ "SO-101": "Upgraded SO-100 with refined kinematics",
60
+ "Koch": "Koch arm β€” drawer & manipulation tasks",
61
+ }
62
+
63
+ TASK_LABELS = {
64
+ "pick_and_place": "Pick and Place",
65
+ "push_object": "Push Object",
66
+ "grasp_and_lift": "Grasp and Lift",
67
+ "stacking": "Stacking",
68
+ "drawer_open_close": "Drawer Open / Close",
69
+ }
70
+
71
+ FAILURE_LABELS = {
72
+ "grasp_slip": "Grasp Slip",
73
+ "velocity_spike": "Velocity Spike",
74
+ "torque_saturation": "Torque Saturation",
75
+ }
76
+
77
+ # ── Default parameters per robot Γ— task ──────────────────────────────────────
78
+
79
+ DEFAULTS: Dict[str, Dict] = {
80
+ "SO-100": {"n_eps": 50, "success": 70, "fmin": 1.0, "fmax": 10.0},
81
+ "SO-101": {"n_eps": 50, "success": 70, "fmin": 1.0, "fmax": 10.0},
82
+ "Koch": {"n_eps": 30, "success": 75, "fmin": 0.5, "fmax": 8.0},
83
+ }
84
+
85
+ # ── HTML helpers ──────────────────────────────────────────────────────────────
86
+
87
+ def _make_results_html(result: Dict, robot: str, task: str) -> str:
88
+ score = result["overall_score"]
89
+ band = result["band"]
90
+ n_pass = result["n_passed"]
91
+ n_flag = result["n_flagged"]
92
+ n_eps = result["n_episodes"]
93
+ mismatch = result["mean_mismatch"]
94
+ fb = result["failure_breakdown"]
95
+ scorer = result["scorer_used"]
96
+
97
+ band_cls = band.lower()
98
+ band_desc = {
99
+ "Clean": "Trajectories are smooth and anomaly-free. Ready for policy training.",
100
+ "Review": "Some anomalies detected. Review flagged episodes before training.",
101
+ "Flagged": "High anomaly rate. Best used for failure analysis and augmentation.",
102
+ }.get(band, "")
103
+
104
+ # Failure bars
105
+ total_failures = sum(fb.values()) or 1
106
+ bar_html = ""
107
+ for key, count in sorted(fb.items(), key=lambda x: -x[1]):
108
+ label = FAILURE_LABELS.get(key, key)
109
+ pct = count / total_failures * 100
110
+ bar_html += f"""
111
+ <div class="rg-failure-bar">
112
+ <span class="rg-failure-label">{label}</span>
113
+ <div class="rg-bar-track"><div class="rg-bar-fill" style="width:{pct:.0f}%"></div></div>
114
+ <span class="rg-bar-count">{count}</span>
115
+ </div>"""
116
+
117
+ task_label = TASK_LABELS.get(task, task)
118
+ no_failures = "No failure episodes in dataset." if not fb else ""
119
+
120
+ return f"""
121
+ <div class="rg-results">
122
+ <div class="rg-score-row">
123
+ <div class="rg-score-circle {band_cls}">
124
+ <span class="rg-score-value">{score:.0f}</span>
125
+ <span class="rg-score-denom">/ 100</span>
126
+ </div>
127
+ <div class="rg-score-info">
128
+ <div class="rg-band-badge {band_cls}">{band}</div>
129
+ <div class="rg-band-desc">{band_desc}</div>
130
+ </div>
131
+ </div>
132
+
133
+ <div class="rg-stat-grid">
134
+ <div class="rg-stat">
135
+ <div class="rg-stat-value">{n_eps}</div>
136
+ <div class="rg-stat-label">Total Episodes</div>
137
+ </div>
138
+ <div class="rg-stat">
139
+ <div class="rg-stat-value" style="color:var(--green)">{n_pass}</div>
140
+ <div class="rg-stat-label">Passed</div>
141
+ </div>
142
+ <div class="rg-stat">
143
+ <div class="rg-stat-value" style="color:var(--red)">{n_flag}</div>
144
+ <div class="rg-stat-label">Flagged</div>
145
+ </div>
146
+ <div class="rg-stat">
147
+ <div class="rg-stat-value">{mismatch:.3f}</div>
148
+ <div class="rg-stat-label">Mean Mismatch</div>
149
+ </div>
150
+ <div class="rg-stat">
151
+ <div class="rg-stat-value">{robot}</div>
152
+ <div class="rg-stat-label">Robot</div>
153
+ </div>
154
+ <div class="rg-stat">
155
+ <div class="rg-stat-value" style="font-size:0.9rem">{task_label}</div>
156
+ <div class="rg-stat-label">Task</div>
157
+ </div>
158
+ </div>
159
+
160
+ <div class="rg-failure-section">
161
+ <div class="rg-failure-title">Failure Type Breakdown</div>
162
+ {bar_html or no_failures}
163
+ </div>
164
+
165
+ <div class="rg-scorer-note">
166
+ Scored by HaptalAI misalignment benchmark &middot; scorer: <code>{scorer}</code>
167
+ </div>
168
+ </div>
169
+ """
170
+
171
+
172
+ # ── Download bundle builder ───────────────────────────────────────────────────
173
+
174
+ def _build_zip(
175
+ df: pd.DataFrame,
176
+ result: Dict,
177
+ robot: str,
178
+ task: str,
179
+ n_eps: int,
180
+ success: float,
181
+ fmin: float,
182
+ fmax: float,
183
+ failures: List[str],
184
+ ) -> str:
185
+ """Annotate DF, write parquet + README into a temp zip, return zip path."""
186
+ df_annotated = annotate_quality_scores(df, result)
187
+
188
+ readme_md = generate_readme(
189
+ robot=robot, task=task, n_episodes=n_eps,
190
+ success_rate=success / 100, force_min=fmin, force_max=fmax,
191
+ failures=failures,
192
+ score=result["overall_score"], band=result["band"],
193
+ n_passed=result["n_passed"], n_flagged=result["n_flagged"],
194
+ mean_mismatch=result["mean_mismatch"],
195
+ failure_breakdown=result["failure_breakdown"],
196
+ scorer_used=result["scorer_used"],
197
+ )
198
+
199
+ tag = f"{robot.replace('-', '')}_{task}"
200
+ zip_fd, zip_path = tempfile.mkstemp(suffix=".zip", prefix=f"robogen_{tag}_")
201
+ os.close(zip_fd)
202
+
203
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
204
+ # Parquet
205
+ buf = io.BytesIO()
206
+ df_annotated.to_parquet(buf, index=False)
207
+ zf.writestr(f"robogen_{tag}.parquet", buf.getvalue())
208
+ # README
209
+ zf.writestr("README.md", readme_md.encode("utf-8"))
210
+
211
+ return zip_path
212
+
213
+
214
+ # ── Gradio app ────────────────────────────────────────────────────────────────
215
+
216
+ def build_app() -> gr.Blocks:
217
+
218
+ with gr.Blocks(
219
+ css=_CSS,
220
+ theme=gr.themes.Base(
221
+ primary_hue=gr.themes.colors.purple,
222
+ neutral_hue=gr.themes.colors.slate,
223
+ ),
224
+ title="RoboGen β€” Synthetic Robotics Datasets",
225
+ analytics_enabled=False,
226
+ ) as demo:
227
+
228
+ # ── Persistent state ──────────────────────────────────────────────
229
+ robot_state = gr.State("")
230
+ df_state = gr.State(None)
231
+ result_state = gr.State(None)
232
+
233
+ # ── Header ────────────────────────────────────────────────────────
234
+ gr.HTML("""
235
+ <div class="rg-header">
236
+ <div class="rg-logo">RoboGen</div>
237
+ <div class="rg-tagline">Synthetic robotics datasets, physics-accurate &amp; quality-scored</div>
238
+ <div class="rg-badge">LeRobot-format &nbsp;&middot;&nbsp; SO-100 / SO-101 / Koch &nbsp;&middot;&nbsp; HaptalAI</div>
239
+ </div>
240
+ """)
241
+
242
+ # ────────────────────────────────────────────────────────────────────
243
+ # STEP 1 β€” Robot selection
244
+ # ────────────────────────────────────────────────────────────────────
245
+ with gr.Group(elem_classes=["step-card"]):
246
+ gr.HTML("""
247
+ <div class="step-header">
248
+ <span class="step-num">1</span>
249
+ <span class="step-title">Select Robot</span>
250
+ </div>""")
251
+
252
+ robot_select = gr.Radio(
253
+ choices=["SO-100", "Koch", "SO-101"],
254
+ value=None,
255
+ label="",
256
+ elem_classes=["robot-radio"],
257
+ )
258
+
259
+ # ────────────────────────────────────────────────────────────────────
260
+ # STEP 2 β€” Task selection
261
+ # ────────────────────────────────────────────────────────────────────
262
+ with gr.Group(visible=False, elem_classes=["step-card"]) as step2_grp:
263
+ gr.HTML("""
264
+ <div class="step-header">
265
+ <span class="step-num">2</span>
266
+ <span class="step-title">Select Task</span>
267
+ </div>""")
268
+
269
+ task_select = gr.Dropdown(
270
+ choices=[],
271
+ value=None,
272
+ label="Task",
273
+ interactive=True,
274
+ )
275
+
276
+ # ────────────────────────────────────────────────────────────────────
277
+ # STEP 3 β€” Parameters
278
+ # ────────────────────────────────────────────────────────────────────
279
+ with gr.Group(visible=False, elem_classes=["step-card"]) as step3_grp:
280
+ gr.HTML("""
281
+ <div class="step-header">
282
+ <span class="step-num">3</span>
283
+ <span class="step-title">Configure Parameters</span>
284
+ </div>""")
285
+
286
+ with gr.Row():
287
+ n_episodes_slider = gr.Slider(
288
+ minimum=10, maximum=500, value=50, step=5,
289
+ label="Number of Episodes",
290
+ info="Total episodes in the dataset (10–500)",
291
+ )
292
+ success_slider = gr.Slider(
293
+ minimum=0, maximum=100, value=70, step=5,
294
+ label="Success Rate (%)",
295
+ info="Fraction of episodes with successful trajectories",
296
+ )
297
+
298
+ with gr.Row():
299
+ force_min_slider = gr.Slider(
300
+ minimum=0.1, maximum=10.0, value=1.0, step=0.1,
301
+ label="Min Contact Force (N)",
302
+ info="Lower bound of spring-damper contact force during grasping",
303
+ )
304
+ force_max_slider = gr.Slider(
305
+ minimum=1.0, maximum=20.0, value=10.0, step=0.5,
306
+ label="Max Contact Force (N)",
307
+ info="Upper bound of contact force β€” higher = firmer grip",
308
+ )
309
+
310
+ gr.HTML("""
311
+ <div style="margin: 4px 0 8px;font-size:0.82rem;color:#8892a4;">
312
+ <b>Failure types to include</b> &nbsp;
313
+ <span style="font-style:italic;">
314
+ Grasp Slip β€” gripper opens mid-episode &nbsp;|&nbsp;
315
+ Velocity Spike β€” servo glitch (z&gt;6.5) &nbsp;|&nbsp;
316
+ Torque Saturation β€” joint hits angular limit
317
+ </span>
318
+ </div>""")
319
+
320
+ failure_check = gr.CheckboxGroup(
321
+ choices=["grasp_slip", "velocity_spike", "torque_saturation"],
322
+ value=["grasp_slip", "velocity_spike", "torque_saturation"],
323
+ label="",
324
+ elem_classes=["checkbox-group"],
325
+ )
326
+
327
+ # ────────────────────────────────────────────────────────────────────
328
+ # STEP 4 β€” Generate
329
+ # ────────────────────────────────────────────────────────────────────
330
+ with gr.Group(visible=False, elem_classes=["step-card"]) as step4_grp:
331
+ gr.HTML("""
332
+ <div class="step-header">
333
+ <span class="step-num">4</span>
334
+ <span class="step-title">Generate Dataset</span>
335
+ </div>""")
336
+
337
+ generate_btn = gr.Button(
338
+ "Generate Dataset",
339
+ elem_classes=["btn-generate"],
340
+ size="lg",
341
+ )
342
+ gen_status = gr.Markdown("", elem_classes=["status-msg"])
343
+
344
+ # ────────────────────────────────────────────────────────────────────
345
+ # STEP 5 β€” Results dashboard
346
+ # ────────────────────────────────────────────────────────────────────
347
+ with gr.Group(visible=False, elem_classes=["step-card"]) as step5_grp:
348
+ gr.HTML("""
349
+ <div class="step-header">
350
+ <span class="step-num">5</span>
351
+ <span class="step-title">Quality Results</span>
352
+ </div>""")
353
+ results_html = gr.HTML("")
354
+
355
+ # ────────────────────────────────────────────────────────────────────
356
+ # STEP 6 β€” Email gate + Download
357
+ # ──────────���─────────────────────────────────────────────────────────
358
+ with gr.Group(visible=False, elem_classes=["step-card"]) as step6_grp:
359
+ gr.HTML("""
360
+ <div class="step-header">
361
+ <span class="step-num">6</span>
362
+ <span class="step-title">Download Dataset</span>
363
+ </div>
364
+ <div class="email-gate-note">
365
+ Enter your email to unlock the download. You'll receive occasional
366
+ updates on new robot configs and dataset improvements.
367
+ </div>""")
368
+
369
+ with gr.Row():
370
+ email_input = gr.Textbox(
371
+ placeholder="you@example.com",
372
+ label="Email",
373
+ scale=4,
374
+ max_lines=1,
375
+ )
376
+ email_btn = gr.Button(
377
+ "Confirm β†’",
378
+ elem_classes=["btn-primary"],
379
+ scale=1,
380
+ )
381
+
382
+ email_status = gr.Markdown("", visible=True)
383
+
384
+ download_file = gr.File(
385
+ label="Download robogen_dataset.zip",
386
+ visible=False,
387
+ interactive=False,
388
+ )
389
+
390
+ # ════════════════════════════════════════════════════════════════════
391
+ # EVENT HANDLERS
392
+ # ════════════════════════════════════════════════════════════════════
393
+
394
+ # ── Step 1 β†’ Step 2: Robot selected ──────────────────────────────
395
+ def on_robot_select(robot: str):
396
+ if not robot:
397
+ return (
398
+ gr.update(visible=False),
399
+ gr.update(choices=[], value=None),
400
+ gr.update(visible=False),
401
+ gr.update(visible=False),
402
+ robot,
403
+ )
404
+ tasks_raw = TASKS_BY_ROBOT[robot]
405
+ tasks_disp = [(TASK_LABELS.get(t, t), t) for t in tasks_raw]
406
+ d = DEFAULTS.get(robot, DEFAULTS["SO-100"])
407
+ return (
408
+ gr.update(visible=True), # step2_grp
409
+ gr.update(choices=tasks_disp, value=tasks_raw[0]), # task_select
410
+ gr.update(visible=False), # step3_grp
411
+ gr.update(visible=False), # step4_grp
412
+ robot, # robot_state
413
+ )
414
+
415
+ robot_select.change(
416
+ on_robot_select,
417
+ inputs=[robot_select],
418
+ outputs=[step2_grp, task_select, step3_grp, step4_grp, robot_state],
419
+ )
420
+
421
+ # ── Step 2 β†’ Step 3: Task selected ───────────────────────────────
422
+ def on_task_select(task: str, robot: str):
423
+ if not task or not robot:
424
+ return (
425
+ gr.update(visible=False),
426
+ gr.update(visible=False),
427
+ 50, 70, 1.0, 10.0,
428
+ )
429
+ d = DEFAULTS.get(robot, DEFAULTS["SO-100"])
430
+ cfg_fr = ROBOT_CONFIG[robot]["force_range"]
431
+ return (
432
+ gr.update(visible=True), # step3_grp
433
+ gr.update(visible=True), # step4_grp
434
+ d["n_eps"],
435
+ d["success"],
436
+ cfg_fr[0],
437
+ cfg_fr[1],
438
+ )
439
+
440
+ task_select.change(
441
+ on_task_select,
442
+ inputs=[task_select, robot_state],
443
+ outputs=[
444
+ step3_grp, step4_grp,
445
+ n_episodes_slider, success_slider,
446
+ force_min_slider, force_max_slider,
447
+ ],
448
+ )
449
+
450
+ # ── Step 4: Generate ─────────────────────────────────────────────
451
+ def on_generate(
452
+ robot, task, n_eps, success_pct, fmin, fmax, failures,
453
+ progress=gr.Progress(track_tqdm=False),
454
+ ):
455
+ if not robot or not task:
456
+ return (
457
+ "Please complete steps 1 and 2 first.",
458
+ gr.update(visible=False),
459
+ gr.update(visible=False),
460
+ gr.update(visible=False),
461
+ None, None,
462
+ )
463
+ if not failures:
464
+ failures = list(FAILURE_TYPES)
465
+
466
+ try:
467
+ # ── Generation ──────────────────────────────────────────
468
+ def gen_progress(frac, msg):
469
+ progress(frac * 0.65, desc=msg)
470
+
471
+ progress(0.0, desc="Generating episodes…")
472
+ df = generate_dataset(
473
+ robot=robot, task=task,
474
+ n_episodes=int(n_eps),
475
+ success_rate=success_pct / 100,
476
+ force_min=float(fmin), force_max=float(fmax),
477
+ enabled_failures=list(failures),
478
+ seed=None,
479
+ progress_callback=gen_progress,
480
+ )
481
+
482
+ # ── Scoring ─────────────────────────────────────────────
483
+ progress(0.70, desc="Running quality checks…")
484
+
485
+ def score_progress(frac, msg):
486
+ progress(0.70 + frac * 0.20, desc=msg)
487
+
488
+ result = score_dataset(df, progress_callback=score_progress)
489
+
490
+ progress(0.92, desc="Preparing results…")
491
+ results_panel = _make_results_html(result, robot, task)
492
+ progress(1.0, desc="Done")
493
+
494
+ status = (
495
+ f"Generated {len(df):,} rows across {result['n_episodes']} episodes β€” "
496
+ f"score **{result['overall_score']:.1f}/100** ({result['band']})"
497
+ )
498
+
499
+ return (
500
+ status,
501
+ gr.update(visible=True), # step5_grp
502
+ results_panel, # results_html
503
+ gr.update(visible=True), # step6_grp
504
+ df, # df_state
505
+ result, # result_state
506
+ )
507
+
508
+ except Exception:
509
+ err = traceback.format_exc()
510
+ return (
511
+ f"Generation failed:\n```\n{err}\n```",
512
+ gr.update(visible=False),
513
+ "",
514
+ gr.update(visible=False),
515
+ None, None,
516
+ )
517
+
518
+ generate_btn.click(
519
+ on_generate,
520
+ inputs=[
521
+ robot_state, task_select,
522
+ n_episodes_slider, success_slider,
523
+ force_min_slider, force_max_slider,
524
+ failure_check,
525
+ ],
526
+ outputs=[
527
+ gen_status,
528
+ step5_grp, results_html,
529
+ step6_grp,
530
+ df_state, result_state,
531
+ ],
532
+ )
533
+
534
+ # ── Step 6: Email gate β†’ unlock download ──────────────────────────
535
+ def on_email_submit(
536
+ email: str,
537
+ robot: str,
538
+ task: str,
539
+ n_eps: float,
540
+ success_pct: float,
541
+ fmin: float,
542
+ fmax: float,
543
+ failures: List[str],
544
+ df: Optional[pd.DataFrame],
545
+ result: Optional[Dict],
546
+ ):
547
+ if not email or "@" not in email:
548
+ return (
549
+ "Please enter a valid email address.",
550
+ gr.update(visible=False),
551
+ )
552
+ if df is None or result is None:
553
+ return (
554
+ "Generate a dataset first (Step 4).",
555
+ gr.update(visible=False),
556
+ )
557
+
558
+ # Fire Airtable (failure is non-blocking)
559
+ try:
560
+ ok, msg = log_email(
561
+ email=email.strip(),
562
+ robot=robot, task=task,
563
+ n_episodes=int(n_eps),
564
+ quality_score=result["overall_score"],
565
+ band=result["band"],
566
+ )
567
+ if not ok:
568
+ print(f"[RoboGen] Airtable log failed: {msg}")
569
+ except Exception as exc:
570
+ print(f"[RoboGen] Airtable exception: {exc}")
571
+
572
+ # Build download zip regardless of Airtable outcome
573
+ try:
574
+ zip_path = _build_zip(
575
+ df=df, result=result,
576
+ robot=robot, task=task,
577
+ n_eps=int(n_eps), success=success_pct,
578
+ fmin=float(fmin), fmax=float(fmax),
579
+ failures=list(failures),
580
+ )
581
+ return (
582
+ "Email confirmed. Your download is ready below.",
583
+ gr.update(visible=True, value=zip_path),
584
+ )
585
+ except Exception:
586
+ err = traceback.format_exc()
587
+ return (
588
+ f"Download preparation failed:\n```\n{err}\n```",
589
+ gr.update(visible=False),
590
+ )
591
+
592
+ email_btn.click(
593
+ on_email_submit,
594
+ inputs=[
595
+ email_input,
596
+ robot_state, task_select,
597
+ n_episodes_slider, success_slider,
598
+ force_min_slider, force_max_slider,
599
+ failure_check,
600
+ df_state, result_state,
601
+ ],
602
+ outputs=[email_status, download_file],
603
+ )
604
+
605
+ return demo
606
+
607
+
608
+ # ── Entry point ───────────────────────────────────────────────────────────────
609
+
610
+ if __name__ == "__main__":
611
+ app = build_app()
612
+ app.launch(
613
+ share=True,
614
+ server_port=int(os.environ.get("PORT", 7860)),
615
+ show_error=True,
616
+ )
generator.py ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RoboGen β€” Synthetic Robotics Dataset Generator
3
+ generator.py: Physically-plausible episode generation for LeRobot-format parquet datasets.
4
+
5
+ Schema
6
+ ------
7
+ state_0..state_5 β€” observed joint positions at time t (rad)
8
+ action_0..action_5 β€” joint velocity COMMANDS at time t (rad/s)
9
+
10
+ Physics model
11
+ -------------
12
+ 1. Task-specific waypoints (approach β†’ contact β†’ grasp/push β†’ retract)
13
+ 2. Cubic spline interpolation, clamped boundary (zero velocity at endpoints)
14
+ 3. Velocities = analytical first derivative of position spline (rad/s)
15
+ 4. Sensor noise: Gaussian Οƒ_pos=0.002 rad, Οƒ_vel=0.004 rad/s
16
+ 5. Contact force: spring-damper ramp during contact window
17
+
18
+ Failure modes
19
+ -------------
20
+ grasp_slip β€” smooth until 60-70%, then position discontinuity + velocity collapse
21
+ velocity_spike β€” 1-2 frames with MAD z-score > 6.5 on joint velocity
22
+ torque_saturation β€” joint clamped at limit for β‰₯3 frames (velocity near zero, pos at limit)
23
+
24
+ Z-score note: both injection and detection use robust MAD z-scores so that a single
25
+ spike value does not inflate the reference statistics.
26
+
27
+ Scorer
28
+ ------
29
+ Set SCORER_PATH env var to ~/Downloads/quality_scorer root; falls back to built-in.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ import sys
36
+ from typing import Dict, List, Optional, Tuple
37
+ import math
38
+
39
+ import numpy as np
40
+ import pandas as pd
41
+ from scipy.interpolate import CubicSpline
42
+
43
+ # ── Scorer import ────────────────────────────────────────────────────────────
44
+
45
+ _SCORER_PATH = os.environ.get(
46
+ "SCORER_PATH", os.path.expanduser("~/Downloads/quality_scorer")
47
+ )
48
+ if _SCORER_PATH not in sys.path:
49
+ sys.path.insert(0, _SCORER_PATH)
50
+
51
+ try:
52
+ from scorer.scorer import score_dataset as _ext_score_dataset # type: ignore
53
+ EXTERNAL_SCORER = True
54
+ except ImportError:
55
+ EXTERNAL_SCORER = False
56
+
57
+ # ── Robot / task configuration ───────────────────────────────────────────────
58
+
59
+ ROBOT_CONFIG: Dict[str, Dict] = {
60
+ "SO-100": {
61
+ "n_joints": 6,
62
+ # Per-joint [lo, hi] limits in radians
63
+ "joint_limits": [
64
+ (-3.14, 3.14), # j0 base rotation
65
+ (-1.57, 1.57), # j1 shoulder pitch
66
+ (-0.20, 2.80), # j2 elbow
67
+ (-3.14, 3.14), # j3 wrist roll
68
+ (-1.57, 1.57), # j4 wrist pitch
69
+ (-0.10, 1.00), # j5 gripper
70
+ ],
71
+ "home": np.array([0.00, -0.52, 1.20, 0.00, -0.72, 0.05]),
72
+ "targets": {
73
+ "pick_and_place": np.array([ 0.30, 0.20, 0.80, 0.10, -0.30, 0.50]),
74
+ "push_object": np.array([ 0.50, 0.12, 1.00, 0.00, -0.50, 0.00]),
75
+ "grasp_and_lift": np.array([ 0.22, 0.30, 0.48, 0.18, -0.40, 0.28]),
76
+ "stacking": np.array([ 0.12, 0.42, 0.68, 0.14, -0.30, 0.18]),
77
+ },
78
+ "gripper_joint": 5,
79
+ "torque_joint_pool": [1, 2, 3, 4], # joints that realistically saturate
80
+ "max_velocity": 1.5, # rad/s
81
+ "force_range": (0.5, 12.0),
82
+ },
83
+ "SO-101": {
84
+ "n_joints": 6,
85
+ "joint_limits": [
86
+ (-3.14, 3.14), (-1.57, 1.57), (-0.20, 2.80),
87
+ (-3.14, 3.14), (-1.57, 1.57), (-0.10, 1.00),
88
+ ],
89
+ "home": np.array([0.00, -0.42, 1.12, 0.00, -0.62, 0.05]),
90
+ "targets": {
91
+ "pick_and_place": np.array([ 0.35, 0.24, 0.76, 0.14, -0.26, 0.46]),
92
+ "push_object": np.array([ 0.54, 0.14, 0.96, 0.04, -0.46, 0.04]),
93
+ "grasp_and_lift": np.array([ 0.24, 0.34, 0.46, 0.24, -0.36, 0.24]),
94
+ "stacking": np.array([ 0.14, 0.44, 0.66, 0.19, -0.26, 0.14]),
95
+ },
96
+ "gripper_joint": 5,
97
+ "torque_joint_pool": [1, 2, 3, 4],
98
+ "max_velocity": 1.5,
99
+ "force_range": (0.5, 12.0),
100
+ },
101
+ "Koch": {
102
+ "n_joints": 6,
103
+ "joint_limits": [
104
+ (-3.14, 3.14), (-1.57, 1.57), (-0.10, 2.50),
105
+ (-3.14, 3.14), (-1.57, 1.57), (-0.10, 1.00),
106
+ ],
107
+ "home": np.array([0.00, -0.60, 1.28, 0.00, -0.82, 0.05]),
108
+ "targets": {
109
+ "pick_and_place": np.array([ 0.40, 0.16, 0.88, 0.06, -0.42, 0.58]),
110
+ "drawer_open_close": np.array([ 0.00, 0.10, 1.10, 0.00, -0.60, 0.00]),
111
+ "grasp_and_lift": np.array([ 0.28, 0.26, 0.54, 0.19, -0.46, 0.34]),
112
+ },
113
+ "gripper_joint": 5,
114
+ "torque_joint_pool": [1, 2, 3],
115
+ "max_velocity": 1.2,
116
+ "force_range": (0.3, 10.0),
117
+ },
118
+ }
119
+
120
+ TASKS_BY_ROBOT: Dict[str, List[str]] = {
121
+ "SO-100": ["pick_and_place", "push_object", "grasp_and_lift", "stacking"],
122
+ "SO-101": ["pick_and_place", "push_object", "grasp_and_lift", "stacking"],
123
+ "Koch": ["pick_and_place", "drawer_open_close", "grasp_and_lift"],
124
+ }
125
+
126
+ FAILURE_TYPES = ["grasp_slip", "velocity_spike", "torque_saturation"]
127
+
128
+ FRAMES_PER_EPISODE = 50
129
+ DT = 0.02 # seconds/frame at 50 Hz
130
+
131
+ # Community-calibrated thresholds (MAD z-score basis)
132
+ VELOCITY_SPIKE_Z = 6.5
133
+ MISMATCH_THRESHOLD = 0.50
134
+
135
+ # ── Robust statistics helpers ─────────────────────────────────────────────────
136
+
137
+ def _robust_z(values: np.ndarray) -> np.ndarray:
138
+ """
139
+ Compute per-column MAD z-scores: z = |x - median| / (MAD * 1.4826)
140
+ The 1.4826 factor makes MAD a consistent estimator of Οƒ under normality.
141
+ A single outlier barely affects median or MAD, so injected spikes retain
142
+ their true z-score instead of being pulled down by contaminated reference stats.
143
+ """
144
+ med = np.median(values, axis=0)
145
+ mad = np.median(np.abs(values - med), axis=0)
146
+ scale = mad * 1.4826 + 1e-6
147
+ return np.abs(values - med) / scale
148
+
149
+
150
+ # ── Waypoint factory ──────────────────────────────────────────────────────────
151
+
152
+ def _build_waypoints(
153
+ task: str,
154
+ home: np.ndarray,
155
+ target: np.ndarray,
156
+ rng: np.random.Generator,
157
+ ) -> Tuple[np.ndarray, np.ndarray]:
158
+ """
159
+ Return (t_knots_normalised [0..1], waypoint_matrix [n_knots, n_joints]).
160
+ Waypoints encode task-specific motion primitives; small Gaussian noise
161
+ ensures episode-to-episode variation.
162
+ """
163
+ n = len(home)
164
+ ns = lambda s=0.008: rng.normal(0, s, n)
165
+
166
+ if task == "pick_and_place":
167
+ approach = home + 0.25 * (target - home) + ns()
168
+ pre_grasp = home + 0.44 * (target - home) + ns()
169
+ grasp = home + 0.60 * (target - home) + ns(0.003)
170
+ lift = grasp.copy(); lift[2] = lift[2] * 0.82 + ns(0.003)[2]
171
+ transport = home + 0.82 * (target - home) + ns()
172
+ place = target + ns(0.003)
173
+ t = np.array([0.0, 0.18, 0.38, 0.55, 0.68, 0.83, 1.0])
174
+ w = np.vstack([home, approach, pre_grasp, grasp, lift, transport, place])
175
+
176
+ elif task == "push_object":
177
+ approach = home + 0.35 * (target - home) + ns()
178
+ contact = home + 0.56 * (target - home) + ns(0.004)
179
+ push_end = target + ns(0.006)
180
+ retract = home + 0.20 * (target - home) + ns()
181
+ t = np.array([0.0, 0.28, 0.52, 0.73, 1.0])
182
+ w = np.vstack([home, approach, contact, push_end, retract])
183
+
184
+ elif task == "grasp_and_lift":
185
+ descend = home + 0.28 * (target - home) + ns()
186
+ pre_grasp = home + 0.52 * (target - home) + ns()
187
+ grasp = target + ns(0.003)
188
+ lift = grasp.copy(); lift[2] -= 0.28 + ns(0.005)[2]
189
+ t = np.array([0.0, 0.22, 0.46, 0.68, 1.0])
190
+ w = np.vstack([home, descend, pre_grasp, grasp, lift])
191
+
192
+ elif task == "stacking":
193
+ grasp_pos = home + 0.38 * (target - home) + ns()
194
+ lift_pos = grasp_pos.copy(); lift_pos[2] -= 0.32
195
+ hover = home + 0.70 * (target - home) + ns()
196
+ lower = target + ns(0.004)
197
+ release = lower.copy(); release[5] += 0.18
198
+ t = np.array([0.0, 0.20, 0.40, 0.60, 0.82, 1.0])
199
+ w = np.vstack([home, grasp_pos, lift_pos, hover, lower, release])
200
+
201
+ elif task == "drawer_open_close":
202
+ extend = home + 0.28 * (target - home) + ns()
203
+ at_handle = home + 0.52 * (target - home) + ns(0.004)
204
+ pull_mid = home + 0.74 * (target - home) + ns()
205
+ open_pos = target + ns(0.006)
206
+ t = np.array([0.0, 0.26, 0.50, 0.74, 1.0])
207
+ w = np.vstack([home, extend, at_handle, pull_mid, open_pos])
208
+
209
+ else:
210
+ mid = home + 0.50 * (target - home) + ns()
211
+ t = np.array([0.0, 0.50, 1.0])
212
+ w = np.vstack([home, mid, target])
213
+
214
+ return t, w
215
+
216
+
217
+ # ── Smooth trajectory via cubic spline ────────────────────────────────────────
218
+
219
+ def _smooth_trajectory(
220
+ task: str,
221
+ home: np.ndarray,
222
+ target: np.ndarray,
223
+ n_frames: int,
224
+ rng: np.random.Generator,
225
+ ) -> Tuple[np.ndarray, np.ndarray]:
226
+ """
227
+ Returns:
228
+ positions (n_frames, n_joints) rad β€” joint positions
229
+ velocities (n_frames, n_joints) rad/s β€” analytical spline derivative
230
+ """
231
+ n_joints = len(home)
232
+ t_norm, wpts = _build_waypoints(task, home, target, rng)
233
+ t_knots = t_norm * (n_frames - 1)
234
+ t_frames = np.arange(n_frames, dtype=float)
235
+
236
+ pos = np.zeros((n_frames, n_joints))
237
+ vel = np.zeros((n_frames, n_joints))
238
+
239
+ for j in range(n_joints):
240
+ cs = CubicSpline(t_knots, wpts[:, j], bc_type="clamped")
241
+ pos[:, j] = cs(t_frames)
242
+ vel[:, j] = cs(t_frames, 1) / DT # derivative in rad/frame β†’ rad/s
243
+
244
+ pos += rng.normal(0, 0.002, pos.shape)
245
+ vel += rng.normal(0, 0.004, vel.shape)
246
+ return pos, vel
247
+
248
+
249
+ # ── Contact force model ────────────���──────────────────────────────────────────
250
+
251
+ def _force_profile(
252
+ n_frames: int,
253
+ task: str,
254
+ force_range: Tuple[float, float],
255
+ rng: np.random.Generator,
256
+ ) -> np.ndarray:
257
+ """Spring-damper contact force, non-zero during contact window."""
258
+ f_min, f_max = force_range
259
+ force = np.zeros(n_frames)
260
+ if task not in {"pick_and_place", "grasp_and_lift", "stacking",
261
+ "push_object", "drawer_open_close"}:
262
+ return force
263
+ c0, c1 = int(0.30 * n_frames), int(0.75 * n_frames)
264
+ for i in range(n_frames):
265
+ if c0 <= i <= c1:
266
+ phase = (i - c0) / max(c1 - c0, 1)
267
+ ramp = math.sin(phase * math.pi / 2) if phase < 0.30 else 1.0
268
+ force[i] = ramp * rng.uniform(f_min * 1.5, f_max) + rng.normal(0, 0.12)
269
+ else:
270
+ force[i] = rng.uniform(0, f_min * 0.25)
271
+ return np.clip(force, 0, f_max + 1)
272
+
273
+
274
+ # ── Failure injectors ─────────────────────────────────────────────────────────
275
+
276
+ def _inject_grasp_slip(
277
+ pos: np.ndarray,
278
+ vel: np.ndarray,
279
+ force: np.ndarray,
280
+ gripper: int,
281
+ rng: np.random.Generator,
282
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
283
+ """
284
+ Grasp slip at 60-70% of episode:
285
+ pos : gripper jumps open (discontinuity β‰₯ 0.18 rad)
286
+ vel : transient spike at slip frame, then near-zero (contact lost)
287
+ force: collapses post-slip
288
+ """
289
+ n = pos.shape[0]
290
+ sf = int(rng.uniform(0.60, 0.70) * n)
291
+
292
+ # Position discontinuity β€” gripper opens
293
+ slip_mag = rng.uniform(0.18, 0.38)
294
+ pos[sf:, gripper] += slip_mag
295
+ pos[sf:, max(0, gripper - 1)] += rng.uniform(0.04, 0.12)
296
+
297
+ # Velocity: brief spike at slip frame then collapse
298
+ v_med = float(np.median(np.abs(vel[:sf, gripper]))) + 0.05
299
+ vel[sf, gripper] += v_med * rng.uniform(9.0, 12.0) # big transient
300
+ vel[sf + 1:, gripper] *= rng.uniform(0.04, 0.12)
301
+
302
+ force[sf:] = np.clip(rng.exponential(0.25, n - sf), 0, 0.5)
303
+ return pos, vel, force
304
+
305
+
306
+ def _inject_velocity_spike(
307
+ vel: np.ndarray,
308
+ rng: np.random.Generator,
309
+ ) -> np.ndarray:
310
+ """
311
+ 1-2 frames with MAD z-score > 6.5 in the middle 60% of episode.
312
+ Uses robust reference stats (median / MAD) so the injected spike value
313
+ correctly yields the target z-score when detected later.
314
+ """
315
+ n_frames, n_joints = vel.shape
316
+ n_spikes = int(rng.integers(1, 3))
317
+
318
+ zone = np.arange(int(0.20 * n_frames), int(0.80 * n_frames))
319
+ frames = rng.choice(zone, size=min(n_spikes, len(zone)), replace=False)
320
+
321
+ v_med = np.median(vel, axis=0)
322
+ v_mad = np.median(np.abs(vel - v_med), axis=0) * 1.4826 + 1e-4
323
+
324
+ for sf in frames:
325
+ j = int(rng.integers(0, n_joints - 1)) # skip gripper
326
+ z_target = float(rng.uniform(7.5, 9.8))
327
+ sign = 1.0 if rng.random() > 0.5 else -1.0
328
+ # spike = median + z_target * sigma_MAD β†’ guaranteed MAD z-score β‰ˆ z_target
329
+ vel[sf, j] = v_med[j] + sign * z_target * v_mad[j]
330
+
331
+ return vel
332
+
333
+
334
+ def _inject_torque_saturation(
335
+ pos: np.ndarray,
336
+ vel: np.ndarray,
337
+ joint_limits: List[Tuple[float, float]],
338
+ torque_pool: List[int],
339
+ rng: np.random.Generator,
340
+ ) -> Tuple[np.ndarray, np.ndarray]:
341
+ """
342
+ One arm joint hits its angular limit and holds for β‰₯3 frames:
343
+ pos : clamped at limit (within sensor noise)
344
+ vel : near zero during saturation (joint stalled)
345
+ """
346
+ n = pos.shape[0]
347
+ j = int(rng.choice(torque_pool))
348
+ lo, hi = joint_limits[j]
349
+
350
+ mid_pos = pos[:, j].mean()
351
+ at_limit = hi if mid_pos > (lo + hi) / 2 else lo
352
+
353
+ sat_start = int(rng.uniform(0.25, 0.62) * n)
354
+ sat_len = int(rng.integers(3, 9))
355
+ sat_end = min(sat_start + sat_len, n)
356
+
357
+ pos[sat_start:sat_end, j] = at_limit + rng.normal(0, 0.001, sat_end - sat_start)
358
+ vel[sat_start:sat_end, j] = rng.normal(0, 0.005, sat_end - sat_start)
359
+
360
+ return pos, vel
361
+
362
+
363
+ # ── Episode builder ───────────────────────────────────────────────────────────
364
+
365
+ def _build_episode(
366
+ ep_idx: int,
367
+ robot: str,
368
+ task: str,
369
+ failure_type: str,
370
+ cfg: Dict,
371
+ rng: np.random.Generator,
372
+ ) -> pd.DataFrame:
373
+ """
374
+ Build one LeRobot-compatible episode DataFrame.
375
+
376
+ Columns: state_0..5 (rad), action_0..5 (rad/s), timestamp (s),
377
+ episode_index, frame_index, task, use_for_training,
378
+ failure_type, quality_score (placeholder=0)
379
+ """
380
+ n = cfg["n_joints"]
381
+ home = cfg["home"].copy()
382
+ target = cfg["targets"][task].copy() + rng.normal(0, 0.025, n)
383
+
384
+ pos, vel = _smooth_trajectory(task, home, target, FRAMES_PER_EPISODE, rng)
385
+ force = _force_profile(FRAMES_PER_EPISODE, task, cfg["force_range"], rng)
386
+
387
+ if failure_type == "grasp_slip":
388
+ pos, vel, force = _inject_grasp_slip(pos, vel, force, cfg["gripper_joint"], rng)
389
+ elif failure_type == "velocity_spike":
390
+ vel = _inject_velocity_spike(vel, rng)
391
+ elif failure_type == "torque_saturation":
392
+ pos, vel = _inject_torque_saturation(pos, vel, cfg["joint_limits"],
393
+ cfg["torque_joint_pool"], rng)
394
+
395
+ for j, (lo, hi) in enumerate(cfg["joint_limits"]):
396
+ pos[:, j] = np.clip(pos[:, j], lo, hi)
397
+
398
+ vel = np.clip(vel, -cfg["max_velocity"] * 2, cfg["max_velocity"] * 2)
399
+
400
+ label = failure_type if failure_type != "none" else "success"
401
+
402
+ return pd.DataFrame({
403
+ **{f"state_{j}": pos[:, j] for j in range(n)},
404
+ **{f"action_{j}": vel[:, j] for j in range(n)},
405
+ "timestamp": np.arange(FRAMES_PER_EPISODE, dtype=float) * DT,
406
+ "episode_index": np.full(FRAMES_PER_EPISODE, ep_idx, dtype=int),
407
+ "frame_index": np.arange(FRAMES_PER_EPISODE, dtype=int),
408
+ "task": task,
409
+ "use_for_training": (failure_type == "none"),
410
+ "failure_type": label,
411
+ "quality_score": 0.0,
412
+ })
413
+
414
+
415
+ # ── Dataset generator ─────────────────────────────────────────────────────────
416
+
417
+ def generate_dataset(
418
+ robot: str,
419
+ task: str,
420
+ n_episodes: int,
421
+ success_rate: float,
422
+ force_min: float,
423
+ force_max: float,
424
+ enabled_failures: List[str],
425
+ seed: Optional[int] = None,
426
+ progress_callback = None,
427
+ ) -> pd.DataFrame:
428
+ """
429
+ Generate a full synthetic dataset.
430
+
431
+ Parameters
432
+ ----------
433
+ robot : "SO-100" | "SO-101" | "Koch"
434
+ task : from TASKS_BY_ROBOT[robot]
435
+ n_episodes : 10–500
436
+ success_rate : 0.0–1.0
437
+ force_min/max : contact force range override (N)
438
+ enabled_failures : subset of FAILURE_TYPES
439
+ seed : reproducibility seed
440
+ progress_callback : optional (fraction: float, message: str) β†’ None
441
+
442
+ Returns
443
+ -------
444
+ pd.DataFrame, n_episodes * FRAMES_PER_EPISODE rows
445
+ """
446
+ if robot not in ROBOT_CONFIG:
447
+ raise ValueError(f"Unknown robot '{robot}'. Options: {list(ROBOT_CONFIG)}")
448
+ if task not in TASKS_BY_ROBOT.get(robot, []):
449
+ raise ValueError(f"Task '{task}' invalid for {robot}.")
450
+ if not enabled_failures:
451
+ enabled_failures = list(FAILURE_TYPES)
452
+
453
+ rng = np.random.default_rng(seed)
454
+ cfg = {**ROBOT_CONFIG[robot], "force_range": (force_min, force_max)}
455
+
456
+ n_success = int(round(n_episodes * success_rate))
457
+ n_fail = n_episodes - n_success
458
+ pool = (list(enabled_failures) * (n_fail // max(len(enabled_failures), 1) + 1))[:n_fail]
459
+ manifest = ["none"] * n_success + pool
460
+ rng.shuffle(manifest)
461
+
462
+ frames = []
463
+ for i, ft in enumerate(manifest):
464
+ if progress_callback and i % max(1, n_episodes // 20) == 0:
465
+ progress_callback(i / n_episodes, f"Generating episode {i + 1}/{n_episodes}…")
466
+ frames.append(_build_episode(i, robot, task, ft, cfg, rng))
467
+
468
+ if progress_callback:
469
+ progress_callback(0.95, "Concatenating dataset…")
470
+
471
+ df = pd.concat(frames, ignore_index=True)
472
+ # Embed robot metadata column so scorer can look up joint limits
473
+ df["robot"] = robot
474
+ return df
475
+
476
+
477
+ # ── Built-in scorer ───────────────────────────────────────────────────────────
478
+
479
+ def _builtin_score(df: pd.DataFrame) -> Dict:
480
+ """
481
+ Calibrated quality scorer using MAD z-scores (community thresholds).
482
+
483
+ Detection criteria
484
+ ------------------
485
+ velocity_spike : MAD z-score on action columns > 6.5 in any frame
486
+ grasp_slip : |Ξ”gripper_pos| > 0.10 rad in a single frame
487
+ torque_saturation : β‰₯3 consecutive frames with |velocity| < 0.012 rad/s
488
+ AND position within 3% of known joint limit
489
+ (uses 'robot' column if present, otherwise Β±0.90*Ο€ heuristic)
490
+ mismatch_fraction : (n_spike + slip + sat anomalous frames) / n_frames
491
+ flagged : mismatch_fraction > 0.50
492
+ """
493
+ act_cols = [f"action_{j}" for j in range(6)]
494
+ pos_cols = [f"state_{j}" for j in range(6)]
495
+
496
+ # Try to get joint limits from robot column
497
+ robot_col = df.get("robot", pd.Series(dtype=str))
498
+ first_robot = robot_col.iloc[0] if len(robot_col) else None
499
+ joint_limits = (
500
+ ROBOT_CONFIG[first_robot]["joint_limits"]
501
+ if first_robot in ROBOT_CONFIG else [(-3.14, 3.14)] * 6
502
+ )
503
+
504
+ rows = []
505
+ for ep_idx, ep in df.groupby("episode_index"):
506
+ acts = ep[act_cols].values
507
+ poss = ep[pos_cols].values
508
+ n_f = len(ep)
509
+
510
+ # ── Velocity spike (MAD z-score) ──────────────────────────────────
511
+ z = _robust_z(acts)
512
+ spike_mask = z.max(axis=1) > VELOCITY_SPIKE_Z
513
+ n_spikes = int(spike_mask.sum())
514
+ max_z = float(z.max())
515
+
516
+ # ── Grasp slip ────────────────────────────────────────────────────
517
+ gripper_delta = np.abs(np.diff(poss[:, 5]))
518
+ n_slip_frames = int((gripper_delta > 0.10).sum())
519
+
520
+ # ── Torque saturation (velocity near-zero AND position near limit) ─
521
+ sat_joints = 0
522
+ for j in range(5): # joints 0-4; skip gripper (j=5)
523
+ lo, hi = joint_limits[j]
524
+ limit_tol = abs(hi - lo) * 0.03 # 3% of range from limit
525
+ consec = 0
526
+ for i in range(n_f):
527
+ near_limit = (
528
+ poss[i, j] >= hi - limit_tol or
529
+ poss[i, j] <= lo + limit_tol
530
+ )
531
+ near_zero = abs(acts[i, j]) < 0.012
532
+ if near_limit and near_zero:
533
+ consec += 1
534
+ if consec >= 3:
535
+ sat_joints += 1
536
+ break
537
+ else:
538
+ consec = 0
539
+
540
+ # ── Mismatch fraction & episode score ─────────────────────────────
541
+ n_anomalous = n_spikes + min(1, n_slip_frames) + min(1, sat_joints)
542
+ mismatch_fraction = n_anomalous / max(n_f, 1)
543
+ flagged = mismatch_fraction > MISMATCH_THRESHOLD
544
+
545
+ penalty = n_spikes * 9.0 + n_slip_frames * 7.0 + sat_joints * 6.0 + mismatch_fraction * 18.0
546
+ ep_score = float(np.clip(100.0 - penalty, 0, 100))
547
+
548
+ rows.append({
549
+ "episode_index": ep_idx,
550
+ "failure_type": ep["failure_type"].iloc[0],
551
+ "n_spike_frames": n_spikes,
552
+ "max_velocity_z": round(max_z, 2),
553
+ "n_slip_frames": n_slip_frames,
554
+ "n_sat_joints": sat_joints,
555
+ "mismatch_fraction": round(mismatch_fraction, 4),
556
+ "episode_score": round(ep_score, 2),
557
+ "flagged": flagged,
558
+ })
559
+
560
+ ep_df = pd.DataFrame(rows)
561
+ n_eps = len(ep_df)
562
+ n_flagged = int(ep_df["flagged"].sum())
563
+ n_passed = n_eps - n_flagged
564
+ mean_score = float(ep_df["episode_score"].mean())
565
+ band = "Clean" if mean_score >= 80 else ("Review" if mean_score >= 55 else "Flagged")
566
+
567
+ failure_breakdown = (
568
+ ep_df[ep_df["failure_type"] != "success"]
569
+ .groupby("failure_type").size().to_dict()
570
+ )
571
+
572
+ return {
573
+ "overall_score": round(mean_score, 2),
574
+ "band": band,
575
+ "n_episodes": n_eps,
576
+ "n_passed": n_passed,
577
+ "n_flagged": n_flagged,
578
+ "mean_mismatch": round(float(ep_df["mismatch_fraction"].mean()), 4),
579
+ "failure_breakdown": failure_breakdown,
580
+ "episode_details": ep_df,
581
+ "scorer_used": "builtin",
582
+ }
583
+
584
+
585
+ # ── Public API ────────────────────────────────────────────────────────────────
586
+
587
+ def score_dataset(df: pd.DataFrame, progress_callback=None) -> Dict:
588
+ """Score a dataset; prefers external scorer if SCORER_PATH resolves."""
589
+ if progress_callback:
590
+ progress_callback(0.05, "Running quality checks…")
591
+
592
+ if EXTERNAL_SCORER:
593
+ try:
594
+ result = _ext_score_dataset(df)
595
+ result["scorer_used"] = "external"
596
+ if progress_callback:
597
+ progress_callback(1.0, "Scoring complete (external scorer).")
598
+ return result
599
+ except Exception as exc:
600
+ print(f"[RoboGen] External scorer failed ({exc}); using built-in.")
601
+
602
+ result = _builtin_score(df)
603
+ if progress_callback:
604
+ progress_callback(1.0, "Scoring complete.")
605
+ return result
606
+
607
+
608
+ def annotate_quality_scores(df: pd.DataFrame, score_result: Dict) -> pd.DataFrame:
609
+ """Merge per-episode quality scores into the main DataFrame."""
610
+ ep_scores = (
611
+ score_result["episode_details"][["episode_index", "episode_score"]]
612
+ .rename(columns={"episode_score": "quality_score"})
613
+ )
614
+ return df.drop(columns=["quality_score"], errors="ignore").merge(
615
+ ep_scores, on="episode_index", how="left"
616
+ )
617
+
618
+
619
+ # ── CLI demo ──────────────────────────────────────────────────────────────────
620
+
621
+ if __name__ == "__main__":
622
+ pd.set_option("display.max_columns", 20)
623
+ pd.set_option("display.width", 160)
624
+ pd.set_option("display.float_format", "{:+.4f}".format)
625
+
626
+ DEMO = [
627
+ ("SO-100", "pick_and_place", "none", "SUCCESS "),
628
+ ("SO-100", "pick_and_place", "grasp_slip", "GRASP SLIP "),
629
+ ("SO-100", "pick_and_place", "velocity_spike", "VELOCITY SPIKE "),
630
+ ("SO-100", "pick_and_place", "torque_saturation", "TORQUE SAT. "),
631
+ ]
632
+
633
+ rng = np.random.default_rng(42)
634
+ cfg = ROBOT_CONFIG["SO-100"].copy()
635
+
636
+ print("=" * 92)
637
+ print(" RoboGen β€” generator.py validation")
638
+ print(" state_* = joint positions (rad) | action_* = velocity commands (rad/s)")
639
+ print("=" * 92)
640
+
641
+ for ep_idx, (robot, task, ft, label) in enumerate(DEMO):
642
+ print(f"\n{'─'*92}")
643
+ print(f" Episode {ep_idx} β”‚ {label}β”‚ {robot} / {task}")
644
+ print(f"{'─'*92}")
645
+ ep = _build_episode(ep_idx, robot, task, ft, cfg, rng)
646
+
647
+ cols = ["frame_index", "state_0", "state_4", "state_5",
648
+ "action_0", "action_4", "action_5"]
649
+ sample = pd.concat([ep.head(4), ep.iloc[28:33], ep.tail(4)])[cols]
650
+ print(sample.to_string(index=False))
651
+
652
+ acts = ep[[f"action_{j}" for j in range(6)]].values
653
+ poss = ep[[f"state_{j}" for j in range(6)]].values
654
+
655
+ z = _robust_z(acts)
656
+ n_spikes = int((z.max(1) > VELOCITY_SPIKE_Z).sum())
657
+ max_z = z.max()
658
+ n_slip = int((np.abs(np.diff(poss[:, 5])) > 0.10).sum())
659
+
660
+ jl = cfg["joint_limits"]
661
+ sat_joints = 0
662
+ for j in range(5):
663
+ lo, hi = jl[j]
664
+ tol = abs(hi - lo) * 0.03
665
+ consec = 0
666
+ for i in range(len(ep)):
667
+ nl = poss[i, j] >= hi - tol or poss[i, j] <= lo + tol
668
+ nz = abs(acts[i, j]) < 0.012
669
+ consec = consec + 1 if (nl and nz) else 0
670
+ if consec >= 3: sat_joints += 1; break
671
+
672
+ print(f"\n Vel (action) β”‚ mean={acts.mean():+.3f} std={acts.std():.3f} "
673
+ f"max_MAD_z={max_z:.2f} spike_frames(z>{VELOCITY_SPIKE_Z})={n_spikes}")
674
+ print(f" Gripper pos β”‚ max_Ξ”={np.abs(np.diff(poss[:,5])).max():.4f} rad "
675
+ f"slip_frames(Ξ”>0.10)={n_slip} sat_joints={sat_joints}")
676
+
677
+ print(f"\n{'='*92}")
678
+ print(" 10-episode mini-dataset (60% success, all failure modes, seed=99)")
679
+ print(f"{'='*92}")
680
+ mini = generate_dataset(
681
+ robot="SO-100", task="pick_and_place",
682
+ n_episodes=10, success_rate=0.60,
683
+ force_min=1.0, force_max=10.0,
684
+ enabled_failures=list(FAILURE_TYPES), seed=99,
685
+ )
686
+ res = score_dataset(mini)
687
+ pd.set_option("display.float_format", "{:.2f}".format)
688
+ print(f"\n Overall score : {res['overall_score']:.1f}/100")
689
+ print(f" Band : {res['band']}")
690
+ print(f" Passed/Flagged : {res['n_passed']} / {res['n_flagged']}")
691
+ print(f" Mean mismatch : {res['mean_mismatch']:.4f}")
692
+ print(f" Failures : {res['failure_breakdown']}")
693
+ print(f" Scorer : {res['scorer_used']}")
694
+ print()
695
+ cols = ["episode_index","failure_type","n_spike_frames","max_velocity_z",
696
+ "n_slip_frames","n_sat_joints","mismatch_fraction","episode_score","flagged"]
697
+ print(res["episode_details"][cols].to_string(index=False))
698
+ print(f"\n{'='*92}\n")
readme_gen.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ readme_gen.py β€” dynamically generated README for downloaded RoboGen datasets.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import datetime
7
+ from typing import Dict, List
8
+
9
+
10
+ COLUMN_DOCS = {
11
+ "state_0": "Joint 0 β€” base rotation (rad)",
12
+ "state_1": "Joint 1 β€” shoulder pitch (rad)",
13
+ "state_2": "Joint 2 β€” elbow (rad)",
14
+ "state_3": "Joint 3 β€” wrist roll (rad)",
15
+ "state_4": "Joint 4 β€” wrist pitch (rad)",
16
+ "state_5": "Joint 5 β€” gripper (rad, 0=closed β†’ 1=open)",
17
+ "action_0": "Velocity command joint 0 β€” base (rad/s)",
18
+ "action_1": "Velocity command joint 1 β€” shoulder (rad/s)",
19
+ "action_2": "Velocity command joint 2 β€” elbow (rad/s)",
20
+ "action_3": "Velocity command joint 3 β€” wrist roll (rad/s)",
21
+ "action_4": "Velocity command joint 4 β€” wrist pitch (rad/s)",
22
+ "action_5": "Velocity command joint 5 β€” gripper (rad/s)",
23
+ "timestamp": "Seconds since episode start (50 Hz β†’ Ξ”t=0.02 s)",
24
+ "episode_index": "Integer episode identifier (0-indexed)",
25
+ "frame_index": "Frame number within episode (0–49 for 50-frame episodes)",
26
+ "task": "Task label string",
27
+ "use_for_training":"True for successful episodes only; False for failure episodes",
28
+ "failure_type": "'success' | 'grasp_slip' | 'velocity_spike' | 'torque_saturation'",
29
+ "quality_score": "Per-episode quality score (0–100) from HaptalAI scorer",
30
+ "robot": "Robot model string: 'SO-100' | 'SO-101' | 'Koch'",
31
+ }
32
+
33
+ FAILURE_DESCRIPTIONS = {
34
+ "grasp_slip": "Smooth trajectory until 60-70% of episode, then gripper opens "
35
+ "unintentionally (position discontinuity β‰₯ 0.18 rad) and contact "
36
+ "force collapses. Mimics inadequate grasp force.",
37
+ "velocity_spike": "1-2 isolated frames with joint velocity MAD z-score > 6.5 rad/s, "
38
+ "surrounded by normal motion. Mimics servo glitch or controller "
39
+ "communication dropout.",
40
+ "torque_saturation": "One arm joint clamped at its angular limit for β‰₯ 3 consecutive frames "
41
+ "with near-zero velocity. Mimics joint hitting mechanical stop or "
42
+ "exceeding torque budget.",
43
+ }
44
+
45
+
46
+ def generate_readme(
47
+ robot: str,
48
+ task: str,
49
+ n_episodes: int,
50
+ success_rate: float,
51
+ force_min: float,
52
+ force_max: float,
53
+ failures: List[str],
54
+ score: float,
55
+ band: str,
56
+ n_passed: int,
57
+ n_flagged: int,
58
+ mean_mismatch: float,
59
+ failure_breakdown: Dict[str, int],
60
+ scorer_used: str,
61
+ ) -> str:
62
+ """Generate a complete README.md for the downloaded dataset."""
63
+
64
+ task_display = task.replace("_", " ").title()
65
+ failures_list = "\n".join(f"- **{f}**: {FAILURE_DESCRIPTIONS.get(f, f)}" for f in failures)
66
+ col_table_rows = "\n".join(
67
+ f"| `{col}` | {desc} |" for col, desc in COLUMN_DOCS.items()
68
+ )
69
+ fb_lines = "\n".join(f"- {k}: {v} episodes" for k, v in failure_breakdown.items()) or "- None"
70
+ generated_at = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
71
+ band_emoji = ""
72
+
73
+ return f"""# RoboGen Synthetic Dataset β€” {robot} / {task_display}
74
+
75
+ > Generated by [HaptalAI RoboGen](https://huggingface.co/spaces/HaptalAI/robogen)
76
+ > on {generated_at}
77
+
78
+ ---
79
+
80
+ ## Dataset Summary
81
+
82
+ | Field | Value |
83
+ |---|---|
84
+ | Robot | **{robot}** |
85
+ | Task | **{task_display}** |
86
+ | Total episodes | **{n_episodes}** |
87
+ | Success rate (configured) | **{success_rate * 100:.0f}%** |
88
+ | Contact force range | **{force_min:.1f} – {force_max:.1f} N** |
89
+ | Frames per episode | **50** (50 Hz, Ξ”t = 0.02 s) |
90
+ | Total rows | **{n_episodes * 50:,}** |
91
+
92
+ ---
93
+
94
+ ## Quality Score
95
+
96
+ | Metric | Value |
97
+ |---|---|
98
+ | Overall score | **{score:.1f} / 100** |
99
+ | Band | **{band}** |
100
+ | Episodes passed | **{n_passed}** |
101
+ | Episodes flagged | **{n_flagged}** |
102
+ | Mean mismatch rate | **{mean_mismatch:.4f}** |
103
+ | Scorer | `{scorer_used}` |
104
+
105
+ **Quality bands:**
106
+ - **Clean** (>= 80): suitable for policy training and augmentation
107
+ - **Review** (55-79): usable with caution; inspect flagged episodes
108
+ - **Flagged** (< 55): high anomaly rate; use for failure analysis only
109
+
110
+ ---
111
+
112
+ ## What the Dataset Contains
113
+
114
+ This dataset contains **{n_episodes} synthetic episodes** of a **{robot}** robot performing
115
+ the **{task_display}** task. Each episode is 50 frames (1 second at 50 Hz).
116
+
117
+ **Episode composition:**
118
+ - Success episodes (`use_for_training=True`): ~{success_rate * 100:.0f}% of total
119
+ - Failure episodes: ~{(1 - success_rate) * 100:.0f}% of total
120
+
121
+ ### Failure types included
122
+ {failures_list}
123
+
124
+ ### Failure breakdown in this dataset
125
+ {fb_lines}
126
+
127
+ ---
128
+
129
+ ## Column Reference
130
+
131
+ | Column | Description |
132
+ |---|---|
133
+ {col_table_rows}
134
+
135
+ ---
136
+
137
+ ## Physics Model
138
+
139
+ Joint trajectories are generated using **cubic spline interpolation** over
140
+ task-specific waypoints (approach β†’ contact/grasp β†’ lift/push β†’ retract).
141
+ Velocities are the **analytical first derivative** of the position spline β€” not
142
+ independently sampled β€” ensuring physical consistency between state and action.
143
+
144
+ - Sensor noise: Gaussian Οƒ_pos = 0.002 rad, Οƒ_vel = 0.004 rad/s
145
+ - Contact force: spring-damper model during contact window (30–75% of episode)
146
+ - Episode variation: small Gaussian perturbations on target position (Β±2.5 cm equivalent)
147
+ - Joint limits: enforced per robot specification
148
+
149
+ ---
150
+
151
+ ## Recommended Use
152
+
153
+ Synthetic data is best used for:
154
+ 1. **Policy bootstrapping** β€” pre-train before collecting real demonstrations
155
+ 2. **Augmentation** β€” mix with real data to increase diversity and robustness
156
+ 3. **Failure analysis / anomaly detection** β€” the labelled failure episodes are
157
+ especially useful for training or evaluating anomaly detectors
158
+ 4. **Simulation-to-real transfer research** β€” study domain gap with known ground truth
159
+
160
+ > **Do not** rely solely on synthetic data for safety-critical deployments.
161
+ > Always validate against real demonstrations before deploying to physical hardware.
162
+
163
+ ---
164
+
165
+ ## Validation & Benchmark
166
+
167
+ This dataset was generated and validated by **HaptalAI's misalignment failure benchmark
168
+ and physical failure scorer** β€” the same pipeline used to evaluate community SO-100
169
+ datasets. Calibrated thresholds:
170
+ - Velocity spike: MAD z-score > 6.5 rad/s
171
+ - Mismatch fraction: > 0.50 per episode β†’ flagged
172
+
173
+ For questions, dataset requests, or benchmark access:
174
+ **aarav@haptal.ai**
175
+
176
+ ---
177
+
178
+ *RoboGen is open source. Star us on GitHub and contribute at
179
+ [HaptalAI/robogen](https://github.com/aaravbedi/robogen).*
180
+ """
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio==4.29.0
2
+ numpy>=1.24.0,<2.0.0
3
+ pandas>=2.0.0
4
+ scipy>=1.11.0
5
+ pyarrow>=12.0.0
6
+ requests>=2.31.0
style.css ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ══════════════════════════════════════════════════════════
2
+ RoboGen β€” Dark SaaS UI
3
+ Reference: Claude.ai / Perplexity aesthetic
4
+ ══════════════════════════════════════════════════════════ */
5
+
6
+ /* ── Base token overrides ─────────────────────────────────── */
7
+ :root {
8
+ --bg-base: #0a0a0f;
9
+ --bg-surface: #111118;
10
+ --bg-card: #16161f;
11
+ --bg-input: #1d1d28;
12
+ --border: #2a2a3d;
13
+ --border-focus:#6366f1;
14
+ --accent: #6366f1;
15
+ --accent-dim: #4f46e5;
16
+ --text-primary:#e2e8f0;
17
+ --text-muted: #8892a4;
18
+ --text-faint: #4a5568;
19
+ --green: #22c55e;
20
+ --green-dim: #166534;
21
+ --amber: #f59e0b;
22
+ --amber-dim: #78350f;
23
+ --red: #ef4444;
24
+ --red-dim: #7f1d1d;
25
+ --radius-sm: 8px;
26
+ --radius-md: 12px;
27
+ --radius-lg: 18px;
28
+ }
29
+
30
+ /* ── Gradio shell reset ───────────────────────────────────── */
31
+ body, .gradio-container {
32
+ background: var(--bg-base) !important;
33
+ color: var(--text-primary) !important;
34
+ font-family: "Inter", system-ui, -apple-system, sans-serif !important;
35
+ }
36
+
37
+ .gradio-container {
38
+ max-width: 860px !important;
39
+ margin: 0 auto !important;
40
+ padding: 0 16px 80px !important;
41
+ }
42
+
43
+ footer { display: none !important; }
44
+
45
+ /* ── Header ──────────────────────────────────────────────── */
46
+ .rg-header {
47
+ text-align: center;
48
+ padding: 52px 0 36px;
49
+ }
50
+
51
+ .rg-logo {
52
+ font-size: 2.8rem;
53
+ font-weight: 800;
54
+ letter-spacing: -0.04em;
55
+ background: linear-gradient(135deg, #a5b4fc 0%, #818cf8 50%, #6366f1 100%);
56
+ -webkit-background-clip: text;
57
+ -webkit-text-fill-color: transparent;
58
+ background-clip: text;
59
+ display: inline-block;
60
+ margin-bottom: 6px;
61
+ }
62
+
63
+ .rg-tagline {
64
+ color: var(--text-muted);
65
+ font-size: 1rem;
66
+ letter-spacing: 0.01em;
67
+ }
68
+
69
+ .rg-badge {
70
+ display: inline-flex;
71
+ align-items: center;
72
+ gap: 6px;
73
+ margin-top: 14px;
74
+ padding: 5px 14px;
75
+ background: rgba(99, 102, 241, 0.12);
76
+ border: 1px solid rgba(99, 102, 241, 0.30);
77
+ border-radius: 999px;
78
+ font-size: 0.78rem;
79
+ color: #a5b4fc;
80
+ letter-spacing: 0.02em;
81
+ }
82
+
83
+ /* ── Step cards ──────────────────────────────────────────── */
84
+ .step-card {
85
+ background: var(--bg-card) !important;
86
+ border: 1px solid var(--border) !important;
87
+ border-radius: var(--radius-lg) !important;
88
+ padding: 28px !important;
89
+ margin-bottom: 16px !important;
90
+ transition: border-color 0.2s ease;
91
+ }
92
+
93
+ .step-card:focus-within {
94
+ border-color: var(--border-focus) !important;
95
+ }
96
+
97
+ .step-header {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 12px;
101
+ margin-bottom: 22px;
102
+ }
103
+
104
+ .step-num {
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ width: 28px;
109
+ height: 28px;
110
+ border-radius: 50%;
111
+ background: var(--accent);
112
+ color: #fff;
113
+ font-size: 0.8rem;
114
+ font-weight: 700;
115
+ flex-shrink: 0;
116
+ }
117
+
118
+ .step-title {
119
+ font-size: 1rem;
120
+ font-weight: 600;
121
+ color: var(--text-primary);
122
+ }
123
+
124
+ /* ── Robot selection cards ───────────────────────────────── */
125
+ /* Style Gradio's radio buttons as large click cards */
126
+ .robot-radio .wrap {
127
+ display: flex !important;
128
+ gap: 12px !important;
129
+ flex-wrap: nowrap !important;
130
+ }
131
+
132
+ .robot-radio .wrap label {
133
+ flex: 1 !important;
134
+ min-width: 0 !important;
135
+ display: flex !important;
136
+ flex-direction: column !important;
137
+ align-items: center !important;
138
+ gap: 10px !important;
139
+ padding: 22px 12px !important;
140
+ background: var(--bg-input) !important;
141
+ border: 2px solid var(--border) !important;
142
+ border-radius: var(--radius-md) !important;
143
+ cursor: pointer !important;
144
+ transition: border-color 0.18s, background 0.18s, transform 0.12s !important;
145
+ color: var(--text-primary) !important;
146
+ font-size: 0.95rem !important;
147
+ font-weight: 600 !important;
148
+ text-align: center !important;
149
+ user-select: none !important;
150
+ }
151
+
152
+ .robot-radio .wrap label:hover {
153
+ border-color: var(--accent) !important;
154
+ transform: translateY(-2px) !important;
155
+ }
156
+
157
+ .robot-radio .wrap label:has(input:checked) {
158
+ border-color: var(--accent) !important;
159
+ background: rgba(99,102,241,0.12) !important;
160
+ }
161
+
162
+ .robot-radio .wrap input[type="radio"] {
163
+ display: none !important;
164
+ }
165
+
166
+ .robot-radio > .label-wrap { display: none !important; }
167
+
168
+ /* Robot icons (first span inside label) */
169
+ .robot-icon {
170
+ font-size: 2.2rem;
171
+ line-height: 1;
172
+ }
173
+
174
+ /* ── Dropdowns & Inputs ──────────────────────��──────────── */
175
+ select, input[type="text"], input[type="email"], textarea, .gr-input {
176
+ background: var(--bg-input) !important;
177
+ border: 1px solid var(--border) !important;
178
+ border-radius: var(--radius-sm) !important;
179
+ color: var(--text-primary) !important;
180
+ font-size: 0.9rem !important;
181
+ }
182
+
183
+ select:focus, input:focus {
184
+ border-color: var(--border-focus) !important;
185
+ outline: none !important;
186
+ box-shadow: 0 0 0 3px rgba(99,102,241,0.15) !important;
187
+ }
188
+
189
+ /* Dropdown label */
190
+ label.svelte-1b6s6g, .gr-label, .block label > span {
191
+ color: var(--text-muted) !important;
192
+ font-size: 0.82rem !important;
193
+ letter-spacing: 0.04em !important;
194
+ text-transform: uppercase !important;
195
+ font-weight: 600 !important;
196
+ }
197
+
198
+ /* ── Sliders ────────────────────────────────────────────── */
199
+ input[type="range"] {
200
+ accent-color: var(--accent) !important;
201
+ }
202
+
203
+ /* ── Checkboxes ─────────────────────────────────────────── */
204
+ .checkbox-group .wrap {
205
+ display: flex !important;
206
+ gap: 10px !important;
207
+ flex-wrap: wrap !important;
208
+ }
209
+
210
+ .checkbox-group label {
211
+ display: flex !important;
212
+ align-items: center !important;
213
+ gap: 8px !important;
214
+ padding: 8px 14px !important;
215
+ background: var(--bg-input) !important;
216
+ border: 1px solid var(--border) !important;
217
+ border-radius: 999px !important;
218
+ cursor: pointer !important;
219
+ color: var(--text-primary) !important;
220
+ font-size: 0.85rem !important;
221
+ font-weight: 500 !important;
222
+ transition: border-color 0.15s, background 0.15s !important;
223
+ }
224
+
225
+ .checkbox-group label:has(input:checked) {
226
+ border-color: var(--accent) !important;
227
+ background: rgba(99,102,241,0.12) !important;
228
+ color: #a5b4fc !important;
229
+ }
230
+
231
+ input[type="checkbox"] {
232
+ accent-color: var(--accent) !important;
233
+ }
234
+
235
+ /* ── Buttons ────────────────────────────────────────────── */
236
+ .gr-button, button.gr-button {
237
+ border-radius: var(--radius-sm) !important;
238
+ font-weight: 600 !important;
239
+ font-size: 0.88rem !important;
240
+ padding: 10px 20px !important;
241
+ transition: all 0.15s ease !important;
242
+ border: none !important;
243
+ cursor: pointer !important;
244
+ }
245
+
246
+ .btn-primary {
247
+ background: var(--accent) !important;
248
+ color: #fff !important;
249
+ }
250
+ .btn-primary:hover {
251
+ background: var(--accent-dim) !important;
252
+ transform: translateY(-1px) !important;
253
+ box-shadow: 0 4px 20px rgba(99,102,241,0.35) !important;
254
+ }
255
+
256
+ .btn-generate {
257
+ width: 100% !important;
258
+ padding: 16px !important;
259
+ font-size: 1.05rem !important;
260
+ letter-spacing: 0.02em !important;
261
+ background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
262
+ color: #fff !important;
263
+ border-radius: var(--radius-md) !important;
264
+ box-shadow: 0 4px 24px rgba(99,102,241,0.3) !important;
265
+ }
266
+
267
+ .btn-generate:hover {
268
+ box-shadow: 0 6px 30px rgba(99,102,241,0.45) !important;
269
+ transform: translateY(-2px) !important;
270
+ }
271
+
272
+ .btn-ghost {
273
+ background: transparent !important;
274
+ border: 1px solid var(--border) !important;
275
+ color: var(--text-muted) !important;
276
+ }
277
+ .btn-ghost:hover {
278
+ border-color: var(--border-focus) !important;
279
+ color: var(--text-primary) !important;
280
+ }
281
+
282
+ .btn-download {
283
+ width: 100% !important;
284
+ padding: 14px !important;
285
+ background: rgba(34,197,94,0.15) !important;
286
+ border: 1px solid rgba(34,197,94,0.4) !important;
287
+ color: var(--green) !important;
288
+ border-radius: var(--radius-md) !important;
289
+ font-size: 0.96rem !important;
290
+ }
291
+ .btn-download:hover {
292
+ background: rgba(34,197,94,0.25) !important;
293
+ border-color: var(--green) !important;
294
+ }
295
+
296
+ /* ── Results dashboard ──────────────────────────────────── */
297
+ .rg-results {
298
+ background: var(--bg-card);
299
+ border: 1px solid var(--border);
300
+ border-radius: var(--radius-lg);
301
+ padding: 32px;
302
+ font-family: "Inter", system-ui, sans-serif;
303
+ }
304
+
305
+ .rg-score-row {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 24px;
309
+ margin-bottom: 28px;
310
+ }
311
+
312
+ .rg-score-circle {
313
+ display: flex;
314
+ flex-direction: column;
315
+ align-items: center;
316
+ justify-content: center;
317
+ width: 120px;
318
+ height: 120px;
319
+ border-radius: 50%;
320
+ flex-shrink: 0;
321
+ position: relative;
322
+ }
323
+
324
+ .rg-score-circle.clean {
325
+ background: radial-gradient(circle, rgba(34,197,94,0.15), rgba(34,197,94,0.05));
326
+ border: 3px solid var(--green);
327
+ box-shadow: 0 0 30px rgba(34,197,94,0.2);
328
+ }
329
+
330
+ .rg-score-circle.review {
331
+ background: radial-gradient(circle, rgba(245,158,11,0.15), rgba(245,158,11,0.05));
332
+ border: 3px solid var(--amber);
333
+ box-shadow: 0 0 30px rgba(245,158,11,0.2);
334
+ }
335
+
336
+ .rg-score-circle.flagged {
337
+ background: radial-gradient(circle, rgba(239,68,68,0.15), rgba(239,68,68,0.05));
338
+ border: 3px solid var(--red);
339
+ box-shadow: 0 0 30px rgba(239,68,68,0.2);
340
+ }
341
+
342
+ .rg-score-value {
343
+ font-size: 2.2rem;
344
+ font-weight: 800;
345
+ letter-spacing: -0.04em;
346
+ color: var(--text-primary);
347
+ line-height: 1;
348
+ }
349
+
350
+ .rg-score-denom {
351
+ font-size: 0.85rem;
352
+ color: var(--text-muted);
353
+ margin-top: 2px;
354
+ }
355
+
356
+ .rg-score-info {
357
+ flex: 1;
358
+ }
359
+
360
+ .rg-band-badge {
361
+ display: inline-block;
362
+ padding: 5px 14px;
363
+ border-radius: 999px;
364
+ font-size: 0.82rem;
365
+ font-weight: 700;
366
+ letter-spacing: 0.06em;
367
+ text-transform: uppercase;
368
+ margin-bottom: 10px;
369
+ }
370
+
371
+ .rg-band-badge.clean { background: rgba(34,197,94,0.15); color: var(--green); border: 1px solid rgba(34,197,94,0.4); }
372
+ .rg-band-badge.review { background: rgba(245,158,11,0.15); color: var(--amber); border: 1px solid rgba(245,158,11,0.4); }
373
+ .rg-band-badge.flagged { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.4); }
374
+
375
+ .rg-band-desc {
376
+ font-size: 0.88rem;
377
+ color: var(--text-muted);
378
+ line-height: 1.5;
379
+ }
380
+
381
+ .rg-stat-grid {
382
+ display: grid;
383
+ grid-template-columns: repeat(3, 1fr);
384
+ gap: 12px;
385
+ margin-bottom: 24px;
386
+ }
387
+
388
+ .rg-stat {
389
+ background: var(--bg-input);
390
+ border: 1px solid var(--border);
391
+ border-radius: var(--radius-md);
392
+ padding: 16px;
393
+ text-align: center;
394
+ }
395
+
396
+ .rg-stat-value {
397
+ font-size: 1.6rem;
398
+ font-weight: 700;
399
+ color: var(--text-primary);
400
+ line-height: 1;
401
+ margin-bottom: 4px;
402
+ }
403
+
404
+ .rg-stat-label {
405
+ font-size: 0.75rem;
406
+ color: var(--text-muted);
407
+ letter-spacing: 0.04em;
408
+ text-transform: uppercase;
409
+ }
410
+
411
+ .rg-failure-section {
412
+ margin-top: 20px;
413
+ }
414
+
415
+ .rg-failure-title {
416
+ font-size: 0.82rem;
417
+ font-weight: 600;
418
+ color: var(--text-muted);
419
+ letter-spacing: 0.06em;
420
+ text-transform: uppercase;
421
+ margin-bottom: 10px;
422
+ }
423
+
424
+ .rg-failure-bar {
425
+ display: flex;
426
+ align-items: center;
427
+ gap: 10px;
428
+ margin-bottom: 8px;
429
+ }
430
+
431
+ .rg-failure-label {
432
+ font-size: 0.85rem;
433
+ color: var(--text-muted);
434
+ width: 160px;
435
+ flex-shrink: 0;
436
+ }
437
+
438
+ .rg-bar-track {
439
+ flex: 1;
440
+ height: 6px;
441
+ background: var(--border);
442
+ border-radius: 999px;
443
+ overflow: hidden;
444
+ }
445
+
446
+ .rg-bar-fill {
447
+ height: 100%;
448
+ border-radius: 999px;
449
+ background: var(--accent);
450
+ }
451
+
452
+ .rg-bar-count {
453
+ font-size: 0.82rem;
454
+ color: var(--text-muted);
455
+ width: 40px;
456
+ text-align: right;
457
+ flex-shrink: 0;
458
+ }
459
+
460
+ .rg-scorer-note {
461
+ margin-top: 18px;
462
+ font-size: 0.78rem;
463
+ color: var(--text-faint);
464
+ display: flex;
465
+ align-items: center;
466
+ gap: 6px;
467
+ }
468
+
469
+ /* ── Tooltip helper ─────────────────────────────────────── */
470
+ .tooltip-row {
471
+ display: flex;
472
+ align-items: flex-start;
473
+ gap: 8px;
474
+ margin-top: 4px;
475
+ }
476
+
477
+ .tooltip-label {
478
+ font-size: 0.78rem;
479
+ color: var(--text-faint);
480
+ font-style: italic;
481
+ line-height: 1.4;
482
+ }
483
+
484
+ /* ── Progress & status ──────────────────────────────────── */
485
+ .status-msg {
486
+ font-size: 0.88rem;
487
+ color: var(--text-muted);
488
+ min-height: 1.2em;
489
+ }
490
+
491
+ /* ── Email gate ─────────────────────────────────────────── */
492
+ .email-gate-note {
493
+ font-size: 0.84rem;
494
+ color: var(--text-muted);
495
+ margin-bottom: 14px;
496
+ line-height: 1.5;
497
+ }
498
+
499
+ /* ── Mobile ──────────────────────────────────────────────── */
500
+ @media (max-width: 600px) {
501
+ .robot-radio .wrap {
502
+ flex-direction: column !important;
503
+ }
504
+ .rg-stat-grid {
505
+ grid-template-columns: repeat(2, 1fr) !important;
506
+ }
507
+ .rg-score-row {
508
+ flex-direction: column !important;
509
+ align-items: flex-start !important;
510
+ }
511
+ .gradio-container {
512
+ padding: 0 10px 60px !important;
513
+ }
514
+ }
515
+
516
+ /* ── Gradio block chrome cleanup ────────────────────────── */
517
+ .block.padded { padding: 0 !important; }
518
+ .block { border: none !important; background: transparent !important; box-shadow: none !important; }
519
+ .panel { background: transparent !important; }
520
+ div.form { border: none !important; background: transparent !important; }
521
+ .gr-prose { color: var(--text-primary) !important; }
522
+
523
+ /* ── Hide default Gradio decorations ──────────────────────── */
524
+ .hide-label > div > label { display: none !important; }
525
+ .no-border { border: none !important; background: transparent !important; }