video_monitor/app/analysis/frames.py

141 lines
4.8 KiB
Python
Raw Normal View History

2026-08-30 22:22:11 +08:00
"""帧源:从 ZLMediaKit 输出的 RTSP 流取帧(纯 Python · OpenCV"""
import logging
import time
logger = logging.getLogger("analysis.frames")
try:
import cv2
_CV2_AVAILABLE = True
except Exception as e:
logger.warning("frames: OpenCV 未安装,取帧能力不可用: %s" % str(e))
cv2 = None
_CV2_AVAILABLE = False
class FrameSource(object):
"""从 RTSP URL 持续读取帧;失败时按间隔自动重连。"""
HEALTH_CONNECTING = "connecting"
HEALTH_OK = "ok"
HEALTH_RECONNECTING = "reconnecting"
HEALTH_DISCONNECTED = "disconnected"
def __init__(self, rtsp_url, target_fps=5, reconnect_interval=3.0):
self.rtsp_url = rtsp_url
self.target_fps = max(1, int(target_fps))
self.reconnect_interval = max(1.0, float(reconnect_interval))
self._cap = None
self._src_fps = 25
self._frame_skip = 0
self._closed = False
self._health = self.HEALTH_CONNECTING
self._last_ok_ts = 0.0
self._last_reconnect_ts = 0.0
self._reconnect_fail_count = 0
self._total_reconnects = 0
def health_snapshot(self):
stalled_sec = 0.0
if self._last_ok_ts > 0:
stalled_sec = max(0.0, time.time() - self._last_ok_ts)
return {
"stream_health": self._health,
"last_frame_ts": self._last_ok_ts,
"stalled_sec": round(stalled_sec, 1),
"reconnect_fail_count": self._reconnect_fail_count,
"total_reconnects": self._total_reconnects,
}
def open(self):
if not _CV2_AVAILABLE:
raise RuntimeError("OpenCV 不可用,无法取帧")
if self._cap:
try:
self._cap.release()
except Exception:
pass
self._cap = None
self._cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if not self._cap or not self._cap.isOpened():
self._health = self.HEALTH_DISCONNECTED
raise RuntimeError("打开流失败: %s" % self.rtsp_url)
self._src_fps = float(self._cap.get(cv2.CAP_PROP_FPS)) or 25.0
self._frame_skip = max(0, int(self._src_fps / self.target_fps) - 1)
self._health = self.HEALTH_OK
logger.info(
"FrameSource.open() url=%s src_fps=%.1f target_fps=%d skip=%d",
self.rtsp_url, self._src_fps, self.target_fps, self._frame_skip,
)
return True
def read(self):
"""返回 (ok, frame_bgr);失败时进入重连流程"""
if self._closed:
return False, None
if self._cap is None or not self._cap.isOpened():
return self._reconnect_and_read()
try:
ok = True
for _ in range(self._frame_skip + 1):
ok = self._cap.grab()
if not ok:
break
if not ok:
return self._reconnect_and_read()
ret, frame = self._cap.retrieve()
if not ret or frame is None:
return self._reconnect_and_read()
self._last_ok_ts = time.time()
self._health = self.HEALTH_OK
self._reconnect_fail_count = 0
return True, frame
except Exception as e:
logger.warning("FrameSource.read() error: %s" % str(e))
return self._reconnect_and_read()
def _reconnect_and_read(self):
now = time.time()
self._health = self.HEALTH_RECONNECTING
if now - self._last_reconnect_ts < self.reconnect_interval:
return False, None
self._last_reconnect_ts = now
self._reconnect_fail_count += 1
try:
if self._cap:
self._cap.release()
self._cap = None
self.open()
self._total_reconnects += 1
logger.info(
"FrameSource 重连成功 url=%s (累计重连 %d 次)",
self.rtsp_url, self._total_reconnects,
)
ret, frame = self._cap.read()
if ret and frame is not None:
self._last_ok_ts = time.time()
self._health = self.HEALTH_OK
self._reconnect_fail_count = 0
return True, frame
except Exception as e:
logger.warning(
"FrameSource 重连失败 #%d url=%s: %s",
self._reconnect_fail_count, self.rtsp_url, str(e),
)
self._health = self.HEALTH_DISCONNECTED
return False, None
def close(self):
self._closed = True
self._health = self.HEALTH_DISCONNECTED
try:
if self._cap:
self._cap.release()
except Exception:
pass
self._cap = None
@staticmethod
def is_available():
return _CV2_AVAILABLE