first commit

This commit is contained in:
陈赣
2026-06-03 11:00:50 +08:00
commit 322b72ac5b
18 changed files with 1821 additions and 0 deletions

221
app/stream_worker.py Normal file
View File

@@ -0,0 +1,221 @@
from __future__ import annotations
import threading
import time
from typing import Any, Callable
import cv2
class StreamWorker:
def __init__(
self,
stream_url: str | Callable[[], str],
detector: Any,
frame_skip: int = 3,
jpeg_quality: int = 80,
resize_width: int | None = None,
):
self.stream_url = stream_url
self.detector = detector
self.frame_skip = max(1, frame_skip)
self.jpeg_quality = jpeg_quality
self.resize_width = resize_width
self.lock = threading.Lock()
self.latest_jpeg: bytes | None = None
self.latest_detections: list[dict[str, Any]] = []
self.frame_id = 0
self.updated_at = 0.0
self.running = False
self.connected = False
self.error = "尚未启动"
self.fps = 0.0
self.thread: threading.Thread | None = None
self.reconnect_requested = False
self.reconnect_version = 0
self.resolve_ms = 0.0
self.open_ms = 0.0
self.first_frame_ms = 0.0
def start(self) -> None:
if self.running:
return
self.running = True
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def stop(self) -> None:
self.running = False
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2)
def reconnect(self) -> None:
with self.lock:
self.latest_jpeg = None
self.latest_detections = []
self.frame_id = 0
self.fps = 0.0
self.reconnect_requested = True
self.reconnect_version += 1
self.connected = False
self.error = "正在切换视频源"
self.resolve_ms = 0.0
self.open_ms = 0.0
self.first_frame_ms = 0.0
def get_jpeg(self) -> bytes | None:
with self.lock:
return self.latest_jpeg
def get_snapshot(self) -> dict[str, Any]:
with self.lock:
return {
"frame_id": self.frame_id,
"updated_at": self.updated_at,
"detections": list(self.latest_detections),
"running": self.running,
"connected": self.connected,
"error": self.error,
"fps": round(self.fps, 2),
"timings": {
"resolve_ms": self.resolve_ms,
"open_ms": self.open_ms,
"first_frame_ms": self.first_frame_ms,
},
}
def _run(self) -> None:
cap: cv2.VideoCapture | None = None
last_detections: list[dict[str, Any]] = []
fps_window_start = time.monotonic()
fps_frames = 0
while self.running:
with self.lock:
should_reconnect = self.reconnect_requested
run_version = self.reconnect_version
self.reconnect_requested = False
if should_reconnect:
# 切换摄像头时必须释放旧连接,否则 OpenCV 会继续阻塞读旧流。
if cap is not None:
cap.release()
cap = None
if cap is None or not cap.isOpened():
started = time.monotonic()
stream_url = self.stream_url() if callable(self.stream_url) else self.stream_url
resolve_ms = round((time.monotonic() - started) * 1000, 2)
started = time.monotonic()
cap = cv2.VideoCapture(stream_url)
open_ms = round((time.monotonic() - started) * 1000, 2)
with self.lock:
self.open_ms = open_ms
self.resolve_ms = resolve_ms
self.first_frame_ms = 0.0
if not cap.isOpened():
self._set_connection_state(False, "无法打开视频流2 秒后重试")
cap.release()
cap = None
time.sleep(2)
continue
self._set_connection_state(True, "已连接")
started = time.monotonic()
ok, frame = cap.read()
with self.lock:
current_version = self.reconnect_version
# 丢弃切换期间从旧连接读到的帧,避免前端画面回跳。
if current_version != run_version:
continue
if self.first_frame_ms == 0.0:
with self.lock:
self.first_frame_ms = round((time.monotonic() - started) * 1000, 2)
if not ok:
self._set_connection_state(False, "读取视频帧失败,正在重连")
cap.release()
cap = None
time.sleep(2)
continue
frame = self._resize(frame)
self.frame_id += 1
if self.frame_id % self.frame_skip == 0:
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
last_detections = self.detector.detect(frame_rgb)
annotated = self._draw(frame, last_detections)
ok, jpeg = cv2.imencode(
".jpg",
annotated,
[cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality],
)
if not ok:
continue
fps_frames += 1
now = time.monotonic()
if now - fps_window_start >= 1:
fps = fps_frames / (now - fps_window_start)
fps_window_start = now
fps_frames = 0
else:
fps = self.fps
with self.lock:
current_version = self.reconnect_version
if current_version != run_version:
continue
with self.lock:
self.latest_jpeg = jpeg.tobytes()
self.latest_detections = list(last_detections)
self.updated_at = time.time()
self.connected = True
self.error = ""
self.fps = fps
if cap is not None:
cap.release()
self._set_connection_state(False, "已停止")
def _resize(self, frame: Any) -> Any:
if not self.resize_width:
return frame
height, width = frame.shape[:2]
if width <= self.resize_width:
return frame
scale = self.resize_width / width
return cv2.resize(frame, (self.resize_width, int(height * scale)))
def _draw(self, frame: Any, detections: list[dict[str, Any]]) -> Any:
for detection in detections:
x1, y1, x2, y2 = detection["box"]
label = detection["label"]
score = detection["score"]
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 220, 80), 2)
text = f"{label} {score:.2f}"
cv2.rectangle(frame, (x1, max(0, y1 - 26)), (x1 + 150, y1), (0, 220, 80), -1)
cv2.putText(
frame,
text,
(x1 + 5, max(18, y1 - 7)),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(0, 0, 0),
2,
cv2.LINE_AA,
)
return frame
def _set_connection_state(self, connected: bool, error: str) -> None:
with self.lock:
self.connected = connected
self.error = error
self.updated_at = time.time()