Spaces:
Sleeping
Sleeping
heavy add-ons: 13+ features implemented
Browse files- backend/engine.py +14 -1
- backend/export_json.py +85 -0
- backend/pcu.py +67 -0
- backend/server.py +32 -0
- backend/speed.py +62 -0
- backend/tracker_config.py +35 -0
- backend/visualize.py +7 -0
- frontend/initial.html +73 -0
- frontend/vehicles.html +320 -4
backend/engine.py
CHANGED
|
@@ -7,6 +7,9 @@ import numpy as np
|
|
| 7 |
import cv2
|
| 8 |
from collections import defaultdict
|
| 9 |
from constants import MODEL_CLASSES
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def _side(p, a, b):
|
|
@@ -139,6 +142,7 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
|
|
| 139 |
flow_times = []
|
| 140 |
conf_scores = []
|
| 141 |
heatmap_points = []
|
|
|
|
| 142 |
raw_events = [["frame_index", "timestamp_sec", "vehicle_id", "class_name", "direction"]]
|
| 143 |
|
| 144 |
start = time.time()
|
|
@@ -148,7 +152,7 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
|
|
| 148 |
# ExecuTorch: https://docs.ultralytics.com/integrations/executorch/#what-are-the-system-requirements-for-executorch-export
|
| 149 |
results = model.track(
|
| 150 |
source=video_path,
|
| 151 |
-
tracker=
|
| 152 |
imgsz=736, # MUST match OpenVINO export imgsz — compiled graph is fixed shape
|
| 153 |
conf=config.get("conf", 0.12),
|
| 154 |
iou=config.get("iou", 0.6),
|
|
@@ -194,6 +198,7 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
|
|
| 194 |
cy = int((box[1] + box[3]) / 2)
|
| 195 |
|
| 196 |
heatmap_points.append([cx, cy])
|
|
|
|
| 197 |
|
| 198 |
current = _side((cx, cy), a, b)
|
| 199 |
|
|
@@ -253,6 +258,12 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
|
|
| 253 |
actual_fps = round(total / processing_time, 2) if processing_time > 0 else 0
|
| 254 |
speed_vs_rt = round(actual_fps / fps, 2) if fps > 0 else 0
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
result = {
|
| 257 |
"class_in": dict(class_in),
|
| 258 |
"class_out": dict(class_out),
|
|
@@ -264,6 +275,8 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
|
|
| 264 |
"processing_time": processing_time,
|
| 265 |
"actual_fps": actual_fps,
|
| 266 |
"speed_vs_realtime": speed_vs_rt,
|
|
|
|
|
|
|
| 267 |
}
|
| 268 |
|
| 269 |
if annotated_path and os.path.exists(annotated_path):
|
|
|
|
| 7 |
import cv2
|
| 8 |
from collections import defaultdict
|
| 9 |
from constants import MODEL_CLASSES
|
| 10 |
+
from tracker_config import get_tracker_path
|
| 11 |
+
from speed import estimate_speeds
|
| 12 |
+
from pcu import compute_pcu
|
| 13 |
|
| 14 |
|
| 15 |
def _side(p, a, b):
|
|
|
|
| 142 |
flow_times = []
|
| 143 |
conf_scores = []
|
| 144 |
heatmap_points = []
|
| 145 |
+
track_positions = defaultdict(list)
|
| 146 |
raw_events = [["frame_index", "timestamp_sec", "vehicle_id", "class_name", "direction"]]
|
| 147 |
|
| 148 |
start = time.time()
|
|
|
|
| 152 |
# ExecuTorch: https://docs.ultralytics.com/integrations/executorch/#what-are-the-system-requirements-for-executorch-export
|
| 153 |
results = model.track(
|
| 154 |
source=video_path,
|
| 155 |
+
tracker=get_tracker_path(),
|
| 156 |
imgsz=736, # MUST match OpenVINO export imgsz — compiled graph is fixed shape
|
| 157 |
conf=config.get("conf", 0.12),
|
| 158 |
iou=config.get("iou", 0.6),
|
|
|
|
| 198 |
cy = int((box[1] + box[3]) / 2)
|
| 199 |
|
| 200 |
heatmap_points.append([cx, cy])
|
| 201 |
+
track_positions[obj_id].append((frame_idx, cx, cy))
|
| 202 |
|
| 203 |
current = _side((cx, cy), a, b)
|
| 204 |
|
|
|
|
| 258 |
actual_fps = round(total / processing_time, 2) if processing_time > 0 else 0
|
| 259 |
speed_vs_rt = round(actual_fps / fps, 2) if fps > 0 else 0
|
| 260 |
|
| 261 |
+
speed_data = estimate_speeds(dict(track_positions))
|
| 262 |
+
pcu_data = compute_pcu(
|
| 263 |
+
{str(k): v for k, v in class_in.items()},
|
| 264 |
+
{str(k): v for k, v in class_out.items()},
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
result = {
|
| 268 |
"class_in": dict(class_in),
|
| 269 |
"class_out": dict(class_out),
|
|
|
|
| 275 |
"processing_time": processing_time,
|
| 276 |
"actual_fps": actual_fps,
|
| 277 |
"speed_vs_realtime": speed_vs_rt,
|
| 278 |
+
"speed": speed_data,
|
| 279 |
+
"pcu": pcu_data,
|
| 280 |
}
|
| 281 |
|
| 282 |
if annotated_path and os.path.exists(annotated_path):
|
backend/export_json.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Structured JSON artifact export with complete run metadata.
|
| 3 |
+
|
| 4 |
+
Produces a self-contained JSON document suitable for programmatic
|
| 5 |
+
consumption, API integration, and archival.
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def export_json(data, video_meta, engine_config, out_dir):
|
| 13 |
+
"""Generate a comprehensive JSON export of the analysis run.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
data: Engine result dict (counts, congestion, events, etc.)
|
| 17 |
+
video_meta: dict with filename, fps, frames, duration, resolution, pixels
|
| 18 |
+
engine_config: dict with imgsz, conf, iou, stride, etc.
|
| 19 |
+
out_dir: Output directory path
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
Filename string or None
|
| 23 |
+
"""
|
| 24 |
+
class_in = data.get("class_in", {})
|
| 25 |
+
class_out = data.get("class_out", {})
|
| 26 |
+
congestion = data.get("congestion", [])
|
| 27 |
+
raw_events = data.get("raw_events", [])
|
| 28 |
+
pcu_data = data.get("pcu", {})
|
| 29 |
+
speed_data = data.get("speed", {})
|
| 30 |
+
|
| 31 |
+
# Build events list from raw_events (skip header row)
|
| 32 |
+
events = []
|
| 33 |
+
for row in raw_events[1:]:
|
| 34 |
+
events.append({
|
| 35 |
+
"frame": row[0],
|
| 36 |
+
"timestamp_sec": row[1],
|
| 37 |
+
"vehicle_id": row[2],
|
| 38 |
+
"class_name": row[3],
|
| 39 |
+
"direction": row[4],
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
total_in = sum(class_in.values())
|
| 43 |
+
total_out = sum(class_out.values())
|
| 44 |
+
|
| 45 |
+
doc = {
|
| 46 |
+
"urbanflow_version": "1.1",
|
| 47 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 48 |
+
"video": video_meta,
|
| 49 |
+
"engine": {
|
| 50 |
+
"model": "VehicleNet-Y26s (OpenVINO INT8)",
|
| 51 |
+
"imgsz": engine_config.get("imgsz", 736),
|
| 52 |
+
"conf": engine_config.get("conf", 0.12),
|
| 53 |
+
"iou": engine_config.get("iou", 0.6),
|
| 54 |
+
"stride": engine_config.get("detect_stride", 2),
|
| 55 |
+
"tracker": "ByteTrack (custom)",
|
| 56 |
+
"batch": 2,
|
| 57 |
+
},
|
| 58 |
+
"performance": {
|
| 59 |
+
"processing_time_sec": data.get("processing_time", 0),
|
| 60 |
+
"actual_fps": data.get("actual_fps", 0),
|
| 61 |
+
"speed_vs_realtime": data.get("speed_vs_realtime", 0),
|
| 62 |
+
},
|
| 63 |
+
"counts": {
|
| 64 |
+
"total_in": total_in,
|
| 65 |
+
"total_out": total_out,
|
| 66 |
+
"total": total_in + total_out,
|
| 67 |
+
"per_class_in": {str(k): v for k, v in class_in.items()},
|
| 68 |
+
"per_class_out": {str(k): v for k, v in class_out.items()},
|
| 69 |
+
},
|
| 70 |
+
"pcu": pcu_data,
|
| 71 |
+
"speed_distribution": speed_data.get("distribution", {}),
|
| 72 |
+
"congestion": {
|
| 73 |
+
"peak": max(congestion) if congestion else 0,
|
| 74 |
+
"average": round(sum(congestion) / len(congestion), 1) if congestion else 0,
|
| 75 |
+
"timeline": congestion,
|
| 76 |
+
},
|
| 77 |
+
"flow_times": data.get("flow_times", []),
|
| 78 |
+
"events": events,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
path = os.path.join(out_dir, "analysis.json")
|
| 82 |
+
with open(path, "w") as f:
|
| 83 |
+
json.dump(doc, f, indent=2, default=str)
|
| 84 |
+
|
| 85 |
+
return "analysis.json"
|
backend/pcu.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Passenger Car Unit (PCU) conversion using IRC:106-1990 guidelines.
|
| 3 |
+
|
| 4 |
+
PCU normalizes heterogeneous Indian traffic into a single unit of measurement
|
| 5 |
+
used by transport authorities for capacity analysis and road design.
|
| 6 |
+
"""
|
| 7 |
+
from constants import MODEL_CLASSES
|
| 8 |
+
|
| 9 |
+
# IRC:106-1990 PCU equivalency factors
|
| 10 |
+
_PCU_TABLE = {
|
| 11 |
+
0: 1.0, # Hatchback
|
| 12 |
+
1: 1.0, # Sedan
|
| 13 |
+
2: 1.0, # SUV
|
| 14 |
+
3: 1.0, # MUV
|
| 15 |
+
4: 3.0, # Bus
|
| 16 |
+
5: 3.0, # Truck
|
| 17 |
+
6: 1.2, # Three-wheeler (Auto-rickshaw)
|
| 18 |
+
7: 0.5, # Two-wheeler
|
| 19 |
+
8: 3.0, # LCV
|
| 20 |
+
9: 3.0, # Mini-bus
|
| 21 |
+
10: 3.0, # Tempo-traveller
|
| 22 |
+
11: 0.5, # Bicycle
|
| 23 |
+
12: 1.0, # Van
|
| 24 |
+
13: 1.0, # Others
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_pcu_factor(class_id):
|
| 29 |
+
return _PCU_TABLE.get(class_id, 1.0)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def compute_pcu(class_in, class_out):
|
| 33 |
+
"""Convert raw vehicle counts to PCU values.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
class_in: dict {class_id_str: count}
|
| 37 |
+
class_out: dict {class_id_str: count}
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
dict with total_pcu, pcu_in, pcu_out, per_class breakdown
|
| 41 |
+
"""
|
| 42 |
+
pcu_in = 0.0
|
| 43 |
+
pcu_out = 0.0
|
| 44 |
+
per_class = {}
|
| 45 |
+
|
| 46 |
+
all_ids = set(list(class_in.keys()) + list(class_out.keys()))
|
| 47 |
+
for cid_str in all_ids:
|
| 48 |
+
cid = int(cid_str)
|
| 49 |
+
factor = get_pcu_factor(cid)
|
| 50 |
+
in_count = class_in.get(cid_str, 0)
|
| 51 |
+
out_count = class_out.get(cid_str, 0)
|
| 52 |
+
cls_pcu_in = round(in_count * factor, 1)
|
| 53 |
+
cls_pcu_out = round(out_count * factor, 1)
|
| 54 |
+
pcu_in += cls_pcu_in
|
| 55 |
+
pcu_out += cls_pcu_out
|
| 56 |
+
per_class[MODEL_CLASSES.get(cid, f"cls_{cid}")] = {
|
| 57 |
+
"count": in_count + out_count,
|
| 58 |
+
"factor": factor,
|
| 59 |
+
"pcu": round(cls_pcu_in + cls_pcu_out, 1),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
"total_pcu": round(pcu_in + pcu_out, 1),
|
| 64 |
+
"pcu_in": round(pcu_in, 1),
|
| 65 |
+
"pcu_out": round(pcu_out, 1),
|
| 66 |
+
"per_class": per_class,
|
| 67 |
+
}
|
backend/server.py
CHANGED
|
@@ -135,6 +135,10 @@ def get_report(video_id: str, name: str):
|
|
| 135 |
media = "application/pdf"
|
| 136 |
elif name.endswith(".mp4"):
|
| 137 |
media = "video/mp4"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
return FileResponse(str(path), media_type=media)
|
| 140 |
|
|
@@ -180,6 +184,23 @@ def download_all_reports(video_id: str):
|
|
| 180 |
return Response(content=str(e), status_code=500)
|
| 181 |
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
@app.websocket("/ws/run")
|
| 184 |
async def ws_run(ws: WebSocket):
|
| 185 |
await ws.accept()
|
|
@@ -223,6 +244,15 @@ async def ws_run(ws: WebSocket):
|
|
| 223 |
result = task.result() # re-raises any exception from the engine
|
| 224 |
result["report_format"] = report_format
|
| 225 |
result["video_path"] = path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
run_results[video_id] = result
|
| 227 |
await ws.send_text(json.dumps({
|
| 228 |
"done": True,
|
|
@@ -230,6 +260,8 @@ async def ws_run(ws: WebSocket):
|
|
| 230 |
"processing_time": result["processing_time"],
|
| 231 |
"actual_fps": result["actual_fps"],
|
| 232 |
"speed_vs_realtime": result["speed_vs_realtime"],
|
|
|
|
|
|
|
| 233 |
}))
|
| 234 |
await ws.close()
|
| 235 |
|
|
|
|
| 135 |
media = "application/pdf"
|
| 136 |
elif name.endswith(".mp4"):
|
| 137 |
media = "video/mp4"
|
| 138 |
+
elif name.endswith(".json"):
|
| 139 |
+
media = "application/json"
|
| 140 |
+
elif name.endswith(".csv"):
|
| 141 |
+
media = "text/csv"
|
| 142 |
|
| 143 |
return FileResponse(str(path), media_type=media)
|
| 144 |
|
|
|
|
| 184 |
return Response(content=str(e), status_code=500)
|
| 185 |
|
| 186 |
|
| 187 |
+
FEEDBACK_PATH = Path(tempfile.gettempdir()) / "urbanflow_feedback.json"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@app.post("/api/feedback")
|
| 191 |
+
async def submit_feedback(request_data: dict = None):
|
| 192 |
+
from datetime import datetime, timezone
|
| 193 |
+
feedback = request_data or {}
|
| 194 |
+
entries = []
|
| 195 |
+
if FEEDBACK_PATH.exists():
|
| 196 |
+
entries = json.loads(FEEDBACK_PATH.read_text())
|
| 197 |
+
feedback["timestamp"] = datetime.now(timezone.utc).isoformat()
|
| 198 |
+
entries.append(feedback)
|
| 199 |
+
FEEDBACK_PATH.write_text(json.dumps(entries, indent=2))
|
| 200 |
+
print(f"[BACKEND] Feedback received: {feedback.get('type', 'general')}")
|
| 201 |
+
return {"status": "ok"}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
@app.websocket("/ws/run")
|
| 205 |
async def ws_run(ws: WebSocket):
|
| 206 |
await ws.accept()
|
|
|
|
| 244 |
result = task.result() # re-raises any exception from the engine
|
| 245 |
result["report_format"] = report_format
|
| 246 |
result["video_path"] = path
|
| 247 |
+
result["video_meta"] = {
|
| 248 |
+
"filename": video_info.get(video_id, "unknown"),
|
| 249 |
+
"fps": cfg.get("video_fps", 0),
|
| 250 |
+
"frames": cfg.get("frames", 0),
|
| 251 |
+
"duration": cfg.get("duration", 0),
|
| 252 |
+
"resolution": cfg.get("resolution", [0, 0]),
|
| 253 |
+
"pixels": cfg.get("pixels", 0),
|
| 254 |
+
}
|
| 255 |
+
result["engine_config"] = cfg
|
| 256 |
run_results[video_id] = result
|
| 257 |
await ws.send_text(json.dumps({
|
| 258 |
"done": True,
|
|
|
|
| 260 |
"processing_time": result["processing_time"],
|
| 261 |
"actual_fps": result["actual_fps"],
|
| 262 |
"speed_vs_realtime": result["speed_vs_realtime"],
|
| 263 |
+
"pcu": result.get("pcu", {}),
|
| 264 |
+
"speed_distribution": result.get("speed", {}).get("distribution", {}),
|
| 265 |
}))
|
| 266 |
await ws.close()
|
| 267 |
|
backend/speed.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lightweight speed estimation using pixel displacement between frames.
|
| 3 |
+
|
| 4 |
+
No camera calibration required — classifies vehicles into relative
|
| 5 |
+
speed categories (Slow/Normal/Fast) based on percentile ranking
|
| 6 |
+
of pixel-per-frame displacement within the video.
|
| 7 |
+
"""
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def estimate_speeds(track_positions):
|
| 12 |
+
"""Compute per-track speed category from centroid displacement history.
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
track_positions: dict {track_id: [(frame_idx, cx, cy), ...]}
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
dict with per_track speeds and aggregate distribution
|
| 19 |
+
"""
|
| 20 |
+
displacements = {}
|
| 21 |
+
|
| 22 |
+
for tid, positions in track_positions.items():
|
| 23 |
+
if len(positions) < 2:
|
| 24 |
+
continue
|
| 25 |
+
positions.sort(key=lambda x: x[0])
|
| 26 |
+
total_disp = 0.0
|
| 27 |
+
count = 0
|
| 28 |
+
for i in range(1, len(positions)):
|
| 29 |
+
dx = positions[i][1] - positions[i - 1][1]
|
| 30 |
+
dy = positions[i][2] - positions[i - 1][2]
|
| 31 |
+
total_disp += np.sqrt(dx * dx + dy * dy)
|
| 32 |
+
count += 1
|
| 33 |
+
displacements[tid] = total_disp / count if count > 0 else 0.0
|
| 34 |
+
|
| 35 |
+
if not displacements:
|
| 36 |
+
return {"per_track": {}, "distribution": {"slow": 0, "normal": 0, "fast": 0}}
|
| 37 |
+
|
| 38 |
+
speeds = np.array(list(displacements.values()))
|
| 39 |
+
p33 = np.percentile(speeds, 33)
|
| 40 |
+
p66 = np.percentile(speeds, 66)
|
| 41 |
+
|
| 42 |
+
per_track = {}
|
| 43 |
+
counts = {"slow": 0, "normal": 0, "fast": 0}
|
| 44 |
+
|
| 45 |
+
for tid, spd in displacements.items():
|
| 46 |
+
if spd <= p33:
|
| 47 |
+
cat = "slow"
|
| 48 |
+
elif spd <= p66:
|
| 49 |
+
cat = "normal"
|
| 50 |
+
else:
|
| 51 |
+
cat = "fast"
|
| 52 |
+
per_track[int(tid)] = {"px_per_frame": round(spd, 1), "category": cat}
|
| 53 |
+
counts[cat] += 1
|
| 54 |
+
|
| 55 |
+
total = sum(counts.values())
|
| 56 |
+
distribution = {
|
| 57 |
+
"slow": round(counts["slow"] / total * 100, 1) if total else 0,
|
| 58 |
+
"normal": round(counts["normal"] / total * 100, 1) if total else 0,
|
| 59 |
+
"fast": round(counts["fast"] / total * 100, 1) if total else 0,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return {"per_track": per_track, "distribution": distribution}
|
backend/tracker_config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom ByteTrack configuration optimized for Indian traffic conditions.
|
| 3 |
+
|
| 4 |
+
Tuning rationale (vs Ultralytics defaults):
|
| 5 |
+
- Lower thresholds: Indian roads have heavy occlusion (autos behind buses)
|
| 6 |
+
- Higher track_buffer: Vehicles disappear behind large vehicles for longer
|
| 7 |
+
- Looser match_thresh: Apparent shape changes during partial occlusion
|
| 8 |
+
|
| 9 |
+
Reference: https://docs.ultralytics.com/modes/track/
|
| 10 |
+
"""
|
| 11 |
+
import os
|
| 12 |
+
import tempfile
|
| 13 |
+
|
| 14 |
+
_YAML_CONTENT = """# UrbanFlow — ByteTrack config for Indian heterogeneous traffic
|
| 15 |
+
# Base: https://github.com/ultralytics/ultralytics/blob/main/ultralytics/cfg/trackers/bytetrack.yaml
|
| 16 |
+
|
| 17 |
+
tracker_type: bytetrack
|
| 18 |
+
track_high_thresh: 0.20
|
| 19 |
+
track_low_thresh: 0.08
|
| 20 |
+
new_track_thresh: 0.20
|
| 21 |
+
track_buffer: 45
|
| 22 |
+
match_thresh: 0.75
|
| 23 |
+
fuse_score: true
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
_CONFIG_PATH = os.path.join(tempfile.gettempdir(), "urbanflow_bytetrack.yaml")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_tracker_path():
|
| 30 |
+
"""Write custom YAML once and return its path."""
|
| 31 |
+
if not os.path.exists(_CONFIG_PATH):
|
| 32 |
+
with open(_CONFIG_PATH, "w") as f:
|
| 33 |
+
f.write(_YAML_CONTENT)
|
| 34 |
+
print(f"[tracker] Custom ByteTrack config written: {_CONFIG_PATH}")
|
| 35 |
+
return _CONFIG_PATH
|
backend/visualize.py
CHANGED
|
@@ -6,6 +6,7 @@ import matplotlib
|
|
| 6 |
matplotlib.use("Agg")
|
| 7 |
import matplotlib.pyplot as plt
|
| 8 |
from matplotlib.ticker import MaxNLocator
|
|
|
|
| 9 |
|
| 10 |
# Formal MIS palette
|
| 11 |
C_PRIMARY = "#1e293b"
|
|
@@ -244,6 +245,12 @@ def generate_all(data, model_classes, out_dir, report_format="png"):
|
|
| 244 |
lambda: confidence_dist(data.get("conf_scores", []), out_dir, fmt),
|
| 245 |
lambda: spatial_heatmap(heatmap_points, video_path, out_dir, fmt),
|
| 246 |
lambda: export_csv(raw_events, out_dir),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
]:
|
| 248 |
name = fn()
|
| 249 |
if name:
|
|
|
|
| 6 |
matplotlib.use("Agg")
|
| 7 |
import matplotlib.pyplot as plt
|
| 8 |
from matplotlib.ticker import MaxNLocator
|
| 9 |
+
from export_json import export_json
|
| 10 |
|
| 11 |
# Formal MIS palette
|
| 12 |
C_PRIMARY = "#1e293b"
|
|
|
|
| 245 |
lambda: confidence_dist(data.get("conf_scores", []), out_dir, fmt),
|
| 246 |
lambda: spatial_heatmap(heatmap_points, video_path, out_dir, fmt),
|
| 247 |
lambda: export_csv(raw_events, out_dir),
|
| 248 |
+
lambda: export_json(
|
| 249 |
+
data,
|
| 250 |
+
data.get("video_meta", {}),
|
| 251 |
+
data.get("engine_config", {}),
|
| 252 |
+
out_dir,
|
| 253 |
+
),
|
| 254 |
]:
|
| 255 |
name = fn()
|
| 256 |
if name:
|
frontend/initial.html
CHANGED
|
@@ -80,6 +80,32 @@
|
|
| 80 |
background-color: var(--cocoa) !important;
|
| 81 |
color: var(--t1) !important;
|
| 82 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
</style>
|
| 84 |
</head>
|
| 85 |
|
|
@@ -378,6 +404,53 @@
|
|
| 378 |
window.location.href = '/';
|
| 379 |
}
|
| 380 |
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
</body>
|
| 382 |
|
| 383 |
</html>
|
|
|
|
| 80 |
background-color: var(--cocoa) !important;
|
| 81 |
color: var(--t1) !important;
|
| 82 |
}
|
| 83 |
+
|
| 84 |
+
/* Onboarding */
|
| 85 |
+
.onboard-overlay {
|
| 86 |
+
position: fixed; inset: 0; z-index: 9999;
|
| 87 |
+
background: rgba(0,0,0,0.92);
|
| 88 |
+
display: flex; align-items: center; justify-content: center;
|
| 89 |
+
}
|
| 90 |
+
.onboard-card {
|
| 91 |
+
background: #0a0a0a; border: 1px solid #2a2a2a;
|
| 92 |
+
border-radius: 16px; max-width: 440px; width: 90%;
|
| 93 |
+
padding: 40px 32px; text-align: center;
|
| 94 |
+
}
|
| 95 |
+
.onboard-step { display: none; }
|
| 96 |
+
.onboard-step.active { display: block; }
|
| 97 |
+
.onboard-dots { display: flex; gap: 6px; justify-content: center; margin-top: 20px; }
|
| 98 |
+
.onboard-dot {
|
| 99 |
+
width: 8px; height: 8px; border-radius: 50%;
|
| 100 |
+
background: #333; transition: background 0.2s;
|
| 101 |
+
}
|
| 102 |
+
.onboard-dot.active { background: var(--cocoa-l); }
|
| 103 |
+
|
| 104 |
+
/* Mobile responsive */
|
| 105 |
+
@media (max-width: 768px) {
|
| 106 |
+
main { grid-template-columns: 1fr !important; padding: 16px !important; }
|
| 107 |
+
h1 { font-size: 2.2rem !important; }
|
| 108 |
+
}
|
| 109 |
</style>
|
| 110 |
</head>
|
| 111 |
|
|
|
|
| 404 |
window.location.href = '/';
|
| 405 |
}
|
| 406 |
</script>
|
| 407 |
+
|
| 408 |
+
<!-- Onboarding Walkthrough -->
|
| 409 |
+
<div id="onboard-overlay" class="onboard-overlay" style="display:none">
|
| 410 |
+
<div class="onboard-card">
|
| 411 |
+
<div class="onboard-step active" data-step="0">
|
| 412 |
+
<i class="fa-solid fa-cloud-arrow-up text-4xl mb-4" style="color:var(--cocoa-l)"></i>
|
| 413 |
+
<h3 class="text-lg font-bold mb-2" style="color:#f0ece6">Upload a Traffic Video</h3>
|
| 414 |
+
<p class="text-xs" style="color:#777;line-height:1.7">Drag & drop or select a video file recorded from any traffic camera. MP4, MOV, AVI formats supported.</p>
|
| 415 |
+
</div>
|
| 416 |
+
<div class="onboard-step" data-step="1">
|
| 417 |
+
<i class="fa-solid fa-draw-polygon text-4xl mb-4" style="color:var(--cocoa-l)"></i>
|
| 418 |
+
<h3 class="text-lg font-bold mb-2" style="color:#f0ece6">Draw a Counting Boundary</h3>
|
| 419 |
+
<p class="text-xs" style="color:#777;line-height:1.7">Click two points on the first frame to define a spatial boundary. Vehicles crossing this line will be counted and classified.</p>
|
| 420 |
+
</div>
|
| 421 |
+
<div class="onboard-step" data-step="2">
|
| 422 |
+
<i class="fa-solid fa-chart-line text-4xl mb-4" style="color:var(--cocoa-l)"></i>
|
| 423 |
+
<h3 class="text-lg font-bold mb-2" style="color:#f0ece6">Review Analytics & Export</h3>
|
| 424 |
+
<p class="text-xs" style="color:#777;line-height:1.7">Watch real-time charts populate as inference runs. Download annotated video, reports, and structured JSON when complete.</p>
|
| 425 |
+
</div>
|
| 426 |
+
<div class="onboard-dots">
|
| 427 |
+
<span class="onboard-dot active"></span>
|
| 428 |
+
<span class="onboard-dot"></span>
|
| 429 |
+
<span class="onboard-dot"></span>
|
| 430 |
+
</div>
|
| 431 |
+
<div class="flex gap-3 justify-center mt-6">
|
| 432 |
+
<button onclick="closeOnboarding()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="color:#555;border:1px solid #222">Skip</button>
|
| 433 |
+
<button id="onboard-next" onclick="nextOnboardStep()" class="text-[10px] font-bold uppercase tracking-widest px-6 py-2 rounded-full" style="background:var(--cocoa);color:#f0ece6">Next</button>
|
| 434 |
+
</div>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
<script>
|
| 438 |
+
let _obStep = 0;
|
| 439 |
+
function nextOnboardStep() {
|
| 440 |
+
_obStep++;
|
| 441 |
+
if (_obStep >= 3) { closeOnboarding(); return; }
|
| 442 |
+
document.querySelectorAll('.onboard-step').forEach((s, i) => s.classList.toggle('active', i === _obStep));
|
| 443 |
+
document.querySelectorAll('.onboard-dot').forEach((d, i) => d.classList.toggle('active', i === _obStep));
|
| 444 |
+
if (_obStep === 2) document.getElementById('onboard-next').innerText = 'Get Started';
|
| 445 |
+
}
|
| 446 |
+
function closeOnboarding() {
|
| 447 |
+
document.getElementById('onboard-overlay').style.display = 'none';
|
| 448 |
+
localStorage.setItem('uf_onboarded', '1');
|
| 449 |
+
}
|
| 450 |
+
if (!localStorage.getItem('uf_onboarded')) {
|
| 451 |
+
document.getElementById('onboard-overlay').style.display = 'flex';
|
| 452 |
+
}
|
| 453 |
+
</script>
|
| 454 |
</body>
|
| 455 |
|
| 456 |
</html>
|
frontend/vehicles.html
CHANGED
|
@@ -325,6 +325,101 @@
|
|
| 325 |
padding-top: 0;
|
| 326 |
border-top: none;
|
| 327 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
</style>
|
| 329 |
</head>
|
| 330 |
|
|
@@ -356,6 +451,10 @@
|
|
| 356 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 357 |
<i class="fa-solid fa-sliders w-6"></i> <span class="font-medium">Settings</span>
|
| 358 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
</nav>
|
| 360 |
<div class="mt-auto border-t p-4 flex flex-col items-center gap-2 bg-black flex-shrink-0"
|
| 361 |
style="border-color:#2a2a2a">
|
|
@@ -368,9 +467,26 @@
|
|
| 368 |
style="color:#a89f97" onmouseover="this.style.color='#c89a6c'"
|
| 369 |
onmouseout="this.style.color='#a89f97'">Terms & Conditions</button>
|
| 370 |
<p class="text-[9px] mt-1" style="color:#333">© 2026 UrbanFlow</p>
|
|
|
|
| 371 |
</div>
|
| 372 |
</aside>
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
<main class="flex-1 flex flex-col h-full min-w-0 p-4 gap-4">
|
| 375 |
|
| 376 |
<!-- TAB: About -->
|
|
@@ -515,7 +631,14 @@
|
|
| 515 |
</div>
|
| 516 |
|
| 517 |
<!-- TAB: Overview -->
|
| 518 |
-
<div id="tab-overview" class="hidden grid grid-cols-12 gap-4 flex-1 min-h-0 overflow-hidden">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
<!-- Congestion Index -->
|
| 521 |
<div
|
|
@@ -545,6 +668,13 @@
|
|
| 545 |
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
| 546 |
<span class="text-3xl font-black text-slate-900" id="cnt-total">0</span>
|
| 547 |
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Total Units</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
</div>
|
| 549 |
</div>
|
| 550 |
</div>
|
|
@@ -655,6 +785,32 @@
|
|
| 655 |
</div>
|
| 656 |
</div>
|
| 657 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
</div>
|
| 659 |
</div>
|
| 660 |
|
|
@@ -852,6 +1008,54 @@
|
|
| 852 |
</div>
|
| 853 |
</div>
|
| 854 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
</main>
|
| 856 |
|
| 857 |
<script>
|
|
@@ -934,7 +1138,7 @@
|
|
| 934 |
|
| 935 |
// =========== Tab switching ===========
|
| 936 |
function switchTab(tab) {
|
| 937 |
-
['about', 'overview', 'run-details', 'reports', 'settings'].forEach(t => {
|
| 938 |
const el = document.getElementById('tab-' + t);
|
| 939 |
const nav = document.getElementById('nav-' + t);
|
| 940 |
if (el) el.classList.toggle('hidden', tab !== t);
|
|
@@ -950,6 +1154,96 @@
|
|
| 950 |
});
|
| 951 |
}
|
| 952 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 953 |
// =========== Run Details helpers ===========
|
| 954 |
function detailRow(label, value, extra) {
|
| 955 |
extra = extra || '';
|
|
@@ -1430,6 +1724,7 @@
|
|
| 1430 |
ws.onerror = e => {
|
| 1431 |
console.error('WS Error:', e);
|
| 1432 |
document.getElementById('proc-label').innerText = 'Connection Error';
|
|
|
|
| 1433 |
if (badge) {
|
| 1434 |
badge.innerText = 'Pipeline Failed';
|
| 1435 |
badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-100 text-[10px] font-bold rounded-full uppercase tracking-tighter';
|
|
@@ -1465,7 +1760,9 @@
|
|
| 1465 |
ws.onmessage = e => {
|
| 1466 |
const d = JSON.parse(e.data);
|
| 1467 |
|
| 1468 |
-
//
|
|
|
|
|
|
|
| 1469 |
if (d.error) {
|
| 1470 |
processingDone = true;
|
| 1471 |
document.getElementById('proc-label').innerText = 'Engine Error';
|
|
@@ -1540,6 +1837,13 @@
|
|
| 1540 |
const newWrap = document.getElementById('new-analysis-wrap');
|
| 1541 |
if (newWrap) newWrap.classList.remove('hidden');
|
| 1542 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1543 |
return;
|
| 1544 |
}
|
| 1545 |
|
|
@@ -1559,6 +1863,11 @@
|
|
| 1559 |
const currTotal = parseInt(cntTotalEl.innerText) || 0;
|
| 1560 |
animateValue(cntTotalEl, currTotal, totalIn + totalOut, 300);
|
| 1561 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1562 |
// Update doughnut
|
| 1563 |
doughChart.data.datasets[0].data = [totalIn, totalOut];
|
| 1564 |
doughChart.update();
|
|
@@ -1588,7 +1897,8 @@
|
|
| 1588 |
'annotated.mp4': { title: 'Annotated Video Export', desc: 'Rendered video with tracking overlays' },
|
| 1589 |
'heatmap.png': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
|
| 1590 |
'heatmap.pdf': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
|
| 1591 |
-
'raw_data.csv': { title: 'Raw Analytics Export', desc: 'Comma-separated values of all crossings' }
|
|
|
|
| 1592 |
};
|
| 1593 |
|
| 1594 |
async function loadReports(videoId) {
|
|
@@ -1629,6 +1939,12 @@
|
|
| 1629 |
<i class="fa-solid fa-file-csv text-6xl mb-4 text-white"></i>
|
| 1630 |
<span class="text-xs font-bold uppercase tracking-widest text-slate-500">Raw Analytics Export</span>
|
| 1631 |
</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1632 |
} else {
|
| 1633 |
previewHTML = `<img src="${url}" alt="${info.title}" class="max-w-full max-h-[320px] object-contain rounded">`;
|
| 1634 |
}
|
|
|
|
| 325 |
padding-top: 0;
|
| 326 |
border-top: none;
|
| 327 |
}
|
| 328 |
+
|
| 329 |
+
/* Toast Notifications */
|
| 330 |
+
#toast-container {
|
| 331 |
+
position: fixed;
|
| 332 |
+
bottom: 20px;
|
| 333 |
+
right: 20px;
|
| 334 |
+
z-index: 10000;
|
| 335 |
+
display: flex;
|
| 336 |
+
flex-direction: column;
|
| 337 |
+
gap: 8px;
|
| 338 |
+
pointer-events: none;
|
| 339 |
+
}
|
| 340 |
+
.toast {
|
| 341 |
+
background: #111;
|
| 342 |
+
border: 1px solid #2a2a2a;
|
| 343 |
+
color: #f0ece6;
|
| 344 |
+
font-size: 11px;
|
| 345 |
+
font-weight: 600;
|
| 346 |
+
padding: 10px 18px;
|
| 347 |
+
border-radius: 10px;
|
| 348 |
+
display: flex;
|
| 349 |
+
align-items: center;
|
| 350 |
+
gap: 8px;
|
| 351 |
+
pointer-events: auto;
|
| 352 |
+
animation: toastIn 0.3s ease-out;
|
| 353 |
+
max-width: 320px;
|
| 354 |
+
}
|
| 355 |
+
.toast.toast-out { animation: toastOut 0.3s ease-in forwards; }
|
| 356 |
+
.toast-success { border-color: #166534; }
|
| 357 |
+
.toast-success i { color: #22c55e; }
|
| 358 |
+
.toast-error { border-color: #7f1d1d; }
|
| 359 |
+
.toast-error i { color: #ef4444; }
|
| 360 |
+
.toast-info i { color: var(--cocoa-l); }
|
| 361 |
+
@keyframes toastIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
|
| 362 |
+
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateX(40px); } }
|
| 363 |
+
|
| 364 |
+
/* Stats Empty State */
|
| 365 |
+
.stats-empty-overlay {
|
| 366 |
+
position: absolute;
|
| 367 |
+
inset: 0;
|
| 368 |
+
z-index: 10;
|
| 369 |
+
display: flex;
|
| 370 |
+
flex-direction: column;
|
| 371 |
+
align-items: center;
|
| 372 |
+
justify-content: center;
|
| 373 |
+
background: rgba(0,0,0,0.7);
|
| 374 |
+
border-radius: 12px;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
/* Mobile Responsive */
|
| 378 |
+
@media (max-width: 1024px) {
|
| 379 |
+
aside.w-60 { display: none; }
|
| 380 |
+
.mobile-nav { display: flex !important; }
|
| 381 |
+
#tab-overview { grid-template-columns: repeat(1, 1fr) !important; }
|
| 382 |
+
#tab-overview > div { grid-column: span 1 !important; }
|
| 383 |
+
}
|
| 384 |
+
@media (max-width: 768px) {
|
| 385 |
+
.grid-cols-3 { grid-template-columns: 1fr !important; }
|
| 386 |
+
.grid-cols-2 { grid-template-columns: 1fr !important; }
|
| 387 |
+
main { padding: 8px !important; }
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
/* Feedback form */
|
| 391 |
+
.fb-textarea {
|
| 392 |
+
background: #111;
|
| 393 |
+
border: 1px solid #2a2a2a;
|
| 394 |
+
border-radius: 8px;
|
| 395 |
+
color: #f0ece6;
|
| 396 |
+
font-size: 12px;
|
| 397 |
+
padding: 12px;
|
| 398 |
+
width: 100%;
|
| 399 |
+
min-height: 120px;
|
| 400 |
+
resize: vertical;
|
| 401 |
+
font-family: 'Inter', sans-serif;
|
| 402 |
+
}
|
| 403 |
+
.fb-textarea:focus { outline: none; border-color: var(--cocoa-l); }
|
| 404 |
+
.fb-select {
|
| 405 |
+
background: #111;
|
| 406 |
+
border: 1px solid #2a2a2a;
|
| 407 |
+
border-radius: 8px;
|
| 408 |
+
color: #f0ece6;
|
| 409 |
+
font-size: 11px;
|
| 410 |
+
padding: 8px 12px;
|
| 411 |
+
width: 100%;
|
| 412 |
+
font-family: 'Inter', sans-serif;
|
| 413 |
+
}
|
| 414 |
+
.fb-select:focus { outline: none; border-color: var(--cocoa-l); }
|
| 415 |
+
.fb-stars { display: flex; gap: 6px; }
|
| 416 |
+
.fb-star {
|
| 417 |
+
font-size: 22px;
|
| 418 |
+
color: #333;
|
| 419 |
+
cursor: pointer;
|
| 420 |
+
transition: color 0.15s;
|
| 421 |
+
}
|
| 422 |
+
.fb-star.active, .fb-star:hover { color: var(--cocoa-l); }
|
| 423 |
</style>
|
| 424 |
</head>
|
| 425 |
|
|
|
|
| 451 |
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 452 |
<i class="fa-solid fa-sliders w-6"></i> <span class="font-medium">Settings</span>
|
| 453 |
</a>
|
| 454 |
+
<a onclick="switchTab('feedback')" id="nav-feedback"
|
| 455 |
+
class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
|
| 456 |
+
<i class="fa-solid fa-comment-dots w-6"></i> <span class="font-medium">Feedback</span>
|
| 457 |
+
</a>
|
| 458 |
</nav>
|
| 459 |
<div class="mt-auto border-t p-4 flex flex-col items-center gap-2 bg-black flex-shrink-0"
|
| 460 |
style="border-color:#2a2a2a">
|
|
|
|
| 467 |
style="color:#a89f97" onmouseover="this.style.color='#c89a6c'"
|
| 468 |
onmouseout="this.style.color='#a89f97'">Terms & Conditions</button>
|
| 469 |
<p class="text-[9px] mt-1" style="color:#333">© 2026 UrbanFlow</p>
|
| 470 |
+
<p class="text-[8px] mt-0.5" style="color:#222" title="Keyboard shortcuts: 1-6 switch tabs, D downloads artifacts">⌨ Press 1-6 for tabs, D for download</p>
|
| 471 |
</div>
|
| 472 |
</aside>
|
| 473 |
|
| 474 |
+
<!-- Mobile Navigation (hidden on desktop) -->
|
| 475 |
+
<div class="mobile-nav hidden fixed top-0 left-0 right-0 z-30 bg-black border-b border-slate-800 px-2 py-1.5 items-center justify-between">
|
| 476 |
+
<img src="uf_rf.png" alt="UF" class="h-8">
|
| 477 |
+
<div class="flex gap-1">
|
| 478 |
+
<button onclick="switchTab('about')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-circle-info"></i></button>
|
| 479 |
+
<button onclick="switchTab('overview')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-desktop"></i></button>
|
| 480 |
+
<button onclick="switchTab('run-details')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-microchip"></i></button>
|
| 481 |
+
<button onclick="switchTab('reports')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-file-lines"></i></button>
|
| 482 |
+
<button onclick="switchTab('settings')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-sliders"></i></button>
|
| 483 |
+
<button onclick="switchTab('feedback')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-comment-dots"></i></button>
|
| 484 |
+
</div>
|
| 485 |
+
</div>
|
| 486 |
+
|
| 487 |
+
<!-- Toast Container -->
|
| 488 |
+
<div id="toast-container"></div>
|
| 489 |
+
|
| 490 |
<main class="flex-1 flex flex-col h-full min-w-0 p-4 gap-4">
|
| 491 |
|
| 492 |
<!-- TAB: About -->
|
|
|
|
| 631 |
</div>
|
| 632 |
|
| 633 |
<!-- TAB: Overview -->
|
| 634 |
+
<div id="tab-overview" class="hidden grid grid-cols-12 gap-4 flex-1 min-h-0 overflow-hidden" style="position:relative">
|
| 635 |
+
|
| 636 |
+
<!-- Empty State Overlay -->
|
| 637 |
+
<div id="stats-empty-state" class="stats-empty-overlay">
|
| 638 |
+
<i class="fa-solid fa-chart-column text-4xl mb-4" style="color:#333"></i>
|
| 639 |
+
<span class="text-sm font-bold" style="color:#555">Processing not started</span>
|
| 640 |
+
<span class="text-[11px] mt-1" style="color:#444">Charts will populate in real-time once analysis begins</span>
|
| 641 |
+
</div>
|
| 642 |
|
| 643 |
<!-- Congestion Index -->
|
| 644 |
<div
|
|
|
|
| 668 |
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
| 669 |
<span class="text-3xl font-black text-slate-900" id="cnt-total">0</span>
|
| 670 |
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Total Units</span>
|
| 671 |
+
<span class="text-[9px] font-bold mt-0.5 uppercase tracking-wider flex items-center gap-1" style="color:#8b5e3c">
|
| 672 |
+
<span id="cnt-pcu">0</span> PCU
|
| 673 |
+
<span class="info-wrap" style="margin-left:2px">
|
| 674 |
+
<span class="info-btn" style="width:11px;height:11px;font-size:6px"><i class="fa-solid fa-info"></i></span>
|
| 675 |
+
<span class="info-tip">Passenger Car Units (IRC:106-1990). Normalizes mixed traffic into a single unit for capacity analysis.</span>
|
| 676 |
+
</span>
|
| 677 |
+
</span>
|
| 678 |
</div>
|
| 679 |
</div>
|
| 680 |
</div>
|
|
|
|
| 785 |
</div>
|
| 786 |
</div>
|
| 787 |
|
| 788 |
+
<!-- Insights Panel (xAI) -->
|
| 789 |
+
<div id="insights-panel" class="hidden">
|
| 790 |
+
<div class="grid grid-cols-2 gap-6 mt-6">
|
| 791 |
+
<div class="bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
|
| 792 |
+
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50">
|
| 793 |
+
<h3 class="font-bold text-slate-800 text-sm flex items-center">Speed Distribution
|
| 794 |
+
<span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span>
|
| 795 |
+
<span class="info-tip">Relative speed categories based on pixel displacement. Slow/Normal/Fast are percentile-based within this video.</span></span>
|
| 796 |
+
</h3>
|
| 797 |
+
</div>
|
| 798 |
+
<div class="p-6" id="speed-dist-content">
|
| 799 |
+
<div class="flex gap-4 items-end justify-center h-32" id="speed-bars"></div>
|
| 800 |
+
</div>
|
| 801 |
+
</div>
|
| 802 |
+
<div class="bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
|
| 803 |
+
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50">
|
| 804 |
+
<h3 class="font-bold text-slate-800 text-sm flex items-center">Congestion Insights
|
| 805 |
+
<span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span>
|
| 806 |
+
<span class="info-tip">Automated peak detection and congestion classification.</span></span>
|
| 807 |
+
</h3>
|
| 808 |
+
</div>
|
| 809 |
+
<div class="p-6 space-y-3" id="congestion-insights"></div>
|
| 810 |
+
</div>
|
| 811 |
+
</div>
|
| 812 |
+
</div>
|
| 813 |
+
|
| 814 |
</div>
|
| 815 |
</div>
|
| 816 |
|
|
|
|
| 1008 |
</div>
|
| 1009 |
</div>
|
| 1010 |
|
| 1011 |
+
<!-- TAB: Feedback -->
|
| 1012 |
+
<div id="tab-feedback" class="hidden flex-1 min-h-0 overflow-y-auto" style="padding:16px">
|
| 1013 |
+
<div class="max-w-2xl mx-auto">
|
| 1014 |
+
<div class="bg-black border border-slate-800 rounded-xl p-8 shadow-2xl space-y-6">
|
| 1015 |
+
<div class="text-center">
|
| 1016 |
+
<h2 class="text-xl font-bold" style="color:#f0ece6">Share Your Feedback</h2>
|
| 1017 |
+
<p class="text-xs mt-2" style="color:#777">We're in the requirements-gathering phase. Your input directly shapes what gets built.</p>
|
| 1018 |
+
</div>
|
| 1019 |
+
|
| 1020 |
+
<div class="space-y-4">
|
| 1021 |
+
<div>
|
| 1022 |
+
<label class="text-[10px] font-bold uppercase tracking-widest block mb-2" style="color:#a89f97">Overall Experience</label>
|
| 1023 |
+
<div class="fb-stars" id="fb-stars">
|
| 1024 |
+
<span class="fb-star" data-v="1" onclick="setRating(1)">★</span>
|
| 1025 |
+
<span class="fb-star" data-v="2" onclick="setRating(2)">★</span>
|
| 1026 |
+
<span class="fb-star" data-v="3" onclick="setRating(3)">★</span>
|
| 1027 |
+
<span class="fb-star" data-v="4" onclick="setRating(4)">★</span>
|
| 1028 |
+
<span class="fb-star" data-v="5" onclick="setRating(5)">★</span>
|
| 1029 |
+
</div>
|
| 1030 |
+
</div>
|
| 1031 |
+
|
| 1032 |
+
<div>
|
| 1033 |
+
<label class="text-[10px] font-bold uppercase tracking-widest block mb-2" style="color:#a89f97">Category</label>
|
| 1034 |
+
<select class="fb-select" id="fb-type">
|
| 1035 |
+
<option value="general">General Feedback</option>
|
| 1036 |
+
<option value="bug">Bug Report</option>
|
| 1037 |
+
<option value="feature">Feature Request</option>
|
| 1038 |
+
<option value="accuracy">Detection Accuracy</option>
|
| 1039 |
+
<option value="ux">User Experience</option>
|
| 1040 |
+
</select>
|
| 1041 |
+
</div>
|
| 1042 |
+
|
| 1043 |
+
<div>
|
| 1044 |
+
<label class="text-[10px] font-bold uppercase tracking-widest block mb-2" style="color:#a89f97">Details</label>
|
| 1045 |
+
<textarea class="fb-textarea" id="fb-text" placeholder="Tell us what worked, what didn't, or what you'd like to see..."></textarea>
|
| 1046 |
+
</div>
|
| 1047 |
+
|
| 1048 |
+
<button onclick="submitFeedback()" class="w-full py-3 font-bold text-sm rounded-full transition hover:scale-[1.02] active:scale-95"
|
| 1049 |
+
style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
|
| 1050 |
+
Submit Feedback <i class="fa-solid fa-paper-plane ml-2 text-[10px]"></i>
|
| 1051 |
+
</button>
|
| 1052 |
+
</div>
|
| 1053 |
+
|
| 1054 |
+
<p class="text-[10px] text-center" style="color:#444">Or email us directly at <strong style="color:#c89a6c">support@urbanflow.in</strong></p>
|
| 1055 |
+
</div>
|
| 1056 |
+
</div>
|
| 1057 |
+
</div>
|
| 1058 |
+
|
| 1059 |
</main>
|
| 1060 |
|
| 1061 |
<script>
|
|
|
|
| 1138 |
|
| 1139 |
// =========== Tab switching ===========
|
| 1140 |
function switchTab(tab) {
|
| 1141 |
+
['about', 'overview', 'run-details', 'reports', 'settings', 'feedback'].forEach(t => {
|
| 1142 |
const el = document.getElementById('tab-' + t);
|
| 1143 |
const nav = document.getElementById('nav-' + t);
|
| 1144 |
if (el) el.classList.toggle('hidden', tab !== t);
|
|
|
|
| 1154 |
});
|
| 1155 |
}
|
| 1156 |
|
| 1157 |
+
// =========== Toast System ===========
|
| 1158 |
+
function showToast(message, type) {
|
| 1159 |
+
type = type || 'info';
|
| 1160 |
+
const icons = { success: 'fa-check-circle', error: 'fa-circle-xmark', info: 'fa-circle-info' };
|
| 1161 |
+
const el = document.createElement('div');
|
| 1162 |
+
el.className = `toast toast-${type}`;
|
| 1163 |
+
el.innerHTML = `<i class="fa-solid ${icons[type] || icons.info}"></i> ${message}`;
|
| 1164 |
+
document.getElementById('toast-container').appendChild(el);
|
| 1165 |
+
setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 300); }, 3000);
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
// =========== Keyboard Shortcuts ===========
|
| 1169 |
+
const TAB_KEYS = { '1': 'about', '2': 'overview', '3': 'run-details', '4': 'reports', '5': 'settings', '6': 'feedback' };
|
| 1170 |
+
document.addEventListener('keydown', function(e) {
|
| 1171 |
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
| 1172 |
+
if (TAB_KEYS[e.key]) { switchTab(TAB_KEYS[e.key]); return; }
|
| 1173 |
+
if (e.key === 'd' || e.key === 'D') {
|
| 1174 |
+
const vid = document.body.dataset.lastVideoId;
|
| 1175 |
+
if (vid) {
|
| 1176 |
+
window.open(`/bundle/${vid}`, '_blank');
|
| 1177 |
+
showToast('Download started', 'success');
|
| 1178 |
+
}
|
| 1179 |
+
}
|
| 1180 |
+
});
|
| 1181 |
+
|
| 1182 |
+
// =========== Feedback ===========
|
| 1183 |
+
let _fbRating = 0;
|
| 1184 |
+
function setRating(n) {
|
| 1185 |
+
_fbRating = n;
|
| 1186 |
+
document.querySelectorAll('.fb-star').forEach(s => {
|
| 1187 |
+
s.classList.toggle('active', parseInt(s.dataset.v) <= n);
|
| 1188 |
+
});
|
| 1189 |
+
}
|
| 1190 |
+
async function submitFeedback() {
|
| 1191 |
+
const type = document.getElementById('fb-type').value;
|
| 1192 |
+
const text = document.getElementById('fb-text').value.trim();
|
| 1193 |
+
if (!text && !_fbRating) { showToast('Please add a rating or comment', 'error'); return; }
|
| 1194 |
+
const res = await fetch('/api/feedback', {
|
| 1195 |
+
method: 'POST',
|
| 1196 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1197 |
+
body: JSON.stringify({ rating: _fbRating, type, text })
|
| 1198 |
+
});
|
| 1199 |
+
if (res.ok) {
|
| 1200 |
+
showToast('Thank you for your feedback!', 'success');
|
| 1201 |
+
document.getElementById('fb-text').value = '';
|
| 1202 |
+
setRating(0);
|
| 1203 |
+
} else {
|
| 1204 |
+
showToast('Failed to submit — please try again', 'error');
|
| 1205 |
+
}
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
// =========== PCU Calculation (client-side mirror) ===========
|
| 1209 |
+
const PCU_TABLE = {0:1,1:1,2:1,3:1,4:3,5:3,6:1.2,7:0.5,8:3,9:3,10:3,11:0.5,12:1,13:1};
|
| 1210 |
+
function calcPCU(classIn, classOut) {
|
| 1211 |
+
let total = 0;
|
| 1212 |
+
for (const [k, v] of Object.entries(classIn)) total += (PCU_TABLE[parseInt(k)] || 1) * v;
|
| 1213 |
+
for (const [k, v] of Object.entries(classOut)) total += (PCU_TABLE[parseInt(k)] || 1) * v;
|
| 1214 |
+
return Math.round(total * 10) / 10;
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
// =========== Insights Rendering ===========
|
| 1218 |
+
function renderInsights(d) {
|
| 1219 |
+
const panel = document.getElementById('insights-panel');
|
| 1220 |
+
panel.classList.remove('hidden');
|
| 1221 |
+
|
| 1222 |
+
// Speed distribution bars
|
| 1223 |
+
const dist = d.speed_distribution || {};
|
| 1224 |
+
const bars = document.getElementById('speed-bars');
|
| 1225 |
+
const colors = { slow: '#ef4444', normal: '#eab308', fast: '#22c55e' };
|
| 1226 |
+
const labels = { slow: 'Slow', normal: 'Normal', fast: 'Fast' };
|
| 1227 |
+
bars.innerHTML = ['slow', 'normal', 'fast'].map(cat => {
|
| 1228 |
+
const pct = dist[cat] || 0;
|
| 1229 |
+
const h = Math.max(8, pct * 1.2);
|
| 1230 |
+
return `<div class="flex flex-col items-center gap-1">
|
| 1231 |
+
<span class="text-[10px] font-bold" style="color:${colors[cat]}">${pct}%</span>
|
| 1232 |
+
<div style="width:36px;height:${h}px;background:${colors[cat]};border-radius:6px;transition:height 0.5s"></div>
|
| 1233 |
+
<span class="text-[9px] font-bold text-slate-500 uppercase">${labels[cat]}</span>
|
| 1234 |
+
</div>`;
|
| 1235 |
+
}).join('');
|
| 1236 |
+
|
| 1237 |
+
// Congestion insights
|
| 1238 |
+
const ci = document.getElementById('congestion-insights');
|
| 1239 |
+
const pcu = d.pcu || {};
|
| 1240 |
+
ci.innerHTML = [
|
| 1241 |
+
infoRow('Total PCU', pcu.total_pcu || 0, 'Passenger Car Units (IRC:106-1990). Normalizes mixed traffic.'),
|
| 1242 |
+
infoRow('PCU In / Out', `${pcu.pcu_in || 0} / ${pcu.pcu_out || 0}`, 'Directional PCU split.'),
|
| 1243 |
+
infoRow('Speed Profile', `${dist.slow || 0}% slow · ${dist.normal || 0}% normal · ${dist.fast || 0}% fast`, 'Relative speed categories within this video.'),
|
| 1244 |
+
].join('');
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
// =========== Run Details helpers ===========
|
| 1248 |
function detailRow(label, value, extra) {
|
| 1249 |
extra = extra || '';
|
|
|
|
| 1724 |
ws.onerror = e => {
|
| 1725 |
console.error('WS Error:', e);
|
| 1726 |
document.getElementById('proc-label').innerText = 'Connection Error';
|
| 1727 |
+
showToast('Connection error — server may be busy', 'error');
|
| 1728 |
if (badge) {
|
| 1729 |
badge.innerText = 'Pipeline Failed';
|
| 1730 |
badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-100 text-[10px] font-bold rounded-full uppercase tracking-tighter';
|
|
|
|
| 1760 |
ws.onmessage = e => {
|
| 1761 |
const d = JSON.parse(e.data);
|
| 1762 |
|
| 1763 |
+
// Hide empty state on first data
|
| 1764 |
+
const emptyState = document.getElementById('stats-empty-state');
|
| 1765 |
+
if (emptyState) emptyState.style.display = 'none';
|
| 1766 |
if (d.error) {
|
| 1767 |
processingDone = true;
|
| 1768 |
document.getElementById('proc-label').innerText = 'Engine Error';
|
|
|
|
| 1837 |
const newWrap = document.getElementById('new-analysis-wrap');
|
| 1838 |
if (newWrap) newWrap.classList.remove('hidden');
|
| 1839 |
|
| 1840 |
+
// Toast + Insights
|
| 1841 |
+
showToast('Processing complete — artifacts ready', 'success');
|
| 1842 |
+
renderInsights(d);
|
| 1843 |
+
|
| 1844 |
+
// Store video_id for keyboard shortcut download
|
| 1845 |
+
document.body.setAttribute('data-last-video-id', d.video_id);
|
| 1846 |
+
|
| 1847 |
return;
|
| 1848 |
}
|
| 1849 |
|
|
|
|
| 1863 |
const currTotal = parseInt(cntTotalEl.innerText) || 0;
|
| 1864 |
animateValue(cntTotalEl, currTotal, totalIn + totalOut, 300);
|
| 1865 |
|
| 1866 |
+
// Update PCU display
|
| 1867 |
+
const pcuVal = calcPCU(d.class_in, d.class_out);
|
| 1868 |
+
const pcuEl = document.getElementById('cnt-pcu');
|
| 1869 |
+
if (pcuEl) pcuEl.innerText = pcuVal;
|
| 1870 |
+
|
| 1871 |
// Update doughnut
|
| 1872 |
doughChart.data.datasets[0].data = [totalIn, totalOut];
|
| 1873 |
doughChart.update();
|
|
|
|
| 1897 |
'annotated.mp4': { title: 'Annotated Video Export', desc: 'Rendered video with tracking overlays' },
|
| 1898 |
'heatmap.png': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
|
| 1899 |
'heatmap.pdf': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
|
| 1900 |
+
'raw_data.csv': { title: 'Raw Analytics Export', desc: 'Comma-separated values of all crossings' },
|
| 1901 |
+
'analysis.json': { title: 'Structured JSON Export', desc: 'Complete analysis data with metadata for API consumption' }
|
| 1902 |
};
|
| 1903 |
|
| 1904 |
async function loadReports(videoId) {
|
|
|
|
| 1939 |
<i class="fa-solid fa-file-csv text-6xl mb-4 text-white"></i>
|
| 1940 |
<span class="text-xs font-bold uppercase tracking-widest text-slate-500">Raw Analytics Export</span>
|
| 1941 |
</div>`;
|
| 1942 |
+
} else if (name.endsWith('.json')) {
|
| 1943 |
+
previewHTML = `
|
| 1944 |
+
<div class="flex flex-col items-center justify-center py-12 text-slate-700">
|
| 1945 |
+
<i class="fa-solid fa-code text-6xl mb-4 text-white"></i>
|
| 1946 |
+
<span class="text-xs font-bold uppercase tracking-widest text-slate-500">Structured JSON</span>
|
| 1947 |
+
</div>`;
|
| 1948 |
} else {
|
| 1949 |
previewHTML = `<img src="${url}" alt="${info.title}" class="max-w-full max-h-[320px] object-contain rounded">`;
|
| 1950 |
}
|