Initial RoboGen v1
Browse files- README.md +20 -7
- __pycache__/airtable.cpython-310.pyc +0 -0
- __pycache__/airtable.cpython-311.pyc +0 -0
- __pycache__/generator.cpython-310.pyc +0 -0
- __pycache__/generator.cpython-311.pyc +0 -0
- __pycache__/readme_gen.cpython-310.pyc +0 -0
- __pycache__/readme_gen.cpython-311.pyc +0 -0
- airtable.py +73 -0
- app.py +616 -0
- generator.py +698 -0
- readme_gen.py +180 -0
- requirements.txt +6 -0
- style.css +525 -0
README.md
CHANGED
|
@@ -1,13 +1,26 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 · 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 & quality-scored</div>
|
| 238 |
+
<div class="rg-badge">LeRobot-format · SO-100 / SO-101 / Koch · 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>
|
| 313 |
+
<span style="font-style:italic;">
|
| 314 |
+
Grasp Slip β gripper opens mid-episode |
|
| 315 |
+
Velocity Spike β servo glitch (z>6.5) |
|
| 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; }
|