cyberai-1 commited on
Commit
624863d
Β·
1 Parent(s): cf63d5c
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # System deps for OpenCV
6
+ RUN apt-get update && apt-get install -y \
7
+ libgl1-mesa-glx \
8
+ libglib2.0-0 \
9
+ ffmpeg \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ COPY backend/requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ COPY backend/ ./backend/
16
+ COPY frontend/ ./frontend/
17
+
18
+ WORKDIR /app/backend
19
+
20
+ EXPOSE 7860
21
+
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AIMS Senegal β€” Computer Vision Project 2
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,12 +1,222 @@
1
  ---
2
- title: Traffic Tracker
3
- emoji: 🐠
4
  colorFrom: blue
5
- colorTo: gray
6
- sdk: gradio
7
- sdk_version: 6.13.0
8
- app_file: app.py
9
  pinned: false
10
  ---
 
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Computer Vison | Traffic Tracker
 
3
  colorFrom: blue
4
+ colorTo: purple
5
+ sdk: docker
6
+ app_port: 7860
 
7
  pinned: false
8
  ---
9
+ # TrafficSense β€” Road Traffic Object Counting & Tracking
10
 
11
+ > AIMS Senegal Β· Computer Vision Project 2 Β· April 2026
12
+
13
+ A real-time computer vision system for detecting, tracking, and counting road-traffic objects across multiple video scenes. Built with **YOLOv8** + **ByteTrack**, deployed via **FastAPI**, with a live web dashboard.
14
+
15
+ ![License](https://img.shields.io/badge/license-MIT-blue)
16
+ ![Python](https://img.shields.io/badge/python-3.10%2B-green)
17
+ ![YOLO](https://img.shields.io/badge/model-YOLOv8-red)
18
+
19
+ ---
20
+
21
+ ## Features
22
+
23
+ | Feature | Details |
24
+ |---|---|
25
+ | **Detection** | YOLOv8n/s/m/l via Ultralytics β€” supports nano to large |
26
+ | **Tracking** | ByteTrack (built into Ultralytics) β€” persistent unique IDs |
27
+ | **Classes** | person, bicycle, car, motorbike, bus, truck |
28
+ | **Counting** | Virtual counting line β€” counts unique crossings + direction |
29
+ | **Logging** | JSONL detections, JSON summary, CSV frame stats per scene |
30
+ | **Web UI** | Upload video, select classes, view live annotated stream |
31
+ | **Dashboard** | Compare scenes, bar charts, timeline, global stats |
32
+ | **CLI** | Run without server for batch processing |
33
+ | **Fine-tuning** | Script to train on custom labeled dataset |
34
+
35
+ ---
36
+
37
+ ## Project Structure
38
+
39
+ ```
40
+ traffic-tracker/
41
+ β”œβ”€β”€ backend/
42
+ β”‚ β”œβ”€β”€ main.py # FastAPI REST + SSE server
43
+ β”‚ β”œβ”€β”€ tracker.py # YOLOv8 + ByteTrack engine
44
+ β”‚ β”œβ”€β”€ run_tracker.py # CLI: process video without server
45
+ β”‚ β”œβ”€β”€ finetune.py # Fine-tune on custom dataset
46
+ β”‚ β”œβ”€β”€ extract_frames.py # Extract frames for labeling
47
+ β”‚ └── requirements.txt
48
+ β”œβ”€β”€ frontend/
49
+ β”‚ └── index.html # Full web interface (single file)
50
+ β”œβ”€β”€ logs/ # Auto-created: JSONL + JSON + CSV logs
51
+ β”œβ”€β”€ uploads/ # Auto-created: uploaded videos
52
+ β”œβ”€β”€ output/ # Auto-created: annotated output videos
53
+ └── README.md
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Quick Start
59
+
60
+ ### 1. Install dependencies
61
+
62
+ ```bash
63
+ cd backend
64
+ pip install -r requirements.txt
65
+ ```
66
+
67
+ > GPU users: `pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118`
68
+
69
+ ### 2. Start the API server
70
+
71
+ ```bash
72
+ cd backend
73
+ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
74
+ ```
75
+
76
+ ### 3. Open the web interface
77
+
78
+ Open `frontend/index.html` in your browser, **or** visit `http://localhost:8000` (the server serves it automatically).
79
+
80
+ ### 4. Analyse a video
81
+
82
+ 1. Drop a traffic video into the upload zone
83
+ 2. Give the scene a name (e.g. `intersection_A`)
84
+ 3. Select classes to track
85
+ 4. Choose model (YOLOv8n is fastest; YOLOv8s balances speed/accuracy)
86
+ 5. Click **START ANALYSIS**
87
+ 6. Watch the annotated live stream and live counters
88
+
89
+ ---
90
+
91
+ ## CLI Usage (no server needed)
92
+
93
+ ```bash
94
+ # Basic run (display window)
95
+ python run_tracker.py --video traffic.mp4 --scene roundabout_1 --show
96
+
97
+ # Save output video + logs
98
+ python run_tracker.py --video traffic.mp4 --scene highway_cam --classes car truck bus --save
99
+
100
+ # Custom confidence + model
101
+ python run_tracker.py --video traffic.mp4 --model yolov8m.pt --conf 0.45 --show --save
102
+ ```
103
+
104
+ ---
105
+
106
+ ## API Endpoints
107
+
108
+ | Method | Endpoint | Description |
109
+ |---|---|---|
110
+ | `POST` | `/upload` | Upload video, start processing |
111
+ | `GET` | `/stream/{sid}` | SSE stream of annotated frames |
112
+ | `GET` | `/status/{sid}` | Processing progress + live stats |
113
+ | `GET` | `/summary/{sid}` | Final summary JSON |
114
+ | `GET` | `/dashboard` | Aggregated multi-scene stats |
115
+ | `GET` | `/logs` | List all saved log files |
116
+ | `GET` | `/classes` | Available traffic classes |
117
+
118
+ ---
119
+
120
+ ## Log File Schema (shared data format)
121
+
122
+ All groups must follow this schema for dashboard merging.
123
+
124
+ ### `*_detections.jsonl` β€” one JSON per line
125
+
126
+ ```json
127
+ {
128
+ "frame": 1234,
129
+ "timestamp": 41.13,
130
+ "scene": "intersection_A",
131
+ "track_id": 7,
132
+ "class": "car",
133
+ "confidence": 0.872,
134
+ "bbox": [120, 340, 280, 450],
135
+ "center": [200, 395],
136
+ "crossed_line": true,
137
+ "direction": "down"
138
+ }
139
+ ```
140
+
141
+ ### `*_summary.json`
142
+
143
+ ```json
144
+ {
145
+ "scene": "intersection_A",
146
+ "session_id": "abc123",
147
+ "processed_at": "2026-04-25T14:30:00",
148
+ "total_frames": 1800,
149
+ "duration_sec": 60.0,
150
+ "fps": 30.0,
151
+ "resolution": [1920, 1080],
152
+ "selected_classes": ["car", "bus", "truck", "person"],
153
+ "total_unique_objects": 142,
154
+ "count_per_class": {"car": 98, "bus": 12, "truck": 17, "person": 15},
155
+ "direction_counts": {"car": {"up": 43, "down": 55}},
156
+ "temporal_distribution": [
157
+ {"bucket_10s": 0, "detections": 34},
158
+ {"bucket_10s": 1, "detections": 51}
159
+ ]
160
+ }
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Fine-tuning on Custom Data
166
+
167
+ ```bash
168
+ # 1. Extract frames from your traffic videos
169
+ python extract_frames.py --video traffic1.mp4 --out frames/ --every 10
170
+
171
+ # 2. Label with Roboflow (free) β†’ export as YOLO format
172
+ # https://roboflow.com
173
+
174
+ # 3. Fine-tune
175
+ python finetune.py --data dataset.yaml --model yolov8s.pt --epochs 50 --device 0
176
+
177
+ # 4. Use your fine-tuned model
178
+ python run_tracker.py --video test.mp4 --model runs/traffic/finetune/weights/best.pt
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Video Sources
184
+
185
+ - [Pexels Traffic Videos](https://www.pexels.com/search/videos/traffic/) (free, no attribution required)
186
+ - Record your own: 1 min minimum, distinct scenes (intersection, roundabout, highway, urban)
187
+
188
+ ---
189
+
190
+ ## Deliverables Checklist
191
+
192
+ - [x] Detection model (YOLOv8 pre-trained)
193
+ - [x] Fine-tuning script + instructions
194
+ - [x] ByteTrack tracking with persistent IDs
195
+ - [x] Virtual counting line + direction detection
196
+ - [x] Unique object counting (not per-frame)
197
+ - [x] Detailed detection logs (JSONL + JSON + CSV)
198
+ - [x] Shared data schema across groups
199
+ - [x] Web interface (upload, class selection, live display)
200
+ - [x] Bounding boxes, labels, IDs, live counters
201
+ - [x] "No object detected" indicator
202
+ - [x] Multi-scene dashboard with comparisons
203
+ - [x] GitHub-ready structure
204
+
205
+ ### Bonus features implemented
206
+ - [x] Direction tracking (up/down crossing)
207
+ - [x] Visual trail per tracked object
208
+ - [x] Temporal traffic intensity chart
209
+ - [x] Scene comparison table
210
+ - [x] CSV frame statistics export
211
+
212
+ ---
213
+
214
+ ## License
215
+
216
+ MIT β€” see [LICENSE](LICENSE)
217
+
218
+ ---
219
+
220
+ ## Authors
221
+
222
+ *AIMS Senegal β€” Computer Vision 2026*
backend/dataset.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dataset.yaml β€” YOLO training dataset configuration
2
+ # Edit the `path` field to match your dataset directory
3
+ # Annotate using Roboflow (https://roboflow.com) and export as YOLOv8 format
4
+
5
+ path: /path/to/your/dataset # root directory
6
+ train: images/train # training images (relative to path)
7
+ val: images/val # validation images (relative to path)
8
+ test: images/test # (optional) test images
9
+
10
+ # Number of classes
11
+ nc: 6
12
+
13
+ # Class names β€” must match COCO traffic subset indices used in tracker.py
14
+ names:
15
+ 0: person
16
+ 1: bicycle
17
+ 2: car
18
+ 3: motorbike
19
+ 5: bus
20
+ 7: truck
21
+
22
+ # ── Notes ──────────────────────────────────────────────────────────────────
23
+ # When exporting from Roboflow or Label Studio, remap your class indices
24
+ # to match the COCO indices above (0,1,2,3,5,7) if needed.
25
+ #
26
+ # Recommended dataset size per scene:
27
+ # - β‰₯ 500 annotated frames
28
+ # - Mix of day/night, weather conditions
29
+ # - Multiple angles and zoom levels
30
+ #
31
+ # Labeling tips:
32
+ # - Label ALL visible objects, including partially occluded ones
33
+ # - Use tight bounding boxes
34
+ # - Consistent class names (lowercase, no spaces)
backend/extract_frames.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ extract_frames.py β€” Extract frames from videos for dataset creation
3
+ Usage:
4
+ python extract_frames.py --video traffic.mp4 --out frames/ --every 15
5
+ """
6
+ import cv2
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+
11
+ def extract_frames(video_path: str, out_dir: str, every: int = 10, max_frames: int = 5000):
12
+ cap = cv2.VideoCapture(video_path)
13
+ out = Path(out_dir)
14
+ out.mkdir(parents=True, exist_ok=True)
15
+ n, saved = 0, 0
16
+
17
+ while True:
18
+ ret, frame = cap.read()
19
+ if not ret or saved >= max_frames:
20
+ break
21
+ if n % every == 0:
22
+ fp = out / f"frame_{saved:06d}.jpg"
23
+ cv2.imwrite(str(fp), frame)
24
+ saved += 1
25
+ n += 1
26
+
27
+ cap.release()
28
+ print(f"Saved {saved} frames to {out_dir}")
29
+
30
+
31
+ if __name__ == "__main__":
32
+ p = argparse.ArgumentParser()
33
+ p.add_argument("--video", required=True)
34
+ p.add_argument("--out", default="frames")
35
+ p.add_argument("--every", type=int, default=10)
36
+ p.add_argument("--max", type=int, default=5000)
37
+ args = p.parse_args()
38
+ extract_frames(args.video, args.out, args.every, args.max)
backend/finetune.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ finetune.py β€” Fine-tune YOLOv8 on a custom traffic dataset
3
+ Usage:
4
+ python finetune.py --data dataset.yaml --model yolov8s.pt --epochs 30
5
+
6
+ dataset.yaml format (create yours):
7
+ path: /path/to/dataset
8
+ train: images/train
9
+ val: images/val
10
+ nc: 6
11
+ names: ['person','bicycle','car','motorbike','bus','truck']
12
+
13
+ You can prepare a dataset by:
14
+ 1. Downloading Pexels traffic videos
15
+ 2. Extracting frames with extract_frames.py
16
+ 3. Labeling with Roboflow or Label Studio
17
+ 4. Exporting in YOLO format
18
+ """
19
+
20
+ import argparse
21
+ from pathlib import Path
22
+ from ultralytics import YOLO
23
+
24
+
25
+ def parse_args():
26
+ p = argparse.ArgumentParser(description="Fine-tune YOLOv8 for traffic detection")
27
+ p.add_argument("--data", type=str, default="dataset.yaml", help="Path to dataset YAML")
28
+ p.add_argument("--model", type=str, default="yolov8s.pt", help="Base model weights")
29
+ p.add_argument("--epochs", type=int, default=30, help="Training epochs")
30
+ p.add_argument("--imgsz", type=int, default=640, help="Image size")
31
+ p.add_argument("--batch", type=int, default=16, help="Batch size")
32
+ p.add_argument("--device", type=str, default="0", help="Device: 0 (GPU) or cpu")
33
+ p.add_argument("--project", type=str, default="runs/traffic", help="Save directory")
34
+ p.add_argument("--name", type=str, default="finetune", help="Experiment name")
35
+ return p.parse_args()
36
+
37
+
38
+ def main():
39
+ args = parse_args()
40
+
41
+ print(f"[INFO] Loading base model: {args.model}")
42
+ model = YOLO(args.model)
43
+
44
+ print(f"[INFO] Starting fine-tuning on: {args.data}")
45
+ results = model.train(
46
+ data=args.data,
47
+ epochs=args.epochs,
48
+ imgsz=args.imgsz,
49
+ batch=args.batch,
50
+ device=args.device,
51
+ project=args.project,
52
+ name=args.name,
53
+ # Augmentation (good for traffic)
54
+ hsv_h=0.015, # hue shift
55
+ hsv_s=0.7, # saturation
56
+ hsv_v=0.4, # brightness
57
+ fliplr=0.5, # horizontal flip
58
+ mosaic=1.0, # mosaic augmentation
59
+ mixup=0.1, # mixup
60
+ # Optimizer
61
+ optimizer="AdamW",
62
+ lr0=0.001,
63
+ lrf=0.01,
64
+ momentum=0.937,
65
+ weight_decay=0.0005,
66
+ warmup_epochs=3,
67
+ # Early stopping
68
+ patience=10,
69
+ # Logging
70
+ plots=True,
71
+ verbose=True,
72
+ )
73
+
74
+ best_weights = Path(args.project) / args.name / "weights" / "best.pt"
75
+ print(f"\n[INFO] Training complete!")
76
+ print(f"[INFO] Best weights saved to: {best_weights}")
77
+ print(f"[INFO] Use in tracker: --model {best_weights}")
78
+
79
+ # Validate
80
+ print("\n[INFO] Running validation...")
81
+ val_results = model.val()
82
+ print(f"[INFO] mAP50: {val_results.box.map50:.4f}")
83
+ print(f"[INFO] mAP50-95: {val_results.box.map:.4f}")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
backend/main.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ main.py β€” FastAPI backend for Traffic Monitoring System
3
+ Endpoints:
4
+ POST /upload β†’ upload video, start processing
5
+ GET /stream/{sid} β†’ SSE stream of annotated frames (base64 JPEG)
6
+ GET /status/{sid} β†’ processing status + live stats
7
+ GET /summary/{sid} β†’ final summary JSON
8
+ GET /logs β†’ list all saved log files
9
+ GET /dashboard β†’ aggregated stats across all sessions
10
+ """
11
+
12
+ import asyncio
13
+ import base64
14
+ import json
15
+ import os
16
+ import time
17
+ import uuid
18
+ import threading
19
+ from collections import defaultdict
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ import cv2
24
+ import numpy as np
25
+ from fastapi import FastAPI, File, Form, UploadFile, HTTPException
26
+ from fastapi.middleware.cors import CORSMiddleware
27
+ from fastapi.responses import (
28
+ FileResponse, HTMLResponse, JSONResponse, StreamingResponse
29
+ )
30
+ from fastapi.staticfiles import StaticFiles
31
+
32
+ from tracker import TrafficTracker, TRAFFIC_CLASSES, DEFAULT_CLASSES
33
+
34
+ # ─── App setup ──────────────────────────────────────────────────────────────
35
+ app = FastAPI(title="Traffic Monitoring API", version="1.0.0")
36
+
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["*"],
40
+ allow_methods=["*"],
41
+ allow_headers=["*"],
42
+ )
43
+
44
+ BASE_DIR = Path(__file__).parent.parent
45
+ UPLOAD_DIR = BASE_DIR / "uploads"; UPLOAD_DIR.mkdir(exist_ok=True)
46
+ LOG_DIR = BASE_DIR / "logs"; LOG_DIR.mkdir(exist_ok=True)
47
+ OUTPUT_DIR = BASE_DIR / "output"; OUTPUT_DIR.mkdir(exist_ok=True)
48
+ STATIC_DIR = BASE_DIR / "frontend"
49
+
50
+ if STATIC_DIR.exists():
51
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
52
+
53
+ # ─── In-memory session store ─────────────────────────────────────────────────
54
+ sessions: dict[str, dict] = {}
55
+ # Each session: {
56
+ # "status": "processing" | "done" | "error",
57
+ # "frames": deque of base64 JPEG strings (latest N),
58
+ # "stats": latest frame stat dict,
59
+ # "summary": full summary dict (when done),
60
+ # "tracker": TrafficTracker instance,
61
+ # "error": str | None,
62
+ # "scene_name": str,
63
+ # }
64
+
65
+ from collections import deque
66
+ SESSION_FRAME_BUFFER = 2 # keep only last N frames in memory
67
+
68
+ # ─── Routes ──────────────────────────────────────────────────────────────────
69
+
70
+ @app.get("/", response_class=HTMLResponse)
71
+ async def root():
72
+ index = STATIC_DIR / "index.html"
73
+ if index.exists():
74
+ return HTMLResponse(index.read_text())
75
+ return HTMLResponse("<h1>Traffic Monitoring API</h1><p>Frontend not found.</p>")
76
+
77
+
78
+ @app.get("/health")
79
+ async def health():
80
+ return {"status": "ok", "sessions": len(sessions)}
81
+
82
+
83
+ @app.get("/classes")
84
+ async def get_classes():
85
+ """Return all supported traffic object classes."""
86
+ return {"classes": DEFAULT_CLASSES, "all": list(TRAFFIC_CLASSES.values())}
87
+
88
+
89
+ @app.post("/upload")
90
+ async def upload_video(
91
+ file: UploadFile = File(...),
92
+ scene_name: str = Form("scene_01"),
93
+ classes: str = Form(""), # comma-separated class names
94
+ conf: float = Form(0.35),
95
+ model: str = Form("yolov8n.pt"), # yolov8n/s/m/l/x
96
+ save_output: bool = Form(False),
97
+ ):
98
+ """Upload a video file and start processing in a background thread."""
99
+ sid = str(uuid.uuid4())[:12]
100
+
101
+ # Save uploaded file
102
+ suffix = Path(file.filename).suffix or ".mp4"
103
+ video_path = UPLOAD_DIR / f"{sid}{suffix}"
104
+ content = await file.read()
105
+ video_path.write_bytes(content)
106
+
107
+ selected = [c.strip() for c in classes.split(",") if c.strip()] or DEFAULT_CLASSES
108
+
109
+ sessions[sid] = {
110
+ "status": "processing",
111
+ "frames": deque(maxlen=SESSION_FRAME_BUFFER),
112
+ "stats": None,
113
+ "summary": None,
114
+ "tracker": None,
115
+ "error": None,
116
+ "scene_name": scene_name,
117
+ "video_path": str(video_path),
118
+ "progress": 0,
119
+ "total_frames": 0,
120
+ }
121
+
122
+ # Start background processing thread
123
+ thread = threading.Thread(
124
+ target=_process_video_thread,
125
+ args=(sid, str(video_path), scene_name, selected, conf, model, save_output),
126
+ daemon=True,
127
+ )
128
+ thread.start()
129
+
130
+ return {"session_id": sid, "scene_name": scene_name, "status": "processing"}
131
+
132
+
133
+ def _process_video_thread(
134
+ sid: str,
135
+ video_path: str,
136
+ scene_name: str,
137
+ selected_classes: list,
138
+ conf: float,
139
+ model_name: str,
140
+ save_output: bool,
141
+ ):
142
+ """Run YOLO+ByteTrack in a background thread, push frames to session."""
143
+ session = sessions[sid]
144
+ try:
145
+ cap = cv2.VideoCapture(video_path)
146
+ if not cap.isOpened():
147
+ session["status"] = "error"
148
+ session["error"] = "Could not open video file."
149
+ return
150
+
151
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
152
+ session["total_frames"] = total
153
+
154
+ tracker = TrafficTracker(
155
+ model_path=model_name,
156
+ selected_classes=selected_classes,
157
+ conf_threshold=conf,
158
+ scene_name=scene_name,
159
+ output_dir=str(LOG_DIR),
160
+ )
161
+ tracker.setup_video(cap)
162
+ session["tracker"] = tracker
163
+
164
+ # Optional video writer
165
+ out_writer = None
166
+ out_path = None
167
+ if save_output:
168
+ out_path = str(OUTPUT_DIR / f"{sid}_output.mp4")
169
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
170
+ out_writer = cv2.VideoWriter(
171
+ out_path, fourcc, tracker.fps,
172
+ (tracker.frame_width, tracker.frame_height)
173
+ )
174
+
175
+ while True:
176
+ ret, frame = cap.read()
177
+ if not ret:
178
+ break
179
+
180
+ annotated, frame_stat = tracker.process_frame(frame)
181
+ session["stats"] = frame_stat
182
+ session["progress"] = tracker.frame_index
183
+
184
+ # Encode frame to JPEG base64 for SSE streaming
185
+ _, buf = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 75])
186
+ b64 = base64.b64encode(buf).decode()
187
+ session["frames"].append(b64)
188
+
189
+ if out_writer:
190
+ out_writer.write(annotated)
191
+
192
+ cap.release()
193
+ if out_writer:
194
+ out_writer.release()
195
+
196
+ # Save logs + build summary
197
+ log_paths = tracker.save_logs()
198
+ summary = tracker.get_summary()
199
+ summary["log_files"] = log_paths
200
+ if out_path:
201
+ summary["output_video"] = out_path
202
+
203
+ session["summary"] = summary
204
+ session["status"] = "done"
205
+
206
+ except Exception as e:
207
+ sessions[sid]["status"] = "error"
208
+ sessions[sid]["error"] = str(e)
209
+ raise
210
+
211
+
212
+ @app.get("/stream/{sid}")
213
+ async def stream_frames(sid: str):
214
+ """Server-Sent Events stream of annotated frames (base64 JPEG)."""
215
+ if sid not in sessions:
216
+ raise HTTPException(404, "Session not found")
217
+
218
+ async def event_generator():
219
+ last_sent_index = 0
220
+ while True:
221
+ session = sessions[sid]
222
+ frames = list(session["frames"])
223
+ if frames:
224
+ for frame_b64 in frames[last_sent_index:]:
225
+ stats = json.dumps(session.get("stats") or {})
226
+ data = json.dumps({"frame": frame_b64, "stats": stats})
227
+ yield f"data: {data}\n\n"
228
+ last_sent_index = len(frames)
229
+
230
+ if session["status"] in ("done", "error"):
231
+ yield f"data: {json.dumps({'event': 'done', 'status': session['status']})}\n\n"
232
+ break
233
+
234
+ await asyncio.sleep(0.04) # ~25fps cap
235
+
236
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
237
+
238
+
239
+ @app.get("/status/{sid}")
240
+ async def get_status(sid: str):
241
+ if sid not in sessions:
242
+ raise HTTPException(404, "Session not found")
243
+ s = sessions[sid]
244
+ return {
245
+ "session_id": sid,
246
+ "status": s["status"],
247
+ "scene_name": s["scene_name"],
248
+ "progress": s["progress"],
249
+ "total_frames": s["total_frames"],
250
+ "stats": s["stats"],
251
+ "error": s["error"],
252
+ }
253
+
254
+
255
+ @app.get("/summary/{sid}")
256
+ async def get_summary(sid: str):
257
+ if sid not in sessions:
258
+ raise HTTPException(404, "Session not found")
259
+ s = sessions[sid]
260
+ if s["status"] != "done":
261
+ return JSONResponse({"detail": "Processing not complete yet."}, status_code=202)
262
+ return s["summary"]
263
+
264
+
265
+ @app.get("/logs")
266
+ async def list_logs():
267
+ """List all saved log files."""
268
+ files = [
269
+ {
270
+ "name": f.name,
271
+ "size_kb": round(f.stat().st_size / 1024, 1),
272
+ "modified": f.stat().st_mtime,
273
+ }
274
+ for f in sorted(LOG_DIR.glob("*"), key=lambda x: x.stat().st_mtime, reverse=True)
275
+ ]
276
+ return {"logs": files}
277
+
278
+
279
+ @app.get("/dashboard")
280
+ async def dashboard_data():
281
+ """Aggregate all completed session summaries into a dashboard payload."""
282
+ completed = [
283
+ s["summary"] for s in sessions.values()
284
+ if s["status"] == "done" and s["summary"]
285
+ ]
286
+
287
+ # Also load from saved JSON files
288
+ for p in sorted(LOG_DIR.glob("*_summary.json")):
289
+ try:
290
+ data = json.loads(p.read_text())
291
+ # Avoid duplicates by session_id
292
+ if not any(c.get("session_id") == data.get("session_id") for c in completed):
293
+ completed.append(data)
294
+ except Exception:
295
+ pass
296
+
297
+ if not completed:
298
+ return {"scenes": [], "global": {}}
299
+
300
+ global_counts: dict[str, int] = defaultdict(int)
301
+ for s in completed:
302
+ for cls_, cnt in s.get("count_per_class", {}).items():
303
+ global_counts[cls_] += cnt
304
+
305
+ return {
306
+ "scenes": completed,
307
+ "global_counts": dict(global_counts),
308
+ "total_scenes": len(completed),
309
+ "total_objects": sum(global_counts.values()),
310
+ }
311
+
312
+
313
+ @app.get("/log/{filename}")
314
+ async def download_log(filename: str):
315
+ p = LOG_DIR / filename
316
+ if not p.exists():
317
+ raise HTTPException(404)
318
+ return FileResponse(str(p), filename=filename)
backend/requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ ultralytics>=8.3.0
2
+ opencv-python>=4.9.0
3
+ fastapi>=0.111.0
4
+ uvicorn[standard]>=0.30.0
5
+ python-multipart>=0.0.9
6
+ numpy>=1.26.0
7
+ lapx>=0.5.5 # ByteTrack dependency
backend/run_tracker.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_tracker.py β€” Command-line video processing (no server needed)
3
+ Usage:
4
+ python run_tracker.py --video traffic.mp4 --scene intersection_A --show
5
+ python run_tracker.py --video traffic.mp4 --classes car truck bus --conf 0.4 --save
6
+ """
7
+ import cv2
8
+ import argparse
9
+ from pathlib import Path
10
+ from tracker import TrafficTracker, DEFAULT_CLASSES
11
+
12
+
13
+ def parse_args():
14
+ p = argparse.ArgumentParser(description="TrafficSense CLI Tracker")
15
+ p.add_argument("--video", required=True, help="Path to input video")
16
+ p.add_argument("--model", default="yolov8s.pt", help="YOLO model weights")
17
+ p.add_argument("--scene", default="scene_01", help="Scene name for logs")
18
+ p.add_argument("--classes", nargs="+", default=DEFAULT_CLASSES, help="Classes to track")
19
+ p.add_argument("--conf", type=float, default=0.35, help="Confidence threshold")
20
+ p.add_argument("--show", action="store_true", help="Display video window")
21
+ p.add_argument("--save", action="store_true", help="Save annotated output video")
22
+ p.add_argument("--logs", default="logs", help="Directory to save logs")
23
+ p.add_argument("--out", default="output", help="Directory for output video")
24
+ p.add_argument("--line", type=float, default=0.55, help="Counting line position (0-1)")
25
+ return p.parse_args()
26
+
27
+
28
+ def main():
29
+ args = parse_args()
30
+
31
+ cap = cv2.VideoCapture(args.video)
32
+ if not cap.isOpened():
33
+ print(f"[ERROR] Cannot open video: {args.video}")
34
+ return
35
+
36
+ tracker = TrafficTracker(
37
+ model_path=args.model,
38
+ selected_classes=args.classes,
39
+ conf_threshold=args.conf,
40
+ scene_name=args.scene,
41
+ output_dir=args.logs,
42
+ counting_line_ratio=args.line,
43
+ )
44
+ tracker.setup_video(cap)
45
+
46
+ out_writer = None
47
+ if args.save:
48
+ Path(args.out).mkdir(parents=True, exist_ok=True)
49
+ out_path = str(Path(args.out) / f"{args.scene}_output.mp4")
50
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
51
+ out_writer = cv2.VideoWriter(
52
+ out_path, fourcc, tracker.fps,
53
+ (tracker.frame_width, tracker.frame_height)
54
+ )
55
+ print(f"[INFO] Saving to: {out_path}")
56
+
57
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
58
+ print(f"[INFO] Processing {total} frames | Scene: {args.scene} | Classes: {args.classes}")
59
+
60
+ while True:
61
+ ret, frame = cap.read()
62
+ if not ret:
63
+ break
64
+
65
+ annotated, stats = tracker.process_frame(frame)
66
+
67
+ if out_writer:
68
+ out_writer.write(annotated)
69
+
70
+ if args.show:
71
+ cv2.imshow(f"TrafficSense β€” {args.scene}", annotated)
72
+ if cv2.waitKey(1) & 0xFF == ord('q'):
73
+ break
74
+
75
+ # Progress
76
+ if tracker.frame_index % 50 == 0:
77
+ pct = tracker.frame_index / max(total, 1) * 100
78
+ print(f" [{pct:5.1f}%] frame {tracker.frame_index}/{total} "
79
+ f"counts: {dict(tracker.count_per_class)}")
80
+
81
+ cap.release()
82
+ if out_writer:
83
+ out_writer.release()
84
+ if args.show:
85
+ cv2.destroyAllWindows()
86
+
87
+ print("\n[INFO] Processing complete.")
88
+ log_paths = tracker.save_logs()
89
+ summary = tracker.get_summary()
90
+
91
+ print("\n── SUMMARY ──────────────────────────────────")
92
+ print(f" Scene: {summary['scene']}")
93
+ print(f" Duration: {summary['duration_sec']}s")
94
+ print(f" Total frames: {summary['total_frames']}")
95
+ print(f" Unique objects: {summary['total_unique_objects']}")
96
+ print(f" Per class: {summary['count_per_class']}")
97
+ print(f"\n Logs saved to: {log_paths}")
98
+ print("─────────────────────────────────────────────")
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
backend/tracker.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tracker.py β€” Core detection + tracking engine
3
+ Uses YOLOv8 (ultralytics) with ByteTrack built-in tracker.
4
+ Supports: cars, buses, trucks, motorbikes, bicycles, pedestrians
5
+ """
6
+
7
+ import cv2
8
+ import json
9
+ import time
10
+ import uuid
11
+ import numpy as np
12
+ from pathlib import Path
13
+ from datetime import datetime
14
+ from collections import defaultdict
15
+ from ultralytics import YOLO
16
+
17
+ # ─── Class configuration ────────────────────────────────────────────────────
18
+ TRAFFIC_CLASSES = {
19
+ 0: "person",
20
+ 1: "bicycle",
21
+ 2: "car",
22
+ 3: "motorbike",
23
+ 5: "bus",
24
+ 7: "truck",
25
+ }
26
+
27
+ CLASS_COLORS = {
28
+ "person": (0, 200, 255),
29
+ "bicycle": (50, 255, 50),
30
+ "car": (255, 165, 0),
31
+ "motorbike": (255, 50, 200),
32
+ "bus": (0, 100, 255),
33
+ "truck": (180, 0, 255),
34
+ }
35
+
36
+ DEFAULT_CLASSES = list(TRAFFIC_CLASSES.values())
37
+
38
+
39
+ # ─── Counting line helper ────────────────────────────────────────────────────
40
+ def crosses_line(prev_center, curr_center, line_y):
41
+ """Returns True + direction when a track crosses a horizontal counting line."""
42
+ if prev_center is None:
43
+ return False, None
44
+ py, cy = prev_center[1], curr_center[1]
45
+ if py < line_y <= cy:
46
+ return True, "down"
47
+ if py > line_y >= cy:
48
+ return True, "up"
49
+ return False, None
50
+
51
+
52
+ # ─── TrafficTracker ──────────────────────────────────────────────────────────
53
+ class TrafficTracker:
54
+ def __init__(
55
+ self,
56
+ model_path: str = "yolov8n.pt",
57
+ selected_classes: list[str] = None,
58
+ conf_threshold: float = 0.35,
59
+ scene_name: str = "scene_01",
60
+ output_dir: str = "logs",
61
+ counting_line_ratio: float = 0.55, # fraction of frame height
62
+ ):
63
+ self.model = YOLO(model_path)
64
+ self.selected_classes = selected_classes or DEFAULT_CLASSES
65
+ self.conf = conf_threshold
66
+ self.scene_name = scene_name
67
+ self.output_dir = Path(output_dir)
68
+ self.output_dir.mkdir(parents=True, exist_ok=True)
69
+ self.counting_line_ratio = counting_line_ratio
70
+
71
+ # State
72
+ self.session_id = str(uuid.uuid4())[:8]
73
+ self.frame_index = 0
74
+ self.fps = 30.0
75
+ self.frame_width = 0
76
+ self.frame_height = 0
77
+ self.counting_line_y = 0
78
+
79
+ # Tracking state
80
+ self.track_history: dict[int, list] = defaultdict(list) # id -> [centers]
81
+ self.counted_ids: set[int] = set()
82
+ self.count_per_class: dict[str, int] = defaultdict(int)
83
+ self.direction_counts: dict[str, dict[str, int]] = defaultdict(
84
+ lambda: {"up": 0, "down": 0}
85
+ )
86
+
87
+ # Per-frame detection log
88
+ self.detection_log: list[dict] = []
89
+ self.frame_stats: list[dict] = [] # aggregated per frame
90
+
91
+ # Class filter (COCO indices)
92
+ self._class_ids = [
93
+ cid for cid, name in TRAFFIC_CLASSES.items()
94
+ if name in self.selected_classes
95
+ ]
96
+
97
+ # ── Setup ────────────────────────────────────────────────────────────────
98
+ def setup_video(self, cap: cv2.VideoCapture):
99
+ self.fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
100
+ self.frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
101
+ self.frame_height= int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
102
+ self.counting_line_y = int(self.frame_height * self.counting_line_ratio)
103
+
104
+ # ── Process one frame ────────────────────────────────────────────────────
105
+ def process_frame(self, frame: np.ndarray) -> tuple[np.ndarray, dict]:
106
+ self.frame_index += 1
107
+ timestamp = self.frame_index / self.fps
108
+
109
+ results = self.model.track(
110
+ frame,
111
+ persist=True,
112
+ conf=self.conf,
113
+ classes=self._class_ids,
114
+ tracker="bytetrack.yaml",
115
+ verbose=False,
116
+ )
117
+
118
+ annotated = frame.copy()
119
+ frame_detections = []
120
+ per_class_in_frame = defaultdict(int)
121
+ any_object_visible = False
122
+
123
+ result = results[0]
124
+
125
+ if result.boxes is not None and len(result.boxes) > 0:
126
+ boxes = result.boxes
127
+ for box in boxes:
128
+ # Extract fields
129
+ cls_id = int(box.cls[0])
130
+ cls_name = TRAFFIC_CLASSES.get(cls_id, "unknown")
131
+ if cls_name not in self.selected_classes:
132
+ continue
133
+
134
+ conf_val = float(box.conf[0])
135
+ x1, y1, x2, y2 = map(int, box.xyxy[0])
136
+ track_id = int(box.id[0]) if box.id is not None else -1
137
+ cx = (x1 + x2) // 2
138
+ cy = (y1 + y2) // 2
139
+ center = (cx, cy)
140
+
141
+ any_object_visible = True
142
+ per_class_in_frame[cls_name] += 1
143
+
144
+ # Counting logic
145
+ prev_center = (
146
+ self.track_history[track_id][-1]
147
+ if self.track_history[track_id] else None
148
+ )
149
+ crossed, direction = crosses_line(prev_center, center, self.counting_line_y)
150
+ self.track_history[track_id].append(center)
151
+
152
+ if crossed and track_id not in self.counted_ids:
153
+ self.counted_ids.add(track_id)
154
+ self.count_per_class[cls_name] += 1
155
+ self.direction_counts[cls_name][direction] += 1
156
+
157
+ # Log entry
158
+ det = {
159
+ "frame": self.frame_index,
160
+ "timestamp": round(timestamp, 3),
161
+ "scene": self.scene_name,
162
+ "track_id": track_id,
163
+ "class": cls_name,
164
+ "confidence": round(conf_val, 3),
165
+ "bbox": [x1, y1, x2, y2],
166
+ "center": [cx, cy],
167
+ "crossed_line": crossed,
168
+ "direction": direction,
169
+ }
170
+ frame_detections.append(det)
171
+ self.detection_log.append(det)
172
+
173
+ # Draw bounding box
174
+ color = CLASS_COLORS.get(cls_name, (255, 255, 255))
175
+ cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 2)
176
+ label = f"{cls_name} #{track_id} {conf_val:.2f}"
177
+ (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.55, 1)
178
+ cv2.rectangle(annotated, (x1, y1 - th - 8), (x1 + tw + 4, y1), color, -1)
179
+ cv2.putText(annotated, label, (x1 + 2, y1 - 4),
180
+ cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 0), 1)
181
+
182
+ # Trail
183
+ trail = self.track_history[track_id][-20:]
184
+ for i in range(1, len(trail)):
185
+ alpha = i / len(trail)
186
+ tc = tuple(int(c * alpha) for c in color)
187
+ cv2.line(annotated, trail[i - 1], trail[i], tc, 2)
188
+
189
+ # Draw counting line
190
+ cv2.line(annotated, (0, self.counting_line_y),
191
+ (self.frame_width, self.counting_line_y), (0, 255, 255), 2)
192
+ cv2.putText(annotated, "COUNT LINE", (10, self.counting_line_y - 6),
193
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
194
+
195
+ # Overlay counters
196
+ self._draw_counters(annotated, any_object_visible)
197
+
198
+ # Frame stats
199
+ frame_stat = {
200
+ "frame": self.frame_index,
201
+ "timestamp": round(timestamp, 3),
202
+ "scene": self.scene_name,
203
+ "detections": len(frame_detections),
204
+ "per_class": dict(per_class_in_frame),
205
+ "any_visible": any_object_visible,
206
+ "cumulative": dict(self.count_per_class),
207
+ }
208
+ self.frame_stats.append(frame_stat)
209
+
210
+ return annotated, frame_stat
211
+
212
+ # ── Overlay ──────────────────────────────────────────────────────────────
213
+ def _draw_counters(self, frame: np.ndarray, any_visible: bool):
214
+ overlay = frame.copy()
215
+ cv2.rectangle(overlay, (0, 0), (260, 30 + 22 * len(self.selected_classes) + 30),
216
+ (20, 20, 20), -1)
217
+ cv2.addWeighted(overlay, 0.6, frame, 0.4, 0, frame)
218
+
219
+ cv2.putText(frame, "TRAFFIC COUNTER", (10, 20),
220
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2)
221
+
222
+ y = 42
223
+ for cls_name in self.selected_classes:
224
+ count = self.count_per_class.get(cls_name, 0)
225
+ color = CLASS_COLORS.get(cls_name, (255, 255, 255))
226
+ label = f" {cls_name:<12} {count:>4}"
227
+ cv2.putText(frame, label, (8, y),
228
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
229
+ y += 22
230
+
231
+ if not any_visible:
232
+ cv2.putText(frame, "NO OBJECTS DETECTED", (10, y + 10),
233
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (80, 80, 80), 1)
234
+
235
+ # ── Save logs ─────────────────────────────────────────────────────────────
236
+ def save_logs(self) -> dict:
237
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
238
+ prefix = f"{self.scene_name}_{ts}"
239
+
240
+ # Detection log (JSONL)
241
+ log_path = self.output_dir / f"{prefix}_detections.jsonl"
242
+ with open(log_path, "w") as f:
243
+ for entry in self.detection_log:
244
+ f.write(json.dumps(entry) + "\n")
245
+
246
+ # Summary JSON
247
+ summary = self.get_summary()
248
+ sum_path = self.output_dir / f"{prefix}_summary.json"
249
+ with open(sum_path, "w") as f:
250
+ json.dump(summary, f, indent=2)
251
+
252
+ # Frame stats CSV
253
+ import csv
254
+ csv_path = self.output_dir / f"{prefix}_frame_stats.csv"
255
+ if self.frame_stats:
256
+ with open(csv_path, "w", newline="") as f:
257
+ writer = csv.DictWriter(f, fieldnames=self.frame_stats[0].keys())
258
+ writer.writeheader()
259
+ writer.writerows(self.frame_stats)
260
+
261
+ return {
262
+ "detection_log": str(log_path),
263
+ "summary": str(sum_path),
264
+ "frame_stats": str(csv_path),
265
+ }
266
+
267
+ # ── Summary ───────────────────────────────────────────────────────────────
268
+ def get_summary(self) -> dict:
269
+ total_duration = self.frame_index / self.fps
270
+ total_objects = sum(self.count_per_class.values())
271
+
272
+ # Traffic intensity over time (10-second buckets)
273
+ buckets: dict[int, int] = defaultdict(int)
274
+ for stat in self.frame_stats:
275
+ bucket = int(stat["timestamp"] // 10)
276
+ buckets[bucket] += stat["detections"]
277
+ temporal = [{"bucket_10s": k, "detections": v} for k, v in sorted(buckets.items())]
278
+
279
+ return {
280
+ "scene": self.scene_name,
281
+ "session_id": self.session_id,
282
+ "processed_at": datetime.now().isoformat(),
283
+ "total_frames": self.frame_index,
284
+ "duration_sec": round(total_duration, 2),
285
+ "fps": round(self.fps, 2),
286
+ "resolution": [self.frame_width, self.frame_height],
287
+ "selected_classes":self.selected_classes,
288
+ "total_unique_objects": total_objects,
289
+ "count_per_class": dict(self.count_per_class),
290
+ "direction_counts":dict(self.direction_counts),
291
+ "temporal_distribution": temporal,
292
+ }
frontend/index.html ADDED
@@ -0,0 +1,916 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>TrafficSense β€” Road Traffic Monitor</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700;900&family=Barlow:wght@300;400;500&display=swap" rel="stylesheet"/>
8
+ <style>
9
+ :root {
10
+ --bg: #080c10;
11
+ --surface: #0d1218;
12
+ --panel: #111820;
13
+ --border: #1e2d3d;
14
+ --accent: #00e5ff;
15
+ --amber: #ffb300;
16
+ --green: #00ff88;
17
+ --red: #ff3860;
18
+ --text: #c8d8e8;
19
+ --dim: #4a6070;
20
+ --mono: 'Share Tech Mono', monospace;
21
+ --head: 'Barlow Condensed', sans-serif;
22
+ --body: 'Barlow', sans-serif;
23
+ }
24
+
25
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
26
+
27
+ body {
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ font-family: var(--body);
31
+ min-height: 100vh;
32
+ overflow-x: hidden;
33
+ }
34
+
35
+ /* ── SCANLINE overlay ── */
36
+ body::before {
37
+ content: '';
38
+ position: fixed; inset: 0;
39
+ background: repeating-linear-gradient(
40
+ 0deg,
41
+ transparent,
42
+ transparent 2px,
43
+ rgba(0,0,0,0.07) 2px,
44
+ rgba(0,0,0,0.07) 4px
45
+ );
46
+ pointer-events: none;
47
+ z-index: 9999;
48
+ }
49
+
50
+ /* ── HEADER ── */
51
+ header {
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: space-between;
55
+ padding: 0 28px;
56
+ height: 58px;
57
+ border-bottom: 1px solid var(--border);
58
+ background: var(--surface);
59
+ position: sticky; top: 0; z-index: 100;
60
+ }
61
+
62
+ .logo {
63
+ display: flex; align-items: center; gap: 12px;
64
+ }
65
+
66
+ .logo-icon {
67
+ width: 32px; height: 32px;
68
+ border: 2px solid var(--accent);
69
+ display: grid; place-items: center;
70
+ font-family: var(--mono);
71
+ font-size: 14px;
72
+ color: var(--accent);
73
+ position: relative;
74
+ }
75
+ .logo-icon::after {
76
+ content: '';
77
+ position: absolute; inset: 3px;
78
+ border: 1px solid var(--accent);
79
+ opacity: 0.4;
80
+ }
81
+
82
+ .logo-text { font-family: var(--head); font-weight: 700; font-size: 22px; letter-spacing: 2px; }
83
+ .logo-text span { color: var(--accent); }
84
+
85
+ .header-status {
86
+ display: flex; align-items: center; gap: 20px;
87
+ font-family: var(--mono); font-size: 12px; color: var(--dim);
88
+ }
89
+
90
+ .status-dot {
91
+ width: 8px; height: 8px; border-radius: 50%;
92
+ background: var(--dim);
93
+ display: inline-block; margin-right: 6px;
94
+ }
95
+ .status-dot.live { background: var(--green); box-shadow: 0 0 8px var(--green); animation: pulse 1.4s ease infinite; }
96
+ .status-dot.proc { background: var(--amber); box-shadow: 0 0 8px var(--amber); animation: pulse 0.8s ease infinite; }
97
+ .status-dot.err { background: var(--red); }
98
+
99
+ @keyframes pulse {
100
+ 0%, 100% { opacity: 1; } 50% { opacity: 0.35; }
101
+ }
102
+
103
+ nav { display: flex; gap: 4px; }
104
+ .nav-btn {
105
+ background: none; border: none; cursor: pointer;
106
+ font-family: var(--head); font-weight: 600; font-size: 13px;
107
+ letter-spacing: 1.5px; color: var(--dim); padding: 6px 14px;
108
+ border-bottom: 2px solid transparent; transition: all .2s;
109
+ }
110
+ .nav-btn.active, .nav-btn:hover { color: var(--accent); border-color: var(--accent); }
111
+
112
+ /* ── MAIN LAYOUT ── */
113
+ main { display: flex; gap: 0; height: calc(100vh - 58px); }
114
+
115
+ /* ── SIDEBAR ── */
116
+ aside {
117
+ width: 300px; min-width: 300px;
118
+ background: var(--surface);
119
+ border-right: 1px solid var(--border);
120
+ display: flex; flex-direction: column;
121
+ overflow-y: auto;
122
+ }
123
+
124
+ .sidebar-section {
125
+ padding: 20px;
126
+ border-bottom: 1px solid var(--border);
127
+ }
128
+ .sidebar-section:last-child { border-bottom: none; }
129
+
130
+ .section-label {
131
+ font-family: var(--head);
132
+ font-weight: 700; font-size: 11px;
133
+ letter-spacing: 2.5px;
134
+ color: var(--dim);
135
+ text-transform: uppercase;
136
+ margin-bottom: 14px;
137
+ }
138
+
139
+ /* Upload zone */
140
+ .upload-zone {
141
+ border: 2px dashed var(--border);
142
+ border-radius: 4px;
143
+ padding: 24px 16px;
144
+ text-align: center;
145
+ cursor: pointer;
146
+ transition: all .2s;
147
+ position: relative;
148
+ }
149
+ .upload-zone:hover, .upload-zone.drag-over {
150
+ border-color: var(--accent);
151
+ background: rgba(0,229,255,0.04);
152
+ }
153
+ .upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
154
+ .upload-icon { font-size: 28px; margin-bottom: 8px; }
155
+ .upload-text { font-size: 13px; color: var(--dim); }
156
+ .upload-text b { color: var(--text); }
157
+ .upload-name { font-family: var(--mono); font-size: 11px; color: var(--accent); margin-top: 8px; word-break: break-all; }
158
+
159
+ /* Form inputs */
160
+ .field { margin-bottom: 14px; }
161
+ .field label { display: block; font-size: 11px; color: var(--dim); letter-spacing: 1px; margin-bottom: 6px; font-family: var(--mono); }
162
+ .field input[type=text], .field input[type=number], .field select {
163
+ width: 100%; background: var(--panel); border: 1px solid var(--border);
164
+ color: var(--text); font-family: var(--mono); font-size: 13px;
165
+ padding: 8px 10px; outline: none; border-radius: 2px;
166
+ transition: border-color .2s;
167
+ }
168
+ .field input:focus, .field select:focus { border-color: var(--accent); }
169
+
170
+ /* Class checkboxes */
171
+ .class-grid {
172
+ display: grid; grid-template-columns: 1fr 1fr;
173
+ gap: 6px;
174
+ }
175
+ .cls-check {
176
+ display: flex; align-items: center; gap: 8px;
177
+ background: var(--panel); border: 1px solid var(--border);
178
+ padding: 7px 10px; cursor: pointer; transition: all .2s;
179
+ border-radius: 2px;
180
+ }
181
+ .cls-check input { display: none; }
182
+ .cls-check.active { border-color: var(--accent); background: rgba(0,229,255,0.07); }
183
+ .cls-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
184
+ .cls-name { font-size: 12px; font-family: var(--mono); }
185
+
186
+ /* Buttons */
187
+ .btn {
188
+ display: block; width: 100%;
189
+ font-family: var(--head); font-weight: 700; font-size: 14px;
190
+ letter-spacing: 2px; text-transform: uppercase;
191
+ padding: 12px; border: none; cursor: pointer;
192
+ transition: all .2s; border-radius: 2px;
193
+ }
194
+ .btn-primary {
195
+ background: var(--accent); color: var(--bg);
196
+ }
197
+ .btn-primary:hover { background: #33eeff; box-shadow: 0 0 20px rgba(0,229,255,0.35); }
198
+ .btn-primary:disabled { background: var(--dim); color: var(--bg); cursor: not-allowed; box-shadow: none; }
199
+ .btn-secondary {
200
+ background: transparent; color: var(--dim);
201
+ border: 1px solid var(--border); margin-top: 8px;
202
+ }
203
+ .btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
204
+
205
+ /* Progress */
206
+ .progress-bar { height: 3px; background: var(--border); margin-top: 12px; border-radius: 2px; overflow: hidden; }
207
+ .progress-fill { height: 100%; background: var(--accent); transition: width .3s; width: 0%; }
208
+
209
+ /* Counter cards */
210
+ .counter-grid { display: flex; flex-direction: column; gap: 6px; }
211
+ .counter-card {
212
+ display: flex; align-items: center; justify-content: space-between;
213
+ background: var(--panel); border: 1px solid var(--border);
214
+ padding: 8px 12px; border-radius: 2px;
215
+ }
216
+ .counter-card .cls-label { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--mono); }
217
+ .counter-card .cls-count { font-family: var(--mono); font-size: 16px; color: var(--accent); font-weight: bold; }
218
+
219
+ /* ── CONTENT AREA ── */
220
+ .content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
221
+
222
+ /* View tabs */
223
+ .view-tabs {
224
+ display: flex; gap: 0;
225
+ background: var(--surface); border-bottom: 1px solid var(--border);
226
+ padding: 0 20px;
227
+ }
228
+ .view-tab {
229
+ font-family: var(--head); font-weight: 600; font-size: 12px;
230
+ letter-spacing: 1.5px; color: var(--dim); padding: 10px 20px;
231
+ border: none; background: none; cursor: pointer;
232
+ border-bottom: 2px solid transparent;
233
+ text-transform: uppercase; transition: all .2s;
234
+ }
235
+ .view-tab.active { color: var(--accent); border-color: var(--accent); }
236
+
237
+ /* Video panel */
238
+ #view-video { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #030608; position: relative; overflow: hidden; }
239
+
240
+ .video-canvas-wrap { position: relative; max-width: 100%; max-height: 100%; }
241
+
242
+ #liveCanvas {
243
+ max-width: 100%; max-height: calc(100vh - 160px);
244
+ display: block;
245
+ border: 1px solid var(--border);
246
+ }
247
+
248
+ .no-signal {
249
+ display: flex; flex-direction: column; align-items: center; gap: 16px;
250
+ opacity: 0.35;
251
+ }
252
+ .no-signal-icon { font-size: 48px; }
253
+ .no-signal-text { font-family: var(--mono); font-size: 13px; letter-spacing: 2px; }
254
+
255
+ /* Dashboard panel */
256
+ #view-dashboard {
257
+ flex: 1; overflow-y: auto; padding: 24px;
258
+ display: none;
259
+ }
260
+
261
+ .dash-grid {
262
+ display: grid;
263
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
264
+ gap: 16px;
265
+ margin-bottom: 24px;
266
+ }
267
+
268
+ .stat-card {
269
+ background: var(--surface); border: 1px solid var(--border);
270
+ padding: 20px; border-radius: 2px;
271
+ }
272
+ .stat-card .sc-label { font-family: var(--mono); font-size: 11px; color: var(--dim); letter-spacing: 1.5px; margin-bottom: 10px; }
273
+ .stat-card .sc-value { font-family: var(--head); font-size: 36px; font-weight: 900; color: var(--accent); }
274
+ .stat-card .sc-unit { font-family: var(--mono); font-size: 12px; color: var(--dim); }
275
+
276
+ .chart-card {
277
+ background: var(--surface); border: 1px solid var(--border);
278
+ padding: 20px; border-radius: 2px;
279
+ margin-bottom: 16px;
280
+ }
281
+ .chart-card h3 { font-family: var(--head); font-size: 13px; letter-spacing: 2px; color: var(--dim); margin-bottom: 16px; text-transform: uppercase; }
282
+
283
+ /* Bar chart */
284
+ .bar-chart { display: flex; flex-direction: column; gap: 10px; }
285
+ .bar-row { display: flex; align-items: center; gap: 10px; }
286
+ .bar-label { font-family: var(--mono); font-size: 12px; color: var(--dim); width: 90px; flex-shrink: 0; }
287
+ .bar-track { flex: 1; height: 20px; background: var(--panel); border-radius: 2px; overflow: hidden; position: relative; }
288
+ .bar-fill { height: 100%; border-radius: 2px; transition: width 0.8s ease; }
289
+ .bar-count { font-family: var(--mono); font-size: 12px; color: var(--text); width: 36px; text-align: right; }
290
+
291
+ /* Timeline chart */
292
+ #timelineCanvas {
293
+ width: 100%; height: 120px; display: block;
294
+ border: 1px solid var(--border);
295
+ }
296
+
297
+ /* Scene table */
298
+ .scene-table { width: 100%; border-collapse: collapse; font-size: 13px; }
299
+ .scene-table th {
300
+ font-family: var(--mono); font-size: 11px; letter-spacing: 1px;
301
+ color: var(--dim); padding: 8px 12px; text-align: left;
302
+ border-bottom: 1px solid var(--border);
303
+ }
304
+ .scene-table td { padding: 10px 12px; border-bottom: 1px solid rgba(30,45,61,0.5); }
305
+ .scene-table tr:hover td { background: var(--panel); }
306
+ .badge {
307
+ display: inline-block; padding: 2px 8px;
308
+ font-family: var(--mono); font-size: 11px;
309
+ border-radius: 2px; background: rgba(0,229,255,0.1);
310
+ color: var(--accent); border: 1px solid rgba(0,229,255,0.25);
311
+ }
312
+
313
+ /* Logs view */
314
+ #view-logs { flex: 1; overflow-y: auto; padding: 24px; display: none; }
315
+ .log-entry {
316
+ background: var(--surface); border: 1px solid var(--border);
317
+ padding: 14px 16px; margin-bottom: 8px; border-radius: 2px;
318
+ display: flex; align-items: center; justify-content: space-between;
319
+ font-family: var(--mono); font-size: 12px;
320
+ }
321
+ .log-entry:hover { border-color: var(--accent); }
322
+ .log-filename { color: var(--text); }
323
+ .log-meta { color: var(--dim); font-size: 11px; }
324
+ .log-dl { color: var(--accent); text-decoration: none; font-size: 11px; }
325
+ .log-dl:hover { text-decoration: underline; }
326
+
327
+ /* Toast */
328
+ #toast {
329
+ position: fixed; bottom: 24px; right: 24px;
330
+ background: var(--panel); border: 1px solid var(--border);
331
+ color: var(--text); font-family: var(--mono); font-size: 12px;
332
+ padding: 12px 20px; border-radius: 2px;
333
+ transform: translateY(80px); opacity: 0;
334
+ transition: all .3s; z-index: 9000;
335
+ }
336
+ #toast.show { transform: translateY(0); opacity: 1; }
337
+ #toast.success { border-color: var(--green); color: var(--green); }
338
+ #toast.error { border-color: var(--red); color: var(--red); }
339
+
340
+ /* Scrollbar */
341
+ ::-webkit-scrollbar { width: 4px; height: 4px; }
342
+ ::-webkit-scrollbar-track { background: var(--bg); }
343
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
344
+
345
+ .hidden { display: none !important; }
346
+ .mt8 { margin-top: 8px; }
347
+ </style>
348
+ </head>
349
+ <body>
350
+
351
+ <!-- ══ HEADER ══════════════════════════════════════════════════════════════ -->
352
+ <header>
353
+ <div class="logo">
354
+ <div class="logo-icon">TS</div>
355
+ <div class="logo-text">TRAFFIC<span>SENSE</span></div>
356
+ </div>
357
+
358
+ <nav>
359
+ <button class="nav-btn active" onclick="showView('video')">LIVE</button>
360
+ <button class="nav-btn" onclick="showView('dashboard')">DASHBOARD</button>
361
+ <button class="nav-btn" onclick="showView('logs')">LOGS</button>
362
+ </nav>
363
+
364
+ <div class="header-status">
365
+ <span><span class="status-dot" id="connDot"></span><span id="connLabel">IDLE</span></span>
366
+ <span id="clockDisplay" style="font-size:11px">--:--:--</span>
367
+ </div>
368
+ </header>
369
+
370
+ <!-- ══ MAIN ════════════════════════════════════════════════════════════════ -->
371
+ <main>
372
+
373
+ <!-- ── SIDEBAR ── -->
374
+ <aside>
375
+ <!-- Upload -->
376
+ <div class="sidebar-section">
377
+ <div class="section-label">Video Source</div>
378
+ <label class="upload-zone" id="uploadZone">
379
+ <input type="file" id="videoFile" accept="video/*"/>
380
+ <div class="upload-icon">πŸ“Ή</div>
381
+ <div class="upload-text"><b>Drop video here</b><br>or click to browse</div>
382
+ <div class="upload-name" id="fileName"></div>
383
+ </label>
384
+ </div>
385
+
386
+ <!-- Config -->
387
+ <div class="sidebar-section">
388
+ <div class="section-label">Configuration</div>
389
+ <div class="field">
390
+ <label>SCENE NAME</label>
391
+ <input type="text" id="sceneName" value="scene_01" placeholder="e.g. intersection_A"/>
392
+ </div>
393
+ <div class="field">
394
+ <label>MODEL</label>
395
+ <select id="modelSelect">
396
+ <option value="yolov8n.pt">YOLOv8n β€” nano (fastest)</option>
397
+ <option value="yolov8s.pt" selected>YOLOv8s β€” small</option>
398
+ <option value="yolov8m.pt">YOLOv8m β€” medium</option>
399
+ <option value="yolov8l.pt">YOLOv8l β€” large</option>
400
+ <option value="yolov11n.pt">YOLOv11n β€” nano</option>
401
+ <option value="yolov11s.pt">YOLOv11s β€” small</option>
402
+ </select>
403
+ </div>
404
+ <div class="field">
405
+ <label>CONFIDENCE THRESHOLD</label>
406
+ <input type="number" id="confThresh" value="0.35" min="0.1" max="0.95" step="0.05"/>
407
+ </div>
408
+ </div>
409
+
410
+ <!-- Classes -->
411
+ <div class="sidebar-section">
412
+ <div class="section-label">Track Classes</div>
413
+ <div class="class-grid" id="classGrid"></div>
414
+ </div>
415
+
416
+ <!-- Controls -->
417
+ <div class="sidebar-section">
418
+ <div class="section-label">Actions</div>
419
+ <button class="btn btn-primary" id="startBtn" onclick="startProcessing()">β–Ά START ANALYSIS</button>
420
+ <button class="btn btn-secondary hidden" id="stopBtn" onclick="stopProcessing()">β–  STOP</button>
421
+ <div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
422
+ <div style="font-family:var(--mono);font-size:11px;color:var(--dim);margin-top:6px;text-align:center" id="progressLabel"></div>
423
+ </div>
424
+
425
+ <!-- Live counters -->
426
+ <div class="sidebar-section">
427
+ <div class="section-label">Live Counts (Unique)</div>
428
+ <div class="counter-grid" id="counterGrid">
429
+ <div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:12px">
430
+ No data yet
431
+ </div>
432
+ </div>
433
+ </div>
434
+
435
+ <!-- Frame info -->
436
+ <div class="sidebar-section" id="frameInfoPanel" style="display:none">
437
+ <div class="section-label">Frame Info</div>
438
+ <div id="frameInfo" style="font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.8"></div>
439
+ </div>
440
+ </aside>
441
+
442
+ <!-- ── CONTENT ── -->
443
+ <div class="content">
444
+ <div class="view-tabs">
445
+ <button class="view-tab active" onclick="switchTab('video', this)">VIDEO FEED</button>
446
+ <button class="view-tab" onclick="switchTab('dashboard', this)">ANALYTICS</button>
447
+ <button class="view-tab" onclick="switchTab('logs', this)">LOG FILES</button>
448
+ </div>
449
+
450
+ <!-- VIDEO VIEW -->
451
+ <div id="view-video" style="flex:1;display:flex;align-items:center;justify-content:center;background:#030608;overflow:hidden">
452
+ <div id="noSignal" class="no-signal">
453
+ <div class="no-signal-icon">πŸ“‘</div>
454
+ <div class="no-signal-text">AWAITING VIDEO FEED</div>
455
+ <div style="font-family:var(--mono);font-size:11px;color:var(--dim)">Upload a video and click START</div>
456
+ </div>
457
+ <canvas id="liveCanvas" class="hidden"></canvas>
458
+ </div>
459
+
460
+ <!-- DASHBOARD VIEW -->
461
+ <div id="view-dashboard" style="flex:1;overflow-y:auto;padding:24px;display:none">
462
+ <div class="dash-grid" id="dashStats"></div>
463
+
464
+ <div class="chart-card">
465
+ <h3>Objects by Class β€” All Scenes</h3>
466
+ <div class="bar-chart" id="barChart"></div>
467
+ </div>
468
+
469
+ <div class="chart-card">
470
+ <h3>Traffic Intensity Timeline (10s buckets)</h3>
471
+ <canvas id="timelineCanvas"></canvas>
472
+ </div>
473
+
474
+ <div class="chart-card">
475
+ <h3>Scene Comparison</h3>
476
+ <table class="scene-table">
477
+ <thead>
478
+ <tr>
479
+ <th>SCENE</th><th>DURATION</th><th>TOTAL OBJECTS</th>
480
+ <th>CARS</th><th>PEDESTRIANS</th><th>TRUCKS/BUS</th>
481
+ </tr>
482
+ </thead>
483
+ <tbody id="sceneTable"></tbody>
484
+ </table>
485
+ </div>
486
+ </div>
487
+
488
+ <!-- LOGS VIEW -->
489
+ <div id="view-logs" style="flex:1;overflow-y:auto;padding:24px;display:none">
490
+ <div id="logList"><div style="font-family:var(--mono);font-size:13px;color:var(--dim)">No logs yet.</div></div>
491
+ </div>
492
+ </div>
493
+ </main>
494
+
495
+ <div id="toast"></div>
496
+
497
+ <script>
498
+ // ══ CONFIG ════════════════════════════════════════════════════════════════
499
+ const API_BASE = 'http://localhost:8000';
500
+
501
+ const CLASS_COLORS = {
502
+ person: '#00e5ff',
503
+ bicycle: '#00ff88',
504
+ car: '#ffb300',
505
+ motorbike: '#ff6eb4',
506
+ bus: '#4fc3f7',
507
+ truck: '#ce93d8',
508
+ };
509
+
510
+ const ALL_CLASSES = ['person','bicycle','car','motorbike','bus','truck'];
511
+
512
+ // ══ STATE ═════════════════════════════════════════════════════════════════
513
+ let currentSid = null;
514
+ let sseSource = null;
515
+ let activeTab = 'video';
516
+ let selectedFile = null;
517
+ let cumulativeCounts = {};
518
+ let pollInterval = null;
519
+
520
+ // ══ INIT ══════════════════════════════════════════════════════════════════
521
+ window.onload = () => {
522
+ buildClassGrid();
523
+ setInterval(updateClock, 1000);
524
+ updateClock();
525
+ checkHealth();
526
+
527
+ document.getElementById('videoFile').addEventListener('change', e => {
528
+ selectedFile = e.target.files[0];
529
+ document.getElementById('fileName').textContent = selectedFile ? selectedFile.name : '';
530
+ });
531
+
532
+ // Drag-and-drop on upload zone
533
+ const zone = document.getElementById('uploadZone');
534
+ zone.addEventListener('dragover', ev => { ev.preventDefault(); zone.classList.add('drag-over'); });
535
+ zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
536
+ zone.addEventListener('drop', ev => {
537
+ ev.preventDefault(); zone.classList.remove('drag-over');
538
+ if (ev.dataTransfer.files[0]) {
539
+ selectedFile = ev.dataTransfer.files[0];
540
+ document.getElementById('fileName').textContent = selectedFile.name;
541
+ }
542
+ });
543
+ };
544
+
545
+ function buildClassGrid() {
546
+ const grid = document.getElementById('classGrid');
547
+ grid.innerHTML = '';
548
+ ALL_CLASSES.forEach(cls => {
549
+ const label = document.createElement('label');
550
+ label.className = 'cls-check active';
551
+ label.innerHTML = `
552
+ <input type="checkbox" value="${cls}" checked/>
553
+ <span class="cls-dot" style="background:${CLASS_COLORS[cls]}"></span>
554
+ <span class="cls-name">${cls}</span>
555
+ `;
556
+ label.querySelector('input').addEventListener('change', function() {
557
+ label.classList.toggle('active', this.checked);
558
+ });
559
+ grid.appendChild(label);
560
+ });
561
+ }
562
+
563
+ function updateClock() {
564
+ const now = new Date();
565
+ document.getElementById('clockDisplay').textContent =
566
+ now.toLocaleTimeString('en-GB', {hour12: false});
567
+ }
568
+
569
+ async function checkHealth() {
570
+ try {
571
+ const r = await fetch(`${API_BASE}/health`);
572
+ if (r.ok) setConnected(true);
573
+ else setConnected(false);
574
+ } catch { setConnected(false); }
575
+ }
576
+
577
+ function setConnected(ok) {
578
+ const dot = document.getElementById('connDot');
579
+ const label = document.getElementById('connLabel');
580
+ dot.className = 'status-dot ' + (ok ? 'live' : '');
581
+ label.textContent = ok ? 'CONNECTED' : 'SERVER OFFLINE';
582
+ }
583
+
584
+ // ══ NAV / TAB ════════════════════════════════════════════════════════════
585
+ function showView(v) { switchTab(v, null); }
586
+
587
+ function switchTab(tab, btn) {
588
+ activeTab = tab;
589
+ document.querySelectorAll('.view-tab').forEach(b => b.classList.remove('active'));
590
+ if (btn) btn.classList.add('active');
591
+
592
+ document.getElementById('view-video').style.display = (tab==='video') ? 'flex' : 'none';
593
+ document.getElementById('view-dashboard').style.display = (tab==='dashboard') ? 'block' : 'none';
594
+ document.getElementById('view-logs').style.display = (tab==='logs') ? 'block' : 'none';
595
+
596
+ if (tab === 'dashboard') loadDashboard();
597
+ if (tab === 'logs') loadLogs();
598
+ }
599
+
600
+ // ══ PROCESSING ════════════════════════════════════════════════════════════
601
+ async function startProcessing() {
602
+ if (!selectedFile) { toast('Please upload a video file first.', 'error'); return; }
603
+
604
+ const selected = [...document.querySelectorAll('#classGrid input:checked')].map(i => i.value);
605
+ if (!selected.length) { toast('Select at least one class.', 'error'); return; }
606
+
607
+ const formData = new FormData();
608
+ formData.append('file', selectedFile);
609
+ formData.append('scene_name', document.getElementById('sceneName').value || 'scene_01');
610
+ formData.append('classes', selected.join(','));
611
+ formData.append('conf', document.getElementById('confThresh').value);
612
+ formData.append('model', document.getElementById('modelSelect').value);
613
+ formData.append('save_output', 'false');
614
+
615
+ document.getElementById('startBtn').disabled = true;
616
+ document.getElementById('stopBtn').classList.remove('hidden');
617
+ setStatus('proc', 'PROCESSING');
618
+ cumulativeCounts = {};
619
+
620
+ try {
621
+ const r = await fetch(`${API_BASE}/upload`, { method:'POST', body: formData });
622
+ if (!r.ok) throw new Error(await r.text());
623
+ const data = await r.json();
624
+ currentSid = data.session_id;
625
+ toast(`Session started: ${currentSid}`, 'success');
626
+ startSSE(currentSid);
627
+ startPoll(currentSid);
628
+ } catch(e) {
629
+ toast('Upload failed: ' + e.message, 'error');
630
+ resetUI();
631
+ }
632
+ }
633
+
634
+ function stopProcessing() {
635
+ if (sseSource) { sseSource.close(); sseSource = null; }
636
+ if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
637
+ resetUI();
638
+ toast('Processing stopped.', '');
639
+ }
640
+
641
+ function resetUI() {
642
+ document.getElementById('startBtn').disabled = false;
643
+ document.getElementById('stopBtn').classList.add('hidden');
644
+ document.getElementById('progressFill').style.width = '0%';
645
+ document.getElementById('progressLabel').textContent = '';
646
+ setStatus('', 'IDLE');
647
+ }
648
+
649
+ function setStatus(cls, label) {
650
+ document.getElementById('connDot').className = 'status-dot ' + cls;
651
+ document.getElementById('connLabel').textContent = label;
652
+ }
653
+
654
+ // ══ SSE STREAM ════════════════════════════════════════════════════════════
655
+ const canvas = document.getElementById('liveCanvas');
656
+ const ctx = canvas.getContext('2d');
657
+ let imgObj = new Image();
658
+
659
+ function startSSE(sid) {
660
+ if (sseSource) sseSource.close();
661
+ document.getElementById('noSignal').classList.add('hidden');
662
+ canvas.classList.remove('hidden');
663
+
664
+ sseSource = new EventSource(`${API_BASE}/stream/${sid}`);
665
+ sseSource.onmessage = ev => {
666
+ const msg = JSON.parse(ev.data);
667
+
668
+ if (msg.event === 'done') {
669
+ sseSource.close();
670
+ setStatus('live', 'DONE');
671
+ document.getElementById('startBtn').disabled = false;
672
+ document.getElementById('stopBtn').classList.add('hidden');
673
+ document.getElementById('progressFill').style.width = '100%';
674
+ toast('Processing complete!', 'success');
675
+ fetchAndShowSummary(sid);
676
+ return;
677
+ }
678
+
679
+ if (msg.frame) {
680
+ imgObj.onload = () => {
681
+ canvas.width = imgObj.naturalWidth || imgObj.width;
682
+ canvas.height = imgObj.naturalHeight || imgObj.height;
683
+ ctx.drawImage(imgObj, 0, 0);
684
+ };
685
+ imgObj.src = 'data:image/jpeg;base64,' + msg.frame;
686
+ }
687
+
688
+ if (msg.stats) {
689
+ const stats = typeof msg.stats === 'string' ? JSON.parse(msg.stats) : msg.stats;
690
+ updateSidebarStats(stats);
691
+ }
692
+ };
693
+
694
+ sseSource.onerror = () => {
695
+ if (currentSid) checkStatus(currentSid);
696
+ };
697
+ }
698
+
699
+ function startPoll(sid) {
700
+ if (pollInterval) clearInterval(pollInterval);
701
+ pollInterval = setInterval(() => checkStatus(sid), 2000);
702
+ }
703
+
704
+ async function checkStatus(sid) {
705
+ try {
706
+ const r = await fetch(`${API_BASE}/status/${sid}`);
707
+ const d = await r.json();
708
+ if (d.total_frames > 0) {
709
+ const pct = Math.min(100, Math.round(d.progress / d.total_frames * 100));
710
+ document.getElementById('progressFill').style.width = pct + '%';
711
+ document.getElementById('progressLabel').textContent = `${d.progress} / ${d.total_frames} frames (${pct}%)`;
712
+ }
713
+ if (d.status === 'done' || d.status === 'error') {
714
+ clearInterval(pollInterval);
715
+ }
716
+ } catch {}
717
+ }
718
+
719
+ function updateSidebarStats(stats) {
720
+ if (!stats || !stats.cumulative) return;
721
+ cumulativeCounts = stats.cumulative;
722
+
723
+ // Update counter cards
724
+ const grid = document.getElementById('counterGrid');
725
+ grid.innerHTML = '';
726
+ for (const [cls, cnt] of Object.entries(cumulativeCounts)) {
727
+ if (cnt === 0) continue;
728
+ const card = document.createElement('div');
729
+ card.className = 'counter-card';
730
+ card.innerHTML = `
731
+ <div class="cls-label">
732
+ <span class="cls-dot" style="background:${CLASS_COLORS[cls]||'#fff'}"></span>
733
+ ${cls}
734
+ </div>
735
+ <div class="cls-count">${cnt}</div>
736
+ `;
737
+ grid.appendChild(card);
738
+ }
739
+ if (!grid.children.length) grid.innerHTML = '<div style="font-family:var(--mono);font-size:11px;color:var(--dim);text-align:center;padding:12px">No crossings yet</div>';
740
+
741
+ // Frame info
742
+ document.getElementById('frameInfoPanel').style.display = 'block';
743
+ document.getElementById('frameInfo').innerHTML = `
744
+ FRAME: ${stats.frame || '--'}<br>
745
+ TIME: ${stats.timestamp || '--'}s<br>
746
+ SCENE: ${stats.scene || '--'}<br>
747
+ DETECTIONS: ${stats.detections || 0}<br>
748
+ VISIBLE: ${stats.any_visible ? '<span style="color:var(--green)">YES</span>' : '<span style="color:var(--dim)">NO</span>'}
749
+ `;
750
+ }
751
+
752
+ async function fetchAndShowSummary(sid) {
753
+ try {
754
+ const r = await fetch(`${API_BASE}/summary/${sid}`);
755
+ if (!r.ok) return;
756
+ const summary = await r.json();
757
+ updateSidebarStats({ cumulative: summary.count_per_class, frame: summary.total_frames });
758
+ toast(`Total: ${summary.total_unique_objects} unique objects counted.`, 'success');
759
+ } catch {}
760
+ }
761
+
762
+ // ══ DASHBOARD ════════════════════════════════════════════════════════════
763
+ async function loadDashboard() {
764
+ try {
765
+ const r = await fetch(`${API_BASE}/dashboard`);
766
+ const d = await r.json();
767
+ renderDashboard(d);
768
+ } catch(e) {
769
+ document.getElementById('dashStats').innerHTML =
770
+ '<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">Server not reachable.</div>';
771
+ }
772
+ }
773
+
774
+ function renderDashboard(d) {
775
+ const { global_counts={}, total_scenes=0, total_objects=0, scenes=[] } = d;
776
+
777
+ // Stat cards
778
+ const statsEl = document.getElementById('dashStats');
779
+ const allDuration = scenes.reduce((s,sc) => s + (sc.duration_sec||0), 0);
780
+ statsEl.innerHTML = `
781
+ ${statCard('SCENES', total_scenes, 'videos')}
782
+ ${statCard('TOTAL OBJECTS', total_objects, 'unique crossings')}
783
+ ${statCard('TOTAL DURATION', Math.round(allDuration), 'seconds')}
784
+ ${statCard('AVG/SCENE', total_scenes ? Math.round(total_objects/total_scenes) : 0, 'objects')}
785
+ `;
786
+
787
+ // Bar chart
788
+ const barEl = document.getElementById('barChart');
789
+ const max = Math.max(...Object.values(global_counts), 1);
790
+ barEl.innerHTML = Object.entries(global_counts).map(([cls, cnt]) => `
791
+ <div class="bar-row">
792
+ <div class="bar-label">${cls}</div>
793
+ <div class="bar-track">
794
+ <div class="bar-fill" style="width:${(cnt/max*100).toFixed(1)}%;background:${CLASS_COLORS[cls]||'#888'}"></div>
795
+ </div>
796
+ <div class="bar-count">${cnt}</div>
797
+ </div>
798
+ `).join('') || '<div style="color:var(--dim);font-family:var(--mono);font-size:12px">No data</div>';
799
+
800
+ // Timeline
801
+ renderTimeline(scenes);
802
+
803
+ // Scene table
804
+ const tbody = document.getElementById('sceneTable');
805
+ tbody.innerHTML = scenes.map(sc => `
806
+ <tr>
807
+ <td><span class="badge">${sc.scene}</span></td>
808
+ <td>${sc.duration_sec}s</td>
809
+ <td style="color:var(--accent);font-family:var(--mono)">${sc.total_unique_objects}</td>
810
+ <td>${sc.count_per_class?.car||0}</td>
811
+ <td>${sc.count_per_class?.person||0}</td>
812
+ <td>${(sc.count_per_class?.truck||0)+(sc.count_per_class?.bus||0)}</td>
813
+ </tr>
814
+ `).join('') || '<tr><td colspan="6" style="color:var(--dim);font-family:var(--mono);padding:20px">No scenes processed yet.</td></tr>';
815
+ }
816
+
817
+ function statCard(label, value, unit) {
818
+ return `<div class="stat-card">
819
+ <div class="sc-label">${label}</div>
820
+ <div class="sc-value">${value}</div>
821
+ <div class="sc-unit">${unit}</div>
822
+ </div>`;
823
+ }
824
+
825
+ function renderTimeline(scenes) {
826
+ const c = document.getElementById('timelineCanvas');
827
+ const ctx2 = c.getContext('2d');
828
+ c.width = c.offsetWidth || 800;
829
+ c.height = 120;
830
+
831
+ // Merge all temporal distributions
832
+ const merged = {};
833
+ for (const sc of scenes) {
834
+ for (const t of (sc.temporal_distribution||[])) {
835
+ merged[t.bucket_10s] = (merged[t.bucket_10s]||0) + t.detections;
836
+ }
837
+ }
838
+
839
+ const keys = Object.keys(merged).map(Number).sort((a,b)=>a-b);
840
+ if (!keys.length) { ctx2.fillStyle='#1e2d3d'; ctx2.fillRect(0,0,c.width,c.height); return; }
841
+
842
+ const vals = keys.map(k=>merged[k]);
843
+ const maxV = Math.max(...vals, 1);
844
+
845
+ ctx2.clearRect(0,0,c.width,c.height);
846
+ ctx2.fillStyle='#0d1218'; ctx2.fillRect(0,0,c.width,c.height);
847
+
848
+ // Grid lines
849
+ ctx2.strokeStyle = '#1e2d3d'; ctx2.lineWidth = 1;
850
+ [0.25,0.5,0.75,1].forEach(f => {
851
+ const y = c.height - f*c.height*0.9 - 10;
852
+ ctx2.beginPath(); ctx2.moveTo(0,y); ctx2.lineTo(c.width,y); ctx2.stroke();
853
+ });
854
+
855
+ // Area fill
856
+ const pad = 10;
857
+ const w = (c.width - pad*2) / Math.max(keys.length-1,1);
858
+ const pts = keys.map((k,i) => [pad + i*w, c.height - pad - (merged[k]/maxV)*(c.height-pad*2)]);
859
+
860
+ ctx2.beginPath();
861
+ ctx2.moveTo(pts[0][0], c.height - pad);
862
+ pts.forEach(([x,y]) => ctx2.lineTo(x,y));
863
+ ctx2.lineTo(pts[pts.length-1][0], c.height - pad);
864
+ ctx2.closePath();
865
+ const grad = ctx2.createLinearGradient(0,0,0,c.height);
866
+ grad.addColorStop(0, 'rgba(0,229,255,0.4)');
867
+ grad.addColorStop(1, 'rgba(0,229,255,0)');
868
+ ctx2.fillStyle = grad; ctx2.fill();
869
+
870
+ ctx2.beginPath(); ctx2.strokeStyle='#00e5ff'; ctx2.lineWidth=2;
871
+ pts.forEach(([x,y],i) => i===0 ? ctx2.moveTo(x,y) : ctx2.lineTo(x,y));
872
+ ctx2.stroke();
873
+
874
+ // X axis labels
875
+ ctx2.fillStyle='#4a6070'; ctx2.font='10px Share Tech Mono'; ctx2.textAlign='center';
876
+ keys.forEach((k,i) => {
877
+ if (i % Math.ceil(keys.length/8) === 0)
878
+ ctx2.fillText(`${k*10}s`, pad+i*w, c.height-1);
879
+ });
880
+ }
881
+
882
+ // ══ LOGS ══════════════════════════════════════════════════════════════════
883
+ async function loadLogs() {
884
+ try {
885
+ const r = await fetch(`${API_BASE}/logs`);
886
+ const d = await r.json();
887
+ const el = document.getElementById('logList');
888
+ if (!d.logs.length) {
889
+ el.innerHTML = '<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">No log files yet.</div>';
890
+ return;
891
+ }
892
+ el.innerHTML = d.logs.map(f => `
893
+ <div class="log-entry">
894
+ <div>
895
+ <div class="log-filename">${f.name}</div>
896
+ <div class="log-meta">${f.size_kb} KB Β· ${new Date(f.modified*1000).toLocaleString()}</div>
897
+ </div>
898
+ <a class="log-dl" href="${API_BASE}/log/${f.name}" download>⬇ Download</a>
899
+ </div>
900
+ `).join('');
901
+ } catch {
902
+ document.getElementById('logList').innerHTML = '<div style="font-family:var(--mono);font-size:12px;color:var(--dim)">Server not reachable.</div>';
903
+ }
904
+ }
905
+
906
+ // ══ TOAST ════════════════════════════════════════════════════════════════
907
+ function toast(msg, type='') {
908
+ const el = document.getElementById('toast');
909
+ el.textContent = msg;
910
+ el.className = 'show ' + type;
911
+ clearTimeout(el._t);
912
+ el._t = setTimeout(() => el.className='', 3500);
913
+ }
914
+ </script>
915
+ </body>
916
+ </html>