Files
yolo/backend/main.py
Kunpeng 25fa0c5825 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
2026-08-13 16:50:23 +08:00

263 lines
8.4 KiB
Python

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 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"
load_dotenv(PROJECT_ROOT / ".env")
DEFAULT_WEIGHTS = PROJECT_ROOT / "runs" / "detect" / "smoke_fire_yolo11s_v1-4" / "weights" / "best.pt"
WEIGHTS_PATH = Path(os.getenv("YOLO_WEIGHTS", str(DEFAULT_WEIGHTS))).expanduser().resolve()
DEVICE = os.getenv("YOLO_DEVICE") or None
IMAGE_SIZE = int(os.getenv("YOLO_IMGSZ", "768"))
CONFIDENCE = float(os.getenv("YOLO_CONF", "0.40"))
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),
)
# 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
@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=["*"],
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["*"],
)
@lru_cache(maxsize=1)
def get_model() -> YOLO:
if not WEIGHTS_PATH.is_file():
raise FileNotFoundError(f"YOLO weights not found: {WEIGHTS_PATH}")
return YOLO(str(WEIGHTS_PATH))
def validate_image(payload: bytes) -> Image.Image:
if not payload:
raise HTTPException(status_code=400, detail="Uploaded file is empty")
if len(payload) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Uploaded file exceeds 15 MB")
try:
image = Image.open(io.BytesIO(payload))
if image.width * image.height > MAX_IMAGE_PIXELS:
raise HTTPException(
status_code=413,
detail="Image dimensions are too large",
)
image.load()
return image.convert("RGB")
except (OSError, UnidentifiedImageError) as error:
raise HTTPException(status_code=415, detail="Only valid image files are supported") from error
def predict_image(image: Image.Image) -> dict[str, Any]:
started_at = time.perf_counter()
try:
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:
raise HTTPException(status_code=500, detail=f"Inference failed: {error}") from error
detections = []
for box in result.boxes:
class_id = int(box.cls.item())
detections.append({
"class": CLASS_NAMES.get(class_id, str(class_id)),
"class_id": class_id,
"confidence": round(float(box.conf.item()), 6),
"box": [round(float(value), 2) for value in box.xyxy[0].tolist()],
})
return {
"detections": detections,
"image": {"width": image.width, "height": image.height},
"inference_ms": round((time.perf_counter() - started_at) * 1000, 1),
}
@app.get("/api/health")
def health() -> dict[str, Any]:
return {
"status": "ok",
"weights": str(WEIGHTS_PATH),
"weights_available": WEIGHTS_PATH.is_file(),
"wechat_alerts_enabled": ALERT_MANAGER.channels["wechat"],
"feishu_alerts_enabled": ALERT_MANAGER.channels["feishu"],
"alert_channels": ALERT_MANAGER.channels,
}
@app.post("/api/detect")
def detect(
file: UploadFile = File(...), # noqa: B008 — FastAPI dependency idiom
session_id: str | None = None,
) -> dict[str, Any]:
payload = file.file.read(MAX_UPLOAD_BYTES + 1)
image = validate_image(payload)
result = predict_image(image)
# 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=active_session,
classes=result["alert"]["classes"],
detections=result["detections"],
notification_channels=result["alert"].get(
"notification_channels",
ALERT_MANAGER.channels,
),
)
return result
@app.delete("/api/sessions/{session_id}")
def reset_detection_session(session_id: str) -> dict[str, str]:
ALERT_MANAGER.reset(session_id)
return {"status": "reset"}
@app.get("/api/events")
def list_events(
limit: int = 100,
status: str | None = None,
) -> dict[str, Any]:
if status and status not in EVENT_STATUSES:
raise HTTPException(status_code=400, detail="Invalid event status")
return {
"events": EVENT_STORE.list(limit=limit, status=status),
"summary": EVENT_STORE.summary(),
}
@app.get("/api/robots")
def list_robots() -> dict[str, Any]:
robots = ALERT_MANAGER.channel_status
return {
"robots": robots,
"summary": {
"total": len(robots),
"configured": sum(robot["configured"] for robot in robots),
"delivered": sum(robot["delivered"] for robot in robots),
"failed": sum(robot["failed"] for robot in robots),
},
}
@app.post("/api/robots/{channel}/test")
def test_robot(channel: str) -> dict[str, Any]:
try:
robot = ALERT_MANAGER.test_channel(channel)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
except RuntimeError as error:
raise HTTPException(
status_code=502,
detail=f"机器人消息发送失败: {error}",
) from error
return {"status": "sent", "robot": robot}
@app.patch("/api/events/{event_id}")
def update_event(
event_id: str,
update: EventStatusUpdate,
) -> dict[str, Any]:
try:
event = EVENT_STORE.update(event_id, update.status)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
return event
@app.get("/api/dashboard")
def dashboard() -> dict[str, Any]:
return {
"summary": EVENT_STORE.summary(),
"recent_events": EVENT_STORE.list(limit=6),
"system": health(),
"robots": list_robots(),
"detection": {
"confidence": CONFIDENCE,
"iou": IOU,
"image_size": IMAGE_SIZE,
"confirm_frames": ALERT_MANAGER.confirm_frames,
"cooldown_seconds": ALERT_MANAGER.cooldown_seconds,
},
}
@app.get("/")
def frontend() -> FileResponse:
return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/{asset_path:path}")
def frontend_asset(asset_path: str) -> FileResponse:
requested = (FRONTEND_DIR / asset_path).resolve()
if FRONTEND_DIR not in requested.parents or not requested.is_file():
raise HTTPException(status_code=404, detail="Asset not found")
return FileResponse(requested)