what
This commit is contained in:
1 parent
4201a3cbb1
commit
0cd543a21a
10 files changed
+1457
-256
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,9 @@
|
||||
"""Package entrypoint so `python -m server` / `uv run -m server` works."""
|
||||
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
|
||||
# Execute top-level server.py as __main__
|
||||
runpy.run_path(
|
||||
str(Path(__file__).resolve().parent.parent / "server.py"), run_name="__main__"
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""API route definitions for the smoking detection server."""
|
||||
|
||||
import time
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Form, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import server.runtime as rt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def ping():
|
||||
"""Health check for the mobile app to verify connectivity."""
|
||||
return {"status": "ok", "name": "SmokingDetection"}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(username: str = Form(...), password: str = Form(...)):
|
||||
try:
|
||||
if rt.db.get_user_by_username(username):
|
||||
return {"status": "fail", "message": "Username already taken"}
|
||||
new_id = rt.db.create_user(username, password)
|
||||
return {"status": "success", "user_id": new_id, "message": "Account created"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(username: str = Form(...), password: str = Form(...)):
|
||||
try:
|
||||
user = rt.db.get_user_by_credentials(username, password)
|
||||
if user:
|
||||
return {"status": "success", "user_id": user.id, "username": user.username}
|
||||
return {"status": "fail", "message": "Invalid username or password"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def get_history(user_id: int | None = None):
|
||||
try:
|
||||
records = rt.db.get_detections(user_id)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"user_id": r.user_id,
|
||||
"image_path": r.image_path,
|
||||
"confidence": r.confidence,
|
||||
"timestamp": r.timestamp,
|
||||
"camera_source": r.camera_source,
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/clear_history")
|
||||
def clear_history(user_id: int | None = Form(default=None)):
|
||||
try:
|
||||
deleted = rt.db.clear_detections(user_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "History cleared",
|
||||
"deleted": deleted,
|
||||
"user_id": user_id,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/live/status")
|
||||
def live_status():
|
||||
"""Get current live pipeline status."""
|
||||
return {"status": "success", "live": rt.manager.status()}
|
||||
|
||||
|
||||
@router.post("/live/start")
|
||||
def live_start(source: str | None = Form(default=None)):
|
||||
"""Start live pipeline (optionally with source override)."""
|
||||
ok, message = rt.manager.start(source)
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/stop")
|
||||
def live_stop():
|
||||
"""Stop live pipeline."""
|
||||
ok, message = rt.manager.stop()
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/pause")
|
||||
def live_pause():
|
||||
"""Pause live pipeline (stops source, resumable)."""
|
||||
ok, message = rt.manager.pause()
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/resume")
|
||||
def live_resume():
|
||||
"""Resume paused live pipeline."""
|
||||
ok, message = rt.manager.resume()
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/switch_source")
|
||||
def live_switch_source(source: str = Form(...)):
|
||||
"""Switch active live source (webcam index or RTSP URL)."""
|
||||
ok, message = rt.manager.switch_source(source)
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/snapshot")
|
||||
def snapshot():
|
||||
"""Return the latest frame as a JPEG."""
|
||||
if rt.live_frame is not None:
|
||||
_, jpg = cv2.imencode(".jpg", rt.live_frame.data)
|
||||
return Response(content=jpg.tobytes(), media_type="image/jpeg")
|
||||
return Response(status_code=404)
|
||||
|
||||
|
||||
@router.get("/video_feed")
|
||||
def video_feed():
|
||||
"""MJPEG stream - drop into an <img> tag."""
|
||||
|
||||
def generate():
|
||||
while True:
|
||||
if rt.live_frame is not None:
|
||||
from pipeline.ops.drawing import draw_detections
|
||||
|
||||
img = rt.live_frame.data.copy()
|
||||
draw_detections(img, rt.live_frame.detections)
|
||||
_, jpg = cv2.imencode(".jpg", img)
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n\r\n" + jpg.tobytes() + b"\r\n"
|
||||
)
|
||||
time.sleep(0.033)
|
||||
|
||||
return StreamingResponse(
|
||||
generate(), media_type="multipart/x-mixed-replace;boundary=frame"
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Runtime state and utility helpers for the FastAPI server."""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
from db import SQLiteRepository
|
||||
from db.repository import Repository
|
||||
from pipeline.protocols import Sink
|
||||
from pipeline.sources.stream import ThreadedStreamSource
|
||||
from pipeline.types import Frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global runtime state
|
||||
# (kept as module-level singletons for current single-instance server design)
|
||||
db: Repository = SQLiteRepository()
|
||||
pipeline = None
|
||||
source: ThreadedStreamSource | None = None
|
||||
live_frame: Frame | None = None
|
||||
|
||||
|
||||
SourceFactory = Callable[[str], ThreadedStreamSource]
|
||||
SinkFactory = Callable[[ThreadedStreamSource], list[Sink]]
|
||||
PipelineFactory = Callable[[ThreadedStreamSource, list[Sink]], Any]
|
||||
|
||||
|
||||
class LiveFrameSink(Sink):
|
||||
"""Captures the latest frame so API endpoints can stream it."""
|
||||
|
||||
def handle(self, frame: Frame):
|
||||
global live_frame
|
||||
live_frame = frame
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class PipelineManager:
|
||||
"""Controls the live pipeline lifecycle (start/stop/pause/resume/switch)."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._start_thread: threading.Thread | None = None
|
||||
self._sinks: list[Sink] = []
|
||||
|
||||
self._source_factory: SourceFactory | None = None
|
||||
self._sink_factory: SinkFactory | None = None
|
||||
self._pipeline_factory: PipelineFactory | None = None
|
||||
|
||||
self._source_spec: str = "0"
|
||||
self.running = False
|
||||
self.starting = False
|
||||
self.paused = False
|
||||
self.last_error: str | None = None
|
||||
|
||||
def configure(
|
||||
self,
|
||||
source_factory: SourceFactory,
|
||||
sink_factory: SinkFactory,
|
||||
pipeline_factory: PipelineFactory,
|
||||
initial_source: str = "0",
|
||||
):
|
||||
with self._lock:
|
||||
self._source_factory = source_factory
|
||||
self._sink_factory = sink_factory
|
||||
self._pipeline_factory = pipeline_factory
|
||||
self._source_spec = initial_source
|
||||
|
||||
def _close_sinks(self):
|
||||
for sink in self._sinks:
|
||||
try:
|
||||
sink.close()
|
||||
except Exception:
|
||||
logger.exception("Error closing sink: %s", sink.__class__.__name__)
|
||||
self._sinks = []
|
||||
|
||||
def _run_wrapper(self):
|
||||
global pipeline, source
|
||||
assert pipeline is not None and source is not None
|
||||
try:
|
||||
pipeline.process_stream(source)
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.last_error = str(e)
|
||||
logger.exception("Pipeline crashed")
|
||||
finally:
|
||||
with self._lock:
|
||||
self.running = False
|
||||
# Only auto-close sinks when pipeline exits by itself
|
||||
self._close_sinks()
|
||||
pipeline = None
|
||||
source = None
|
||||
|
||||
def start(self, source_spec: str | None = None) -> tuple[bool, str]:
|
||||
global pipeline, source, live_frame
|
||||
with self._lock:
|
||||
if self.starting:
|
||||
return False, "Pipeline is starting"
|
||||
|
||||
if self.running:
|
||||
return False, "Pipeline already running"
|
||||
|
||||
if source_spec is not None:
|
||||
self._source_spec = source_spec
|
||||
|
||||
if (
|
||||
not self._source_factory
|
||||
or not self._sink_factory
|
||||
or not self._pipeline_factory
|
||||
):
|
||||
return False, "PipelineManager not configured"
|
||||
|
||||
source_spec_local = self._source_spec
|
||||
source_factory = self._source_factory
|
||||
sink_factory = self._sink_factory
|
||||
pipeline_factory = self._pipeline_factory
|
||||
self.starting = True
|
||||
|
||||
try:
|
||||
source_obj = source_factory(source_spec_local)
|
||||
sinks_obj = sink_factory(source_obj)
|
||||
pipeline_obj = pipeline_factory(source_obj, sinks_obj)
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.starting = False
|
||||
self.last_error = str(e)
|
||||
return False, f"Failed to build pipeline: {e}"
|
||||
|
||||
with self._lock:
|
||||
if self.running:
|
||||
self.starting = False
|
||||
try:
|
||||
source_obj.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping source after race")
|
||||
return False, "Pipeline already running"
|
||||
|
||||
source = source_obj
|
||||
self._sinks = sinks_obj
|
||||
pipeline = pipeline_obj
|
||||
live_frame = None
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_wrapper,
|
||||
daemon=True,
|
||||
name="pipeline",
|
||||
)
|
||||
self._thread.start()
|
||||
self.starting = False
|
||||
self.running = True
|
||||
self.paused = False
|
||||
self.last_error = None
|
||||
return True, f"Pipeline started (source={self._source_spec})"
|
||||
|
||||
def start_async(self, source_spec: str | None = None) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if self.starting:
|
||||
return False, "Pipeline is starting"
|
||||
if self.running:
|
||||
return False, "Pipeline already running"
|
||||
|
||||
def _runner():
|
||||
ok, msg = self.start(source_spec)
|
||||
if ok:
|
||||
logger.info(msg)
|
||||
else:
|
||||
logger.warning(msg)
|
||||
|
||||
self._start_thread = threading.Thread(
|
||||
target=_runner,
|
||||
daemon=True,
|
||||
name="pipeline-start",
|
||||
)
|
||||
self._start_thread.start()
|
||||
return True, "Pipeline start requested"
|
||||
|
||||
def stop(self) -> tuple[bool, str]:
|
||||
global pipeline, source
|
||||
with self._lock:
|
||||
thread = self._thread
|
||||
source_obj = source
|
||||
|
||||
if source_obj is not None:
|
||||
try:
|
||||
source_obj.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping source")
|
||||
|
||||
if thread and thread.is_alive():
|
||||
thread.join(timeout=3.0)
|
||||
|
||||
with self._lock:
|
||||
self.starting = False
|
||||
self.running = False
|
||||
self.paused = False
|
||||
self._close_sinks()
|
||||
pipeline = None
|
||||
source = None
|
||||
return True, "Pipeline stopped"
|
||||
|
||||
def pause(self) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if not self.running:
|
||||
return False, "Pipeline is not running"
|
||||
|
||||
ok, msg = self.stop()
|
||||
if ok:
|
||||
with self._lock:
|
||||
self.paused = True
|
||||
return True, "Pipeline paused"
|
||||
return False, msg
|
||||
|
||||
def resume(self) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if self.running:
|
||||
return False, "Pipeline already running"
|
||||
if not self.paused:
|
||||
return False, "Pipeline is not paused"
|
||||
|
||||
ok, msg = self.start()
|
||||
if ok:
|
||||
with self._lock:
|
||||
self.paused = False
|
||||
return True, "Pipeline resumed"
|
||||
return False, msg
|
||||
|
||||
def switch_source(self, source_spec: str) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
self._source_spec = source_spec
|
||||
running = self.running
|
||||
paused = self.paused
|
||||
|
||||
if running:
|
||||
self.stop()
|
||||
return self.start(source_spec)
|
||||
|
||||
if paused:
|
||||
return True, f"Source set to {source_spec} (will apply on resume)"
|
||||
|
||||
return self.start(source_spec)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"running": self.running,
|
||||
"starting": self.starting,
|
||||
"paused": self.paused,
|
||||
"source": self._source_spec,
|
||||
"last_error": self.last_error,
|
||||
"thread_alive": bool(self._thread and self._thread.is_alive()),
|
||||
}
|
||||
|
||||
|
||||
manager = PipelineManager()
|
||||
|
||||
|
||||
def get_lan_address() -> tuple[str, str]:
|
||||
"""Return (ip, hostname) for the primary LAN interface."""
|
||||
ip = "127.0.0.1"
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
probe.connect(("8.8.8.8", 80))
|
||||
ip = probe.getsockname()[0]
|
||||
probe.close()
|
||||
except Exception:
|
||||
pass
|
||||
hostname = socket.getfqdn()
|
||||
return ip, hostname
|
||||
|
||||
|
||||
def run_pipeline():
|
||||
"""Blocking call used by the background pipeline thread."""
|
||||
assert pipeline is not None and source is not None
|
||||
try:
|
||||
pipeline.process_stream(source)
|
||||
except Exception:
|
||||
logger.exception("Pipeline crashed")
|
||||
Reference in new issue
Block a user