woah
This commit is contained in:
1 parent
358f4650b5
commit
b08ba4028c
5 files changed
+153
-12
No files matched your search
Vendored
+2
-1
@@ -5,6 +5,7 @@
|
||||
"**/*.rpymc": true,
|
||||
"**/cache/": true
|
||||
},
|
||||
"pylint.args": ["--generated-members=cv2.*", "--extension-pkg-whitelist=cv2"]
|
||||
"pylint.args": ["--generated-members=cv2.*", "--extension-pkg-whitelist=cv2"],
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
// "editor.fontFamily": "'JetbrainsMono Nerd Font', Consolas, 'Courier New', monospace"
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import glob
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import cv2
|
||||
@@ -19,9 +20,10 @@ class FileStreamSource(StreamSource):
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self._fps = 30.0
|
||||
self._image_exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
|
||||
|
||||
# Probe for FPS if it's a single file
|
||||
if "*" not in self.path:
|
||||
if "*" not in self.path and not self._is_image_path(self.path):
|
||||
try:
|
||||
cap = cv2.VideoCapture(self.path)
|
||||
if cap.isOpened():
|
||||
@@ -30,6 +32,16 @@ class FileStreamSource(StreamSource):
|
||||
except Exception as e:
|
||||
logger.warning("Could not probe FPS from %s: %s", path, e)
|
||||
|
||||
def _is_image_path(self, value: str) -> bool:
|
||||
return Path(value).suffix.lower() in self._image_exts
|
||||
|
||||
def _iter_image_files(self, directory: Path) -> list[str]:
|
||||
files: list[str] = []
|
||||
for ext in self._image_exts:
|
||||
files.extend(glob.glob(str(directory / f"*{ext}")))
|
||||
files.extend(glob.glob(str(directory / f"*{ext.upper()}")))
|
||||
return sorted(set(files))
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
return self._fps
|
||||
@@ -45,6 +57,28 @@ class FileStreamSource(StreamSource):
|
||||
yield Frame(timestamp=time.time(), data=img, frame_id=frame_id)
|
||||
return
|
||||
|
||||
file_path = Path(self.path)
|
||||
|
||||
# Handle single image file
|
||||
if self._is_image_path(self.path):
|
||||
img = cv2.imread(self.path)
|
||||
if img is None:
|
||||
logger.warning("Could not read image: %s", self.path)
|
||||
return
|
||||
logger.info("Loaded single image: %s", self.path)
|
||||
yield Frame(timestamp=time.time(), data=img, frame_id=0)
|
||||
return
|
||||
|
||||
# Handle directory of images
|
||||
if file_path.exists() and file_path.is_dir():
|
||||
files = self._iter_image_files(file_path)
|
||||
logger.info("Loading %d images from directory: %s", len(files), self.path)
|
||||
for frame_id, file in enumerate(files):
|
||||
img = cv2.imread(file)
|
||||
if img is not None:
|
||||
yield Frame(timestamp=time.time(), data=img, frame_id=frame_id)
|
||||
return
|
||||
|
||||
logger.info("Opening video file: %s", self.path)
|
||||
cap = cv2.VideoCapture(self.path)
|
||||
frame_id = 0
|
||||
|
||||
@@ -25,7 +25,7 @@ import server.runtime as rt
|
||||
from pipeline.config import PipelineConfig
|
||||
from pipeline.core import SmokingDetectionPipeline
|
||||
from pipeline.models import YoloCigaretteDetector, YoloClassifier, YoloPersonDetector
|
||||
from pipeline.protocols import Sink
|
||||
from pipeline.protocols import Sink, StreamSource
|
||||
from pipeline.sinks import FileAlertStore, MetricsSink
|
||||
from pipeline.sinks.database import DatabaseSmartEventSink
|
||||
from pipeline.sinks.video import VideoFileStore
|
||||
@@ -98,11 +98,14 @@ async def app_lifespan(_app: FastAPI):
|
||||
logger.info("Models loaded.")
|
||||
|
||||
# 3. Configure live pipeline manager
|
||||
def source_factory(source_spec: str) -> ThreadedStreamSource:
|
||||
def source_factory(source_spec: str) -> StreamSource:
|
||||
if source_spec.lower() in {"phone", "android", "mobile"}:
|
||||
rt.phone_source.reset()
|
||||
return rt.phone_source
|
||||
cam = int(source_spec) if source_spec.isdigit() else source_spec
|
||||
return ThreadedStreamSource(cam)
|
||||
|
||||
def sink_factory(src: ThreadedStreamSource) -> list[Sink]:
|
||||
def sink_factory(src: StreamSource) -> list[Sink]:
|
||||
return [
|
||||
rt.LiveFrameSink(),
|
||||
DatabaseSmartEventSink(rt.db, fps=src.fps, output_dir="static/events"),
|
||||
@@ -110,7 +113,7 @@ async def app_lifespan(_app: FastAPI):
|
||||
FileAlertStore("alerts.jsonl"),
|
||||
]
|
||||
|
||||
def pipeline_factory(src: ThreadedStreamSource, sinks: list[Sink]):
|
||||
def pipeline_factory(src: StreamSource, sinks: list[Sink]):
|
||||
config = PipelineConfig(
|
||||
idle_interval=0.5,
|
||||
active_interval=0.0,
|
||||
|
||||
@@ -91,6 +91,18 @@ def live_start(source: str | None = Form(default=None)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/start_phone")
|
||||
def live_start_phone():
|
||||
"""Start live pipeline using phone frame push source."""
|
||||
ok, message = rt.manager.switch_source("phone")
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
"hint": "Push JPEG frames to /live/push_frame",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/stop")
|
||||
def live_stop():
|
||||
"""Stop live pipeline."""
|
||||
@@ -135,6 +147,25 @@ def live_switch_source(source: str = Form(...)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/push_frame")
|
||||
async def live_push_frame(frame: UploadFile = File(...)):
|
||||
"""Push one JPEG frame from phone/app into live source queue."""
|
||||
try:
|
||||
data = await frame.read()
|
||||
finally:
|
||||
await frame.close()
|
||||
|
||||
if not data:
|
||||
return {"status": "fail", "message": "Empty frame payload"}
|
||||
|
||||
ok, message = rt.push_live_frame_bytes(data)
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/clips/submit")
|
||||
async def clips_submit(
|
||||
file: UploadFile | None = File(default=None),
|
||||
|
||||
+78
-6
@@ -10,11 +10,13 @@ from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
from typing import Any, Callable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from db import SQLiteRepository
|
||||
from db.repository import Repository
|
||||
from pipeline.protocols import Sink
|
||||
from pipeline.protocols import Sink, StreamSource
|
||||
from pipeline.sources.file import FileStreamSource
|
||||
from pipeline.sources.stream import ThreadedStreamSource
|
||||
from pipeline.types import Frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,13 +25,13 @@ logger = logging.getLogger(__name__)
|
||||
# (kept as module-level singletons for current single-instance server design)
|
||||
db: Repository = SQLiteRepository()
|
||||
pipeline = None
|
||||
source: ThreadedStreamSource | None = None
|
||||
source: StreamSource | None = None
|
||||
live_frame: Frame | None = None
|
||||
|
||||
|
||||
SourceFactory = Callable[[str], ThreadedStreamSource]
|
||||
SinkFactory = Callable[[ThreadedStreamSource], list[Sink]]
|
||||
PipelineFactory = Callable[[ThreadedStreamSource, list[Sink]], Any]
|
||||
SourceFactory = Callable[[str], StreamSource]
|
||||
SinkFactory = Callable[[StreamSource], list[Sink]]
|
||||
PipelineFactory = Callable[[StreamSource, list[Sink]], Any]
|
||||
|
||||
ClipSourceFactory = Callable[[str], FileStreamSource]
|
||||
ClipSinkFactory = Callable[[FileStreamSource, str], list[Sink]]
|
||||
@@ -47,6 +49,76 @@ class LiveFrameSink(Sink):
|
||||
pass
|
||||
|
||||
|
||||
class PushFrameSource(StreamSource):
|
||||
"""Frame source that receives JPEG frames pushed via API."""
|
||||
|
||||
def __init__(self, fps: float = 15.0, max_queue: int = 120):
|
||||
self._fps = fps
|
||||
self._queue: Queue[Frame] = Queue(maxsize=max_queue)
|
||||
self._running = True
|
||||
self._frame_id = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
return self._fps
|
||||
|
||||
def reset(self):
|
||||
with self._lock:
|
||||
self._running = True
|
||||
self._frame_id = 0
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self._queue.task_done()
|
||||
except Empty:
|
||||
break
|
||||
|
||||
def push_jpeg(self, data: bytes) -> tuple[bool, str]:
|
||||
if not self._running:
|
||||
return False, "phone source not running"
|
||||
|
||||
arr = np.frombuffer(data, dtype=np.uint8)
|
||||
image = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return False, "invalid image payload"
|
||||
|
||||
with self._lock:
|
||||
frame = Frame(timestamp=time.time(), data=image, frame_id=self._frame_id)
|
||||
self._frame_id += 1
|
||||
|
||||
if self._queue.full():
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self._queue.task_done()
|
||||
except Empty:
|
||||
pass
|
||||
|
||||
self._queue.put(frame)
|
||||
return True, "frame pushed"
|
||||
|
||||
def stream(self):
|
||||
while self._running:
|
||||
try:
|
||||
frame = self._queue.get(timeout=0.5)
|
||||
except Empty:
|
||||
continue
|
||||
try:
|
||||
yield frame
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
|
||||
phone_source = PushFrameSource()
|
||||
|
||||
|
||||
def push_live_frame_bytes(data: bytes) -> tuple[bool, str]:
|
||||
return phone_source.push_jpeg(data)
|
||||
|
||||
|
||||
class PipelineManager:
|
||||
"""Controls the live pipeline lifecycle (start/stop/pause/resume/switch)."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user