feat: SQLite persistence, non-blocking inference, modular frontend
Backend:
- Persist alert events and robot delivery counters in SQLite (data/app.db,
YOLO_DB_PATH override); EventStore keeps its interface, DeliveryStatsStore
uses read/write-through UPSERT; in-memory fallback remains for standalone
AlertManager use
- Serve /api/detect from a sync endpoint and serialize YOLO inference with a
lock, so concurrent requests no longer block the event loop
- Unify single-image and session detection: requests without session_id
share the fixed single-image session and run full frame confirmation
- Store UTC Z-suffixed timestamps for sortable string comparison; close
executor and database on shutdown via FastAPI lifespan
Frontend:
- Split app.js into ES modules (main.js + modules/{dom,api,ui,charts,store}
+ views/{dashboard,inspection,events,robots,robotCards}), no build step
- Escape all server data interpolated into innerHTML; guard missing
event.classes; replace lazy element ID list with memoized qs()
- Remove hardcoded fake stats (trend badge, device donut segment)
Docs:
- Rewrite README: accurate weight policy (only trained best.pt committed,
no Git LFS), SQLite persistence, single-worker note, data asset inventory
- Align models/pretrained/README.md with actual files; add YOLO_DB_PATH to
.env.example; add persistence unit tests; ruff clean
This commit is contained in:
@@ -2,21 +2,25 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel
|
||||
from ultralytics import YOLO
|
||||
|
||||
from .alerting import AlertManager
|
||||
from .events import EVENT_STATUSES, EventStore
|
||||
from .storage import Database, DeliveryStatsStore
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
||||
@@ -30,17 +34,37 @@ IOU = float(os.getenv("YOLO_IOU", "0.45"))
|
||||
MAX_UPLOAD_BYTES = 15 * 1024 * 1024
|
||||
MAX_IMAGE_PIXELS = int(os.getenv("YOLO_MAX_IMAGE_PIXELS", "25000000"))
|
||||
CLASS_NAMES = {0: "smoke", 1: "fire"}
|
||||
DB_PATH = Path(
|
||||
os.getenv("YOLO_DB_PATH", str(PROJECT_ROOT / "data" / "app.db"))
|
||||
).expanduser().resolve()
|
||||
DB = Database(DB_PATH)
|
||||
EVENT_STORE = EventStore(db=DB)
|
||||
ALERT_MANAGER = AlertManager(
|
||||
confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")),
|
||||
cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")),
|
||||
stats_store=DeliveryStatsStore(DB),
|
||||
)
|
||||
EVENT_STORE = EventStore()
|
||||
# Ultralytics models are not thread-safe: serialize inference so concurrent
|
||||
# /api/detect requests from the FastAPI threadpool cannot race each other.
|
||||
INFERENCE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class EventStatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
app = FastAPI(title="Smoke Fire Detector API", version="0.1.0")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
yield
|
||||
ALERT_MANAGER.close()
|
||||
DB.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Smoke Fire Detector API",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -77,14 +101,15 @@ def validate_image(payload: bytes) -> Image.Image:
|
||||
def predict_image(image: Image.Image) -> dict[str, Any]:
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
result = get_model().predict(
|
||||
source=image,
|
||||
conf=CONFIDENCE,
|
||||
iou=IOU,
|
||||
imgsz=IMAGE_SIZE,
|
||||
device=DEVICE,
|
||||
verbose=False,
|
||||
)[0]
|
||||
with INFERENCE_LOCK:
|
||||
result = get_model().predict(
|
||||
source=image,
|
||||
conf=CONFIDENCE,
|
||||
iou=IOU,
|
||||
imgsz=IMAGE_SIZE,
|
||||
device=DEVICE,
|
||||
verbose=False,
|
||||
)[0]
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
except Exception as error:
|
||||
@@ -118,30 +143,25 @@ def health() -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/api/detect")
|
||||
async def detect(
|
||||
file: UploadFile = File(...),
|
||||
def detect(
|
||||
file: UploadFile = File(...), # noqa: B008 — FastAPI dependency idiom
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = await file.read(MAX_UPLOAD_BYTES + 1)
|
||||
payload = file.file.read(MAX_UPLOAD_BYTES + 1)
|
||||
image = validate_image(payload)
|
||||
result = predict_image(image)
|
||||
result["alert"] = (
|
||||
ALERT_MANAGER.evaluate(
|
||||
session_id,
|
||||
result["detections"],
|
||||
image,
|
||||
)
|
||||
if session_id
|
||||
else {
|
||||
"triggered": False,
|
||||
"classes": [],
|
||||
"notification_enabled": ALERT_MANAGER.enabled,
|
||||
"notification_channels": ALERT_MANAGER.channels,
|
||||
}
|
||||
# Requests without a session share the fixed "single-image" session, so
|
||||
# the consecutive-frame confirmation works there as well. Reset it with
|
||||
# DELETE /api/sessions/single-image.
|
||||
active_session = session_id or "single-image"
|
||||
result["alert"] = ALERT_MANAGER.evaluate(
|
||||
active_session,
|
||||
result["detections"],
|
||||
image,
|
||||
)
|
||||
if result["alert"]["triggered"]:
|
||||
result["event"] = EVENT_STORE.create(
|
||||
session_id=session_id or "single-image",
|
||||
session_id=active_session,
|
||||
classes=result["alert"]["classes"],
|
||||
detections=result["detections"],
|
||||
notification_channels=result["alert"].get(
|
||||
|
||||
Reference in New Issue
Block a user