141 lines
4.8 KiB
Python
141 lines
4.8 KiB
Python
|
|
"""帧源:从 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
|