""" main.py — FastAPI backend for Traffic Monitoring System Processes frames as fast as possible; frontend controls playback timing. """ import asyncio import base64 import csv import json import re import shutil import subprocess import time import uuid import threading import urllib.parse import urllib.request from collections import defaultdict, deque from pathlib import Path import cv2 import numpy as np from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from tracker import TrafficTracker, DEFAULT_CLASSES # ─── App setup ──────────────────────────────────────────────────────────────── app = FastAPI(title="Traffic Monitoring API", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) BASE_DIR = Path(__file__).parent.parent UPLOAD_DIR = BASE_DIR / "uploads"; UPLOAD_DIR.mkdir(exist_ok=True) LOG_DIR = BASE_DIR / "logs"; LOG_DIR.mkdir(exist_ok=True) OUTPUT_DIR = BASE_DIR / "output"; OUTPUT_DIR.mkdir(exist_ok=True) STATIC_DIR = BASE_DIR / "frontend" if STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # ─── Session store ───────────────────────────────────────────────────────────── sessions: dict[str, dict] = {} webcam_sessions: dict[str, dict] = {} scene_lock = threading.Lock() def _safe_log_path(filename: str) -> Path: path = (LOG_DIR / filename).resolve() if LOG_DIR.resolve() not in path.parents and path != LOG_DIR.resolve(): raise HTTPException(400, "Invalid filename") return path def _get_summary_for_session(sid: str) -> dict | None: if sid in sessions: return sessions[sid].get("summary") if sid in webcam_sessions: return webcam_sessions[sid].get("summary") for summary_path in LOG_DIR.glob("*_summary.json"): try: summary = json.loads(summary_path.read_text()) except Exception: continue if summary.get("session_id") == sid: return summary return None def _get_completed_session_summary(sid: str) -> dict: if sid in sessions: s = sessions[sid] if s["status"] != "done": raise HTTPException(202, "Processing not complete yet") summary = _get_summary_for_session(sid) if not summary: raise HTTPException(404, "Session not found") return summary def _detection_csv_for_summary(summary: dict) -> Path | None: log_files = summary.get("log_files") or {} explicit_path = log_files.get("detections_csv") if explicit_path: path = Path(explicit_path) if path.exists(): return path group_id = str(summary.get("group_id") or "").strip() scene = str(summary.get("scene") or "").strip() if group_id and scene: matches = sorted( LOG_DIR.glob(f"{group_id}_{scene}_*_detections.csv"), key=lambda p: p.stat().st_mtime, reverse=True, ) if matches: return matches[0] return None def _position_heatmap_for_summary(summary: dict, cols: int = 24, rows: int = 24) -> dict: csv_path = _detection_csv_for_summary(summary) cells: dict[tuple[int, int], int] = defaultdict(int) class_cells: dict[tuple[int, int], dict[str, int]] = defaultdict(lambda: defaultdict(int)) total_points = 0 if not csv_path: return {"cols": cols, "rows": rows, "max_density": 0, "total_points": 0, "cells": []} try: with csv_path.open(newline="", encoding="utf-8") as f: for row in csv.DictReader(f): try: cx = float(row.get("cx") or "") cy = float(row.get("cy") or "") width = float(row.get("frame_width") or 0) height = float(row.get("frame_height") or 0) except ValueError: continue if width <= 0 or height <= 0: continue col = min(cols - 1, max(0, int((cx / width) * cols))) row_idx = min(rows - 1, max(0, int((cy / height) * rows))) class_name = str(row.get("class_name") or "unknown") cells[(col, row_idx)] += 1 class_cells[(col, row_idx)][class_name] += 1 total_points += 1 except Exception: return {"cols": cols, "rows": rows, "max_density": 0, "total_points": 0, "cells": []} max_density = max(cells.values(), default=0) return { "cols": cols, "rows": rows, "max_density": int(max_density), "total_points": int(total_points), "cells": [ { "x": x, "y": y, "count": int(count), "classes": dict(class_cells[(x, y)]), } for (x, y), count in sorted(cells.items(), key=lambda item: (item[0][1], item[0][0])) ], } def _iter_known_scene_names(group_id: str): for session in sessions.values(): if session.get("group_id") == group_id and session.get("scene_name"): yield session["scene_name"] for session in webcam_sessions.values(): if session.get("group_id") == group_id and session.get("scene_name"): yield session["scene_name"] for summary_path in LOG_DIR.glob("*_summary.json"): try: summary = json.loads(summary_path.read_text()) except Exception: continue if summary.get("group_id") == group_id and summary.get("scene"): yield summary["scene"] def _next_scene_name(requested_scene: str, group_id: str) -> str: requested = (requested_scene or "scene").strip() or "scene" exact_exists = any(scene_name == requested for scene_name in _iter_known_scene_names(group_id)) suffix_match = re.match(r"^(.+?)_(\d{2,})$", requested) base = suffix_match.group(1) if exact_exists and suffix_match else requested scene_pattern = re.compile(rf"^{re.escape(base)}(?:_(\d{{2,}}))?$") order_numbers = [] for scene_name in _iter_known_scene_names(group_id): match = scene_pattern.match(scene_name) if not match: continue order_numbers.append(int(match.group(1) or 0)) if not order_numbers: return base return f"{base}_{max(order_numbers) + 1:02d}" def _ensure_browser_compatible_video(video_path: str) -> str: """ OpenCV's mp4v output downloads fine, but many browsers will not play it in a