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
263 lines
8.4 KiB
Python
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)
|