Guadalahacks 2026 · Adaptive signal control for urban intersections
A camera mounted above an intersection feeds frames into a YOLOv8 vehicle detector. Detected vehicles are counted per lane using configurable polygon regions. The count drives a timing algorithm that allocates green-light time proportionally to real-time demand. A React dashboard displays live phase state, countdown, cycle timeline, and event log — and lets operators draw multi-intersection networks with green-wave coordination.
- System Architecture
- Traffic Engineering Theory
- Phase 1 — Single Intersection
- Phase 2 — Road Network
- Car Simulation Model
- Computer Vision Pipeline
- Project Structure
- Prerequisites & Installation
- Running the System
- API Reference
- Configuration Reference
- Training Custom Models
- Tech Stack
- Roadmap
┌─────────────────────────────────────────────────────────────────┐
│ VISION (Python) │
│ │
│ Camera / video / RTSP → YOLOv8 detector → lane counter │
│ (MSMF / DSHOW / ANY) yolo11n_traffic polygon zones │
│ yolo11n_road │
│ road segmentor │
│ │ │
│ POST /intersections/A1/cars (every 1 s) │
│ POST /source/progress (playback telemetry) │
└────────────────────────────────┬────────────────────────────────┘
│
┌────────────────────────────────▼────────────────────────────────┐
│ BACKEND (Node.js — Express + WebSocket) │
│ │
│ POST /intersections/:id/cars → SignalController.update() │
│ GET /intersections/:id → SignalController.snapshot() │
│ POST /source/* → source swap + progress ack │
│ │
│ SignalController (1 s tick) │
│ NS_GREEN → NS_YELLOW → EW_GREEN → EW_YELLOW → repeat │
│ green = clamp(15 + cars×2, 15, 50) │
│ │
│ WebSocket broadcast → UPDATE | SOURCE_* messages │
└────────────────────────────────┬────────────────────────────────┘
│ ws://
┌────────────────────────────────▼────────────────────────────────┐
│ FRONTEND (React 19 + Vite — http://localhost:5173) │
│ │
│ Phase 1 — Single intersection │
│ ├─ Live phase/countdown/cycle bar │
│ ├─ Top-down SVG intersection view (car shapes) │
│ ├─ VideoSourcePanel: camera / photo / RTSP input │
│ └─ Export car counts → Phase 2 │
│ │
│ Phase 2 — Network editor (fully client-side, no backend) │
│ ├─ SVG node/edge graph editor (900×540, 20 px snap) │
│ ├─ Per-intersection adaptive signal timing │
│ ├─ Animated car simulation (microscopic, physics-based) │
│ └─ Green-wave offset sync (⚡ 30 km/h design speed) │
└─────────────────────────────────────────────────────────────────┘
Standalone mode: Phase 1 works entirely in the browser — no backend or camera required. Use the +/− controls to simulate traffic. Phase 2 is always standalone.
JTCS uses a demand-proportional green time model derived from Webster's optimal cycle formula (F.V. Webster, 1958):
C_opt = (1.5 × L + 5) / (1 − Y)
where L = total lost time per cycle and Y = sum of critical volume/saturation-flow ratios. JTCS approximates Y linearly by vehicle count:
green_time = clamp(BASE_GREEN + cars × PER_CAR, min = BASE_GREEN, max = MAX_GREEN)
| Constant | Value | Source / Derivation |
|---|---|---|
BASE_GREEN |
15 s | MUTCD minimum: 12 m crosswalk ÷ 1.2 m/s (slow pedestrian) + 2 s startup ≈ 12 s, rounded up to 15 s for safety |
PER_CAR |
2 s | HCM 6th ed. saturation headway for passenger cars: 1.8–2.1 s/veh — JTCS uses 2.0 s for mixed traffic |
MAX_GREEN |
50 s | MUTCD maximum for isolated intersections; beyond 50 s, driver noncompliance increases sharply |
YELLOW |
3 s | MUTCD: perception-reaction time (1 s) + braking v²/2a where v = 14 m/s, a = 3.0 m/s² ≈ 3 s |
Both backend/src/core/timingEngine.js and frontend/src/hooks/useTimingEngine.js use identical values — they must be kept in sync.
When one approach has more lanes, it processes proportionally more vehicles. JTCS redistributes total green time in a 3:2 ratio (60% / 40%) when lane counts differ — an approximation of the HCM capacity ratio for 2-lane vs. 1-lane approaches at equal saturation flow per lane (~1,800 veh/h/lane):
if (nsLanes > ewLanes) {
nsGreen = Math.round(total × 3/5) // 60%
ewGreen = Math.round(total × 2/5) // 40%
}For multi-intersection coordination, JTCS computes a demand-scaled cycle between MIN_CYCLE and MAX_CYCLE:
t = min(totalQueuedVehicles / 120, 1.0)
cycleLength = MIN_CYCLE + t × (MAX_CYCLE − MIN_CYCLE)
The normalization constant 120 approximates saturation demand for a 60 s cycle: ~2 phases × 2 lanes × 30 veh/phase. Reaching 120 queued vehicles → maximum cycle justified.
| Constant | Value | Reason |
|---|---|---|
MIN_CYCLE |
60 s | HCM minimum for coordinated arterials; below 60 s pedestrian intervals become impractical |
MAX_CYCLE |
100 s | MUTCD recommends ≤ 120 s; beyond 100 s, delay to side-street traffic grows nonlinearly |
MIN_GREEN |
8 s | Minimum per phase to prevent starvation (~1–2 vehicles clearing the stop line) |
A green wave staggers signal offsets so vehicles at the design speed encounter consecutive greens. JTCS uses 30 km/h (8.33 m/s) — the typical Guadalajara urban arterial operating speed:
offset(A→B) = distance_m / 8.33 m/s (seconds)
The ⚡ Sync Wave button computes these offsets from the edge lengths (using a canvas scale of ~0.5 m/pixel) and applies them as phaseOffset values on each node, propagating the green front along each arterial.
PAUSED (initial state — starts on page load)
│
└── startSimulation(cars, lanes) ──► NS_GREEN ──► NS_YELLOW (3 s)
▲ │
│ EW_YELLOW (3 s)
└──── EW_GREEN ◄─┘
Each state is driven by a 1 s tick. When timeLeft reaches 0, the state advances and green times are recomputed from the current car count — timing adapts to traffic in real time.
| Component | Function |
|---|---|
| IntersectionView | 260×260 SVG top-down view. Car shapes (rect + windshield), traffic light bulbs, flow arrows at corners |
| CycleBar | Segmented horizontal bar showing NS-green / yellow / EW-green / yellow timing. Previews pending values before commit |
| StatsPanel | Stat cards: NS green, EW green, cycle length, yellow |
| EventLog | Per-session collapsible event log. Groups events by startSimulation() call |
| VideoSourcePanel | Switch between camera (browser getUserMedia), photo upload, or RTSP source at runtime |
The Phase 1 dashboard keeps two separate car/lane states:
| State | Owner | Used by |
|---|---|---|
pendingCars / pendingLanes |
Local useState |
CycleBar (preview) |
state.cars / state.lanes |
useTrafficState hook |
IntersectionView, StatsPanel, actual timing |
This lets operators adjust values and preview the cycle timing before committing with Start Simulation.
Transfers live car counts from a Phase 1 session into a Phase 2 network node:
- Requires: Phase 2 has at least one edge AND Phase 1 simulation is running
- Click Export → P2 → Phase 2 shows an import banner
- Click any node in the Phase 2 map
classifyAndDistribute()classifies each incoming edge as NS or EW by angle (|sin θ| ≥ |cos θ|→ NS)- Distributes Phase 1
cars.NSamong NS approaches,cars.EWamong EW approaches proportionally - Spawns orange cars into the network with retry logic (up to 20 attempts × 250 ms if blocked)
A fully client-side multi-intersection network editor — no backend required.
| Input | Action |
|---|---|
| Click canvas | Place node (Node mode) |
S key |
Switch to Edge drawing mode |
| Click node (Edge mode) | Start / complete a road segment |
| Right-click while dragging | Cycle path style: straight → H-V → V-H |
Esc |
Return to Select mode |
Del / Backspace |
Delete selected node or edge |
Space |
Play / pause car simulation |
| Button | Function |
|---|---|
| ▶ / ⏸ | Play / pause car simulation |
| ⟳ Reset Cars | Clear all cars, re-populate from current edges |
| 🏙 Auto City | Assign spawn rates by graph topology, start simulation |
| ↑ Load Map | Load SVG background image at 35% opacity (for tracing) |
| ↓ Export SVG | Download jtcs-network.svg with embedded signal data |
| ⚡ Sync Wave | Apply green-wave offsets (30 km/h design speed) |
| ⏱ slider | Car lifetime: 5–120 s (default 20 s) |
| Property | Range | Notes |
|---|---|---|
| Direction | One-way / Two-way | One-way disables backward lane |
| Priority | Major / Normal / Minor | Used by Auto City for spawn rate defaults |
| Forward lanes | 0–3 | |
| Backward lanes | 0–3 | |
| Spawn enabled | On / Off | Disable for transit-only edges |
| Spawn rate | 0.5–60 s | Interval between spawns per lane per direction |
- Colored circles at the stop line of each approach — red/green per signal state
- Green arrows on each node — one per approach currently in green phase, pointing toward the source neighbor
useCarSimulation.js implements a microscopic traffic simulation using an edge-parametric position model.
Each car holds a parameter t ∈ [0, 1] along its current edge:
pixel_x = from.x + t × (to.x − from.x)
pixel_y = from.y + t × (to.y − from.y)
When t ≥ 1, the car transitions to the next edge at t = 0 (random walk).
| Constant | Value | Real-world analog |
|---|---|---|
BASE_SPEED |
20 px/s | ~30 km/h at ~0.5 m/px canvas scale |
DECEL_PX |
26 px | ~13 m braking zone before stop line |
NODE_R |
16 px | Intersection radius ~8 m |
FOLLOW_PX |
18 px | ~9 m minimum following distance (~1 s headway) |
| Speed variation | ±18% | speed = BASE_SPEED × (0.82 + random × 0.36) — prevents platoon lock-step |
const distLeft = (1 − car.t) × edgeLen
if (distLeft < DECEL_PX + NODE_R && signal is RED) {
if (distLeft ≤ NODE_R + 4) { /* hold */ }
speed *= (distLeft − holdDist) / DECEL_PX // smooth deceleration
}Cars on the same lane (same edgeId + fwd) are sorted by t. Each car checks the gap to the car ahead:
if (gap < FOLLOW_T) {
speed = gap < FOLLOW_T × 0.5 ? 0 : speed × ((gap / FOLLOW_T − 0.5) / 0.5)
}Speed drops linearly to zero as the gap closes to half the minimum following distance.
At each node, the car picks a connected edge at random. At dead ends, it U-turns.
| Type | Color | Entry point | Blocked behavior |
|---|---|---|---|
| Interval (regular) | Blue hsl(188–240, 78%, 62%) |
t = 0 |
Skip if red light or gap too small |
| Burst (P1→P2 export) | Orange hsl(30–55, 90%, 62%) |
t = random ∈ [0, 0.7] |
Retry up to 20× with 250 ms delay |
Red-light blocking: regular spawns are skipped when the destination node's signal is red for that approach — only burst (exported) cars bypass this check.
Frame (640×480)
│
├─► VehicleDetector (yolo11n_traffic.pt)
│ Classes: bus(0) car(1) motorcycle(2) truck(3)
│ Filter: bbox area < 45% of frame (anti-overfit)
│
└─► RoadSegmentor (yolo11n_road.pt)
Class: road(0)
Output: road mask → lane polygon zones
│
▼
LaneCounter (polygon zone containment)
│
▼
{ NS: n, EW: m } → POST /intersections/A1/cars (every 1 s)
| Model | File | Dataset | Classes | mAP@50 |
|---|---|---|---|---|
| Vehicle detector | weights/yolo11n_traffic.pt |
Roboflow traffic | bus, car, motorcycle, truck | ~72% |
| Road segmentor | weights/yolo11n_road.pt |
CamVid (Kaggle, 469 imgs) | road | ~85% |
The n (nano) variant is required for Raspberry Pi 4 deployment (~30 fps). The s (small) variant gives higher mAP for PC/server use.
VehicleDetector.max_area_fraction = 0.45Filters detections whose bounding box exceeds 45% of the frame — prevents the model from flagging the vehicle the camera is mounted on, or detecting a close-range bus as multiple vehicles. This was needed after the KITTI dataset (289 imgs) caused severe overfitting; switching to CamVid (469 imgs) and adding this filter resolved it.
Windows MSMF holds exclusive camera access. open_camera() tries three backends in order:
for backend in [cv2.CAP_MSMF, cv2.CAP_DSHOW, cv2.CAP_ANY]:
cap = cv2.VideoCapture(index, backend)
if cap.isOpened(): return capThe browser camera tab (getUserMedia) is intentionally separate — it avoids the MSMF exclusive-access conflict.
.
├── frontend/ React 19 dashboard (Vite)
│ └── src/
│ ├── App.jsx Router + theme toggle (light/dark)
│ ├── App.css Global CSS vars: --bg, --surface, --border, --text, --muted, --accent
│ ├── pages/
│ │ ├── Phase1Page.jsx Single-intersection dashboard
│ │ └── Phase2Page.jsx SVG network editor
│ ├── hooks/
│ │ ├── useTrafficState.js Signal FSM — standalone (mirrors backend)
│ │ ├── useTimingEngine.js Timing formula + exported constants
│ │ ├── useNetworkBuilder.js Phase 2 graph state + signal sim
│ │ └── useCarSimulation.js Car physics — RAF loop + spawning
│ └── components/
│ ├── Intersection/ IntersectionView (SVG), TrafficLight
│ ├── Dashboard/ CycleBar, StatsPanel, EventLog
│ ├── Camera/ VideoSourcePanel (camera/photo/RTSP)
│ └── Network/ NetworkMap, CarLayer, NetworkControls, NetworkTimingPanel
│
├── backend/ Node.js / Express
│ └── src/
│ ├── server.js Entry point: mounts routes, WS broadcast, injectBroadcast()
│ ├── routes/
│ │ ├── intersections.js GET /:id, POST /:id/cars
│ │ ├── source.js Vision source management + injectBroadcast export
│ │ └── network.js Phase 2 registration (stub)
│ └── core/
│ ├── timingEngine.js SignalController — MUTCD state machine, 1 s tick
│ └── coordinationEngine.js Green-wave algorithm (stub, returns offset=0)
│
├── vision/ Python CV pipeline
│ ├── streamer.py Main loop: camera → detector → counter → API POST
│ ├── detector.py YOLOv8 wrapper (yolo11n_traffic.pt)
│ ├── road_segmentor.py Road segmentation (yolo11n_road.pt) → RoadGeometry
│ ├── counter.py Polygon lane counter
│ └── test_detection.py Smoke test — no backend required
│
├── training/
│ └── training_cars/ Vehicle detection fine-tuning (YOLOv11n/s)
│ ├── download_dataset.py Downloads Roboflow dataset (ROBOFLOW_API_KEY)
│ └── train.py --model n (RPi) | s (PC)
│
├── training/road/ Road segmentation training (YOLOv11n-seg)
│ ├── download_dataset.py Downloads CamVid from Kaggle
│ └── train.py
│
├── start.ps1 One-command launcher (backend + optional vision)
└── weights/
├── yolo11n_traffic.pt Fine-tuned vehicle detector (~5 MB, committed)
└── yolo11n_road.pt Fine-tuned road segmentor (~5 MB, committed)
| Component | Version |
|---|---|
| Node.js | 18+ |
| Python | 3.10+ |
| Camera | Webcam, USB, or RTSP stream (optional — standalone mode works without) |
cd frontend
npm installcd backend
npm installcd vision
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS / Linux / Raspberry Pi
source .venv/bin/activate
pip install -r requirements.txtModel weights (weights/yolo11n_traffic.pt and weights/yolo11n_road.pt) are committed to the repository — no separate download needed.
start.ps1 at the repo root handles everything: builds the frontend if needed, starts the backend (which serves the app on port 3001), optionally starts the vision streamer, and opens the browser automatically.
# Backend + frontend only (no camera)
.\start.ps1
# With vision streamer (default camera index 0)
.\start.ps1 -Vision
# With a specific camera index
.\start.ps1 -Vision -Source 2
# With a video file
.\start.ps1 -Vision -Source "video.mp4"
# With an RTSP stream
.\start.ps1 -Vision -Source "rtsp://192.168.1.10:8554/stream"The app opens at http://localhost:3001. The backend serves both the REST API and the built frontend from the same port — no separate dev server needed.
First run:
start.ps1automatically runsnpm run buildinsidefrontend/ifdist/doesn't exist yet. Subsequent runs skip the build and start immediately.
cd frontend && npm run dev
# → http://localhost:5173Use the +/− buttons in Phase 1 to simulate traffic. Phase 2 is always standalone.
Terminal 1 — Backend:
cd backend && npm run dev
# → http://localhost:3001Terminal 2 — Vision:
cd vision
source .venv/bin/activate # or .venv\Scripts\activate on Windows
python streamer.py # built-in webcam (index 0)
python streamer.py --source 1 # alternate camera index
python streamer.py --source video.mp4 # video file
python streamer.py --source rtsp://... # IP camera / RTSP
python streamer.py --preview # open OpenCV window (calibration)
python streamer.py --no-push # detection only, no API push
python test_detection.py # smoke test without backendimport cv2
for i in range(6):
cap = cv2.VideoCapture(i, cv2.CAP_MSMF)
print(f'{i}: {"OK" if cap.isOpened() else "no"}')
cap.release()- Run in calibration mode:
python streamer.py --no-push --preview - The NS polygon appears yellow, EW appears blue in the preview window
- Edit the polygon coordinates in
vision/streamer.py:NS_POLYGON = [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] EW_POLYGON = [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
- Re-run with
--no-pushto verify, then run normally
Returns current signal state and timing.
{
"id": "A1",
"cars": { "NS": 4, "EW": 2 },
"currentState": {
"phase": "NS_GREEN",
"ns_signal": "GREEN",
"ew_signal": "RED",
"time_remaining": 18,
"ns_green_duration": 23,
"ew_green_duration": 19
}
}Vision streamer pushes vehicle counts. Triggers a WebSocket UPDATE broadcast to all clients.
{ "cars": { "NS": 6, "EW": 2 }, "lanes": { "NS": 2, "EW": 1 } }{ "current": "camera:0", "pending": null, "progress": 0.0 }Queue a runtime source change.
{ "source": "rtsp://192.168.1.10:8554/stream" }Streamer reports video file playback progress (0–1). Progress = 1 signals camera-ready ACK.
{ "source": "video.mp4", "progress": 0.42, "loopsCompleted": 1, "isFile": true }Cancel a queued source change before the streamer picks it up.
type |
Payload | When |
|---|---|---|
UPDATE |
{ phase, timeLeft, cars, lanes, cycleTime } |
Every 1 s |
SOURCE_PENDING |
{ source } |
Source swap queued |
SOURCE_PROGRESS |
{ source, progress, loopsCompleted, isFile } |
Each frame during video playback |
SOURCE_PENDING_CANCELLED |
{ source } |
Pending swap cancelled |
Defined in backend/src/core/timingEngine.js and mirrored in frontend/src/hooks/useTimingEngine.js. Both files must stay identical.
| Constant | Value | Meaning |
|---|---|---|
BASE_GREEN |
15 s | Minimum green per direction |
PER_CAR |
2 s | Additional green per vehicle detected |
MAX_GREEN |
50 s | Maximum green per phase |
YELLOW_DURATION |
3 s | Fixed yellow interval |
Formula: green = clamp(15 + cars × 2, min=15, max=50)
| Variable | File | Default | Description |
|---|---|---|---|
PORT |
backend/.env |
3001 |
Backend listen port |
VITE_API_URL |
frontend/.env.local |
http://localhost:3001 |
REST base URL (created locally, not in repo) |
VITE_WS_URL |
frontend/.env.local |
ws://localhost:3001 |
WebSocket URL |
ROBOFLOW_API_KEY |
training/training_cars/.env |
— | Required for vehicle dataset download |
KAGGLE_USERNAME |
training/road/.env |
luisxavierxd |
Kaggle credentials for road dataset |
KAGGLE_KEY |
training/road/.env |
(see file) | Kaggle API key |
| Constant | Description |
|---|---|
API_URL |
Backend base URL (default http://localhost:3001) |
INTERSECTION_ID |
Intersection identifier (default "A1") |
PUSH_INTERVAL |
Seconds between count POSTs (default 1.0) |
NS_POLYGON |
Four-point pixel polygon for the NS lane region |
EW_POLYGON |
Four-point pixel polygon for the EW lane region |
Fine-tunes YOLOv11 on a custom traffic dataset from Roboflow. Classes: bus, car, motorcycle, truck.
cd training/training_cars
cp .env.example .env # set ROBOFLOW_API_KEY
pip install -r requirements.txt
python download_dataset.py # ~800 annotated images
python train.py --model n # yolo11n — optimized for Raspberry Pi 4
python train.py --model s # yolo11s — higher mAP for PC deploymentOutput: runs/detect/train/weights/best.pt → copy to weights/yolo11n_traffic.pt
Fine-tunes YOLOv11-seg on CamVid to detect drivable road surfaces, used to auto-generate lane polygon zones.
cd training/road
pip install -r requirements.txt
python download_dataset.py # CamVid from Kaggle (469 labeled images)
python train.py --model nOutput: runs/segment/train/weights/best.pt → copy to weights/yolo11n_road.pt
Dataset note: CamVid (469 imgs) was chosen over KITTI (289 imgs). KITTI caused severe overfitting due to insufficient dataset size — validation loss diverged after ~20 epochs.
Base model policy: Base YOLO checkpoints (yolo*.pt at repo root) are in .gitignore and auto-downloaded by Ultralytics. Only fine-tuned models in weights/ are committed (~11 MB total).
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite, plain CSS (CSS custom properties for theming) |
| Visualization | Inline SVG — all charts, maps, and intersection views |
| Backend | Node.js, Express 4, ws (WebSocket) |
| Vision | Python 3.10+, Ultralytics YOLOv11, OpenCV |
| Training | Ultralytics YOLOv11, Roboflow SDK, Kaggle API |
| Deployment target | Raspberry Pi 4 (vision) + any Node.js host (backend) |
| Feature | Status |
|---|---|
| Demand-proportional timing formula (Webster-derived) | ✅ |
| 3:2 lane capacity adjustment | ✅ |
| Frontend signal state machine (mirrors backend exactly) | ✅ |
| React dashboard: phase display, cycle bar, event log | ✅ |
| Backend Express API + WebSocket broadcast | ✅ |
| YOLOv11n vehicle detector (fine-tuned) | ✅ |
| YOLOv11n-seg road segmentor (fine-tuned) | ✅ |
| Per-lane polygon counter | ✅ |
| Vision streamer: camera / video / RTSP / headless | ✅ |
| Runtime source hot-swap | ✅ |
Browser camera tab via getUserMedia (no backend) |
✅ |
| Photo upload + 2-photo NS/EW analysis | ✅ |
| Pending preview vs. committed running state | ✅ |
| Top-down SVG intersection view with car shapes | ✅ |
| Export car counts → Phase 2 | ✅ |
| Light / dark mode toggle (persisted) | ✅ |
| Wire WebSocket live updates into Phase 1 dashboard | ⏳ |
| Field test with real intersection camera | ⏳ |
| Feature | Status |
|---|---|
| SVG node/edge graph editor (900×540, 20 px snap) | ✅ |
| Edge path styles: straight, H-V, V-H | ✅ |
| Per-intersection adaptive signal timing | ✅ |
| Green-wave offset sync (⚡ Sync Wave) | ✅ |
| SVG background overlay (Load Map) | ✅ |
| SVG export | ✅ |
| Microscopic car simulation: spawning, physics, stop-line, following | ✅ |
| Red/green stop indicators per approach | ✅ |
| Green phase arrow indicators per node | ✅ |
| Auto City mode (topology-based spawn assignment) | ✅ |
| Spawn rate per edge: 0.5–60 s | ✅ |
| Car lifetime slider: 5–120 s | ✅ |
| Import from Phase 1 by intersection (NS/EW classification) | ✅ |
| Light / dark mode (canvas + all UI) | ✅ |
| Backend network registration API | ⏳ |
| Backend green-wave coordination engine | ⏳ |
| Wire Phase 2 editor to backend | ⏳ |