Browse Source

feat(dashboard): implement GUI orchestrator and fix simulation I/O pipelines

This commit introduces a Flask-based web dashboard for the BATL CARLA orchestrator
and hardens the underlying data processing pipeline to support real-time UI tracking.

Dashboard & GUI:
- Added `dashboard/` directory containing the Flask backend (`app.py`), HTML UI,
  and static assets (CSS/JS) for a seamless browser-based simulation controller.
- Created `dashboard.bat` to launch the web dashboard environment.

Pipeline Stability (Unbuffered Streaming):
- `run.bat`: Forced `PYTHONUNBUFFERED=1` to ensure subprocess stdout/stderr
  is immediately available.
- `dashboard/app.py`: Injected the unbuffered environment flag into `subprocess.Popen`
  to prevent the UI console from freezing during heavy post-processing tasks.
- `src/recorder.py` & `data_to_mcap.py`: Appended `flush=True` to stdout prints
  to guarantee immediate log propagation to the web dashboard text stream.

Foxglove Data Integrity:
- `data_to_mcap.py`: Hardened internal JSON schemas (`foxglove.Pose`,
  `foxglove.CompressedImage`, `foxglove.PointCloud`) by adding `$schema`, `$id`,
  and `title` metadata tags over draft-2020-12. This prevents Foxglove Studio from
  attempting internet CDN lookups, fully resolving the "Unable to parse ok response
  body as json" crashes when importing local MCAP sets.
1843_integration
RUSHIL AMBARISH KADU 2 months ago
parent
commit
eea4c78b2f
  1. 41
      dashboard.bat
  2. 138
      dashboard/app.py
  3. 217
      dashboard/static/app.js
  4. 479
      dashboard/static/style.css
  5. 117
      dashboard/templates/index.html
  6. 19
      data_to_mcap.py
  7. 13
      intel/context.md
  8. 68
      intel/dashboard.md
  9. 3
      run.bat
  10. 4
      src/recorder.py

41
dashboard.bat

@ -0,0 +1,41 @@
@echo off
setlocal
:: -----------------------------------------------------------------
:: dashboard.bat — BATL CARLA Orchestrator launcher
:: Place this file anywhere in the project root.
:: It activates the carla312 conda env and starts dashboard/app.py
:: -----------------------------------------------------------------
:: Resolve the project root to the directory containing THIS .bat file
set "PROJECT_ROOT=%~dp0"
:: Remove trailing backslash
if "%PROJECT_ROOT:~-1%"=="\" set "PROJECT_ROOT=%PROJECT_ROOT:~0,-1%"
echo -------------------------------------------------
echo BATL CARLA Orchestrator
echo Project root: %PROJECT_ROOT%
echo -------------------------------------------------
:: Activate conda environment
call C:\ProgramData\miniconda3\Scripts\activate.bat carla312
if %errorlevel% neq 0 (
echo [ERROR] Failed to activate conda environment 'carla312'.
pause
exit /b 1
)
:: Open browser to dashboard (slight delay lets Flask start first)
timeout /t 1 /nobreak >nul
start "" "http://127.0.0.1:5000"
:: Launch the Flask app — always from PROJECT_ROOT so run.bat is reachable
cd /d "%PROJECT_ROOT%"
python dashboard\app.py
if %errorlevel% neq 0 (
echo [ERROR] Application crashed or failed to start.
pause
)
endlocal

138
dashboard/app.py

@ -0,0 +1,138 @@
"""
dashboard/app.py
----------------
Flask backend for the BATL CARLA Orchestrator Dashboard.
Serves the HTML UI, exposes REST endpoints, and streams
real-time simulation output via Server-Sent Events (SSE).
NOTE: This file lives inside the /dashboard sub-folder.
PROJECT_ROOT is therefore TWO levels up: dashboard/app.py dashboard/ Fox/
"""
import os
import sys
from flask import Flask, render_template, request, jsonify, Response
import subprocess
# ------------------------------------------------------------------
# Path bootstrapping — make the project root importable regardless
# of which directory the user launches from.
# ------------------------------------------------------------------
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
import config
from src.scenario_loader import list_scenarios
# ------------------------------------------------------------------
# Flask app — templates and static assets are siblings of this file.
# ------------------------------------------------------------------
DASHBOARD_DIR = os.path.dirname(os.path.abspath(__file__))
app = Flask(
__name__,
template_folder=os.path.join(DASHBOARD_DIR, "templates"),
static_folder=os.path.join(DASHBOARD_DIR, "static"),
)
# ── Routes ────────────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/config", methods=["GET"])
def get_config():
scenarios = list_scenarios()
return jsonify({
"scenarios": scenarios,
"default_scenario": getattr(config, "DEFAULT_SCENARIO", "braking"),
"default_frames": getattr(config, "MAX_FRAMES", 200),
"default_weather": getattr(config, "DEFAULT_WEATHER", "ClearNoon"),
"weather_options": [
"ClearNoon", "CloudyNoon", "WetNoon",
"SoftRainNoon", "HardRainNoon", "ClearSunset", "Night"
],
})
@app.route("/api/scenario_params/<scenario_name>", methods=["GET"])
def get_scenario_params(scenario_name):
from src.scenario_loader import load_scenario
try:
scenario = load_scenario(scenario_name)
tunable_params = {}
# Convention: tunable constants are UPPERCASE class-level attributes
for attr in dir(scenario):
if attr.isupper() and not attr.startswith("_"):
val = getattr(scenario, attr)
if isinstance(val, (int, float, str, bool)):
tunable_params[attr] = val
return jsonify({"success": True, "params": tunable_params})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
@app.route("/api/run", methods=["POST"])
def run_simulation():
data = request.json or {}
scenario = data.get("scenario") or "braking"
frames = data.get("frames") or ""
weather = data.get("weather") or ""
no_record = bool(data.get("no_record"))
params = data.get("params") or ""
# Build the command — run.bat lives in PROJECT_ROOT
cmd = ["cmd.exe", "/c", "run.bat", scenario]
if frames:
cmd.extend(["--frames", str(frames)])
if weather:
cmd.extend(["--weather", weather])
if no_record:
cmd.append("--no-record")
if params:
cmd.extend(["--params", f'"{params}"'])
def generate():
try:
# cwd = project root so that run.bat is reachable
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
cwd=PROJECT_ROOT,
env=env,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
)
yield f"data: [INFO] Starting command: {' '.join(cmd)}\n\n"
for line in iter(process.stdout.readline, ""):
if line:
yield f"data: {line.rstrip()}\n\n"
process.stdout.close()
return_code = process.wait()
yield f"data: [PROCESS_COMPLETED] Exit Code: {return_code}\n\n"
except Exception as e:
yield f"data: [ERROR] Failed to start process: {str(e)}\n\n"
return Response(generate(), mimetype="text/event-stream")
# ── Entry point ───────────────────────────────────────────────────
if __name__ == "__main__":
print("Starting BATL CARLA Dashboard orchestrator...")
print(f" Project root : {PROJECT_ROOT}")
print(f" Dashboard dir: {DASHBOARD_DIR}")
app.run(host="0.0.0.0", port=5000, debug=True)

217
dashboard/static/app.js

@ -0,0 +1,217 @@
document.addEventListener('DOMContentLoaded', () => {
const scenarioList = document.getElementById('scenario-list');
const weatherSelect = document.getElementById('weather');
const framesInput = document.getElementById('frames');
const launchForm = document.getElementById('launch-form');
const launchBtn = document.getElementById('launch-btn');
const launchText = document.getElementById('launch-text');
const launchSpinner = document.getElementById('launch-spinner');
const terminalOutput = document.getElementById('terminal-output');
const clearTerminalBtn = document.getElementById('clear-terminal');
let currentEventSource = null;
// Fetch Initial Config
fetch('/api/config')
.then(response => response.json())
.then(config => {
// Populate Scenarios
config.scenarios.forEach((scenario, index) => {
const isChecked = scenario === config.default_scenario ? 'checked' : '';
const cardHtml = `
<label class="scenario-card">
<input type="radio" name="scenario" value="${scenario}" ${isChecked}>
<div class="card-content">
<span class="card-title">${scenario}</span>
</div>
</label>
`;
scenarioList.insertAdjacentHTML('beforeend', cardHtml);
});
// Listen for scenario changes to load dynamic parameters
const scenarioRadios = document.querySelectorAll('input[name="scenario"]');
scenarioRadios.forEach(radio => {
radio.addEventListener('change', (e) => loadScenarioParams(e.target.value));
});
// Load params for the initially selected scenario
if (config.default_scenario) {
loadScenarioParams(config.default_scenario);
}
// Populate Weather
config.weather_options.forEach(weather => {
const isSelected = weather === config.default_weather ? 'selected' : '';
const optionHtml = `<option value="${weather}" ${isSelected}>${weather}</option>`;
weatherSelect.insertAdjacentHTML('beforeend', optionHtml);
});
// Default Frames
framesInput.value = config.default_frames;
appendLog(`> Config loaded successfully. Found ${config.scenarios.length} scenarios.`, 'system');
})
.catch(error => {
appendLog(`> Error loading config: ${error.message}`, 'error');
});
// Handle Launch
launchForm.addEventListener('submit', (e) => {
e.preventDefault();
// UI Loading State
setLoadingState(true);
appendLog(`\n> --- NEW SIMULATION RUN ---`, 'info');
// Close existing stream if any
if (currentEventSource) {
currentEventSource.close();
}
const formData = new FormData(launchForm);
// Scenario radions are in the sidebar, outside the form
const scenarioRadio = document.querySelector('input[name="scenario"]:checked');
const scenarioValue = scenarioRadio ? scenarioRadio.value : 'braking';
// Serialize dynamic parameters
const paramInputs = document.querySelectorAll('#dynamic-params-container input');
let paramsString = [];
paramInputs.forEach(input => {
if (input.value !== '') {
paramsString.push(`${input.name}=${input.value}`);
}
});
const payload = {
scenario: scenarioValue,
frames: formData.get('frames'),
weather: formData.get('weather'),
no_record: formData.get('no_record') === 'on',
params: paramsString.join(',')
};
// We use fetch API to trigger the run (which could take long), but Server Sent Events (SSE)
// doesn't support POST natively without fetch streams. So we do a POST fetch that returns an SSE stream.
startStream('/api/run', payload);
});
// Clear Terminal
clearTerminalBtn.addEventListener('click', () => {
terminalOutput.innerHTML = '';
appendLog('> Terminal cleared.', 'system');
});
// Utility: Append log to terminal
function appendLog(text, type = '') {
const line = document.createElement('div');
line.className = `log-line ${type}`;
// Simple heuristic for colored logs
if (text.includes('[INFO]')) line.classList.add('info');
if (text.includes('[WARN]')) line.classList.add('warn');
if (text.includes('[ERROR]')) line.classList.add('error');
if (text.includes('Traceback') || text.includes('Exception')) line.classList.add('error');
line.textContent = text;
terminalOutput.appendChild(line);
terminalOutput.scrollTop = terminalOutput.scrollHeight;
}
// Utility: Post JSON and stream response
async function startStream(url, payload) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf('\n\n');
while (boundary !== -1) {
const chunk = buffer.substring(0, boundary);
buffer = buffer.substring(boundary + 2);
if (chunk.startsWith('data: ')) {
const data = chunk.substring(6);
// Check for completion marker
if (data.startsWith('[PROCESS_COMPLETED]')) {
appendLog(`> Simulation Finished. ${data}`, 'info');
setLoadingState(false);
} else {
// Only append if it's not a carriage return override (like tqdm progress bars)
// We do a simple replacement to simulate carriage returns in HTML
if (data.includes('\r')) {
const parts = data.split('\r');
const lastPart = parts[parts.length - 1];
// Replace the content of the very last line if it exists
const lastLine = terminalOutput.lastElementChild;
if (lastLine && !lastLine.textContent.includes('\n')) {
lastLine.textContent = lastPart;
continue;
}
}
appendLog(data);
}
}
boundary = buffer.indexOf('\n\n');
}
}
} catch (error) {
appendLog(`> Connection error: ${error.message}`, 'error');
setLoadingState(false);
}
}
function setLoadingState(isLoading) {
launchBtn.disabled = isLoading;
launchText.style.display = isLoading ? 'none' : 'block';
launchSpinner.style.display = isLoading ? 'block' : 'none';
if (!isLoading) {
launchText.textContent = "Launch Another Simulation";
}
}
// Utility: Fetch and render dynamic scenario parameters
function loadScenarioParams(scenarioName) {
const container = document.getElementById('dynamic-params-container');
container.innerHTML = '<div class="input-group full-width"><span class="helper">Loading parameters...</span></div>';
fetch(`/api/scenario_params/${scenarioName}`)
.then(res => res.json())
.then(data => {
container.innerHTML = '';
if (data.success && Object.keys(data.params).length > 0) {
Object.entries(data.params).forEach(([key, val]) => {
const inputHtml = `
<div class="input-group">
<label for="param_${key}">${key}</label>
<div class="input-wrapper">
<input type="text" id="param_${key}" name="${key}" value="${val}">
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', inputHtml);
});
} else {
container.innerHTML = '<div class="input-group full-width"><span class="helper">No tunable constants found for this scenario.</span></div>';
}
})
.catch(err => {
container.innerHTML = '<div class="input-group full-width"><span class="helper" style="color:var(--danger)">Failed to load parameters.</span></div>';
});
}
});

479
dashboard/static/style.css

@ -0,0 +1,479 @@
:root {
--bg-color: #0d1117;
--panel-bg: rgba(22, 27, 34, 0.65);
--panel-border: rgba(255, 255, 255, 0.08);
--text-primary: #e6edf3;
--text-muted: #8b949e;
--accent-color: #58a6ff;
--accent-hover: #3182ce;
--input-bg: rgba(1, 4, 9, 0.6);
--input-border: rgba(255, 255, 255, 0.1);
--terminal-bg: #010409;
--terminal-text: #e6edf3;
--success: #2ea043;
--warning: #d29922;
--danger: #f85149;
--glass-blur: blur(16px);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
min-height: 100vh;
display: flex;
overflow: hidden;
}
/* Dynamic Background */
.app-background {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(circle at 15% 50%, rgba(88, 166, 255, 0.12), transparent 40%),
radial-gradient(circle at 85% 30%, rgba(46, 160, 67, 0.08), transparent 40%);
z-index: -1;
pointer-events: none;
}
.app-container {
display: flex;
width: 100%;
max-width: 1600px;
margin: 0 auto;
padding: 1.5rem;
gap: 1.5rem;
height: 100vh;
}
/* Glass Panels */
.glass-panel {
background: var(--panel-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--panel-border);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
/* Sidebar */
.sidebar {
width: 320px;
display: flex;
flex-direction: column;
padding: 1.5rem;
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--panel-border);
}
.brand-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--accent-color), #8a2be2);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 4px 15px rgba(88, 166, 255, 0.4);
}
.brand h1 {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.brand .subtitle {
font-size: 0.8rem;
color: var(--text-muted);
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 1px;
margin-bottom: 1rem;
}
/* Scenario List (Radio Cards) */
.scenario-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.scenario-card {
position: relative;
cursor: pointer;
}
.scenario-card input[type="radio"] {
position: absolute;
opacity: 0;
}
.scenario-card .card-content {
padding: 1rem;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 10px;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
align-items: center;
gap: 0.75rem;
}
.scenario-card .card-content::before {
content: '';
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--text-muted);
transition: all 0.2s;
}
.scenario-card:hover .card-content {
border-color: rgba(88, 166, 255, 0.5);
transform: translateY(-2px);
}
.scenario-card input[type="radio"]:checked + .card-content {
background: rgba(88, 166, 255, 0.1);
border-color: var(--accent-color);
}
.scenario-card input[type="radio"]:checked + .card-content::before {
border-color: var(--accent-color);
background-color: var(--accent-color);
box-shadow: 0 0 0 2px var(--input-bg), 0 0 0 4px var(--accent-color);
}
.card-title {
font-weight: 500;
font-size: 0.95rem;
text-transform: capitalize;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Controls Panel */
.controls {
padding: 1.5rem;
}
.panel-header {
margin-bottom: 1.5rem;
}
.panel-header h2 {
font-size: 1.15rem;
font-weight: 600;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.full-width {
grid-column: 1 / -1;
}
/* Form Inputs */
.input-group label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-muted);
}
.input-wrapper, .select-wrapper {
position: relative;
border-radius: 8px;
overflow: hidden;
}
.input-wrapper::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 2px;
background: var(--accent-color);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
.input-wrapper:focus-within::after {
transform: scaleX(1);
}
input[type="text"],
input[type="number"],
select {
width: 100%;
padding: 0.875rem 1rem;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 8px;
color: var(--text-primary);
font-family: 'Inter', sans-serif;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
input:focus, select:focus {
border-color: var(--text-muted);
}
select {
appearance: none;
-webkit-appearance: none;
}
.select-wrapper::before {
content: '▼';
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.7rem;
color: var(--text-muted);
pointer-events: none;
}
/* Toggles */
.toggle-group {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--input-bg);
border: 1px solid var(--input-border);
padding: 1rem;
border-radius: 8px;
}
.toggle-group label {
margin-bottom: 0;
color: var(--text-primary);
}
.toggle-text .helper {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(255, 255, 255, 0.1);
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: var(--text-muted);
transition: .4s;
}
input:checked + .slider {
background-color: var(--accent-color);
}
input:checked + .slider:before {
background-color: white;
transform: translateX(20px);
}
.slider.round { border-radius: 24px; }
.slider.round:before { border-radius: 50%; }
/* Buttons */
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 2rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn-primary {
background: linear-gradient(135deg, #1f6feb, #238636);
background-size: 200% auto;
color: white;
box-shadow: 0 4px 14px rgba(31, 111, 235, 0.3);
}
.btn-primary:hover {
box-shadow: 0 6px 20px rgba(31, 111, 235, 0.5);
background-position: right center;
transform: translateY(-2px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
background: #21262d;
color: #484f58;
box-shadow: none;
cursor: not-allowed;
transform: none;
}
/* Terminal */
.terminal {
flex: 1;
display: flex;
flex-direction: column;
background: var(--terminal-bg);
overflow: hidden;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid var(--input-border);
}
.window-controls {
display: flex;
gap: 6px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.red { background: #ff5f56; }
.yellow { background: #ffbd2e; }
.green { background: #27c93f; }
.terminal-title {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-muted);
}
.icon-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s;
}
.icon-btn:hover {
color: var(--text-primary);
}
.terminal-body {
flex: 1;
padding: 1rem;
overflow-y: auto;
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.log-line {
margin-bottom: 0.2rem;
animation: fadeIn 0.1s ease-in;
}
.log-line.system { color: var(--text-muted); }
.log-line.info { color: var(--accent-color); }
.log-line.warn { color: var(--warning); }
.log-line.error { color: var(--danger); font-weight: bold; }
/* Loader Spinner */
.loader-spinner {
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top: 3px solid white;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
/* Initial Animations */
.pop-in {
animation: popIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.slide-up {
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes popIn {
0% { opacity: 0; transform: scale(0.98); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes slideUp {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}

117
dashboard/templates/index.html

@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BATL CARLA Orchestrator</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-background"></div>
<div class="app-container">
<!-- Sidebar / Scenario Selection -->
<aside class="sidebar glass-panel">
<div class="brand">
<div class="brand-icon">
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
</div>
<div>
<h1>BATL CARLA</h1>
<span class="subtitle">Orchestrator v1.0</span>
</div>
</div>
<div class="nav-section">
<div class="section-title">SCENARIOS</div>
<div id="scenario-list" class="scenario-list">
<!-- Populated dynamically via JS -->
</div>
</div>
</aside>
<!-- Main Workspace -->
<main class="main-content">
<!-- Parameters Configuration -->
<section class="controls glass-panel pop-in">
<div class="panel-header">
<h2>Configuration parameters</h2>
</div>
<form id="launch-form" class="form-grid">
<div class="input-group">
<label for="frames">Simulation Duration (Frames)</label>
<div class="input-wrapper">
<input type="number" id="frames" name="frames" placeholder="e.g. 200">
</div>
</div>
<div class="input-group">
<label for="weather">Weather Preset</label>
<div class="select-wrapper">
<select id="weather" name="weather">
<!-- Populated dynamically -->
</select>
</div>
</div>
<div class="input-group toggle-group">
<div class="toggle-text">
<label for="no_record">Dry Run Mode</label>
<span class="helper">Disable multi-sensor data recording to disk</span>
</div>
<label class="switch">
<input type="checkbox" id="no_record" name="no_record">
<span class="slider round"></span>
</label>
</div>
<div class="full-width">
<div class="section-title" style="margin-top: 1rem;">DYNAMIC SCENARIO PARAMETERS</div>
<div id="dynamic-params-container" class="form-grid">
<!-- Populated dynamically when a scenario is selected -->
<div class="input-group full-width">
<span class="helper">Select a scenario to view its tunable parameters.</span>
</div>
</div>
</div>
<div class="form-actions full-width">
<button type="submit" id="launch-btn" class="btn btn-primary pulse-hover">
<span class="btn-text" id="launch-text">Launch Simulation</span>
<div class="loader-spinner" id="launch-spinner" style="display: none;"></div>
</button>
</div>
</form>
</section>
<!-- Live Terminal Pane -->
<section class="terminal glass-panel slide-up">
<div class="terminal-header">
<div class="window-controls">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<span class="terminal-title">carla312 — Simulation Standard Output</span>
<div class="terminal-actions">
<button id="clear-terminal" class="icon-btn" title="Clear stdout">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</div>
<div class="terminal-body" id="terminal-output">
<div class="log-line system">> Application initialized. Fetching presets...</div>
</div>
</section>
</main>
</div>
<!-- Application Logic -->
<script src="/static/app.js"></script>
</body>
</html>

19
data_to_mcap.py

@ -6,6 +6,9 @@ from mcap.writer import Writer
# Official Foxglove JSON Schemas
FOXGLOVE_POSE_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "foxglove.Pose",
"title": "foxglove.Pose",
"type": "object",
"properties": {
"position": {
@ -20,6 +23,9 @@ FOXGLOVE_POSE_SCHEMA = {
}
FOXGLOVE_IMAGE_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "foxglove.CompressedImage",
"title": "foxglove.CompressedImage",
"type": "object",
"properties": {
"timestamp": {
@ -33,6 +39,9 @@ FOXGLOVE_IMAGE_SCHEMA = {
}
FOXGLOVE_PCL_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "foxglove.PointCloud",
"title": "foxglove.PointCloud",
"type": "object",
"properties": {
"timestamp": {
@ -67,11 +76,11 @@ def convert_folder(folder_path):
output_path = os.path.join(folder_path, f"{folder_name}.mcap")
if os.path.exists(output_path):
print(f"\n>>> Skipping folder (MCAP already exists): {folder_name}")
print(f"\n>>> Skipping folder (MCAP already exists): {folder_name}", flush=True)
return
print(f"\n>>> Processing folder: {folder_name}")
print(f"Target MCAP: {output_path}")
print(f"\n>>> Processing folder: {folder_name}", flush=True)
print(f"Target MCAP: {output_path}", flush=True)
with open(output_path, "wb") as f:
writer = Writer(f)
@ -182,10 +191,10 @@ def convert_folder(folder_path):
frame_count += 1
if frame_count % 50 == 0:
print(f" Processed {frame_count} frames...")
print(f" Processed {frame_count} frames...", flush=True)
writer.finish()
print(f" Done! MCAP saved: {output_path} ({os.path.getsize(output_path)/1024/1024:.2f} MB)")
print(f" Done! MCAP saved: {output_path} ({os.path.getsize(output_path)/1024/1024:.2f} MB)", flush=True)
def main():
root_data = "data"

13
intel/context.md

@ -24,11 +24,17 @@ multi-modal sensor data that can be visualized and analysed in Foxglove Studio.
```
Fox/
├── dashboard.bat ← One-click launcher for the GUI orchestrator (Flask)
├── run.bat ← One-click launcher (activates carla312 conda env)
├── config.py ← All tuneable constants (FPS, sensor params, scenario defaults)
├── data_to_mcap.py ← Converts recorded dataset folders → .mcap files
├── data_inspector.py ← Utility to inspect / debug recorded datasets
├── dashboard/ ← Flask backend and web frontend (GUI)
│ ├── app.py ← Web server bridging API requests to run.bat
│ ├── static/ ← Frontend logic and styling assets
│ └── templates/ ← HTML views
├── src/
│ ├── main.py ← Orchestrator — scenario-agnostic entry point
│ ├── sensors.py ← SensorManager (camera, radar, lidar setup + sync queues)
@ -52,6 +58,7 @@ Fox/
└── intel/
├── context.md ← This file
├── dashboard.md ← Architecture doc for the Web GUI orchestrator
├── showcase.md ← Showcase scenario deep-dive
└── braking.md ← Spawning & physics post-mortem
```
@ -60,6 +67,12 @@ Fox/
## Key Files — Detailed Reference
### `dashboard.bat` & `dashboard/` — Web GUI Orchestrator
A Flask-based web dashboard that provides an intuitive interface for running CARLA scenarios without the CLI. It dynamically fetches available scenarios and config params, translates user choices into `run.bat` commands as a background subprocess, and heavily streams the unbuffered Python stdout text back to the browser using Server-Sent Events (SSE).
> **Full Architecture details:** See `intel/dashboard.md` for a complete breakdown of API routing and extension guidelines.
---
### `config.py`
Single source of truth for all simulation-wide defaults. It NO LONGER contains scenario-specific constants.

68
intel/dashboard.md

@ -0,0 +1,68 @@
# BATL CARLA Dashboard Architecture
This document outlines the architecture, data flow, and inner workings of the BATL CARLA Dashboard (GUI Orchestrator). It is designed to be easily digestible for both human maintainers and AI assistants iterating on the codebase.
## 1. High-Level Overview
The BATL CARLA Dashboard is a web-based GUI that replaces manual CLI interactions. It allows users to:
1. Select scenarios intuitively.
2. Override configuration parameters (frames, weather, etc.).
3. Spawn the simulation as a background process.
4. Stream real-time standard output (stdout) directly from the simulation to the browser.
The system is split into three main layers:
- **Frontend (Browser):** HTML/CSS/JavaScript located in `dashboard/templates` and `dashboard/static`.
- **Backend (Web Server):** A Flask application defined in `dashboard/app.py`.
- **Execution Engine (CLI Pipeline):** The pre-existing `run.bat` wrapper and `src/main.py` Python script.
---
## 2. Component Breakdown
### A. The Launcher (`dashboard.bat`)
This simple batch script is the entry point for the user. It activates the appropriate Python environment and executes `dashboard/app.py`, launching the Flask development server on port 5000.
### B. The Web Server (`dashboard/app.py`)
Flask acts as the bridge between the user's browser HTTP requests and the host operating system's command line.
- **API Endpoints:** It serves `/api/config` and `/api/scenario_params/<name>` to dynamically inform the UI about available scenarios, config limits, and tunable parameters.
- **Process Orchestration:** When a user clicks "Run" on the UI, the frontend sends a `POST` request to `/api/run` containing the selected options. Flask parses this JSON payload and heavily formats a CLI command array.
### C. The Frontend (`dashboard/static/app.js`)
The JavaScript logic handles state management. It:
1. Fetches scenarios on load and populates dropdowns.
2. Intercepts the "Run" form submission to prevent page reloads.
3. Opens a standard **Server-Sent Events (SSE)** connection via the built-in `EventSource` API to receive real-time text streams from Flask.
---
## 3. How the Dashboard Interacts with `run.bat`
The most critical interface in this architecture is how Flask triggers and monitors the underlying CARLA simulation.
When the `/api/run` endpoint is hit:
1. **Command Construction:**
Flask builds the command array. A payload of `{"scenario": "showcase", "frames": 50}` translates to the equivalent of typing:
`cmd.exe /c run.bat showcase --frames 50`
2. **Subprocess Spawning:**
Flask leverages Python's `subprocess.Popen` to spawn a detached child process running the constructed command.
- `cwd=PROJECT_ROOT`: Ensures `run.bat` is executed contextually from the root workspace so relative imports inside `src/main.py` do not break.
- `stdout=subprocess.PIPE` / `stderr=subprocess.STDOUT`: Captures both standard and error console outputs, merging them into a single pipe that Flask can read dynamically.
3. **Stream Unbuffering (Crucial Step):**
By default, Python buffers stdout when piping it to another program, causing massive delays (the GUI appears to "freeze").
To fix this, Flask injects `env["PYTHONUNBUFFERED"] = "1"` into the sub-environment, and `run.bat` also calls `set PYTHONUNBUFFERED=1`. This forces the downstream Python simulation (`src/main.py` and its dependencies) to emit logs instantly, allowing real-time progress bar renders in the GUI console.
4. **Event Streaming (Yield):**
Instead of waiting for the process to return an exit code, Flask uses a specific Python generator (`def generate():`) paired with a standard `Response(mimetype="text/event-stream")`.
As `process.stdout.readline()` pulls new text from the unbuffered stream, Flask yields it to the browser wrapped in `data: ... \n\n` syntax. The JS EventSource catches these fragments and appends them to the `<div id="console">` log box.
---
## 4. Extension Guidelines for AI
When asked to extend the Dashboard, observe the following patterns:
- **Adding new CLI flags:** Update the `POST /api/run` payload parser in `app.py`, extend the `cmd.extend()` builder, and add the corresponding HTML `<input>` in `index.html`.
- **Adding new data streams:** The SSE generator currently pipes raw text. To parse structured data (like active ego speed) for live graphs, consider formatting the intermediate Python prints as JSON and having `app.js` parse the `event.data` payload conditionally.
- **Pathing:** Remember that `app.py` lives in a sub-folder. Operations manipulating the CARLA simulation *must* be rooted up two levels using the `PROJECT_ROOT` pointer established in the file.

3
run.bat

@ -24,6 +24,9 @@ if "%~1"=="" (
exit /b 1
)
:: Force unbuffered stdout/stderr so the Dashboard GUI receives output immediately
set PYTHONUNBUFFERED=1
:: Pass --list-scenarios / -l directly, otherwise treat first arg as scenario name
if "%~1"=="--list-scenarios" (
python src/main.py --list-scenarios

4
src/recorder.py

@ -172,7 +172,7 @@ class Recorder:
def _generate_videos(self):
"""Stitch captured frames into .mp4 files for easy viewing."""
print(f"[INFO] Generating video previews...")
print(f"[INFO] Generating video previews...", flush=True)
# We'll do this for both cameras
configs = [
@ -197,7 +197,7 @@ class Recorder:
frame = cv2.imread(os.path.join(folder, img_name))
out.write(frame)
out.release()
print(f" - Video saved: {out_name}")
print(f" - Video saved: {out_name}", flush=True)
# ---------------- HELPERS ----------------
def _get_actor_class(self, actor) -> str:

Loading…
Cancel
Save