2025-10-20 18:10:07 +08:00
|
|
|
|
# coding:utf-8
|
|
|
|
|
|
from PySide6.QtCore import Qt, QTimer, Signal, QObject
|
2025-10-31 18:52:31 +08:00
|
|
|
|
from PySide6.QtGui import QImage, QPixmap, QPalette, QBrush, QCursor, QIcon, QPainter, QFont
|
|
|
|
|
|
from PySide6.QtWidgets import (
|
|
|
|
|
|
QWidget,
|
|
|
|
|
|
QVBoxLayout,
|
|
|
|
|
|
QHBoxLayout,
|
|
|
|
|
|
QLabel,
|
|
|
|
|
|
QApplication,
|
|
|
|
|
|
QFrame,
|
|
|
|
|
|
QPushButton,
|
|
|
|
|
|
QStackedWidget,
|
|
|
|
|
|
QSizePolicy
|
|
|
|
|
|
)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
import cv2
|
|
|
|
|
|
import time
|
|
|
|
|
|
from threading import Thread, Lock
|
|
|
|
|
|
import sys
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
from .switch_button import SwitchButton
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
import resources.resources_rc
|
2025-11-01 16:13:14 +08:00
|
|
|
|
from utils.image_paths import ImagePaths
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
|
|
|
|
|
|
class VideoStream(QObject):
|
|
|
|
|
|
# 系统(信息)信号
|
|
|
|
|
|
info_signal = Signal(str, str)
|
|
|
|
|
|
|
|
|
|
|
|
# 警告信号(重试): 发送摄像头名称和状态描述
|
|
|
|
|
|
warning_signal = Signal(str, str)
|
|
|
|
|
|
|
|
|
|
|
|
# 异常错误信号(超次数)
|
|
|
|
|
|
error_signal = Signal(str, str)
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, rtsp_url, name="Stream", max_retry=3):
|
|
|
|
|
|
super().__init__()
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.rtsp_url = rtsp_url
|
|
|
|
|
|
self.name = name
|
|
|
|
|
|
self.cap = None
|
|
|
|
|
|
self.frame = None
|
|
|
|
|
|
self.timestamp = 0
|
|
|
|
|
|
self.lock = Lock()
|
|
|
|
|
|
self.running = False
|
|
|
|
|
|
self.thread = None
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.max_retry = max_retry
|
|
|
|
|
|
self.retry_count = 0
|
|
|
|
|
|
self.manual_stop = False # 是否停止自动重连
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
def start(self):
|
|
|
|
|
|
self.running = True
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.thread = Thread(target=self.update, daemon=True)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.thread.start()
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
def reset_retry(self):
|
|
|
|
|
|
with self.lock:
|
|
|
|
|
|
self.retry_count = 0
|
|
|
|
|
|
self.manual_stop = False
|
|
|
|
|
|
if self.cap:
|
|
|
|
|
|
self.cap.release()
|
|
|
|
|
|
self.cap = None
|
|
|
|
|
|
self.info_signal.emit(self.name, "已手动重置, 开始尝试连接...")
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_max_retry(self, failure_type: str):
|
|
|
|
|
|
"""
|
|
|
|
|
|
处理超过最大重试次数的情况
|
|
|
|
|
|
failure_type: 失败类型("连接" 或 "读取")
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.manual_stop = True
|
|
|
|
|
|
error_msg = f"{failure_type}摄像头失败, 已经超过最大次数({self.max_retry}次),可能设备故障,请检查后手动点击重连"
|
|
|
|
|
|
print(f"[{self.name}] {error_msg}")
|
|
|
|
|
|
self.error_signal.emit(self.name, error_msg)
|
|
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_retry_delay(self):
|
|
|
|
|
|
"""计算并执行重试前的延迟(递增间隔)"""
|
|
|
|
|
|
sleep_time = min(0.5 + self.retry_count * 0.5, 5)
|
|
|
|
|
|
warning_msg = (
|
|
|
|
|
|
f"第{self.retry_count}次连接失败,将在{sleep_time:.1f}秒后再次尝试..."
|
|
|
|
|
|
)
|
|
|
|
|
|
self.warning_signal.emit(self.name, warning_msg)
|
|
|
|
|
|
print(f"[{self.name}] {warning_msg}")
|
|
|
|
|
|
time.sleep(sleep_time)
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
def update(self):
|
|
|
|
|
|
while self.running:
|
2025-10-31 18:52:31 +08:00
|
|
|
|
if self.manual_stop:
|
|
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
if self.cap is None or not self.cap.isOpened():
|
2025-10-31 18:52:31 +08:00
|
|
|
|
print(
|
|
|
|
|
|
f"[{self.name}]正在连接 RTSP(第{self.retry_count+1}次): {self.rtsp_url}"
|
|
|
|
|
|
)
|
|
|
|
|
|
info_msg = f"正在连接 RTSP(第{self.retry_count+1}次): {self.rtsp_url}"
|
|
|
|
|
|
self.info_signal.emit(self.name, info_msg)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
try:
|
|
|
|
|
|
self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
if hasattr(cv2, "CAP_PROP_BUFFERSIZE"):
|
|
|
|
|
|
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 2)
|
|
|
|
|
|
if hasattr(cv2, "CAP_PROP_READ_TIMEOUT"):
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.cap.set(cv2.CAP_PROP_READ_TIMEOUT, 2000)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
if hasattr(cv2, "CAP_PROP_TCP_NODELAY"):
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.cap.set(cv2.CAP_PROP_TCP_NODELAY, 1)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
except Exception as ex:
|
|
|
|
|
|
print(f"[{self.name}]连接失败: {ex}")
|
|
|
|
|
|
warning_msg = f"连接失败: {str(ex)},准备重试..."
|
|
|
|
|
|
self.warning_signal.emit(self.name, warning_msg)
|
|
|
|
|
|
if self.cap:
|
|
|
|
|
|
self.cap.release()
|
|
|
|
|
|
self.cap = None
|
|
|
|
|
|
|
|
|
|
|
|
if self.cap is None or not self.cap.isOpened():
|
|
|
|
|
|
self.retry_count += 1
|
|
|
|
|
|
if self.retry_count >= self.max_retry:
|
|
|
|
|
|
self._handle_max_retry("连接")
|
|
|
|
|
|
continue
|
|
|
|
|
|
self._handle_retry_delay()
|
|
|
|
|
|
continue
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.retry_count = 0
|
|
|
|
|
|
self.info_signal.emit(self.name, "连接成功")
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
ret, frame = self.cap.read()
|
|
|
|
|
|
if ret:
|
|
|
|
|
|
with self.lock:
|
|
|
|
|
|
self.frame = frame.copy()
|
|
|
|
|
|
self.timestamp = time.time()
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.retry_count = 0
|
2025-10-20 18:10:07 +08:00
|
|
|
|
else:
|
|
|
|
|
|
print(f"[{self.name}] 读取失败,准备重连...")
|
2025-10-31 18:52:31 +08:00
|
|
|
|
warning_msg = "读取帧失败,准备重连..."
|
|
|
|
|
|
self.warning_signal.emit(self.name, warning_msg)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
if self.cap:
|
|
|
|
|
|
self.cap.release()
|
|
|
|
|
|
self.cap = None
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.retry_count += 1
|
|
|
|
|
|
if self.retry_count >= self.max_retry:
|
|
|
|
|
|
self._handle_max_retry("读取")
|
|
|
|
|
|
continue
|
|
|
|
|
|
self._handle_retry_delay()
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
def read(self):
|
|
|
|
|
|
with self.lock:
|
|
|
|
|
|
if self.frame is not None and time.time() - self.timestamp < 1.5:
|
|
|
|
|
|
return self.frame.copy()
|
|
|
|
|
|
else:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
|
|
self.running = False
|
|
|
|
|
|
if self.thread is not None:
|
|
|
|
|
|
self.thread.join(timeout=0.5)
|
|
|
|
|
|
if self.cap is not None:
|
|
|
|
|
|
self.cap.release()
|
|
|
|
|
|
print(f"[{self.name}] 视频流已停止")
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# ====================== 摄像头框功能模块 ======================
|
2025-10-20 18:10:07 +08:00
|
|
|
|
class CameraModule(QWidget):
|
|
|
|
|
|
"""单个摄像头模块:原图显示"""
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 重置信号,用于通知需要重置连接 (重连)
|
|
|
|
|
|
reset_signal = Signal()
|
|
|
|
|
|
|
|
|
|
|
|
# 视频显示信号,用于通知是否显示视频(刷新视频帧)
|
|
|
|
|
|
# 发送摄像头名 和 是否显示
|
|
|
|
|
|
video_display_signal = Signal(str, bool)
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
2026-01-11 18:00:32 +08:00
|
|
|
|
self, camera_name="摄像头", rtsp_url="", need_rotate_180=True, show_ai = False, parent=None
|
2025-10-31 18:52:31 +08:00
|
|
|
|
):
|
2025-10-20 18:10:07 +08:00
|
|
|
|
super().__init__(parent)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.setObjectName("cameraModule")
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.camera_name = camera_name
|
|
|
|
|
|
self.rtsp_url = rtsp_url
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.need_rotate_180 = need_rotate_180 # 画面是否需要旋转180度后显示
|
2026-01-11 18:00:32 +08:00
|
|
|
|
self.show_ai = show_ai # 是否需要展示ai(为True会多出AI显示按钮)
|
|
|
|
|
|
|
|
|
|
|
|
# 初始化AI显示相关变量为None
|
|
|
|
|
|
self.ai_display_group = None
|
|
|
|
|
|
self.ai_display_label = None
|
|
|
|
|
|
self.ai_display_switch = None
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.setup_ui()
|
|
|
|
|
|
|
|
|
|
|
|
def setup_ui(self):
|
2025-10-31 18:52:31 +08:00
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
|
|
layout.setSpacing(0)
|
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# --- 0. 标题布局: 标题 + 显示标签及开关 ---
|
|
|
|
|
|
title_layout = QHBoxLayout()
|
|
|
|
|
|
title_layout.setContentsMargins(0, 0, 0, 4)
|
|
|
|
|
|
title_layout.setSpacing(0)
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.title_label = QLabel()
|
|
|
|
|
|
self.title_label.setAlignment(Qt.AlignLeft)
|
|
|
|
|
|
self.title_label.setText(f"{self.camera_name}视频")
|
2026-01-11 18:00:32 +08:00
|
|
|
|
# self.title_label.setFixedWidth(212)
|
|
|
|
|
|
self.title_label.setFixedSize(212, 21)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.title_label.setObjectName("cameraTitleLabel")
|
2026-01-11 18:00:32 +08:00
|
|
|
|
# background-image: url({ImagePaths.VIDEO_TITLE_BACKGROUND});
|
|
|
|
|
|
# min-width: 126px;
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.title_label.setStyleSheet(
|
2025-11-01 16:13:14 +08:00
|
|
|
|
f"""
|
|
|
|
|
|
#cameraTitleLabel {{
|
2025-10-31 18:52:31 +08:00
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
color: #16ffff;
|
2025-11-01 16:13:14 +08:00
|
|
|
|
background-image: url({ImagePaths.VIDEO_TITLE_BACKGROUND});
|
2025-10-31 18:52:31 +08:00
|
|
|
|
padding-left: 12px;
|
2025-11-01 16:13:14 +08:00
|
|
|
|
}}
|
2025-10-31 18:52:31 +08:00
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-11 18:00:32 +08:00
|
|
|
|
# 1、创建【显示标签+开关】组合容器
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.display_group = QWidget() # 容器:包裹标签和开关
|
|
|
|
|
|
display_group_layout = QHBoxLayout(self.display_group)
|
|
|
|
|
|
display_group_layout.setContentsMargins(0, 0, 0, 0) # 容器内边距为0
|
|
|
|
|
|
display_group_layout.setSpacing(0)
|
|
|
|
|
|
|
|
|
|
|
|
# 显示标签
|
|
|
|
|
|
self.display_label = QLabel("显示")
|
|
|
|
|
|
self.display_label.setObjectName("displayLabel")
|
|
|
|
|
|
# font-weight: bold;
|
|
|
|
|
|
self.display_label.setStyleSheet("""
|
|
|
|
|
|
#displayLabel {
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
color: #16ffff;
|
|
|
|
|
|
margin: 0px;
|
|
|
|
|
|
padding: 0px;
|
2025-10-20 18:10:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
""")
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 让标签垂直居中
|
|
|
|
|
|
self.display_label.setFixedWidth(40)
|
|
|
|
|
|
self.display_label.setAlignment(Qt.AlignVCenter | Qt.AlignLeft)
|
|
|
|
|
|
|
|
|
|
|
|
# 显示开关
|
|
|
|
|
|
self.display_switch = SwitchButton()
|
|
|
|
|
|
self.display_switch.setChecked(True)
|
|
|
|
|
|
self.display_switch.switched.connect(self.onDisplayButtonSwitched)
|
|
|
|
|
|
|
|
|
|
|
|
# 将显示标签和开关添加到组合容器布局
|
|
|
|
|
|
display_group_layout.addWidget(self.display_label)
|
|
|
|
|
|
display_group_layout.addWidget(self.display_switch, alignment=Qt.AlignLeft)
|
|
|
|
|
|
|
2026-01-11 18:00:32 +08:00
|
|
|
|
if self.show_ai: # 当需要显示AI时才生成
|
|
|
|
|
|
# 2、创建【AI算法标签+开关】组合容器
|
|
|
|
|
|
self.ai_display_group = QWidget() # 容器:包裹AI显示标签和开关
|
|
|
|
|
|
ai_display_group_layout = QHBoxLayout(self.ai_display_group)
|
|
|
|
|
|
ai_display_group_layout.setContentsMargins(0, 0, 0, 0) # 容器内边距为0
|
|
|
|
|
|
ai_display_group_layout.setSpacing(0)
|
|
|
|
|
|
|
|
|
|
|
|
# AI算法显示标签
|
|
|
|
|
|
self.ai_display_label = QLabel("AI算法")
|
|
|
|
|
|
self.ai_display_label.setObjectName("aiDisplayLabel")
|
|
|
|
|
|
# font-weight: bold;
|
|
|
|
|
|
self.ai_display_label.setStyleSheet("""
|
|
|
|
|
|
#aiDisplayLabel {
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
color: #16ffff;
|
|
|
|
|
|
margin: 0px;
|
|
|
|
|
|
padding-left: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
""")
|
|
|
|
|
|
# self.ai_display_label.setFixedWidth(40)
|
|
|
|
|
|
self.ai_display_label.setAlignment(Qt.AlignVCenter | Qt.AlignLeft)
|
|
|
|
|
|
|
|
|
|
|
|
# AI开关
|
|
|
|
|
|
self.ai_display_switch = SwitchButton()
|
|
|
|
|
|
self.ai_display_switch.setChecked(False)
|
|
|
|
|
|
# self.ai_display_switch.switched.connect(self.onDisplayButtonSwitched) # 在camera_controller中处理
|
|
|
|
|
|
|
|
|
|
|
|
# 将AI显示标签和开关添加到组合容器布局
|
|
|
|
|
|
ai_display_group_layout.addWidget(self.ai_display_label)
|
|
|
|
|
|
ai_display_group_layout.addWidget(self.ai_display_switch, alignment=Qt.AlignLeft)
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 添加到标题布局
|
|
|
|
|
|
title_layout.addWidget(self.title_label, alignment=Qt.AlignLeft)
|
2026-01-11 18:00:32 +08:00
|
|
|
|
title_layout.addWidget(self.display_group, alignment=Qt.AlignRight)
|
|
|
|
|
|
if self.show_ai: # 需要添加 AI显示控件
|
|
|
|
|
|
title_layout.addWidget(self.ai_display_group, alignment=Qt.AlignLeft)
|
|
|
|
|
|
self.title_label.setFixedSize(139, 21) # 调整 xxx视频标签的宽度
|
2025-10-31 18:52:31 +08:00
|
|
|
|
|
|
|
|
|
|
# 视频显示容器:使用堆叠布局,让重连按钮和视频标签分层显示
|
|
|
|
|
|
self.video_container = QWidget()
|
|
|
|
|
|
self.video_container.setObjectName("videoContainer")
|
|
|
|
|
|
self.video_container.setFixedSize(327, 199) # 需要同步修改下面的尺寸
|
|
|
|
|
|
# #011d6b #033474
|
|
|
|
|
|
# self.video_container.setStyleSheet(
|
|
|
|
|
|
# "background-color: #011d6b; border: none;"
|
|
|
|
|
|
# ) # 明确视频显示背景色
|
|
|
|
|
|
self.video_container.setStyleSheet(
|
2025-11-01 16:13:14 +08:00
|
|
|
|
f"""
|
|
|
|
|
|
#videoContainer {{
|
|
|
|
|
|
border-image: url({ImagePaths.VIDEO_FRAME_BACKGROUND});
|
|
|
|
|
|
}}
|
2025-10-31 18:52:31 +08:00
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
video_layout = QVBoxLayout(self.video_container)
|
|
|
|
|
|
video_layout.setContentsMargins(6, 6, 5, 6)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 原始图像显示
|
|
|
|
|
|
self.raw_label = QLabel()
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.raw_label.setAlignment(Qt.AlignCenter)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.raw_label.setStyleSheet("border: none;") # 移除背景,使用容器背景
|
|
|
|
|
|
# self.raw_label.setStyleSheet("background-color: red;")
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.raw_label.setText(f"{self.camera_name}摄像头, 连接中...")
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# self.raw_label.setFixedSize(327, 199)
|
|
|
|
|
|
# self.raw_label.setFixedSize(314, 187) # 需要根据视频框背景大小调整 !!!
|
|
|
|
|
|
self.raw_label.setFixedSize(316, 187) # 需要根据视频框背景大小调整 !!!
|
|
|
|
|
|
|
|
|
|
|
|
# 重置按钮:居中显示、透明背景
|
|
|
|
|
|
self.reset_button = QPushButton()
|
|
|
|
|
|
# self.reset_button.setFixedSize(327, 199)
|
|
|
|
|
|
# self.reset_button.setFixedSize(314, 187) # 同上
|
|
|
|
|
|
self.reset_button.setFixedSize(316, 187) # 同上
|
|
|
|
|
|
self.reset_button.setStyleSheet(
|
|
|
|
|
|
"""
|
|
|
|
|
|
QPushButton {
|
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
padding: 0px;
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
self.reset_button.setCursor(QCursor(Qt.PointingHandCursor))
|
|
|
|
|
|
self.reset_button.clicked.connect(self.onRestButtonClicked)
|
|
|
|
|
|
|
|
|
|
|
|
# 加载视频重新连接图片
|
|
|
|
|
|
try:
|
2025-11-01 16:13:14 +08:00
|
|
|
|
pixmap = QPixmap(ImagePaths.INTERFACE_REFRESH)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
if not pixmap.isNull():
|
|
|
|
|
|
self.reset_button.setIcon(QIcon(pixmap))
|
|
|
|
|
|
self.reset_button.setIconSize(pixmap.size())
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 图片加载失败(文件不存在或格式错误)
|
|
|
|
|
|
self.reset_button.setText("点击重置连接")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"加载复位图片失败: {e}")
|
|
|
|
|
|
self.reset_button.setText("点击重置连接")
|
|
|
|
|
|
|
|
|
|
|
|
# 堆叠布局:重连按钮和视频标签分层
|
|
|
|
|
|
self.stacked_widget = QStackedWidget()
|
|
|
|
|
|
self.stacked_widget.addWidget(self.raw_label)
|
|
|
|
|
|
self.stacked_widget.addWidget(self.reset_button)
|
|
|
|
|
|
video_layout.addWidget(self.stacked_widget, alignment=Qt.AlignCenter)
|
|
|
|
|
|
|
|
|
|
|
|
# 添加到主布局
|
|
|
|
|
|
layout.addLayout(title_layout)
|
|
|
|
|
|
layout.addWidget(self.video_container)
|
|
|
|
|
|
|
|
|
|
|
|
# 显示开关切换槽函数
|
|
|
|
|
|
def onDisplayButtonSwitched(self, state:bool):
|
2026-01-11 18:00:32 +08:00
|
|
|
|
# 显示开关打开,state 为 True; 显示开关关闭,state 为 False
|
2025-10-31 18:52:31 +08:00
|
|
|
|
if state:
|
|
|
|
|
|
self.stacked_widget.setCurrentWidget(self.raw_label) # 视频显示标签
|
|
|
|
|
|
self.stacked_widget.setHidden(False)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.stacked_widget.setHidden(True)
|
|
|
|
|
|
self.video_display_signal.emit(self.camera_name, state)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 重连按钮点击槽函数
|
|
|
|
|
|
def onRestButtonClicked(self):
|
|
|
|
|
|
self.stacked_widget.setCurrentWidget(self.raw_label)
|
|
|
|
|
|
self.raw_label.setText(f"{self.camera_name}摄像头, 连接中...")
|
|
|
|
|
|
self.reset_signal.emit() # 发送重连信号
|
|
|
|
|
|
|
|
|
|
|
|
def showResetButton(self):
|
|
|
|
|
|
self.raw_label.setText("")
|
|
|
|
|
|
self.stacked_widget.setCurrentWidget(self.reset_button)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
def update_raw_image(self, image: Optional[QImage]):
|
|
|
|
|
|
if image:
|
|
|
|
|
|
pixmap = QPixmap.fromImage(image)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.raw_label.setPixmap(
|
|
|
|
|
|
pixmap.scaled(
|
|
|
|
|
|
self.raw_label.size(),
|
|
|
|
|
|
Qt.IgnoreAspectRatio, # 忽略比例,占满窗口 可选:Qt.KeepAspectRatio
|
|
|
|
|
|
Qt.SmoothTransformation,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.raw_label.setText("")
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.raw_label.setPixmap(QPixmap())
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 没有图片数据,stream流会自动重连 或 手动重连
|
|
|
|
|
|
self.raw_label.setText(f"{self.camera_name}摄像头, 连接中...")
|
|
|
|
|
|
|
|
|
|
|
|
# 视频显示区域 总的标题 (如 振捣视频)
|
|
|
|
|
|
class VideoTitleWidget(QWidget):
|
|
|
|
|
|
def __init__(self, video_name, parent=None):
|
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
|
self.setStyleSheet("background-color: transparent;") # 整体透明
|
|
|
|
|
|
self.setup_ui(video_name)
|
|
|
|
|
|
|
|
|
|
|
|
def setup_ui(self, video_name):
|
|
|
|
|
|
# 水平布局:文字 + 图标
|
|
|
|
|
|
layout = QHBoxLayout(self)
|
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0) # 布局内边距为0
|
|
|
|
|
|
|
|
|
|
|
|
# 文字标签
|
|
|
|
|
|
self.text_label = QLabel(f"{video_name}视频")
|
|
|
|
|
|
self.text_label.setStyleSheet("""
|
|
|
|
|
|
font-family: "微软雅黑";
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #16FFFF;
|
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
|
""")
|
|
|
|
|
|
font = self.text_label.font()
|
|
|
|
|
|
font.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 3) # 字间距3px(可按需加大)
|
|
|
|
|
|
self.text_label.setFont(font)
|
|
|
|
|
|
|
|
|
|
|
|
# 图标标签
|
|
|
|
|
|
self.icon_label = QLabel()
|
2025-11-01 16:13:14 +08:00
|
|
|
|
self.icon_label.setPixmap(QPixmap(ImagePaths.VIDEO_CAMERA_MARK)) # 需要换为实际的图片的路径
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.icon_label.setStyleSheet("background-color: transparent;")
|
|
|
|
|
|
self.icon_label.setFixedSize(23, 16)
|
|
|
|
|
|
self.icon_label.setScaledContents(True)
|
|
|
|
|
|
|
|
|
|
|
|
# 添加到布局
|
|
|
|
|
|
layout.addWidget(self.text_label, alignment=Qt.AlignCenter)
|
|
|
|
|
|
layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
class VibrationVideoWidget(QWidget):
|
|
|
|
|
|
def __init__(self, parent=None):
|
|
|
|
|
|
super().__init__(parent=parent)
|
|
|
|
|
|
self.setObjectName("vibrationVideoWidget")
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 1. 加载背景图片(替换为你的图片路径)
|
|
|
|
|
|
self.background_image = QImage() # 初始化图片对象
|
|
|
|
|
|
# 注意:图片路径需正确,建议使用绝对路径或项目相对路径
|
2025-11-01 16:13:14 +08:00
|
|
|
|
self.background_path = ImagePaths.VIDEO_BACKGROUND # 你的背景图片路径
|
2025-10-31 18:52:31 +08:00
|
|
|
|
if not self.background_image.load(self.background_path):
|
|
|
|
|
|
# 图片加载失败时的容错处理(显示红色背景作为提示)
|
|
|
|
|
|
print(f"警告:无法加载背景图片 {self.background_path}")
|
|
|
|
|
|
self.background_image = QImage(388, 839, QImage.Format_RGB32)
|
|
|
|
|
|
self.background_image.fill(Qt.GlobalColor.red) # 加载失败时用红色填充
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
# 显示摄像头画面的模组
|
|
|
|
|
|
# 需要修改为相应的地址!!!
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 注:在camera_controller中设置地址url
|
|
|
|
|
|
self.cam1 = CameraModule("上位料斗")
|
2026-01-11 18:00:32 +08:00
|
|
|
|
self.cam2 = CameraModule("下位料斗", show_ai=False)
|
|
|
|
|
|
self.cam3 = CameraModule("模具车", need_rotate_180=False)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
self.setup_ui()
|
|
|
|
|
|
self.connect_signals()
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
def set_camera_urls(self, cam1_url, cam2_url, cam3_url):
|
|
|
|
|
|
"""设置三个摄像头的RTSP URL
|
|
|
|
|
|
注意: 在CameraController中调用
|
|
|
|
|
|
"""
|
|
|
|
|
|
self.cam1.rtsp_url = cam1_url
|
|
|
|
|
|
self.cam2.rtsp_url = cam2_url
|
|
|
|
|
|
self.cam3.rtsp_url = cam3_url
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
def setup_ui(self):
|
|
|
|
|
|
# 视频widget样式
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# self.setFixedSize(387, 720) # 宽387、高792
|
|
|
|
|
|
self.setFixedSize(388, 839) # 背景图片宽388、高879
|
|
|
|
|
|
|
|
|
|
|
|
# 1、总的标题
|
|
|
|
|
|
self.title_widget = VideoTitleWidget("振捣")
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
# 布局设置
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 2. 创建摄像头容器(专门存放cam1/cam2/cam3)
|
|
|
|
|
|
self.camera_container = QWidget() # 容器widget
|
|
|
|
|
|
self.camera_container.setFixedSize(350, 720) # 容器固定尺寸
|
|
|
|
|
|
# 给容器添加样式
|
|
|
|
|
|
self.camera_container.setStyleSheet("""
|
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 容器内部布局(管理三个摄像头模块)
|
|
|
|
|
|
container_layout = QVBoxLayout(self.camera_container)
|
|
|
|
|
|
container_layout.setContentsMargins(6, 6, 6, 6) # 容器内边距
|
|
|
|
|
|
container_layout.setSpacing(6) # 摄像头模块之间的间距
|
|
|
|
|
|
|
|
|
|
|
|
# 将三个摄像头添加到容器布局中
|
|
|
|
|
|
container_layout.addWidget(self.cam1, alignment=Qt.AlignCenter)
|
|
|
|
|
|
container_layout.addWidget(self.cam2, alignment=Qt.AlignCenter)
|
|
|
|
|
|
container_layout.addWidget(self.cam3, alignment=Qt.AlignCenter)
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 主窗口布局(管理摄像头容器等其他部件)
|
|
|
|
|
|
main_layout = QVBoxLayout(self) # 主窗口的布局
|
|
|
|
|
|
main_layout.setContentsMargins(0, 0, 9, 30) # 主布局内边距为0
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 添加标题部件
|
|
|
|
|
|
main_layout.addWidget(self.title_widget, alignment=Qt.AlignCenter) # 标题居中
|
|
|
|
|
|
|
|
|
|
|
|
# 添加43px间距(标题与摄像头容器之间)
|
|
|
|
|
|
main_layout.addSpacing(16)
|
|
|
|
|
|
|
|
|
|
|
|
# 把摄像头容器添加到主布局中(作为一个整体)
|
|
|
|
|
|
main_layout.addWidget(self.camera_container, alignment=Qt.AlignBottom | Qt.AlignHCenter)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 初始化视频流 (注意:设置好了camera_urls之后调用)
|
2025-10-20 18:10:07 +08:00
|
|
|
|
def init_streams(self):
|
|
|
|
|
|
url1 = self.cam1.rtsp_url
|
|
|
|
|
|
url2 = self.cam2.rtsp_url
|
|
|
|
|
|
url3 = self.cam3.rtsp_url
|
|
|
|
|
|
|
|
|
|
|
|
self.stream1 = VideoStream(url1, "Cam1").start()
|
|
|
|
|
|
self.stream2 = VideoStream(url2, "Cam2").start()
|
|
|
|
|
|
self.stream3 = VideoStream(url3, "Cam3").start()
|
|
|
|
|
|
|
|
|
|
|
|
self.timer1 = QTimer()
|
|
|
|
|
|
self.timer1.timeout.connect(self.update_frame1)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.timer2 = QTimer()
|
|
|
|
|
|
self.timer2.timeout.connect(self.update_frame2)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
self.timer3 = QTimer()
|
|
|
|
|
|
self.timer3.timeout.connect(self.update_frame3)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
# 定时读取视频流
|
|
|
|
|
|
self.timer1.start(33)
|
|
|
|
|
|
self.timer2.start(33)
|
|
|
|
|
|
self.timer3.start(33)
|
|
|
|
|
|
|
|
|
|
|
|
def connect_signals(self):
|
2025-10-31 18:52:31 +08:00
|
|
|
|
# 流Stream相关的槽函数连接放在了 camera_controller之中
|
|
|
|
|
|
|
|
|
|
|
|
# 视频显示相关的槽函数连接
|
|
|
|
|
|
self.cam1.video_display_signal.connect(self.onVideoDisplay)
|
|
|
|
|
|
self.cam2.video_display_signal.connect(self.onVideoDisplay)
|
|
|
|
|
|
self.cam3.video_display_signal.connect(self.onVideoDisplay)
|
|
|
|
|
|
|
|
|
|
|
|
def onVideoDisplay(self, cam_name: str, state: bool):
|
|
|
|
|
|
# 1. 摄像头与 stream、timer 的映射关系
|
|
|
|
|
|
cam_mapping = {
|
|
|
|
|
|
"上位料斗": (self.stream1, self.timer1),
|
|
|
|
|
|
"下位料斗": (self.stream2, self.timer2),
|
|
|
|
|
|
"模具车": (self.stream3, self.timer3)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 获取当前摄像头对应的 stream 和 timer(处理无效名称的情况)
|
|
|
|
|
|
stream, timer = cam_mapping.get(cam_name, (None, None))
|
|
|
|
|
|
if not stream or not timer:
|
|
|
|
|
|
print(f"警告:未知摄像头名称 {cam_name}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 根据 state 执行操作
|
|
|
|
|
|
if state: # 显示视频
|
|
|
|
|
|
stream.reset_retry() # 视频流重连
|
|
|
|
|
|
timer.start(33) # 定时器开始,显示视频帧
|
|
|
|
|
|
else: # 关闭视频
|
|
|
|
|
|
timer.stop() # 定时器停止
|
|
|
|
|
|
stream.manual_stop = True # 视频流停止读取
|
|
|
|
|
|
|
|
|
|
|
|
def onStreamError(self, stream_name):
|
|
|
|
|
|
if stream_name == "Cam1":
|
|
|
|
|
|
self.cam1.showResetButton()
|
|
|
|
|
|
elif stream_name == "Cam2":
|
|
|
|
|
|
self.cam2.showResetButton()
|
|
|
|
|
|
elif stream_name == "Cam3":
|
|
|
|
|
|
self.cam3.showResetButton()
|
2025-10-20 18:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
def update_frame1(self):
|
|
|
|
|
|
frame = self.stream1.read()
|
|
|
|
|
|
if frame is not None:
|
|
|
|
|
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
|
|
|
|
if self.cam1.need_rotate_180:
|
|
|
|
|
|
frame_rgb = cv2.rotate(frame_rgb, cv2.ROTATE_180)
|
|
|
|
|
|
h, w, ch = frame_rgb.shape
|
|
|
|
|
|
q_img = QImage(frame_rgb.data, w, h, ch * w, QImage.Format_RGB888)
|
|
|
|
|
|
self.cam1.update_raw_image(q_img)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.cam1.update_raw_image(None)
|
2025-10-31 18:52:31 +08:00
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
def update_frame2(self):
|
|
|
|
|
|
frame = self.stream2.read()
|
|
|
|
|
|
if frame is not None:
|
|
|
|
|
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
|
|
|
|
if self.cam2.need_rotate_180:
|
|
|
|
|
|
frame_rgb = cv2.rotate(frame_rgb, cv2.ROTATE_180)
|
|
|
|
|
|
h, w, ch = frame_rgb.shape
|
|
|
|
|
|
q_img = QImage(frame_rgb.data, w, h, ch * w, QImage.Format_RGB888)
|
|
|
|
|
|
self.cam2.update_raw_image(q_img)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.cam2.update_raw_image(None)
|
|
|
|
|
|
|
|
|
|
|
|
def update_frame3(self):
|
|
|
|
|
|
frame = self.stream3.read()
|
|
|
|
|
|
if frame is not None:
|
|
|
|
|
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
|
|
|
|
if self.cam3.need_rotate_180:
|
|
|
|
|
|
frame_rgb = cv2.rotate(frame_rgb, cv2.ROTATE_180)
|
|
|
|
|
|
h, w, ch = frame_rgb.shape
|
|
|
|
|
|
q_img = QImage(frame_rgb.data, w, h, ch * w, QImage.Format_RGB888)
|
|
|
|
|
|
self.cam3.update_raw_image(q_img)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.cam3.update_raw_image(None)
|
|
|
|
|
|
|
2025-10-31 18:52:31 +08:00
|
|
|
|
def paintEvent(self, event):
|
|
|
|
|
|
# 1. 先调用父类的paintEvent
|
|
|
|
|
|
super().paintEvent(event)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 创建画家对象,绘制背景图片
|
|
|
|
|
|
painter = QPainter(self)
|
|
|
|
|
|
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) # 图片缩放平滑
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 获取部件的实际尺寸(这里是387x720,与setup_ui中一致)
|
|
|
|
|
|
widget_rect = self.rect()
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 绘制背景图片(缩放至部件大小)
|
|
|
|
|
|
# 可选缩放策略:
|
|
|
|
|
|
# - Qt.KeepAspectRatio:保持图片比例,可能有黑边
|
|
|
|
|
|
# - Qt.IgnoreAspectRatio:拉伸填充,可能变形
|
|
|
|
|
|
# - Qt.KeepAspectRatioByExpanding:按比例放大至覆盖部件,可能裁剪边缘
|
|
|
|
|
|
scaled_pixmap = QPixmap.fromImage(self.background_image).scaled(
|
|
|
|
|
|
widget_rect.size(),
|
|
|
|
|
|
Qt.KeepAspectRatio, # 推荐:保持比例,避免变形
|
|
|
|
|
|
Qt.SmoothTransformation # 平滑缩放
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 计算图片绘制位置(居中显示,若有黑边则均匀分布)
|
|
|
|
|
|
x = (widget_rect.width() - scaled_pixmap.width()) // 2
|
|
|
|
|
|
y = (widget_rect.height() - scaled_pixmap.height()) // 2
|
|
|
|
|
|
painter.drawPixmap(x, y, scaled_pixmap)
|
|
|
|
|
|
|
|
|
|
|
|
# 6. 结束绘制
|
|
|
|
|
|
painter.end()
|
|
|
|
|
|
|
2025-10-20 18:10:07 +08:00
|
|
|
|
# ====================== 清理资源 ======================
|
|
|
|
|
|
def closeEvent(self, event):
|
|
|
|
|
|
self.hide()
|
|
|
|
|
|
self.timer1.stop()
|
|
|
|
|
|
self.timer2.stop()
|
|
|
|
|
|
self.timer3.stop()
|
2025-10-31 18:52:31 +08:00
|
|
|
|
self.stream1.stop()
|
|
|
|
|
|
self.stream2.stop()
|
|
|
|
|
|
self.stream3.stop()
|
2025-10-20 18:10:07 +08:00
|
|
|
|
super().closeEvent(event)
|