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)