Files
test/examples/qt_viewer_2.py
T
2026-06-08 17:52:48 +08:00

826 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Orisys SDK 实时监控 — Qt 统一界面
将所有可视化和曲线整合到一个窗口中,适合销售演示使用。
SDK version: 0.3.1
"""
import os
import sys
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
def _fix_qt_plugin_path():
"""OpenCV redirects Qt to cv2/qt/plugins; use PyQt5's plugins instead."""
try:
from PyQt5.QtCore import QLibraryInfo
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = QLibraryInfo.location(
QLibraryInfo.PluginsPath
)
except Exception:
path = os.environ.get("QT_QPA_PLATFORM_PLUGIN_PATH", "")
if "cv2" in path.replace("\\", "/"):
os.environ.pop("QT_QPA_PLATFORM_PLUGIN_PATH", None)
_fix_qt_plugin_path()
import orisys
import numpy as np
import time
import argparse
from datetime import datetime
from scipy.signal import butter, filtfilt
# Force curve sampling rate (Hz), matches typical sensor loop ~30 FPS.
FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ # 4 s window
# Minimum y-axis span so autoscale does not over-zoom on small fluctuations.
Y_AXIS_MIN_SPAN = 20000
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 import QtGui
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=3, 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):
"""
Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data.
Parameters
----------
data : array-like
Input samples acquired at `sample_rate_hz` (default 30 Hz).
cutoff_hz : float
-3 dB cutoff frequency in Hz; use 0 to skip filtering (return raw data).
Otherwise must satisfy 0 < cutoff_hz < sample_rate_hz / 2.
sample_rate_hz : float
Sampling rate of `data` in Hz.
order : int
Butterworth filter order.
Returns
-------
np.ndarray
Filtered 1-D array with the same length as `data`.
"""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
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}"
)
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)
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
# 曲线数据缓冲(先填满 npoints 再滚动;120 点 @ 30 Hz = 4 s
self.npoints = PLOT_BUFFER_POINTS
self.lowpass_cutoff_hz = getattr(args, "cutoff", 5.0)
self._plot_sample_count = 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):
"""Empty buffer; x-axis grows until npoints real samples, then scrolls."""
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)
# 曲线: 青色描边 + 向下渐变填充(与 demo_wuxi 一致)
line_pen = pg.mkPen(color=CURVE_THEME_COLOR, width=2)
# grad_brush = make_gradient_brush(CURVE_THEME_COLOR, max_alpha=120)
curve = p.plot(pen=line_pen, 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 _on_timer(self):
if self.sensor is None:
return
sensor = self.sensor
isimg = sensor.get_img()
if isimg is None:
print("Failed to get image")
return
# 录像写入
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)
is_touched = sensor.compute_deformation(check_motion=True, threshold=0)
sensor.compute_contact()
fps, fn, fx, fy, flow, vnormal, vshear, depth_map = sensor.read_info(
sensor.info.FPS, sensor.info.FNORMAL,
sensor.info.FSHEARX, sensor.info.FSHEARY,
sensor.info.VRAW, sensor.info.VNORMAL,
sensor.info.VSHEAR,
sensor.info.DEPTH
)
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
# print(f"CUPY Available: {sensor.CUPY_AVAILABLE}")
# 图像
_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))
# 显示深度图
div_abs = depth_map
if div_abs.max() > div_abs.min():
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
else:
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
# 应用颜色映射(使用JET:蓝色=低散度,红色=高散度)
depth_colored = cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET)
# mag = draw_magnitude_map(flow)
self.mag_label.setPixmap(cv2_to_pixmap(depth_colored))
# 曲线:先顺序写入 npoints 个真实样本,满后再左移滚动
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.data[:n].copy()
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,
)
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
span = ymax - ymin
if span < Y_AXIS_MIN_SPAN:
mid = (ymin + ymax) / 2
half = Y_AXIS_MIN_SPAN / 2
ymin = mid - half
ymax = mid + half
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 _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()
sys.exit(app.exec_())
if __name__ == '__main__':
main()