This commit is contained in:
lda committed 2026-03-01 19:29:47 +07:00
1 parent 4201a3cbb1
commit 0cd543a21a
10 files changed
+1457 -256

No files matched your search

View File
Whitespace-only changes.
+9
View File
@@ -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__"
)
+165
View File
@@ -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"
)
+280
View File
@@ -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")