Spaces:
Sleeping
Sleeping
| # backend/api/main.py | |
| """ | |
| FastAPI backend for the Smart Parking System. | |
| Endpoints | |
| --------- | |
| GET /status current occupancy of all slots | |
| GET /frame latest annotated frame as a JPEG image | |
| GET /predict Prophet vacancy forecast per slot | |
| GET /recommend top-3 scored slot recommendations | |
| GET /history full occupancy log (for charts) | |
| GET /slots slot map coordinates (for canvas overlay) | |
| """ | |
| import json | |
| import os | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from urllib.error import HTTPError, URLError | |
| from urllib.request import urlopen | |
| from fastapi import FastAPI, HTTPException, Query | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from src.database import get_full_history, get_latest_occupancy, get_analytics_summary, init_db | |
| from src.detector import Detector | |
| from src.predictor import Predictor | |
| from src.recommender import Recommender | |
| # --------------------------------------------------------------------------- | |
| # Paths | |
| # --------------------------------------------------------------------------- | |
| BASE_DIR = Path(__file__).resolve().parent.parent | |
| SLOT_MAP_PATH = BASE_DIR / "data" / "raw" / "slot_map.json" | |
| FRAME_PATH = BASE_DIR / "data" / "annotated_frame.jpg" | |
| IMAGE_DIR = BASE_DIR / "data" / "raw" / "test" # folder of PKLot images | |
| MODEL_DOWNLOAD_URL = "https://huggingface.co/rohanv56/smart-parking-detector-bucket/resolve/main/slot_classifier.pth" | |
| # --------------------------------------------------------------------------- | |
| # Shared state — loaded once at startup, reused across requests | |
| # --------------------------------------------------------------------------- | |
| _detector: Detector | None = None | |
| _predictor: Predictor | None = None | |
| _recommender: Recommender | None = None | |
| # Index into the sorted image list so each /status call advances one frame | |
| _image_list: list[Path] = [] | |
| _frame_index: int = 0 | |
| async def lifespan(app: FastAPI): | |
| """Load heavy objects once when the server starts.""" | |
| global _detector, _predictor, _recommender, _image_list | |
| # Refresh model from Hugging Face before initializing detector. | |
| # If download fails but a local file exists, we continue with local. | |
| model_path = BASE_DIR / "models" / "slot_classifier.pth" | |
| ensure_model_exists(model_path, force_download=False) | |
| init_db() | |
| _detector = Detector() | |
| _predictor = Predictor() | |
| _recommender = Recommender() | |
| # Build a sorted list of all test images to simulate a video stream | |
| _image_list = sorted(IMAGE_DIR.glob("*.jpg")) | |
| if not _image_list: | |
| print(f"[WARNING] No .jpg images found in {IMAGE_DIR}") | |
| print(f"[API] Ready. {len(_image_list)} frames available.") | |
| yield | |
| # Nothing to clean up | |
| # --------------------------------------------------------------------------- | |
| # App | |
| # --------------------------------------------------------------------------- | |
| app = FastAPI( | |
| title="Smart Parking API", | |
| version="1.0.0", | |
| lifespan=lifespan, | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], # tighten to your Vercel URL after deploy | |
| allow_methods=["GET"], | |
| allow_headers=["*"], | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Dependency guard | |
| # --------------------------------------------------------------------------- | |
| def _require(obj, name: str): | |
| if obj is None: | |
| raise HTTPException(status_code=503, detail=f"{name} not initialised.") | |
| return obj | |
| # --------------------------------------------------------------------------- | |
| # GET /status | |
| # --------------------------------------------------------------------------- | |
| def get_status(): | |
| """ | |
| Run the detector on the next frame in the image sequence and return | |
| the current occupancy of all slots. | |
| Response shape: | |
| { | |
| "frame_index": 5, | |
| "total_frames": 300, | |
| "occupied": 74, | |
| "empty": 26, | |
| "slots": { | |
| "slot_001": {"status": "occupied", "confidence": 0.97}, | |
| ... | |
| } | |
| } | |
| """ | |
| global _frame_index | |
| det = _require(_detector, "Detector") | |
| if not _image_list: | |
| raise HTTPException(status_code=404, detail="No images found in test directory.") | |
| # Advance frame (loop back to start when the sequence ends) | |
| img_path = _image_list[_frame_index % len(_image_list)] | |
| _frame_index = (_frame_index + 1) % len(_image_list) | |
| result = det.run(img_path) | |
| occupied = sum(1 for v in result.values() if v["status"] == "occupied") | |
| empty = len(result) - occupied | |
| return { | |
| "frame_index": _frame_index, | |
| "total_frames": len(_image_list), | |
| "occupied": occupied, | |
| "empty": empty, | |
| "slots": result, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # GET /frame | |
| # --------------------------------------------------------------------------- | |
| def get_frame(): | |
| """ | |
| Return the annotated JPEG produced by the most recent /status call. | |
| The frontend polls this to display the live parking lot view. | |
| """ | |
| if not FRAME_PATH.exists(): | |
| raise HTTPException( | |
| status_code=404, | |
| detail="No annotated frame yet. Call /status first.", | |
| ) | |
| return FileResponse( | |
| path=str(FRAME_PATH), | |
| media_type="image/jpeg", | |
| headers={"Cache-Control": "no-store"}, # prevent browser caching stale frames | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # GET /predict | |
| # --------------------------------------------------------------------------- | |
| def get_predict(horizon: int = Query(default=30, ge=1, le=1440)): | |
| """ | |
| Return Prophet vacancy forecasts for all slots. | |
| Query param: | |
| horizon (int, minutes, default 30, max 1440) | |
| Response shape: | |
| { | |
| "horizon_minutes": 30, | |
| "forecasts": [ | |
| {"slot_id": "slot_001", "vacancy_prob": 0.72}, | |
| ... | |
| ] | |
| } | |
| """ | |
| pred = _require(_predictor, "Predictor") | |
| forecasts = pred.predict(horizon_minutes=horizon) | |
| return {"horizon_minutes": horizon, "forecasts": forecasts} | |
| # --------------------------------------------------------------------------- | |
| # GET /recommend | |
| # --------------------------------------------------------------------------- | |
| def get_recommend( | |
| entry_x: float = Query(default=0.0), | |
| entry_y: float = Query(default=0.0), | |
| horizon: int = Query(default=30, ge=1, le=1440), | |
| top_n: int = Query(default=3, ge=1, le=10), | |
| ): | |
| """ | |
| Return the top-N recommended slots scored by distance + vacancy. | |
| Query params: | |
| entry_x, entry_y driver entry point in image-pixel coordinates | |
| horizon forecast horizon in minutes | |
| top_n number of results (default 3) | |
| Response shape: | |
| { | |
| "recommendations": [ | |
| { | |
| "slot_id": "slot_042", | |
| "score": -0.381, | |
| "distance": 124.7, | |
| "vacancy_prob": 0.88, | |
| "cx": 312.0, | |
| "cy": 205.5 | |
| }, | |
| ... | |
| ] | |
| } | |
| """ | |
| rec = _require(_recommender, "Recommender") | |
| recommendations = rec.recommend( | |
| entry_x=entry_x, | |
| entry_y=entry_y, | |
| horizon_minutes=horizon, | |
| top_n=top_n, | |
| ) | |
| return {"recommendations": recommendations} | |
| # --------------------------------------------------------------------------- | |
| # GET /history | |
| # --------------------------------------------------------------------------- | |
| def get_history( | |
| limit: int = Query(default=500, ge=1, le=5000), | |
| offset: int = Query(default=0, ge=0), | |
| ): | |
| """ | |
| Return a paginated slice of the occupancy log for charting. | |
| Query params: | |
| limit — rows per page (default 500, max 5000) | |
| offset — skip this many rows from the start | |
| Response shape: | |
| { | |
| "count": 70000, | |
| "records": [ | |
| {"slot_id": "slot_001", "status": "occupied", | |
| "confidence": 0.97, "logged_at": "2024-01-01T10:00:00+00:00"}, | |
| ... | |
| ] | |
| } | |
| """ | |
| records = get_full_history(limit=limit, offset=offset) | |
| return {"count": len(records), "records": records} | |
| # --------------------------------------------------------------------------- | |
| # GET /analytics | |
| # --------------------------------------------------------------------------- | |
| def get_analytics(): | |
| """ | |
| Return aggregated statistics from the occupancy log. | |
| This avoids sending tens of thousands of raw rows to the frontend. | |
| Response shape: | |
| { | |
| "total_readings": 72000, | |
| "avg_occupancy_pct": 73.2, | |
| "peak_hour": 14, | |
| "busiest_slot": "slot_042", | |
| "hourly_trend": [ | |
| {"hour": "2024-01-01T10:00", "occupied": 74, "empty": 26}, | |
| ... | |
| ] | |
| } | |
| """ | |
| from src.database import get_analytics_summary | |
| return get_analytics_summary() | |
| # --------------------------------------------------------------------------- | |
| # GET /slots | |
| # --------------------------------------------------------------------------- | |
| def get_slots(): | |
| """ | |
| Return the slot map (coordinates + centroids) for the frontend canvas. | |
| Response shape: | |
| { | |
| "reference_frame": "...", | |
| "total_slots": 100, | |
| "slots": [ | |
| {"slot_id": 1, "x": 139, "y": 165, "w": 23, "h": 40, | |
| "cx": 150.5, "cy": 185.0}, | |
| ... | |
| ] | |
| } | |
| """ | |
| if not SLOT_MAP_PATH.exists(): | |
| raise HTTPException(status_code=404, detail="slot_map.json not found.") | |
| with open(SLOT_MAP_PATH, "r") as f: | |
| data = json.load(f) | |
| return data | |
| # --------------------------------------------------------------------------- | |
| # Model download | |
| # --------------------------------------------------------------------------- | |
| def ensure_model_exists(model_path: Path, force_download: bool = False) -> None: | |
| """Download model from Hugging Face; optionally overwrite existing local file.""" | |
| if model_path.exists() and not force_download: | |
| return | |
| print(f"[Model] Downloading from {MODEL_DOWNLOAD_URL}...") | |
| model_path.parent.mkdir(parents=True, exist_ok=True) | |
| try: | |
| with urlopen(MODEL_DOWNLOAD_URL, timeout=300) as response: | |
| with open(model_path, "wb") as f: | |
| while True: | |
| chunk = response.read(1024 * 1024) | |
| if not chunk: | |
| break | |
| f.write(chunk) | |
| print("[Model] Download complete ✓") | |
| except (HTTPError, URLError, TimeoutError, OSError) as exc: | |
| if model_path.exists(): | |
| print(f"[Model] Download failed, using local model: {exc}") | |
| return | |
| raise RuntimeError(f"Failed to download model and no local model found: {exc}") from exc |