rohanv56's picture
Upload 380 files
2d30bad verified
Raw
History Blame Contribute Delete
11.3 kB
# 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
@asynccontextmanager
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
# ---------------------------------------------------------------------------
@app.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
# ---------------------------------------------------------------------------
@app.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
# ---------------------------------------------------------------------------
@app.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
# ---------------------------------------------------------------------------
@app.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
# ---------------------------------------------------------------------------
@app.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
# ---------------------------------------------------------------------------
@app.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
# ---------------------------------------------------------------------------
@app.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