feat: add video detection and robot alerts

This commit is contained in:
2026-08-12 17:15:11 +08:00
parent 67d5b40736
commit bb395e3d9a
22 changed files with 1623 additions and 319 deletions

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""HTTP inference backend for the smoke and fire detector."""

323
backend/alerting.py Normal file
View File

@@ -0,0 +1,323 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import os
import threading
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from io import BytesIO
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from PIL import Image, ImageDraw, ImageFont
LOGGER = logging.getLogger(__name__)
ALERT_CLASSES = ("fire", "smoke")
@dataclass(slots=True)
class SessionState:
consecutive: dict[str, int] = field(
default_factory=lambda: defaultdict(int)
)
last_alert_at: dict[str, float] = field(
default_factory=lambda: defaultdict(float)
)
last_seen_at: float = field(default_factory=time.monotonic)
class AlertManager:
def __init__(
self,
webhook_url: str | None = None,
wechat_webhook_url: str | None = None,
feishu_webhook_url: str | None = None,
feishu_secret: str | None = None,
confirm_frames: int = 3,
cooldown_seconds: float = 60.0,
session_ttl_seconds: float = 3600.0,
) -> None:
self.wechat_webhook_url = (
wechat_webhook_url
or webhook_url
or os.getenv("WECHAT_WEBHOOK_URL")
)
self.feishu_webhook_url = (
feishu_webhook_url or os.getenv("FEISHU_WEBHOOK_URL")
)
self.feishu_secret = feishu_secret or os.getenv("FEISHU_SECRET")
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._states: dict[str, SessionState] = {}
self._lock = threading.Lock()
self._executor = ThreadPoolExecutor(
max_workers=4,
thread_name_prefix="alert-dispatch",
)
@property
def channels(self) -> dict[str, bool]:
return {
"wechat": bool(self.wechat_webhook_url),
"feishu": bool(self.feishu_webhook_url),
}
@property
def enabled(self) -> bool:
return any(self.channels.values())
def evaluate(
self,
session_id: str,
detections: list[dict[str, Any]],
image: Image.Image,
) -> dict[str, Any]:
now = time.monotonic()
present = {
detection["class"]
for detection in detections
if detection.get("class") in ALERT_CLASSES
}
triggered: list[str] = []
with self._lock:
self._prune_sessions(now)
state = self._states.setdefault(session_id, SessionState())
state.last_seen_at = now
for class_name in ALERT_CLASSES:
state.consecutive[class_name] = (
state.consecutive[class_name] + 1
if class_name in present
else 0
)
ready = state.consecutive[class_name] >= self.confirm_frames
cooldown_elapsed = (
now - state.last_alert_at[class_name]
>= self.cooldown_seconds
)
if ready and cooldown_elapsed:
state.last_alert_at[class_name] = now
triggered.append(class_name)
consecutive = dict(state.consecutive)
if triggered and self.enabled:
self._executor.submit(
self._send_alerts,
annotate_image(image, detections),
triggered,
detections,
)
return {
"triggered": bool(triggered),
"classes": triggered,
"confirmed_frames": self.confirm_frames,
"consecutive": consecutive,
"cooldown_seconds": self.cooldown_seconds,
"notification_enabled": self.enabled,
"notification_channels": self.channels,
}
def reset(self, session_id: str) -> None:
with self._lock:
self._states.pop(session_id, None)
def _prune_sessions(self, now: float) -> None:
expired = [
session_id
for session_id, state in self._states.items()
if now - state.last_seen_at > self.session_ttl_seconds
]
for session_id in expired:
del self._states[session_id]
def _send_alerts(
self,
image: Image.Image,
triggered: list[str],
detections: list[dict[str, Any]],
) -> None:
if self.wechat_webhook_url:
try:
self._send_wechat_alert(image, triggered, detections)
except (HTTPError, URLError, TimeoutError, ValueError) as error:
LOGGER.error("WeChat alert failed: %s", error)
if self.feishu_webhook_url:
try:
self._send_feishu_alert(triggered, detections)
except (HTTPError, URLError, TimeoutError, ValueError) as error:
LOGGER.error("Feishu alert failed: %s", error)
def _send_wechat_alert(
self,
image: Image.Image,
triggered: list[str],
detections: list[dict[str, Any]],
) -> None:
if not self.wechat_webhook_url:
return
target_text, max_confidence = alert_summary(triggered, detections)
message = (
f"🔥 烟火检测告警\n"
f"> 检测目标:{target_text}\n"
f"> 最高置信度:{max_confidence:.1%}\n"
f"> 请及时查看现场视频。"
)
post_json(
self.wechat_webhook_url,
{"msgtype": "markdown", "markdown": {"content": message}},
)
image_bytes = encode_jpeg(image)
post_json(
self.wechat_webhook_url,
{
"msgtype": "image",
"image": {
"base64": base64.b64encode(image_bytes).decode("ascii"),
"md5": hashlib.md5(
image_bytes,
usedforsecurity=False,
).hexdigest(),
},
},
)
def _send_feishu_alert(
self,
triggered: list[str],
detections: list[dict[str, Any]],
) -> None:
if not self.feishu_webhook_url:
return
target_text, max_confidence = alert_summary(triggered, detections)
payload: dict[str, Any] = {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"template": "red",
"title": {
"tag": "plain_text",
"content": "烟火检测告警",
},
},
"elements": [
{
"tag": "markdown",
"content": (
f"**检测目标:** {target_text}\n"
f"**最高置信度:** {max_confidence:.1%}\n"
f"**告警时间:** "
f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"请及时查看现场视频。"
),
}
],
},
}
if self.feishu_secret:
timestamp = str(int(time.time()))
payload["timestamp"] = timestamp
payload["sign"] = feishu_signature(
timestamp,
self.feishu_secret,
)
post_json(self.feishu_webhook_url, payload)
def alert_summary(
triggered: list[str],
detections: list[dict[str, Any]],
) -> tuple[str, float]:
labels = {"fire": "火焰", "smoke": "烟雾"}
target_text = "".join(labels[name] for name in triggered)
max_confidence = max(
(
float(detection.get("confidence", 0.0))
for detection in detections
if detection.get("class") in triggered
),
default=0.0,
)
return target_text, max_confidence
def feishu_signature(timestamp: str, secret: str) -> str:
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii")
def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
request = Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode("utf-8"))
error_code = result.get(
"errcode",
result.get("code", result.get("StatusCode", 0)),
)
if error_code != 0:
raise ValueError(
result.get("errmsg")
or result.get("msg")
or result.get("StatusMessage")
or "Unknown robot webhook error"
)
return result
def annotate_image(
image: Image.Image,
detections: list[dict[str, Any]],
) -> Image.Image:
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
font = ImageFont.load_default()
colors = {"fire": "#ff3b30", "smoke": "#00a89b"}
for detection in detections:
box = detection.get("box")
if not box or len(box) != 4:
continue
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)
draw.rectangle(coordinates, outline=color, width=4)
draw.text(
(coordinates[0] + 4, max(0, coordinates[1] - 16)),
f"{class_name} {confidence:.0%}",
fill=color,
font=font,
)
return annotated
def encode_jpeg(image: Image.Image, max_bytes: int = 1_900_000) -> bytes:
working = image.convert("RGB")
quality = 88
while True:
output = BytesIO()
working.save(output, format="JPEG", quality=quality, optimize=True)
payload = output.getvalue()
if len(payload) <= max_bytes:
return payload
if quality > 55:
quality -= 10
continue
width, height = working.size
working = working.resize(
(max(1, int(width * 0.8)), max(1, int(height * 0.8)))
)
quality = 75

154
backend/main.py Normal file
View File

@@ -0,0 +1,154 @@
from __future__ import annotations
import io
import os
import time
from functools import lru_cache
from pathlib import Path
from typing import Any
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 ultralytics import YOLO
from .alerting import AlertManager
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"}
ALERT_MANAGER = AlertManager(
confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")),
cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")),
)
app = FastAPI(title="Smoke Fire Detector API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST", "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:
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")
async def detect(
file: UploadFile = File(...),
session_id: str | None = None,
) -> dict[str, Any]:
payload = await 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,
}
)
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("/")
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)