""" Orisys SDK 实时监控 — Qt 统一界面 将所有可视化和曲线整合到一个窗口中,适合销售演示使用。 SDK version: 0.3.1 """ import os import sys os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" import cv2 import orisys import numpy as np import time import argparse from datetime import datetime try: from scipy.signal import butter, filtfilt except Exception: butter = None filtfilt = None # 下面这些参数只影响右侧三条力曲线的显示,不改变传感器读数、光流图或幅值图数据。 FORCE_SAMPLE_RATE_HZ = 30.0 PLOT_BUFFER_POINTS = 120 PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ Y_AXIS_MIN_SPAN = 20000.0 CURVE_THEME_COLOR = '#00E5FF' from PyQt5.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel, QApplication, QSplitter, QPlainTextEdit, QPushButton, QFileDialog, QLineEdit, ) from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QImage, QPixmap, QFont, QIcon try: import pyqtgraph as pg except Exception: print("PyQtGraph is not installed. Please install it using 'pip install pyqt5 pyqtgraph'.") sys.exit(1) # --------------------------------------------------------------------------- # 工具 # --------------------------------------------------------------------------- def cv2_to_pixmap(img_bgr: np.ndarray) -> QPixmap: rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) return QPixmap.fromImage(qimg) def draw_magnitude_map(vfield, threshold=1.5, colormap=cv2.COLORMAP_JET) -> np.ndarray: flow_field = vfield magnitude = np.sqrt(flow_field[:, :, 0] ** 2 + flow_field[:, :, 1] ** 2) downsample_factor = 10 h, w = magnitude.shape magnitude_downsampled = magnitude[::downsample_factor, ::downsample_factor] magnitude_smoothed = cv2.resize(magnitude_downsampled, (w, h), interpolation=cv2.INTER_CUBIC) magnitude_thresholded = magnitude_smoothed.copy() magnitude_thresholded[magnitude_smoothed < threshold] = 0 mag_min, mag_max = magnitude_thresholded.min(), magnitude_thresholded.max() if mag_max > mag_min: magnitude_norm = ((magnitude_thresholded - mag_min) / (mag_max - mag_min) * 255).astype(np.uint8) else: magnitude_norm = np.zeros_like(magnitude_thresholded, dtype=np.uint8) magnitude_colored = cv2.applyColorMap(magnitude_norm, colormap) out_img = cv2.resize(magnitude_colored, (400, 400), interpolation=cv2.INTER_CUBIC) return out_img def lowpass_filter(data, cutoff_hz, sample_rate_hz=FORCE_SAMPLE_RATE_HZ, order=2): """和 qt_viewer_2.py 一样用于三条力曲线显示的低通滤波。""" y = np.asarray(data, dtype=np.float64) if cutoff_hz == 0 or y.size == 0: return y.copy() nyquist = 0.5 * sample_rate_hz if cutoff_hz < 0 or cutoff_hz >= nyquist: raise ValueError( f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}" ) if butter is not None and filtfilt is not None: b, a = butter(order, cutoff_hz / nyquist, btype="low") padlen = 3 * (max(len(b), len(a)) - 1) if y.size <= padlen: return y.copy() return filtfilt(b, a, y) window = min(max(int(sample_rate_hz / max(cutoff_hz, 1e-6)), 3), y.size) if window % 2 == 0: window -= 1 if window <= 1: return y.copy() kernel = np.ones(window, dtype=np.float64) / window pad = window // 2 forward = np.convolve(np.pad(y, (pad, pad), mode="edge"), kernel, mode="valid") return np.convolve(np.pad(forward[::-1], (pad, pad), mode="edge"), kernel, mode="valid")[::-1] class TeeStream: """同时写入多个流""" def __init__(self, *streams): self.streams = streams def write(self, text): for s in self.streams: s.write(text) def flush(self): for s in self.streams: s.flush() class WidgetLogHandler: """将 print 输出实时显示到 QPlainTextEdit""" def __init__(self, widget: QPlainTextEdit): self.widget = widget self.buf = "" def write(self, text: str): self.buf += text while "\n" in self.buf: line, self.buf = self.buf.split("\n", 1) if line: self.widget.appendPlainText(line) def flush(self): if self.buf: self.widget.appendPlainText(self.buf) self.buf = "" # --------------------------------------------------------------------------- # 样式表 # --------------------------------------------------------------------------- STYLE = """ QMainWindow { background-color: #000000; } /* ---- 图像面板 ---- */ QLabel#arrowsLabel, QLabel#magLabel { border: 1px solid #262626; border-radius: 4px; background-color: #0b0b0b; } /* ---- 顶栏按钮 & 输入 - — */ QPushButton { background-color: #1a1a1a; color: #999999; border: 1px solid #333333; border-radius: 4px; padding: 6px 16px; font-family: "SimHei"; font-size: 13px; } QPushButton:hover { background-color: #262626; color: #cccccc; border: 1px solid #555555; } QPushButton#btnStart { background-color: #0d2818; color: #4caf84; border: 1px solid #1a4d30; } QPushButton#btnStart:hover { background-color: #143d24; color: #6fd4a0; } QPushButton#btnStart:disabled { background-color: #0f0f0f; color: #333333; border: 1px solid #222222; } QPushButton#btnStop { background-color: #2b1010; color: #e05555; border: 1px solid #4d1a1a; } QPushButton#btnStop:hover { background-color: #3d1818; color: #f07070; } QPushButton#btnStop:disabled { background-color: #0f0f0f; color: #333333; border: 1px solid #222222; } QLineEdit { background-color: #0d0d0d; color: #999999; border: 1px solid #333333; border-radius: 4px; padding: 5px 10px; font-size: 13px; } /* ---- 力曲线容器 ---- */ GraphicsLayoutWidget { border: 1px solid #262626; border-radius: 4px; } /* ---- 日志面板 ---- */ QPlainTextEdit { background-color: #0b0b0b; color: #777777; border: 1px solid #262626; border-radius: 4px; font-family: "Cascadia Code", "Consolas", "Courier New", monospace; font-size: 13px; padding: 8px; selection-background-color: #1a3a4a; selection-color: #cccccc; } QPlainTextEdit QScrollBar:vertical { background-color: #0b0b0b; width: 6px; margin: 1px; border-radius: 3px; } QPlainTextEdit QScrollBar::handle:vertical { background-color: #262626; border-radius: 3px; min-height: 30px; } QPlainTextEdit QScrollBar::handle:vertical:hover { background-color: #3a3a3a; } QPlainTextEdit QScrollBar::add-line:vertical, QPlainTextEdit QScrollBar::sub-line:vertical { height: 0px; } QPlainTextEdit QScrollBar::add-page:vertical, QPlainTextEdit QScrollBar::sub-page:vertical { background: none; } QSplitter::handle { background-color: #1a1a1a; } QSplitter::handle:vertical { height: 1px; } QSplitter::handle:horizontal { width: 1px; } """ # --------------------------------------------------------------------------- # 主窗口 # --------------------------------------------------------------------------- class MainWindow(QMainWindow): def __init__(self, args, log_file): super().__init__() self.setWindowTitle("Orisys SDK") self.setStyleSheet(STYLE) logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png") if os.path.exists(logo_path): self.setWindowIcon(QIcon(logo_path)) self.sensor = None self.start_time = None self.args = args # 录像状态 self.is_save = False self.frame_count = 0 self.writer = None self.save_path = None # 曲线数据缓冲:只存原始 fn/fx/fy,滤波只在绘图副本上做。 self.npoints = PLOT_BUFFER_POINTS self.lowpass_cutoff_hz = getattr(args, "cutoff", 10.0) self._init_plot_buffer() self._setup_ui() self._create_plots() # 同步参数到 UI self.camera_edit.setText(args.video) self.config_edit.setText(args.config) self.cal_edit.setText(args.cal) # stdout → 日志文件 + 界面日志面板 self.log_handler = WidgetLogHandler(self.log_widget) sys.stdout = TeeStream(log_file, self.log_handler) sys.stderr = sys.stdout self._y_range_alpha = 0.5 self.timer = QTimer() self.timer.timeout.connect(self._on_timer) self._running = False def _init_plot_buffer(self): self.data = np.full((self.npoints, 2, 3), np.nan, dtype=np.float64) self._plot_sample_count = 0 self._y_range_smooth = [None, None, None] # ---- UI 构建 ---- def _setup_ui(self): central = QWidget() self.setCentralWidget(central) root_layout = QVBoxLayout(central) root_layout.setContentsMargins(6, 6, 6, 6) root_layout.setSpacing(6) # ---- 顶栏 ---- bar = QHBoxLayout() bar.setSpacing(8) self.btn_camera = QPushButton("摄像机") self.btn_camera.setObjectName("btnCamera") self.btn_camera.clicked.connect(self._select_camera) bar.addWidget(self.btn_camera) self.camera_edit = QLineEdit("1") self.camera_edit.setPlaceholderText("摄像头编号 或 视频路径") bar.addWidget(self.camera_edit, 1) self.btn_config = QPushButton("配置") self.btn_config.setObjectName("btnConfig") self.btn_config.clicked.connect(self._select_config) bar.addWidget(self.btn_config) self.config_edit = QLineEdit("./config/ddjx01.json") bar.addWidget(self.config_edit, 1) self.btn_reset = QPushButton("重置") self.btn_reset.setObjectName("btnReset") self.btn_reset.setEnabled(False) self.btn_reset.clicked.connect(self._on_reset) bar.addWidget(self.btn_reset) self.cal_edit = QLineEdit("./config/ddjx01.npy") bar.addWidget(self.cal_edit, 1) self.btn_start = QPushButton("启动") self.btn_start.setObjectName("btnStart") self.btn_start.clicked.connect(self._on_start) bar.addWidget(self.btn_start) self.btn_stop = QPushButton("暂停") self.btn_stop.setObjectName("btnStop") self.btn_stop.setEnabled(False) self.btn_stop.clicked.connect(self._on_stop) bar.addWidget(self.btn_stop) root_layout.addLayout(bar) # ---- 外层纵向 splitter:主区域 / 日志 ---- v_splitter = QSplitter(Qt.Vertical) # ---- 主区域:图像 (左) | 曲线 (右) ---- h_splitter = QSplitter(Qt.Horizontal) # 左侧图像面板 left = QWidget() left_layout = QVBoxLayout(left) left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(6) self.arrows_label = QLabel() self.arrows_label.setObjectName("arrowsLabel") self.arrows_label.setMinimumSize(160, 160) self.arrows_label.setScaledContents(True) self.arrows_label.setAlignment(Qt.AlignCenter) left_layout.addWidget(self.arrows_label) self.mag_label = QLabel() self.mag_label.setObjectName("magLabel") self.mag_label.setMinimumSize(160, 160) self.mag_label.setScaledContents(True) self.mag_label.setAlignment(Qt.AlignCenter) left_layout.addWidget(self.mag_label) h_splitter.addWidget(left) # 右侧力曲线 pg.setConfigOption('background', '#000000') pg.setConfigOption('foreground', '#777777') pg.setConfigOptions(antialias=True) self.graph_widget = pg.GraphicsLayoutWidget() self.graph_widget.setMinimumWidth(300) h_splitter.addWidget(self.graph_widget) self.h_splitter = h_splitter v_splitter.addWidget(h_splitter) # ---- 底部日志面板 ---- self.log_widget = QPlainTextEdit() self.log_widget.setReadOnly(True) self.log_widget.setMaximumBlockCount(2000) font = QFont("Consolas", 12) font.setStyleHint(QFont.Monospace) self.log_widget.setFont(font) v_splitter.addWidget(self.log_widget) v_splitter.setStretchFactor(0, 4) v_splitter.setStretchFactor(1, 1) root_layout.addWidget(v_splitter) def _create_plots(self): titles = ["法向力", "切向力 X", "切向力 Y"] self.plots = [] self.curves = [] self.dot_glows = [] self.dot_cores = [] axis_pen = pg.mkPen(color=(38, 38, 38), width=1) # #262626 label_font = QFont("SimHei", 9) for i, title in enumerate(titles): p = self.graph_widget.addPlot(title=None) # 标题:黑体 p.setTitle(title, color='#999999', size='14pt') p.titleLabel.setFont(QFont("SimHei", 14)) # X 轴标签 p.setLabel('bottom', '时间', units='s') p.getAxis('bottom').label.setFont(label_font) # 仅保留左 + 下轴线,隐藏右 + 上边框 p.showAxis('top', False) p.showAxis('right', False) p.getAxis('left').setPen(axis_pen) p.getAxis('bottom').setPen(axis_pen) # 仅横向网格,极淡 p.showGrid(x=False, y=True, alpha=0.12) # 等宽刻度字体 tick_font = QFont("Consolas", 11) tick_font.setStyleHint(QFont.Monospace) p.getAxis('left').setTickFont(tick_font) p.getAxis('bottom').setTickFont(tick_font) # 曲线样式与 qt_viewer_2.py 对齐 curve = p.plot(pen=pg.mkPen(color=CURVE_THEME_COLOR, width=2), fillLevel=0) # 终点光晕: #00e5ff 30% 透明,半径 8 dot_glow = p.plot([], [], pen=None, symbol='o', symbolSize=8, symbolPen=None, symbolBrush=(0, 229, 255, 76)) # 终点核心: 纯白,半径 4 dot_core = p.plot([], [], pen=None, symbol='o', symbolSize=4, symbolPen=None, symbolBrush=(255, 255, 255, 255)) p.setXRange(0, PLOT_WINDOW_SEC, padding=0) p.enableAutoRange(x=False, y=False) self.plots.append(p) self.curves.append(curve) self.dot_glows.append(dot_glow) self.dot_cores.append(dot_core) if i < 2: self.graph_widget.nextRow() def adjust_square_images(self): """根据窗口高度调整左侧面板宽度,使两张图保持正方形""" top_bar_h = 48 log_h = self.log_widget.height() spacing = 20 available = self.height() - top_bar_h - log_h - spacing each_h = max(available // 2, 160) self.h_splitter.setSizes([each_h, self.width() - each_h]) def _make_force_plot_data(self, data_plot): for i in range(3): data_plot[:, 1, i] = lowpass_filter( data_plot[:, 1, i], self.lowpass_cutoff_hz, sample_rate_hz=FORCE_SAMPLE_RATE_HZ, ) return data_plot # ---- 帧处理 ---- def _on_timer(self): if self.sensor is None: return sensor = self.sensor sensor.get_img() # 录像写入 if self.is_save and self.writer is not None and sensor.frame is not None: self.frame_count += 1 save_img = sensor.frame if len(save_img.shape) == 2 or (len(save_img.shape) == 3 and save_img.shape[2] == 1): if len(save_img.shape) == 3: save_img = save_img.squeeze(2) save_img = cv2.cvtColor(save_img, cv2.COLOR_GRAY2BGR) self.writer.write(save_img) sensor.compute_deformation(check_motion=True, threshold=0) fps, fn, fx, fy, flow, vnormal, vshear = sensor.read_info( sensor.info.FPS, sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY, sensor.info.VRAW, sensor.info.VNORMAL, sensor.info.VSHEAR, ) print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}") # 图像 _bg_bgr = (22, 16, 11) _img = sensor.img _h, _w = _img.shape[:2] black_img = np.empty((_h, _w, 3), dtype=_img.dtype) black_img[:, :, 0] = _bg_bgr[0] black_img[:, :, 1] = _bg_bgr[1] black_img[:, :, 2] = _bg_bgr[2] arrows = orisys.util.draw_arrows( black_img, flow, threshold=2, grid_spacing=20, arrow_scale=0.5, below_threshold_color=(160, 160, 160), ) arrows = cv2.resize(arrows, (400, 400), interpolation=cv2.INTER_CUBIC) self.arrows_label.setPixmap(cv2_to_pixmap(arrows)) mag = draw_magnitude_map(flow) self.mag_label.setPixmap(cv2_to_pixmap(mag)) # 曲线:只对右侧三条力曲线的显示数据做缓冲和低通滤波。 current_time = time.time() - self.start_time if self._plot_sample_count < self.npoints: idx = self._plot_sample_count self.data[idx, 0, :] = current_time self.data[idx, 1, 0] = fn self.data[idx, 1, 1] = fx self.data[idx, 1, 2] = fy self._plot_sample_count += 1 else: for i in range(3): self.data[:-1, 0, i] = self.data[1:, 0, i] self.data[:-1, 1, i] = self.data[1:, 1, i] self.data[-1, 0, :] = current_time self.data[-1, 1, 0] = fn self.data[-1, 1, 1] = fx self.data[-1, 1, 2] = fy n = self._plot_sample_count if n == 0: return data_plot = self._make_force_plot_data(self.data[:n].copy()) for i, curve in enumerate(self.curves): x = data_plot[:, 0, i] y = data_plot[:, 1, i] curve.setData(x, y) ymin, ymax = y.min(), y.max() if self._y_range_smooth[i] is None: self._y_range_smooth[i] = (ymin, ymax) else: prev_min, prev_max = self._y_range_smooth[i] alpha = self._y_range_alpha ymin = alpha * ymin + (1 - alpha) * prev_min ymax = alpha * ymax + (1 - alpha) * prev_max if ymax - ymin < Y_AXIS_MIN_SPAN: mid = (ymin + ymax) / 2 ymin = mid - Y_AXIS_MIN_SPAN / 2 ymax = mid + Y_AXIS_MIN_SPAN / 2 self._y_range_smooth[i] = (ymin, ymax) pad = max((ymax - ymin) * 0.12, 0.01) self.plots[i].setYRange(ymin - pad, ymax + pad) # 终点标记 last_x = x[-1:] last_y = y[-1:] self.dot_glows[i].setData(last_x, last_y) self.dot_cores[i].setData(last_x, last_y) if n < self.npoints: for p in self.plots: p.setXRange(0, PLOT_WINDOW_SEC, padding=0) else: x0 = float(data_plot[0, 0, 0]) x1 = float(data_plot[-1, 0, 0]) if x1 <= x0: x1 = x0 + PLOT_WINDOW_SEC for p in self.plots: p.setXRange(x0, x1, padding=0) # ---- 键盘 & 录像 ---- def _on_start(self): if self._running: return self.btn_start.setText("启动中...") self.btn_start.setEnabled(False) QApplication.processEvents() try: src = self.camera_edit.text() cfg = self.config_edit.text() cal = self.cal_edit.text() verbose = getattr(self.args, 'verbose', True) try: src = int(src) except ValueError: pass self.sensor = orisys.Sensor(src, config_name=cfg, cal_path=cal, verbose=verbose) except Exception as e: self.btn_start.setText("启动") self.btn_start.setEnabled(True) print(f"启动失败: {e}") return self.start_time = time.time() self._init_plot_buffer() self._running = True self.timer.start(10) self.btn_start.setText("启动") self.btn_stop.setEnabled(True) self.btn_reset.setEnabled(True) print(f"▶ 启动 — 摄像机={src}, 配置={cfg}, 标定={cal}") def _on_stop(self): if not self._running: return self._running = False self.timer.stop() self.btn_stop.setText("停止中...") self.btn_stop.setEnabled(False) QApplication.processEvents() if self.sensor is not None: self.sensor.disconnect() self.sensor = None self.btn_start.setEnabled(True) self.btn_stop.setText("暂停") self.btn_reset.setEnabled(False) print("⏸ 已暂停") def _select_camera(self): text = self.camera_edit.text() path, _ = QFileDialog.getOpenFileName( self, "选择视频文件", "data", "视频 (*.mp4 *.avi *.mov);;所有文件 (*)", ) if path: self.camera_edit.setText(path) def _select_config(self): path, _ = QFileDialog.getOpenFileName( self, "选择配置文件", "config", "JSON (*.json);;所有文件 (*)", ) if path: self.config_edit.setText(path) def _select_cal(self): path, _ = QFileDialog.getOpenFileName( self, "选择标定文件", "config", "NumPy (*.npy);;所有文件 (*)", ) if path: self.cal_edit.setText(path) def _on_reset(self): """Reset optical flow tracker when drift occurs (same as basic_pipeline 'r' key).""" if self.sensor is None or not self._running: print("请先启动传感器后再重置追踪器。") return self.sensor.reset() print("追踪器已重置。") def keyPressEvent(self, event): key = event.key() if key == Qt.Key_Escape: if self.isFullScreen(): self.showNormal() else: self.showFullScreen() elif key == Qt.Key_Q: print("\n正在退出示例程序...") self.close() elif key == Qt.Key_S: self._start_recording() elif key == Qt.Key_E: self._stop_recording() elif key == Qt.Key_R: self._on_reset() else: super().keyPressEvent(event) def _start_recording(self): if self.sensor is None: print("请先启动采集") return self.save_path = "./data/pulse_test.mp4" os.makedirs(os.path.dirname(self.save_path), exist_ok=True) save_img = self.sensor.frame if save_img is None: print("当前没有可用帧,无法确定录像尺寸。") return temp_img = save_img.copy() if len(temp_img.shape) == 2 or (len(temp_img.shape) == 3 and temp_img.shape[2] == 1): if len(temp_img.shape) == 3: temp_img = temp_img.squeeze(2) temp_img = cv2.cvtColor(temp_img, cv2.COLOR_GRAY2BGR) fh, fw = temp_img.shape[:2] self.writer = cv2.VideoWriter( self.save_path, cv2.VideoWriter_fourcc(*'mp4v'), 30, (fw, fh), ) if self.writer.isOpened(): self.is_save = True print(f"开始录像:{self.save_path}") else: print(f"无法创建录像文件:{self.save_path}") self.writer = None def _stop_recording(self): self.is_save = False if self.writer is not None: self.writer.release() self.writer = None if self.save_path is not None: print(f"录像结束:{self.save_path},总帧数:{self.frame_count}") else: print(f"录像结束,总帧数:{self.frame_count}") self.frame_count = 0 def closeEvent(self, event): self.timer.stop() if self.writer is not None: self.writer.release() if self.sensor is not None: self.sensor.disconnect() event.accept() # --------------------------------------------------------------------------- # 入口 # --------------------------------------------------------------------------- def main(): script_dir = os.path.dirname(os.path.abspath(__file__)) log_dir = os.path.join(script_dir, "logs") os.makedirs(log_dir, exist_ok=True) log_path = os.path.join(log_dir, f"qt_viewer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log") log_file = open(log_path, 'w', encoding='utf-8') # stdout 到文件的临时重定向(创建 MainWindow 后会加上日志面板) sys.stdout = log_file sys.stderr = sys.stdout parser = argparse.ArgumentParser() parser.add_argument("--video", "-v", type=str, default="0") parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json") parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy") parser.add_argument("--verbose", "-verbose", type=bool, default=True) parser.add_argument( "--cutoff", type=float, default=10.0, help="Low-pass cutoff for force curves (Hz), sample rate 30 Hz", ) args = parser.parse_args() print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏") # 高 DPI 适配(笔记本高分屏) QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) app = QApplication(sys.argv) window = MainWindow(args, log_file) window.showFullScreen() QApplication.processEvents() window.adjust_square_images() window._on_start() sys.exit(app.exec_()) if __name__ == '__main__': main()