add camera client

This commit is contained in:
linbo 2026-03-18 09:19:23 +08:00
parent 3ab4fbc3e0
commit cf033e421b
3 changed files with 1232 additions and 0 deletions

503
clients/dexhand_client.py Normal file
View File

@ -0,0 +1,503 @@
import time
import threading
from typing import Optional, List
import numpy as np
import grpc
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QComboBox,
QSpinBox, QGroupBox, QGridLayout, QLineEdit, QMessageBox)
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtGui import QFont
import pyqtgraph as pg
from clients._path_setup import ensure_paths
ensure_paths()
from cmvr.api import dexhand_service_pb2_grpc
from cmvr.api import dexhand_command_pb2
from google.protobuf.timestamp_pb2 import Timestamp
class DexHandSensorClient:
"""灵巧手传感器数据客户端"""
def __init__(self, ip="192.168.0.222", port=50052, device_id="dexhand_001", timeout=2, retries=1):
self.ip = ip
self.port = port
self.address = f"{ip}:{port}"
self.device_id = device_id
self.timeout = timeout
self.retries = retries
self.channel = None
self.stub = None
self._connect_with_retry()
def _connect_with_retry(self):
"""连接gRPC服务器"""
attempt = 0
while attempt <= self.retries:
print(f"Connecting to DexHand gRPC server at {self.address}... (Attempt {attempt + 1})")
try:
self.channel = grpc.insecure_channel(self.address)
grpc.channel_ready_future(self.channel).result(timeout=self.timeout)
self.stub = dexhand_service_pb2_grpc.DexHandServiceStub(self.channel)
print(f"Successfully connected to DexHand gRPC server: {self.address}")
print(f"Device ID: {self.device_id}")
return True
except grpc.FutureTimeoutError:
attempt += 1
print(f"Connection timed out, attempt {attempt} failed")
if attempt > self.retries:
return False
print(f"Waiting {self.timeout}s before retrying...")
time.sleep(self.timeout)
except Exception as e:
print(f"Connection error: {e}")
return False
return False
def get_sensor_data(self) -> Optional[dexhand_command_pb2.GetSensorDataCommand.Feedback]:
"""获取传感器数据"""
if not self.stub:
print("No gRPC connection available")
return None
try:
# 创建请求
timestamp = Timestamp()
timestamp.GetCurrentTime()
request = dexhand_command_pb2.GetSensorDataCommand.Request()
request.header.device_id = self.device_id
request.header.timestamp.CopyFrom(timestamp)
# 发送请求
feedback = self.stub.GetSensorData(request)
return feedback
except grpc.RpcError as e:
print(f"gRPC error: {e.code()} - {e.details()}")
return None
except Exception as e:
print(f"Error getting sensor data: {e}")
return None
def close(self):
"""关闭连接"""
if self.channel:
self.channel.close()
print(f"Closed connection to DexHand gRPC server {self.address}")
def is_connected(self):
"""检查是否已连接"""
return self.channel is not None and self.stub is not None
class SensorDataPlotWidget(QWidget):
"""传感器数据绘制窗口"""
def __init__(self, parent=None):
super().__init__(parent)
self.client = None
self.update_timer = QTimer()
self.data_buffer = {
'sum': [], # sum数据缓冲区
'peak': [] # peak数据缓冲区
}
self.time_buffer = [] # 时间戳缓冲区
self.max_points = 100 # 最大显示点数
self.current_finger = dexhand_command_pb2.SensorData.INDEX # 默认食指
self.current_part = dexhand_command_pb2.SensorData.TIP # 默认指端
self.setup_ui()
self.setup_plots()
def setup_ui(self):
"""设置UI"""
layout = QVBoxLayout()
self.setLayout(layout)
# 标题
title = QLabel("灵巧手传感器数据监控 - 食指指端")
title_font = QFont()
title_font.setPointSize(14)
title_font.setBold(True)
title.setFont(title_font)
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 控制面板
control_group = QGroupBox("连接配置")
control_layout = QGridLayout()
control_group.setLayout(control_layout)
# IP地址
control_layout.addWidget(QLabel("服务器IP:"), 0, 0)
self.ip_edit = QLineEdit("192.168.0.222")
control_layout.addWidget(self.ip_edit, 0, 1)
# 端口
control_layout.addWidget(QLabel("端口:"), 1, 0)
self.port_edit = QLineEdit("50052")
control_layout.addWidget(self.port_edit, 1, 1)
# 设备ID
control_layout.addWidget(QLabel("设备ID:"), 2, 0)
self.device_id_edit = QLineEdit("dexhand_001")
control_layout.addWidget(self.device_id_edit, 2, 1)
# 完整地址显示
control_layout.addWidget(QLabel("完整地址:"), 3, 0)
self.address_label = QLabel("192.168.0.222:50052")
self.address_label.setStyleSheet("font-weight: bold;")
control_layout.addWidget(self.address_label, 3, 1)
# 连接按钮
self.connect_btn = QPushButton("连接")
self.connect_btn.clicked.connect(self.toggle_connection)
self.connect_btn.setMinimumHeight(40)
self.connect_btn.setStyleSheet("font-weight: bold;")
control_layout.addWidget(self.connect_btn, 4, 0, 1, 2)
# 连接状态
self.status_label = QLabel("未连接")
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setStyleSheet("color: red; font-weight: bold; padding: 5px;")
control_layout.addWidget(self.status_label, 5, 0, 1, 2)
layout.addWidget(control_group)
# 数据配置面板
data_config_group = QGroupBox("数据配置")
data_config_layout = QGridLayout()
data_config_group.setLayout(data_config_layout)
# 更新频率
data_config_layout.addWidget(QLabel("更新频率(ms):"), 0, 0)
self.freq_spin = QSpinBox()
self.freq_spin.setRange(10, 1000)
self.freq_spin.setValue(50)
self.freq_spin.valueChanged.connect(self.set_update_interval)
data_config_layout.addWidget(self.freq_spin, 0, 1)
# 显示点数
data_config_layout.addWidget(QLabel("显示点数:"), 1, 0)
self.points_spin = QSpinBox()
self.points_spin.setRange(10, 500)
self.points_spin.setValue(100)
self.points_spin.valueChanged.connect(self.set_max_points)
data_config_layout.addWidget(self.points_spin, 1, 1)
# 清空数据按钮
self.clear_btn = QPushButton("清空数据")
self.clear_btn.clicked.connect(self.clear_data)
self.clear_btn.setEnabled(False)
data_config_layout.addWidget(self.clear_btn, 2, 0)
# 测试连接按钮
self.test_btn = QPushButton("测试连接")
self.test_btn.clicked.connect(self.test_connection)
data_config_layout.addWidget(self.test_btn, 2, 1)
layout.addWidget(data_config_group)
# 创建绘图区域
plots_widget = QWidget()
plots_layout = QVBoxLayout()
plots_widget.setLayout(plots_layout)
# SUM曲线图
self.sum_plot_widget = pg.PlotWidget(title="食指指端传感器数据 - SUM值")
self.sum_plot_widget.setLabel('left', 'SUM值')
self.sum_plot_widget.setLabel('bottom', '时间 (秒)')
self.sum_plot_widget.showGrid(x=True, y=True, alpha=0.3)
self.sum_plot_widget.addLegend()
self.sum_curve = self.sum_plot_widget.plot(pen='y', name='SUM值', width=2)
plots_layout.addWidget(self.sum_plot_widget)
# PEAK曲线图
self.peak_plot_widget = pg.PlotWidget(title="食指指端传感器数据 - PEAK值")
self.peak_plot_widget.setLabel('left', 'PEAK值')
self.peak_plot_widget.setLabel('bottom', '时间 (秒)')
self.peak_plot_widget.showGrid(x=True, y=True, alpha=0.3)
self.peak_plot_widget.addLegend()
self.peak_curve = self.peak_plot_widget.plot(pen='r', name='PEAK值', width=2)
plots_layout.addWidget(self.peak_plot_widget)
layout.addWidget(plots_widget)
# 实时数据展示
data_group = QGroupBox("当前数据")
data_layout = QHBoxLayout()
data_group.setLayout(data_layout)
self.sum_label = QLabel("SUM: --")
self.sum_label.setStyleSheet("font-weight: bold; color: orange; font-size: 12pt;")
data_layout.addWidget(self.sum_label)
self.peak_label = QLabel("PEAK: --")
self.peak_label.setStyleSheet("font-weight: bold; color: red; font-size: 12pt;")
data_layout.addWidget(self.peak_label)
self.timestamp_label = QLabel("时间: --")
self.timestamp_label.setStyleSheet("font-size: 10pt;")
data_layout.addWidget(self.timestamp_label)
layout.addWidget(data_group)
# 连接输入变化信号
self.ip_edit.textChanged.connect(self.update_address_label)
self.port_edit.textChanged.connect(self.update_address_label)
def update_address_label(self):
"""更新地址标签"""
ip = self.ip_edit.text().strip()
port = self.port_edit.text().strip()
if ip and port:
self.address_label.setText(f"{ip}:{port}")
else:
self.address_label.setText("--")
def setup_plots(self):
"""初始化绘图"""
self.sum_curve.setData([], [])
self.peak_curve.setData([], [])
def test_connection(self):
"""测试连接"""
ip = self.ip_edit.text().strip()
port = self.port_edit.text().strip()
device_id = self.device_id_edit.text().strip()
if not ip or not port:
QMessageBox.warning(self, "警告", "请输入IP地址和端口")
return
try:
port = int(port)
except ValueError:
QMessageBox.warning(self, "警告", "端口必须是数字")
return
# 创建临时客户端测试连接
test_client = DexHandSensorClient(ip, port, device_id, timeout=2, retries=0)
if test_client.is_connected():
QMessageBox.information(self, "成功", "连接测试成功!")
test_client.close()
else:
QMessageBox.critical(self, "失败", "连接测试失败,请检查服务器地址和网络")
def set_client(self, client: DexHandSensorClient):
"""设置客户端"""
self.client = client
self.status_label.setText(f"已连接 (设备: {client.device_id})")
self.status_label.setStyleSheet("color: green; font-weight: bold; padding: 5px;")
self.connect_btn.setText("断开连接")
self.clear_btn.setEnabled(True)
self.ip_edit.setEnabled(False)
self.port_edit.setEnabled(False)
self.device_id_edit.setEnabled(False)
self.test_btn.setEnabled(False)
def clear_client(self):
"""清除客户端"""
self.client = None
self.status_label.setText("未连接")
self.status_label.setStyleSheet("color: red; font-weight: bold; padding: 5px;")
self.connect_btn.setText("连接")
self.clear_btn.setEnabled(False)
self.ip_edit.setEnabled(True)
self.port_edit.setEnabled(True)
self.device_id_edit.setEnabled(True)
self.test_btn.setEnabled(True)
def toggle_connection(self):
"""切换连接状态"""
if self.client is None or not self.client.is_connected():
# 尝试连接
ip = self.ip_edit.text().strip()
port = self.port_edit.text().strip()
device_id = self.device_id_edit.text().strip()
if not ip or not port:
QMessageBox.warning(self, "警告", "请输入IP地址和端口")
return
try:
port = int(port)
except ValueError:
QMessageBox.warning(self, "警告", "端口必须是数字")
return
# 显示连接中状态
self.status_label.setText("连接中...")
self.status_label.setStyleSheet("color: orange; font-weight: bold; padding: 5px;")
QApplication.processEvents()
# 创建客户端
try:
client = DexHandSensorClient(ip, port, device_id)
if client.is_connected():
self.set_client(client)
self.start_updating()
else:
self.clear_client()
QMessageBox.critical(self, "错误", f"无法连接到服务器 {ip}:{port}")
except Exception as e:
self.clear_client()
QMessageBox.critical(self, "错误", f"连接失败: {str(e)}")
else:
# 断开连接
self.stop_updating()
self.client.close()
self.clear_client()
self.clear_data()
def start_updating(self):
"""开始更新数据"""
self.update_timer.timeout.connect(self.update_data)
self.update_timer.start(self.freq_spin.value())
def stop_updating(self):
"""停止更新数据"""
self.update_timer.stop()
try:
self.update_timer.timeout.disconnect()
except:
pass
def set_update_interval(self, ms: int):
"""设置更新间隔"""
if self.update_timer.isActive():
self.update_timer.setInterval(ms)
def set_max_points(self, points: int):
"""设置最大显示点数"""
self.max_points = points
# 如果当前数据超过新限制,截断数据
if len(self.time_buffer) > self.max_points:
self.time_buffer = self.time_buffer[-self.max_points:]
self.data_buffer['sum'] = self.data_buffer['sum'][-self.max_points:]
self.data_buffer['peak'] = self.data_buffer['peak'][-self.max_points:]
def clear_data(self):
"""清空数据"""
self.data_buffer['sum'].clear()
self.data_buffer['peak'].clear()
self.time_buffer.clear()
self.sum_curve.setData([], [])
self.peak_curve.setData([], [])
self.sum_label.setText("SUM: --")
self.peak_label.setText("PEAK: --")
def extract_finger_data(self, feedback):
"""提取食指指端的传感器数据"""
if not feedback or not feedback.sensor:
return None, None
sum_values = []
peak_values = []
for sensor in feedback.sensor:
# 检查是否为食指指端
if (sensor.finger_type == self.current_finger and
sensor.part_type == self.current_part):
# 处理传感器数据
if sensor.data:
for row in sensor.data:
if row.values:
row_array = np.array(row.values)
sum_values.append(np.sum(row_array))
peak_values.append(np.max(row_array))
if sum_values and peak_values:
return np.mean(sum_values), np.mean(peak_values)
return None, None
def update_data(self):
"""更新数据"""
if not self.client or not self.client.is_connected():
return
feedback = self.client.get_sensor_data()
if not feedback:
# 如果获取数据失败,可能连接已断开
if self.client and not self.client.is_connected():
self.toggle_connection() # 自动断开
return
# 提取食指指端数据
sum_val, peak_val = self.extract_finger_data(feedback)
if sum_val is not None and peak_val is not None:
# 更新时间戳
current_time = time.time()
self.time_buffer.append(current_time)
self.data_buffer['sum'].append(sum_val)
self.data_buffer['peak'].append(peak_val)
# 限制缓冲区大小
if len(self.time_buffer) > self.max_points:
self.time_buffer.pop(0)
self.data_buffer['sum'].pop(0)
self.data_buffer['peak'].pop(0)
# 更新曲线
if len(self.time_buffer) > 1:
# 归一化时间显示(相对于第一个点)
t0 = self.time_buffer[0]
plot_time = [t - t0 for t in self.time_buffer]
self.sum_curve.setData(plot_time, self.data_buffer['sum'])
self.peak_curve.setData(plot_time, self.data_buffer['peak'])
# 自动调整Y轴范围
self.sum_plot_widget.autoRange()
self.peak_plot_widget.autoRange()
# 更新标签
self.sum_label.setText(f"SUM: {sum_val:.2f}")
self.peak_label.setText(f"PEAK: {peak_val:.2f}")
self.timestamp_label.setText(f"时间: {time.strftime('%H:%M:%S')}")
class SensorDataWindow(QMainWindow):
"""传感器数据监控主窗口"""
def __init__(self):
super().__init__()
self.setWindowTitle("灵巧手传感器数据监控 - 可配置版")
self.setGeometry(100, 100, 900, 1000)
# 设置中央部件
central_widget = SensorDataPlotWidget()
self.setCentralWidget(central_widget)
def closeEvent(self, event):
"""关闭窗口时清理资源"""
central_widget = self.centralWidget()
if central_widget:
central_widget.stop_updating()
if central_widget.client:
central_widget.client.close()
event.accept()
def main():
"""主函数"""
import sys
app = QApplication(sys.argv)
app.setStyle('Fusion') # 使用Fusion风格看起来更现代
window = SensorDataWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()

View File

@ -0,0 +1,727 @@
# camera_player_qt.py
import sys
import cv2
import av
import numpy as np
import grpc
import threading
import time
from datetime import datetime
from pathlib import Path
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QLineEdit,
QFileDialog, QMessageBox, QStatusBar, QGroupBox,
QGridLayout, QComboBox, QSpinBox, QCheckBox, QFrame)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt5.QtGui import QImage, QPixmap
# 导入生成的protobuf模块
from cmvr.api import camera_service_pb2_grpc
from cmvr.api import camera_command_pb2
from cmvr.api.common_pb2 import CommandHeader
from google.protobuf.timestamp_pb2 import Timestamp
class StreamThread(QThread):
"""流接收线程"""
frame_received = pyqtSignal(object, int, int) # frame, width, height
status_update = pyqtSignal(dict)
error_occurred = pyqtSignal(str)
keyframe_received = pyqtSignal()
# 支持的编码格式
CODECS = {
'H.265 (HEVC)': 'hevc',
'H.264 (AVC)': 'h264'
}
def __init__(self, device_id, server_addr, codec='hevc', wait_for_keyframe=True):
super().__init__()
self.device_id = device_id
self.server_addr = server_addr
self.codec_name = codec
self.wait_for_keyframe = wait_for_keyframe
self.running = False
self.streaming = False
# gRPC
self.channel = None
self.stub = None
# 解码器
self.codec = None
self._init_decoder()
# 统计
self.frame_count = 0
self.fps_counter = 0
self.fps_time = time.time()
self.fps = 0
self.keyframe_count = 0
def _init_decoder(self):
"""初始化解码器"""
try:
# 根据选择的编码创建解码器
self.codec = av.CodecContext.create(self.codec_name, "r")
self.codec.thread_count = 1
# 设置低延迟模式
if hasattr(self.codec, 'flags'):
try:
self.codec.flags |= av.codec.context.AV_CODEC_FLAG_LOW_DELAY
except:
pass
print(f"{self.codec_name.upper()}解码器初始化成功")
except Exception as e:
print(f"✗ 解码器初始化失败: {e}")
self.codec = None
def _create_header(self):
"""创建请求头"""
timestamp = Timestamp()
timestamp.GetCurrentTime()
return CommandHeader.Request(
device_id=self.device_id,
timestamp=timestamp
)
def connect(self):
"""连接服务器"""
try:
self.channel = grpc.insecure_channel(self.server_addr)
grpc.channel_ready_future(self.channel).result(timeout=3)
self.stub = camera_service_pb2_grpc.CameraServiceStub(self.channel)
return True
except Exception as e:
self.error_occurred.emit(f"连接失败: {e}")
return False
def start_camera(self):
"""启动摄像头"""
try:
request = camera_command_pb2.StartCameraCommand.Request(
header=self._create_header()
)
response = self.stub.StartCamera(request, timeout=3)
return response.header.success
except Exception as e:
self.error_occurred.emit(f"启动摄像头失败: {e}")
return False
def stop_camera(self):
"""停止摄像头"""
try:
request = camera_command_pb2.StopCameraCommand.Request(
header=self._create_header()
)
self.stub.StopCamera(request, timeout=3)
except:
pass
def run(self):
"""线程主函数"""
if not self.connect():
return
if not self.start_camera():
return
self.running = True
self.streaming = True
self.frame_count = 0
self.fps_counter = 0
self.fps_time = time.time()
keyframe_received = False
def request_gen():
while self.streaming:
yield camera_command_pb2.GetRGBImageStreamCommand.Request(
header=self._create_header(),
eof=False
)
time.sleep(0.001)
yield camera_command_pb2.GetRGBImageStreamCommand.Request(
header=self._create_header(),
eof=True
)
try:
responses = self.stub.GetRGBImageStream(request_gen())
for response in responses:
if not self.streaming:
break
if response.header.success:
frame_data = response.color_frame.data
is_keyframe = response.color_frame.is_key_frame
# 等待首个关键帧
if self.wait_for_keyframe and not keyframe_received and not is_keyframe:
continue
if is_keyframe and not keyframe_received:
keyframe_received = True
self.keyframe_count += 1
self.keyframe_received.emit()
# 解码
if self.codec and frame_data:
packet = av.Packet(frame_data)
frames = self.codec.decode(packet)
if frames:
# 转换为RGB (已经是RGB格式)
img = frames[0].to_ndarray(format='rgb24')
# 更新统计
self.fps_counter += 1
self.frame_count += 1
# 计算FPS
now = time.time()
if now - self.fps_time >= 1.0:
self.fps = self.fps_counter
self.fps_counter = 0
self.fps_time = now
# 发送状态更新
self.status_update.emit({
'fps': self.fps,
'frames': self.frame_count,
'keyframes': self.keyframe_count
})
# 发送帧到主线程
h, w = img.shape[:2]
self.frame_received.emit(img, w, h)
else:
print(f"流错误: {response.header.error_message}")
except Exception as e:
self.error_occurred.emit(f"流错误: {e}")
finally:
self.stop_camera()
if self.channel:
self.channel.close()
self.running = False
self.streaming = False
def stop(self):
"""停止线程"""
self.streaming = False
self.wait(2000) # 等待最多2秒
class CameraPlayerQt(QMainWindow):
"""基于Qt的摄像头拉流播放器"""
def __init__(self):
super().__init__()
self.setWindowTitle("摄像头拉流播放器 (Qt版)")
self.setMinimumSize(1000, 800)
# 状态变量
self.connected = False
self.streaming = False
self.recording = False
self.stream_thread = None
self.video_writer = None
# 录制目录
self.record_path = Path("./recordings")
self.record_path.mkdir(exist_ok=True)
# 创建UI
self._create_ui()
# 状态栏
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("就绪")
# 定时器用于更新显示
self.display_timer = QTimer()
self.display_timer.timeout.connect(self._update_display)
self.display_timer.start(30) # 约33fps
# 当前帧缓存
self.current_frame = None
self.frame_width = 0
self.frame_height = 0
def _create_ui(self):
"""创建UI"""
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
# ========== 连接设置区域 ==========
conn_group = QGroupBox("连接设置")
conn_layout = QGridLayout()
# 第一行设备ID和编码格式
conn_layout.addWidget(QLabel("设备ID:"), 0, 0)
self.device_id = QLineEdit("right_hand_cam")
conn_layout.addWidget(self.device_id, 0, 1)
conn_layout.addWidget(QLabel("编码格式:"), 0, 2)
self.codec_combo = QComboBox()
self.codec_combo.addItems(['H.265 (HEVC)', 'H.264 (AVC)'])
self.codec_combo.setCurrentIndex(0)
self.codec_combo.setMinimumWidth(120)
conn_layout.addWidget(self.codec_combo, 0, 3)
# 等待关键帧选项
self.wait_keyframe = QCheckBox("等待关键帧")
self.wait_keyframe.setChecked(True)
conn_layout.addWidget(self.wait_keyframe, 0, 4)
# 第二行:服务器地址
conn_layout.addWidget(QLabel("服务器IP:"), 1, 0)
self.server_ip = QLineEdit("192.168.0.222")
conn_layout.addWidget(self.server_ip, 1, 1)
conn_layout.addWidget(QLabel("端口:"), 1, 2)
self.server_port = QLineEdit("50052")
self.server_port.setMaximumWidth(80)
conn_layout.addWidget(self.server_port, 1, 3)
# 超时设置
conn_layout.addWidget(QLabel("超时(秒):"), 1, 4)
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(1, 10)
self.timeout_spin.setValue(3)
self.timeout_spin.setMaximumWidth(60)
conn_layout.addWidget(self.timeout_spin, 1, 5)
# 第三行:录制目录
conn_layout.addWidget(QLabel("录制目录:"), 2, 0)
self.record_dir = QLineEdit(str(self.record_path))
conn_layout.addWidget(self.record_dir, 2, 1, 1, 4)
self.browse_btn = QPushButton("浏览")
self.browse_btn.clicked.connect(self._browse_dir)
conn_layout.addWidget(self.browse_btn, 2, 5)
# 第四行:控制按钮
btn_layout = QHBoxLayout()
self.connect_btn = QPushButton("连接")
self.connect_btn.clicked.connect(self._connect)
self.connect_btn.setMinimumWidth(80)
btn_layout.addWidget(self.connect_btn)
self.preview_btn = QPushButton("预览")
self.preview_btn.clicked.connect(self._toggle_preview)
self.preview_btn.setEnabled(False)
self.preview_btn.setMinimumWidth(80)
btn_layout.addWidget(self.preview_btn)
self.record_btn = QPushButton("录制")
self.record_btn.clicked.connect(self._toggle_recording)
self.record_btn.setEnabled(False)
self.record_btn.setMinimumWidth(80)
btn_layout.addWidget(self.record_btn)
self.disconnect_btn = QPushButton("断开")
self.disconnect_btn.clicked.connect(self._disconnect)
self.disconnect_btn.setEnabled(False)
self.disconnect_btn.setMinimumWidth(80)
btn_layout.addWidget(self.disconnect_btn)
btn_layout.addStretch()
conn_layout.addLayout(btn_layout, 3, 0, 1, 6)
conn_group.setLayout(conn_layout)
main_layout.addWidget(conn_group)
# ========== 状态显示区域 ==========
status_group = QGroupBox("状态信息")
status_layout = QHBoxLayout()
self.conn_status = QLabel("● 未连接")
self.conn_status.setStyleSheet("color: red;")
status_layout.addWidget(self.conn_status)
status_layout.addWidget(QLabel("|"))
self.codec_status = QLabel("编码: H.265")
status_layout.addWidget(self.codec_status)
status_layout.addWidget(QLabel("|"))
self.fps_status = QLabel("FPS: 0")
status_layout.addWidget(self.fps_status)
status_layout.addWidget(QLabel("|"))
self.frame_status = QLabel("帧数: 0")
status_layout.addWidget(self.frame_status)
status_layout.addWidget(QLabel("|"))
self.resolution_status = QLabel("分辨率: -")
status_layout.addWidget(self.resolution_status)
status_layout.addWidget(QLabel("|"))
self.keyframe_status = QLabel("关键帧: 等待")
self.keyframe_status.setStyleSheet("color: orange;")
status_layout.addWidget(self.keyframe_status)
status_layout.addWidget(QLabel("|"))
self.record_status = QLabel("录制: 未开始")
self.record_status.setStyleSheet("color: gray;")
status_layout.addWidget(self.record_status)
status_layout.addStretch()
status_group.setLayout(status_layout)
main_layout.addWidget(status_group)
# ========== 视频预览区域 ==========
video_group = QGroupBox("视频预览")
video_layout = QVBoxLayout()
self.video_label = QLabel()
self.video_label.setAlignment(Qt.AlignCenter)
self.video_label.setMinimumSize(640, 480)
self.video_label.setStyleSheet("background-color: black; border: 1px solid gray;")
video_layout.addWidget(self.video_label)
video_group.setLayout(video_layout)
main_layout.addWidget(video_group, 1) # 1表示拉伸因子
# ========== 操作说明 ==========
info_frame = QFrame()
info_frame.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken)
info_layout = QHBoxLayout(info_frame)
info_label = QLabel("操作说明: ①选择编码格式 ②连接 ③预览 ④录制 (按'停止预览'结束)")
info_label.setStyleSheet("color: gray; padding: 5px;")
info_layout.addWidget(info_label)
main_layout.addWidget(info_frame)
def _browse_dir(self):
"""浏览目录"""
directory = QFileDialog.getExistingDirectory(self, "选择录制目录",
self.record_dir.text())
if directory:
self.record_dir.setText(directory)
self.record_path = Path(directory)
self.record_path.mkdir(exist_ok=True)
def _get_server_addr(self):
"""获取服务器地址"""
return f"{self.server_ip.text()}:{self.server_port.text()}"
def _get_codec(self):
"""获取编码格式"""
codec_map = {
'H.265 (HEVC)': 'hevc',
'H.264 (AVC)': 'h264'
}
return codec_map[self.codec_combo.currentText()]
def _connect(self):
"""连接服务器"""
codec = self._get_codec()
self.status_bar.showMessage(f"正在连接服务器 (编码: {codec.upper()})...")
# 更新编码状态显示
self.codec_status.setText(f"编码: {self.codec_combo.currentText()}")
# 创建流线程
self.stream_thread = StreamThread(
self.device_id.text(),
self._get_server_addr(),
codec=codec,
wait_for_keyframe=self.wait_keyframe.isChecked()
)
# 连接信号
self.stream_thread.frame_received.connect(self._on_frame_received)
self.stream_thread.status_update.connect(self._on_status_update)
self.stream_thread.error_occurred.connect(self._on_error)
self.stream_thread.keyframe_received.connect(self._on_keyframe_received)
# 连接测试
if self.stream_thread.connect():
self.connected = True
self.conn_status.setText("● 已连接")
self.conn_status.setStyleSheet("color: green;")
self.connect_btn.setEnabled(False)
self.preview_btn.setEnabled(True)
self.disconnect_btn.setEnabled(True)
self.codec_combo.setEnabled(False)
self.wait_keyframe.setEnabled(False)
self.status_bar.showMessage("服务器连接成功", 3000)
else:
self.stream_thread = None
def _disconnect(self):
"""断开连接"""
# 停止预览
if self.streaming:
self._stop_preview()
# 停止录制
if self.recording:
self._stop_recording()
# 清理线程
if self.stream_thread:
self.stream_thread.stop()
self.stream_thread = None
self.connected = False
self.conn_status.setText("● 未连接")
self.conn_status.setStyleSheet("color: red;")
self.connect_btn.setEnabled(True)
self.preview_btn.setEnabled(False)
self.preview_btn.setText("预览")
self.record_btn.setEnabled(False)
self.record_btn.setText("录制")
self.disconnect_btn.setEnabled(False)
self.codec_combo.setEnabled(True)
self.wait_keyframe.setEnabled(True)
# 清空画面
self.video_label.clear()
self.current_frame = None
# 重置状态
self.keyframe_status.setText("关键帧: 等待")
self.keyframe_status.setStyleSheet("color: orange;")
self.fps_status.setText("FPS: 0")
self.frame_status.setText("帧数: 0")
self.resolution_status.setText("分辨率: -")
self.status_bar.showMessage("已断开连接", 3000)
def _toggle_preview(self):
"""切换预览"""
if not self.streaming:
self._start_preview()
else:
self._stop_preview()
def _start_preview(self):
"""开始预览"""
if not self.stream_thread:
return
self.streaming = True
self.stream_thread.streaming = True
self.stream_thread.start()
self.preview_btn.setText("停止")
self.record_btn.setEnabled(True)
if self.wait_keyframe.isChecked():
self.keyframe_status.setText("关键帧: 等待中...")
self.keyframe_status.setStyleSheet("color: orange;")
else:
self.keyframe_status.setText("关键帧: 不等待")
self.keyframe_status.setStyleSheet("color: gray;")
self.status_bar.showMessage("预览已启动...")
def _stop_preview(self):
"""停止预览"""
if self.stream_thread:
self.stream_thread.streaming = False
self.stream_thread.wait(2000)
self.streaming = False
self.preview_btn.setText("预览")
self.record_btn.setEnabled(False)
# 停止录制
if self.recording:
self._stop_recording()
self.keyframe_status.setText("关键帧: 等待")
self.keyframe_status.setStyleSheet("color: orange;")
# 清空画面
self.video_label.clear()
self.current_frame = None
self.status_bar.showMessage("预览已停止", 3000)
def _toggle_recording(self):
"""切换录制"""
if not self.recording:
self._start_recording()
else:
self._stop_recording()
def _start_recording(self):
"""开始录制"""
if not self.streaming:
QMessageBox.warning(self, "警告", "请先启动预览")
return
# 更新录制目录
self.record_path = Path(self.record_dir.text())
self.record_path.mkdir(parents=True, exist_ok=True)
# 生成文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
codec_name = self.codec_combo.currentText().replace(' ', '_')
filename = self.record_path / f"rec_{self.device_id.text()}_{codec_name}_{timestamp}.mp4"
# 获取画面尺寸
if self.current_frame is not None:
height, width = self.current_frame.shape[:2]
else:
width, height = 640, 480
# 创建VideoWriter
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
self.video_writer = cv2.VideoWriter(str(filename), fourcc, 30.0, (width, height))
if self.video_writer.isOpened():
self.recording = True
self.record_btn.setText("停止")
self.record_status.setText("● 录制中")
self.record_status.setStyleSheet("color: red;")
self.status_bar.showMessage(f"开始录制: {filename.name}", 3000)
else:
QMessageBox.critical(self, "错误", "无法创建视频文件")
def _stop_recording(self):
"""停止录制"""
if self.video_writer:
self.video_writer.release()
self.video_writer = None
self.recording = False
self.record_btn.setText("录制")
self.record_status.setText("录制: 未开始")
self.record_status.setStyleSheet("color: gray;")
self.status_bar.showMessage("录制已停止", 3000)
def _on_frame_received(self, frame, width, height):
"""收到新帧"""
self.current_frame = frame
self.frame_width = width
self.frame_height = height
# 录制
if self.recording and self.video_writer:
# 转换为BGR用于录制
frame_bgr = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
self.video_writer.write(frame_bgr)
def _update_display(self):
"""更新显示"""
if self.current_frame is not None:
# 直接使用QImage避免转换
bytes_per_line = 3 * self.frame_width
qimage = QImage(self.current_frame.data, self.frame_width,
self.frame_height, bytes_per_line, QImage.Format_RGB888)
# 缩放以适应标签大小
label_size = self.video_label.size()
pixmap = QPixmap.fromImage(qimage).scaled(
label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.video_label.setPixmap(pixmap)
# 更新分辨率显示
self.resolution_status.setText(f"分辨率: {self.frame_width}x{self.frame_height}")
def _on_status_update(self, stats):
"""状态更新"""
self.fps_status.setText(f"FPS: {stats['fps']}")
self.frame_status.setText(f"帧数: {stats['frames']}")
def _on_keyframe_received(self):
"""收到关键帧"""
if self.wait_keyframe.isChecked():
self.keyframe_status.setText("关键帧: 已接收")
self.keyframe_status.setStyleSheet("color: green;")
def _on_error(self, msg):
"""错误处理"""
QMessageBox.critical(self, "错误", msg)
self.status_bar.showMessage(f"错误: {msg}")
def closeEvent(self, event):
"""窗口关闭事件"""
# 停止预览
if self.streaming:
self._stop_preview()
# 停止录制
if self.recording:
self._stop_recording()
# 断开连接
if self.connected:
self._disconnect()
event.accept()
def main():
app = QApplication(sys.argv)
# 设置应用样式
app.setStyle('Fusion')
# 设置全局样式表
app.setStyleSheet("""
QGroupBox {
font-weight: bold;
border: 1px solid gray;
border-radius: 5px;
margin-top: 10px;
padding-top: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px 0 5px;
}
QPushButton {
padding: 5px 10px;
}
QLineEdit {
padding: 3px;
}
QComboBox {
padding: 3px;
}
""")
window = CameraPlayerQt()
window.show()
print("\n" + "=" * 60)
print("摄像头拉流播放器 (Qt版) 启动")
print("=" * 60)
print("支持编码:")
print(" • H.265 (HEVC) - 高压缩率")
print(" • H.264 (AVC) - 兼容性好")
print("\n操作步骤:")
print("1. 选择编码格式")
print("2. 输入设备ID、服务器IP和端口")
print("3. 点击【连接】按钮")
print("4. 点击【预览】按钮开始观看")
print("5. 点击【录制】按钮开始/停止录制")
print("=" * 60 + "\n")
sys.exit(app.exec_())
if __name__ == "__main__":
main()

View File

@ -8,6 +8,8 @@ PyQt5==5.15.11
requests==2.32.5 requests==2.32.5
yourdfpy yourdfpy
uvicorn==0.38.0 uvicorn==0.38.0
av>=10.0.0
opencv-python>=4.0.0
numpy==1.26.4 numpy==1.26.4
opencv-contrib-python==4.10.0.84 opencv-contrib-python==4.10.0.84