This commit is contained in:
lda committed 2026-03-01 19:47:58 +07:00
1 parent 0cd543a21a
commit 61af362da5
6 files changed
+414 -8

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ wheels/
alerts.jsonl
output.jsonl
output.mp4
static/alerts/
static/
screenshots/
events/
logs/
+61 -6
View File
@@ -63,6 +63,9 @@ The server runs the detection pipeline in the background while serving a REST AP
# Default: webcam 0, classic pipeline
uv run server.py
# Package entrypoint (same server)
uv run -m server
# Use stage-based pipeline with a specific camera
uv run server.py --staged --source 2
@@ -76,6 +79,14 @@ uv run server.py --host 0.0.0.0 --port 9000
uvicorn server:app --host 0.0.0.0 --port 8000
```
### Live + Clip Control (Server Runtime)
The server now runs a lifecycle manager for the live pipeline and a queued clip-job worker:
- **Live manager**: start/stop/pause/resume/switch source at runtime.
- **Clip jobs**: submit files to a queue, process one-at-a-time, track status.
- **Resource policy**: clip jobs can temporarily pause live processing and resume after job completion.
On startup the server prints the LAN address for the mobile app:
```
@@ -122,7 +133,7 @@ uv run main.py --source videos/test.mp4 --debug
### Check Available Webcams
```bash
uv run -m tools.webcam_check
uv run -m scripts.webcam_check
```
### CLI Arguments
@@ -181,6 +192,10 @@ The system generates multiple artifacts during execution:
```text
gp/
├── server.py # FastAPI server (runs pipeline + REST API)
├── server/ # Modular server runtime + routes
│ ├── runtime.py # PipelineManager + ClipJobManager
│ ├── routes.py # API routers (/live/*, /clips/*, auth/history)
│ └── __main__.py # `uv run -m server` entrypoint
├── main.py # CLI entry point (standalone pipeline)
├── pyproject.toml # Project config (uv/pip)
├── models/ # Directory for .pt model weights
@@ -228,8 +243,9 @@ gp/
│ ├── geometry.py # Box expansion, zone filtering
│ └── drawing.py # Visualization helpers
│
├── tools/
│ └── webcam_check.py # Utility to list available webcams
├── scripts/
│ ├── webcam_check.py # Utility to list available webcams
│ └── live_stress_test.py # Stress test for /live/* and queue responsiveness
│
└── SmokeDetectionApp/ # Android companion app
└── app/src/main/java/com/example/smokedetection/
@@ -289,17 +305,56 @@ pipeline = SmokingDetectionPipeline(..., config=config)
The server (`server.py`) exposes these endpoints:
| Method | Endpoint | Description |
| :----- | :--------------- | :---------------------------------- |
| :----- | :-------------------- | :---------------------------------------------- |
| GET | `/ping` | Health check (verify connectivity) |
| POST | `/register` | Create a new user account |
| POST | `/login` | Authenticate and get user ID |
| GET | `/history` | Get detection records for a user |
| POST | `/clear_history` | Delete all records for a user |
| GET | `/history` | Get detection records (optionally by `user_id`) |
| POST | `/clear_history` | Delete detection records (optional `user_id`) |
| GET | `/snapshot` | Latest camera frame as JPEG |
| GET | `/video_feed` | MJPEG stream with detection overlay |
| GET | `/live/status` | Live pipeline state (`running/paused/source`) |
| POST | `/live/start` | Start live pipeline (optional `source`) |
| POST | `/live/stop` | Stop live pipeline |
| POST | `/live/pause` | Pause live pipeline |
| POST | `/live/resume` | Resume paused live pipeline |
| POST | `/live/switch_source` | Switch active webcam/RTSP source |
| POST | `/clips/submit` | Queue a clip job (upload file or local path) |
| GET | `/clips` | List recent clip jobs + queue stats |
| GET | `/clips/{job_id}` | Get status/details for a clip job |
Interactive docs at **<http://localhost:8000/docs>** (Swagger UI).
### Quick Test Calls
PowerShell examples:
```powershell
$base='http://127.0.0.1:8000'
# Live controls
Invoke-WebRequest -UseBasicParsing -Uri ($base+'/live/status')
Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($base+'/live/switch_source') -Body @{source='2'}
Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($base+'/live/pause')
Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($base+'/live/resume')
# Queue clip from local path
Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($base+'/clips/submit') -Body @{path='test_media/139474-772542450_medium.mp4'}
# List jobs and inspect one job
Invoke-WebRequest -UseBasicParsing -Uri ($base+'/clips')
Invoke-WebRequest -UseBasicParsing -Uri ($base+'/clips/<job_id>')
```
curl examples:
```bash
curl http://127.0.0.1:8000/live/status
curl -X POST -F "source=2" http://127.0.0.1:8000/live/switch_source
curl -X POST -F "path=test_media/139474-772542450_medium.mp4" http://127.0.0.1:8000/clips/submit
curl http://127.0.0.1:8000/clips
```
## 📱 Android App
The companion app (`SmokeDetectionApp/`) connects to the server for auth, alert history, and live camera feed.
File renamed without changes.
+63
View File
@@ -28,6 +28,8 @@ from pipeline.models import YoloCigaretteDetector, YoloClassifier, YoloPersonDet
from pipeline.protocols import Sink
from pipeline.sinks import FileAlertStore, MetricsSink
from pipeline.sinks.database import DatabaseSmartEventSink
from pipeline.sinks.video import VideoFileStore
from pipeline.sources.file import FileStreamSource
from pipeline.sources.stream import ThreadedStreamSource
from pipeline.staged_core import StageBasedPipeline
from server.routes import router as api_router
@@ -147,6 +149,65 @@ async def app_lifespan(_app: FastAPI):
pipeline_factory=pipeline_factory,
initial_source=_args.source,
)
def clip_source_factory(path: str) -> FileStreamSource:
return FileStreamSource(path)
def clip_sink_factory(src: FileStreamSource, job_id: str) -> list[Sink]:
job_dir = Path("static/clip_jobs") / job_id
events_dir = job_dir / "events"
job_dir.mkdir(parents=True, exist_ok=True)
events_dir.mkdir(parents=True, exist_ok=True)
return [
VideoFileStore(str(job_dir / "processed.mp4"), fps=src.fps),
DatabaseSmartEventSink(
rt.db,
fps=src.fps,
camera_source=f"ClipJob:{job_id}",
output_dir=str(events_dir),
),
MetricsSink(log_interval=200),
FileAlertStore(str(job_dir / "alerts.jsonl")),
]
def clip_pipeline_factory(src: FileStreamSource, sinks: list[Sink]):
config = PipelineConfig(
idle_interval=0.5,
active_interval=0.0,
cooldown_frames=max(1, int(src.fps * 2)),
)
if _args.staged or _args.vlm:
vlm_key = _args.vlm_key or os.environ.get(
"OPENAI_API_KEY"
if _args.vlm_provider == "openai"
else "ANTHROPIC_API_KEY"
)
return StageBasedPipeline(
person_model=person_model,
cigarette_model=cig_model,
verifier_model=verifier,
vlm_api_key=vlm_key if _args.vlm else None,
vlm_provider=_args.vlm_provider,
vlm_model=_args.vlm_model,
use_vlm=_args.vlm,
sinks=sinks,
config=config,
)
return SmokingDetectionPipeline(
person_model=person_model,
cigarette_model=cig_model,
verifier_model=verifier,
sinks=sinks,
config=config,
)
rt.clip_jobs.configure(
source_factory=clip_source_factory,
sink_factory=clip_sink_factory,
pipeline_factory=clip_pipeline_factory,
)
ok, msg = rt.manager.start_async()
if not ok:
raise RuntimeError(msg)
@@ -168,6 +229,7 @@ async def app_lifespan(_app: FastAPI):
# Shutdown
logger.info("Shutting down...")
rt.manager.stop()
rt.clip_jobs.shutdown()
rt.db.close()
logger.info("Goodbye.")
@@ -181,6 +243,7 @@ app.include_router(api_router)
# Serve static assets (alert images, CSS/JS, etc.)
Path("static/alerts").mkdir(parents=True, exist_ok=True)
Path("static/events").mkdir(parents=True, exist_ok=True)
Path("static/clip_jobs").mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
+73 -1
View File
@@ -1,9 +1,11 @@
"""API route definitions for the smoking detection server."""
import shutil
import time
from pathlib import Path
import cv2
from fastapi import APIRouter, Form, Response
from fastapi import APIRouter, File, Form, Response, UploadFile
from fastapi.responses import StreamingResponse
import server.runtime as rt
@@ -133,6 +135,76 @@ def live_switch_source(source: str = Form(...)):
}
@router.post("/clips/submit")
async def clips_submit(
file: UploadFile | None = File(default=None),
path: str | None = Form(default=None),
):
"""Queue a clip processing job from uploaded file or existing local path."""
if file is None and not path:
return {
"status": "fail",
"message": "Provide either an uploaded file or a local file path",
}
clip_input_path = ""
source_name = None
try:
if file is not None:
uploads_dir = Path("media/clip_jobs/uploads")
uploads_dir.mkdir(parents=True, exist_ok=True)
source_name = file.filename or "upload.mp4"
suffix = Path(source_name).suffix or ".mp4"
safe_name = f"clip_{int(time.time() * 1000)}{suffix}"
saved_path = uploads_dir / safe_name
with saved_path.open("wb") as out:
shutil.copyfileobj(file.file, out)
clip_input_path = str(saved_path)
else:
local_path = Path(path or "")
if not local_path.exists() or not local_path.is_file():
return {
"status": "fail",
"message": f"File not found: {local_path}",
}
clip_input_path = str(local_path)
source_name = local_path.name
finally:
if file is not None:
await file.close()
job = rt.clip_jobs.submit(clip_input_path, source_name)
return {
"status": "success",
"message": "Clip job queued",
"job": job,
"queue": rt.clip_jobs.status(),
}
@router.get("/clips")
def clips_list(limit: int = 20):
"""List recent clip jobs."""
return {
"status": "success",
"queue": rt.clip_jobs.status(),
"jobs": rt.clip_jobs.list(limit=max(1, min(limit, 200))),
}
@router.get("/clips/{job_id}")
def clips_get(job_id: str):
"""Get one clip job state."""
job = rt.clip_jobs.get(job_id)
if not job:
return {"status": "fail", "message": "Job not found", "job_id": job_id}
return {"status": "success", "job": job}
@router.get("/snapshot")
def snapshot():
"""Return the latest frame as a JPEG."""
+216
View File
@@ -3,11 +3,16 @@
import logging
import socket
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from queue import Empty, Queue
from typing import Any, Callable
from db import SQLiteRepository
from db.repository import Repository
from pipeline.protocols import Sink
from pipeline.sources.file import FileStreamSource
from pipeline.sources.stream import ThreadedStreamSource
from pipeline.types import Frame
@@ -25,6 +30,10 @@ SourceFactory = Callable[[str], ThreadedStreamSource]
SinkFactory = Callable[[ThreadedStreamSource], list[Sink]]
PipelineFactory = Callable[[ThreadedStreamSource, list[Sink]], Any]
ClipSourceFactory = Callable[[str], FileStreamSource]
ClipSinkFactory = Callable[[FileStreamSource, str], list[Sink]]
ClipPipelineFactory = Callable[[FileStreamSource, list[Sink]], Any]
class LiveFrameSink(Sink):
"""Captures the latest frame so API endpoints can stream it."""
@@ -257,6 +266,213 @@ class PipelineManager:
manager = PipelineManager()
@dataclass
class ClipJob:
job_id: str
input_path: str
submitted_at: str
source_name: str | None = None
status: str = "queued" # queued | running | done | failed
started_at: str | None = None
finished_at: str | None = None
error: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"job_id": self.job_id,
"input_path": self.input_path,
"source_name": self.source_name,
"status": self.status,
"submitted_at": self.submitted_at,
"started_at": self.started_at,
"finished_at": self.finished_at,
"error": self.error,
}
class ClipJobManager:
"""Simple queued clip processor with a single worker."""
def __init__(self):
self._lock = threading.RLock()
self._queue: Queue[str] = Queue()
self._jobs: dict[str, ClipJob] = {}
self._ordered_ids: list[str] = []
self._source_factory: ClipSourceFactory | None = None
self._sink_factory: ClipSinkFactory | None = None
self._pipeline_factory: ClipPipelineFactory | None = None
self._shutdown = False
self._worker = threading.Thread(
target=self._worker_loop,
daemon=True,
name="clip-worker",
)
self._worker.start()
def configure(
self,
source_factory: ClipSourceFactory,
sink_factory: ClipSinkFactory,
pipeline_factory: ClipPipelineFactory,
):
with self._lock:
self._source_factory = source_factory
self._sink_factory = sink_factory
self._pipeline_factory = pipeline_factory
def submit(self, input_path: str, source_name: str | None = None) -> dict[str, Any]:
now = datetime.now().isoformat(timespec="seconds")
job_id = f"job_{int(time.time() * 1000)}"
job = ClipJob(
job_id=job_id,
input_path=input_path,
source_name=source_name,
submitted_at=now,
)
with self._lock:
self._jobs[job_id] = job
self._ordered_ids.append(job_id)
self._queue.put(job_id)
return job.to_dict()
def get(self, job_id: str) -> dict[str, Any] | None:
with self._lock:
job = self._jobs.get(job_id)
return job.to_dict() if job else None
def list(self, limit: int = 20) -> list[dict[str, Any]]:
with self._lock:
ids = self._ordered_ids[-limit:]
ids.reverse()
return [self._jobs[job_id].to_dict() for job_id in ids]
def status(self) -> dict[str, Any]:
with self._lock:
queued = sum(1 for job in self._jobs.values() if job.status == "queued")
running = sum(1 for job in self._jobs.values() if job.status == "running")
done = sum(1 for job in self._jobs.values() if job.status == "done")
failed = sum(1 for job in self._jobs.values() if job.status == "failed")
return {
"queued": queued,
"running": running,
"done": done,
"failed": failed,
"worker_alive": self._worker.is_alive(),
}
def shutdown(self):
with self._lock:
self._shutdown = True
if self._worker.is_alive():
self._worker.join(timeout=2.0)
def _set_job_state(
self,
job_id: str,
status: str,
*,
started_at: str | None = None,
finished_at: str | None = None,
error: str | None = None,
):
with self._lock:
job = self._jobs.get(job_id)
if not job:
return
job.status = status
if started_at is not None:
job.started_at = started_at
if finished_at is not None:
job.finished_at = finished_at
if error is not None:
job.error = error
def _worker_loop(self):
while True:
with self._lock:
if self._shutdown:
return
try:
job_id = self._queue.get(timeout=0.5)
except Empty:
continue
self._process_job(job_id)
self._queue.task_done()
def _process_job(self, job_id: str):
with self._lock:
job = self._jobs.get(job_id)
source_factory = self._source_factory
sink_factory = self._sink_factory
pipeline_factory = self._pipeline_factory
if not job:
return
if not source_factory or not sink_factory or not pipeline_factory:
self._set_job_state(job_id, "failed", error="ClipJobManager not configured")
return
self._set_job_state(
job_id,
"running",
started_at=datetime.now().isoformat(timespec="seconds"),
error=None,
)
paused_live = False
try:
live_status = manager.status()
if live_status.get("running"):
ok, _ = manager.pause()
paused_live = ok
src = source_factory(job.input_path)
sinks = sink_factory(src, job_id)
pipe = pipeline_factory(src, sinks)
try:
pipe.process_stream(src)
finally:
try:
src.stop()
except Exception:
logger.exception("Failed to stop clip source for %s", job_id)
for sink in sinks:
try:
sink.close()
except Exception:
logger.exception("Failed to close clip sink for %s", job_id)
self._set_job_state(
job_id,
"done",
finished_at=datetime.now().isoformat(timespec="seconds"),
)
logger.info("Clip job completed: %s", job_id)
except Exception as e:
logger.exception("Clip job failed: %s", job_id)
self._set_job_state(
job_id,
"failed",
finished_at=datetime.now().isoformat(timespec="seconds"),
error=str(e),
)
finally:
if paused_live:
manager.resume()
clip_jobs = ClipJobManager()
def get_lan_address() -> tuple[str, str]:
"""Return (ip, hostname) for the primary LAN interface."""
ip = "127.0.0.1"