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,
|
"**/*.rpymc": true,
|
||||||
"**/cache/": 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"
|
// "editor.fontFamily": "'JetbrainsMono Nerd Font', Consolas, 'Courier New', monospace"
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import glob
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@@ -19,9 +20,10 @@ class FileStreamSource(StreamSource):
|
|||||||
def __init__(self, path: str):
|
def __init__(self, path: str):
|
||||||
self.path = path
|
self.path = path
|
||||||
self._fps = 30.0
|
self._fps = 30.0
|
||||||
|
self._image_exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
|
||||||
|
|
||||||
# Probe for FPS if it's a single file
|
# 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:
|
try:
|
||||||
cap = cv2.VideoCapture(self.path)
|
cap = cv2.VideoCapture(self.path)
|
||||||
if cap.isOpened():
|
if cap.isOpened():
|
||||||
@@ -30,6 +32,16 @@ class FileStreamSource(StreamSource):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not probe FPS from %s: %s", path, 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
|
@property
|
||||||
def fps(self) -> float:
|
def fps(self) -> float:
|
||||||
return self._fps
|
return self._fps
|
||||||
@@ -45,6 +57,28 @@ class FileStreamSource(StreamSource):
|
|||||||
yield Frame(timestamp=time.time(), data=img, frame_id=frame_id)
|
yield Frame(timestamp=time.time(), data=img, frame_id=frame_id)
|
||||||
return
|
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)
|
logger.info("Opening video file: %s", self.path)
|
||||||
cap = cv2.VideoCapture(self.path)
|
cap = cv2.VideoCapture(self.path)
|
||||||
frame_id = 0
|
frame_id = 0
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import server.runtime as rt
|
|||||||
from pipeline.config import PipelineConfig
|
from pipeline.config import PipelineConfig
|
||||||
from pipeline.core import SmokingDetectionPipeline
|
from pipeline.core import SmokingDetectionPipeline
|
||||||
from pipeline.models import YoloCigaretteDetector, YoloClassifier, YoloPersonDetector
|
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 import FileAlertStore, MetricsSink
|
||||||
from pipeline.sinks.database import DatabaseSmartEventSink
|
from pipeline.sinks.database import DatabaseSmartEventSink
|
||||||
from pipeline.sinks.video import VideoFileStore
|
from pipeline.sinks.video import VideoFileStore
|
||||||
@@ -98,11 +98,14 @@ async def app_lifespan(_app: FastAPI):
|
|||||||
logger.info("Models loaded.")
|
logger.info("Models loaded.")
|
||||||
|
|
||||||
# 3. Configure live pipeline manager
|
# 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
|
cam = int(source_spec) if source_spec.isdigit() else source_spec
|
||||||
return ThreadedStreamSource(cam)
|
return ThreadedStreamSource(cam)
|
||||||
|
|
||||||
def sink_factory(src: ThreadedStreamSource) -> list[Sink]:
|
def sink_factory(src: StreamSource) -> list[Sink]:
|
||||||
return [
|
return [
|
||||||
rt.LiveFrameSink(),
|
rt.LiveFrameSink(),
|
||||||
DatabaseSmartEventSink(rt.db, fps=src.fps, output_dir="static/events"),
|
DatabaseSmartEventSink(rt.db, fps=src.fps, output_dir="static/events"),
|
||||||
@@ -110,7 +113,7 @@ async def app_lifespan(_app: FastAPI):
|
|||||||
FileAlertStore("alerts.jsonl"),
|
FileAlertStore("alerts.jsonl"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def pipeline_factory(src: ThreadedStreamSource, sinks: list[Sink]):
|
def pipeline_factory(src: StreamSource, sinks: list[Sink]):
|
||||||
config = PipelineConfig(
|
config = PipelineConfig(
|
||||||
idle_interval=0.5,
|
idle_interval=0.5,
|
||||||
active_interval=0.0,
|
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")
|
@router.post("/live/stop")
|
||||||
def live_stop():
|
def live_stop():
|
||||||
"""Stop live pipeline."""
|
"""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")
|
@router.post("/clips/submit")
|
||||||
async def clips_submit(
|
async def clips_submit(
|
||||||
file: UploadFile | None = File(default=None),
|
file: UploadFile | None = File(default=None),
|
||||||
|
|||||||
+78
-6
@@ -10,11 +10,13 @@ from pathlib import Path
|
|||||||
from queue import Empty, Queue
|
from queue import Empty, Queue
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from db import SQLiteRepository
|
from db import SQLiteRepository
|
||||||
from db.repository import Repository
|
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.file import FileStreamSource
|
||||||
from pipeline.sources.stream import ThreadedStreamSource
|
|
||||||
from pipeline.types import Frame
|
from pipeline.types import Frame
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,13 +25,13 @@ logger = logging.getLogger(__name__)
|
|||||||
# (kept as module-level singletons for current single-instance server design)
|
# (kept as module-level singletons for current single-instance server design)
|
||||||
db: Repository = SQLiteRepository()
|
db: Repository = SQLiteRepository()
|
||||||
pipeline = None
|
pipeline = None
|
||||||
source: ThreadedStreamSource | None = None
|
source: StreamSource | None = None
|
||||||
live_frame: Frame | None = None
|
live_frame: Frame | None = None
|
||||||
|
|
||||||
|
|
||||||
SourceFactory = Callable[[str], ThreadedStreamSource]
|
SourceFactory = Callable[[str], StreamSource]
|
||||||
SinkFactory = Callable[[ThreadedStreamSource], list[Sink]]
|
SinkFactory = Callable[[StreamSource], list[Sink]]
|
||||||
PipelineFactory = Callable[[ThreadedStreamSource, list[Sink]], Any]
|
PipelineFactory = Callable[[StreamSource, list[Sink]], Any]
|
||||||
|
|
||||||
ClipSourceFactory = Callable[[str], FileStreamSource]
|
ClipSourceFactory = Callable[[str], FileStreamSource]
|
||||||
ClipSinkFactory = Callable[[FileStreamSource, str], list[Sink]]
|
ClipSinkFactory = Callable[[FileStreamSource, str], list[Sink]]
|
||||||
@@ -47,6 +49,76 @@ class LiveFrameSink(Sink):
|
|||||||
pass
|
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:
|
class PipelineManager:
|
||||||
"""Controls the live pipeline lifecycle (start/stop/pause/resume/switch)."""
|
"""Controls the live pipeline lifecycle (start/stop/pause/resume/switch)."""
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user