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:
2026-08-13 16:50:23 +08:00
parent 4dee7f664d
commit 25fa0c5825
25 changed files with 1058 additions and 669 deletions

View File

@@ -11,7 +11,6 @@ import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime
from io import BytesIO
from typing import Any
from urllib.error import HTTPError, URLError
@@ -19,6 +18,8 @@ from urllib.request import Request, urlopen
from PIL import Image, ImageDraw, ImageFont
from .storage import DeliveryStatsStore, utc_now_iso
LOGGER = logging.getLogger(__name__)
ALERT_CLASSES = ("fire", "smoke")
CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"}
@@ -45,6 +46,7 @@ class AlertManager:
confirm_frames: int = 3,
cooldown_seconds: float = 60.0,
session_ttl_seconds: float = 3600.0,
stats_store: DeliveryStatsStore | None = None,
) -> None:
self.wechat_webhook_url = (
wechat_webhook_url
@@ -58,6 +60,7 @@ class AlertManager:
self.confirm_frames = max(1, confirm_frames)
self.cooldown_seconds = max(0.0, cooldown_seconds)
self.session_ttl_seconds = max(60.0, session_ttl_seconds)
self.stats_store = stats_store
self._states: dict[str, SessionState] = {}
self._lock = threading.Lock()
self._delivery_stats = {
@@ -92,11 +95,27 @@ class AlertManager:
@property
def channel_status(self) -> list[dict[str, Any]]:
configured = self.channels
with self._lock:
if self.stats_store is not None:
saved = self.stats_store.get_all()
stats = {
channel: dict(values)
for channel, values in self._delivery_stats.items()
channel: {
"delivered": 0,
"failed": 0,
"tests": 0,
"last_status": "idle",
"last_delivery_at": None,
"last_test_at": None,
"last_error": None,
**saved.get(channel, {}),
}
for channel in CHANNEL_NAMES
}
else:
with self._lock:
stats = {
channel: dict(values)
for channel, values in self._delivery_stats.items()
}
return [
{
"id": channel,
@@ -191,6 +210,10 @@ class AlertManager:
with self._lock:
self._states.pop(session_id, None)
def close(self) -> None:
"""Shut down the alert dispatch executor (call on service shutdown)."""
self._executor.shutdown(wait=True, cancel_futures=True)
def _prune_sessions(self, now: float) -> None:
expired = [
session_id
@@ -230,7 +253,15 @@ class AlertManager:
error: str | None = None,
test: bool = False,
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
if self.stats_store is not None:
self.stats_store.record(
channel,
success=success,
error=error,
test=test,
)
return
now = utc_now_iso()
with self._lock:
stats = self._delivery_stats[channel]
stats["last_status"] = "success" if success else "failed"
@@ -386,7 +417,7 @@ def alert_summary(
def feishu_signature(timestamp: str, secret: str) -> str:
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
string_to_sign = f"{timestamp}\n{secret}".encode()
digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii")
@@ -429,7 +460,7 @@ def annotate_image(
class_name = str(detection.get("class", "target"))
confidence = float(detection.get("confidence", 0.0))
color = colors.get(class_name, "#ffd166")
coordinates = tuple(int(round(value)) for value in box)
coordinates = tuple(round(value) for value in box)
draw.rectangle(coordinates, outline=color, width=4)
draw.text(
(coordinates[0] + 4, max(0, coordinates[1] - 16)),

View File

@@ -1,18 +1,44 @@
from __future__ import annotations
import threading
import json
import uuid
from collections import deque
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
from .storage import Database, utc_now_iso
EVENT_STATUSES = {"pending", "acknowledged", "resolved"}
def _local_today_utc_boundary() -> str:
"""UTC Z-format timestamp of local midnight, for 'today' comparisons."""
local_midnight = datetime.now().astimezone().replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
return (
local_midnight.astimezone(UTC)
.isoformat(timespec="seconds")
.replace("+00:00", "Z")
)
class EventStore:
def __init__(self, max_events: int = 500) -> None:
self._events: deque[dict[str, Any]] = deque(maxlen=max_events)
self._lock = threading.Lock()
"""Persistent alert event store backed by a single SQLite table.
Keeps at most ``max_events`` rows (newest first, ordered by rowid so
events created within the same second keep a stable insertion order).
"""
def __init__(
self,
db: Database | None = None,
max_events: int = 500,
) -> None:
self._db = db or Database(":memory:")
self._max_events = max(1, max_events)
def create(
self,
@@ -21,7 +47,6 @@ class EventStore:
detections: list[dict[str, Any]],
notification_channels: dict[str, bool],
) -> dict[str, Any]:
now = datetime.now().astimezone()
confidences = [
float(detection.get("confidence", 0.0))
for detection in detections
@@ -29,7 +54,7 @@ class EventStore:
]
event = {
"id": uuid.uuid4().hex[:12],
"created_at": now.isoformat(timespec="seconds"),
"created_at": utc_now_iso(),
"session_id": session_id,
"classes": classes,
"max_confidence": round(max(confidences, default=0.0), 6),
@@ -38,17 +63,44 @@ class EventStore:
"notification_channels": notification_channels,
"handled_at": None,
}
with self._lock:
self._events.appendleft(event)
return dict(event)
self._db.execute(
"""
INSERT INTO events (
id, created_at, session_id, classes, max_confidence,
detection_count, status, notification_channels, handled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event["id"],
event["created_at"],
session_id,
json.dumps(classes, ensure_ascii=False),
event["max_confidence"],
event["detection_count"],
event["status"],
json.dumps(notification_channels, ensure_ascii=False),
None,
),
)
self._db.execute(
"""
DELETE FROM events WHERE id IN (
SELECT id FROM events ORDER BY rowid DESC LIMIT -1 OFFSET ?
)
""",
(self._max_events,),
)
return event
def list(
self,
limit: int = 100,
status: str | None = None,
) -> list[dict[str, Any]]:
with self._lock:
events = [dict(event) for event in self._events]
rows = self._db.execute(
"SELECT * FROM events ORDER BY rowid DESC",
).fetchall()
events = [self._to_dict(row) for row in rows]
if status:
events = [event for event in events if event["status"] == status]
return events[: max(1, min(limit, 500))]
@@ -56,42 +108,57 @@ class EventStore:
def update(self, event_id: str, status: str) -> dict[str, Any] | None:
if status not in EVENT_STATUSES:
raise ValueError(f"Unsupported event status: {status}")
with self._lock:
for event in self._events:
if event["id"] != event_id:
continue
event["status"] = status
event["handled_at"] = (
None
if status == "pending"
else datetime.now().astimezone().isoformat(
timespec="seconds"
)
)
return dict(event)
return None
cursor = self._db.execute(
"""
UPDATE events SET status = ?, handled_at = ?
WHERE id = ?
""",
(status, None if status == "pending" else utc_now_iso(), event_id),
)
if cursor.rowcount == 0:
return None
row = self._db.execute(
"SELECT * FROM events WHERE id = ?",
(event_id,),
).fetchone()
return self._to_dict(row)
def summary(self) -> dict[str, int]:
today = datetime.now().astimezone().date()
with self._lock:
events = [dict(event) for event in self._events]
today_events = [
event
for event in events
if datetime.fromisoformat(event["created_at"]).date() == today
]
rows = self._db.execute(
"SELECT created_at, classes, status FROM events",
).fetchall()
today_boundary = _local_today_utc_boundary()
today_events = 0
counts = {status: 0 for status in EVENT_STATUSES}
fire = 0
smoke = 0
for row in rows:
counts[row["status"]] += 1
classes = json.loads(row["classes"])
if row["created_at"] >= today_boundary:
today_events += 1
fire += "fire" in classes
smoke += "smoke" in classes
return {
"total": len(events),
"today": len(today_events),
"pending": sum(
event["status"] == "pending" for event in events
),
"acknowledged": sum(
event["status"] == "acknowledged" for event in events
),
"resolved": sum(
event["status"] == "resolved" for event in events
),
"fire": sum("fire" in event["classes"] for event in events),
"smoke": sum("smoke" in event["classes"] for event in events),
"total": len(rows),
"today": today_events,
"pending": counts["pending"],
"acknowledged": counts["acknowledged"],
"resolved": counts["resolved"],
"fire": fire,
"smoke": smoke,
}
@staticmethod
def _to_dict(row: Any) -> dict[str, Any]:
return {
"id": row["id"],
"created_at": row["created_at"],
"session_id": row["session_id"],
"classes": json.loads(row["classes"]),
"max_confidence": row["max_confidence"],
"detection_count": row["detection_count"],
"status": row["status"],
"notification_channels": json.loads(row["notification_channels"]),
"handled_at": row["handled_at"],
}

View File

@@ -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(

137
backend/storage.py Normal file
View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import sqlite3
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
session_id TEXT NOT NULL,
classes TEXT NOT NULL,
max_confidence REAL NOT NULL,
detection_count INTEGER NOT NULL,
status TEXT NOT NULL,
notification_channels TEXT NOT NULL,
handled_at TEXT
);
CREATE TABLE IF NOT EXISTS delivery_stats (
channel TEXT PRIMARY KEY,
delivered INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
tests INTEGER NOT NULL DEFAULT 0,
last_status TEXT NOT NULL DEFAULT 'idle',
last_delivery_at TEXT,
last_test_at TEXT,
last_error TEXT
);
"""
def utc_now_iso() -> str:
"""Current UTC time as a sortable ISO string with a Z suffix."""
return (
datetime.now(UTC)
.isoformat(timespec="seconds")
.replace("+00:00", "Z")
)
class Database:
"""Single shared SQLite connection guarded by a lock.
The API service is low-write and multi-threaded (FastAPI threadpool,
alert dispatch executor), so one connection with ``check_same_thread=False``
plus a lock is simpler and safer on Windows than per-operation connections.
"""
def __init__(self, path: str | Path) -> None:
resolved = Path(path)
resolved.parent.mkdir(parents=True, exist_ok=True)
self._connection = sqlite3.connect(
str(resolved),
check_same_thread=False,
)
self._connection.row_factory = sqlite3.Row
self._lock = threading.Lock()
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA busy_timeout=5000")
self._connection.execute("PRAGMA synchronous=NORMAL")
self._connection.executescript(SCHEMA)
self._connection.commit()
def execute(
self,
statement: str,
parameters: tuple[Any, ...] = (),
) -> sqlite3.Cursor:
with self._lock:
cursor = self._connection.execute(statement, parameters)
self._connection.commit()
return cursor
def close(self) -> None:
with self._lock:
self._connection.close()
class DeliveryStatsStore:
"""Persistent per-channel delivery counters (read-through / write-through).
Each ``record`` call applies an atomic UPSERT carrying 0/1 increments and
the new ``last_*`` values, so there is no in-memory copy to drift from disk.
"""
def __init__(self, db: Database) -> None:
self._db = db
def get_all(self) -> dict[str, dict[str, Any]]:
rows = self._db.execute(
"SELECT * FROM delivery_stats",
).fetchall()
return {row["channel"]: dict(row) for row in rows}
def record(
self,
channel: str,
*,
success: bool,
error: str | None = None,
test: bool = False,
) -> None:
now = utc_now_iso()
self._db.execute(
"""
INSERT INTO delivery_stats (
channel, delivered, failed, tests, last_status,
last_delivery_at, last_test_at, last_error
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(channel) DO UPDATE SET
delivered = delivered + excluded.delivered,
failed = failed + excluded.failed,
tests = tests + excluded.tests,
last_status = excluded.last_status,
last_delivery_at = COALESCE(
excluded.last_delivery_at,
delivery_stats.last_delivery_at
),
last_test_at = COALESCE(
excluded.last_test_at,
delivery_stats.last_test_at
),
last_error = excluded.last_error
""",
(
channel,
1 if success else 0,
0 if success else 1,
1 if test else 0,
"success" if success else "failed",
now if not test else None,
now if test else None,
None if success else error,
),
)