This commit is contained in:
lda committed 2026-03-01 20:34:31 +07:00
1 parent 358f4650b5
commit b08ba4028c
5 files changed
+153 -12

No files matched your search

+2 -1
View File
@@ -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"
}
+35 -1
View File
@@ -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
+7 -4
View File
@@ -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,
+31
View File
@@ -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
View File
@@ -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)."""