added dd06 test support, organized 2d viewer and 3d viewer code organization.

This commit is contained in:
ykcheungab
2026-07-26 17:03:44 +08:00
parent 28e6c7c8a9
commit 2e3d0a0609
32 changed files with 2326 additions and 936 deletions
+24
View File
@@ -0,0 +1,24 @@
"""2D Qt sensor monitor (image + force curves; no finger 3D mesh)."""
from .backend import (
ForceHistory,
ForcePlotState,
QtViewerBackend,
ViewerFrame,
image_shape_meta,
panel_width_for_aspect,
select_display_image,
)
from .window import QtViewerWindow, run_viewer
__all__ = [
"ForceHistory",
"ForcePlotState",
"QtViewerBackend",
"QtViewerWindow",
"ViewerFrame",
"image_shape_meta",
"panel_width_for_aspect",
"run_viewer",
"select_display_image",
]
+312
View File
@@ -0,0 +1,312 @@
"""Qt-facing display backend for sensor viewer snapshots.
Separates acquisition (`gui.sensors.FrameSnapshot`) from Qt display state:
normalized images, force history / filtering, and aspect-ratio metadata.
Does not import camera, ROI, or `orisys.Sensor`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
import cv2
import numpy as np
from scipy.signal import butter, filtfilt
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
@dataclass
class ViewerFrame:
"""Normalized display payload for one UI tick."""
display_image: np.ndarray
image_height: int
image_width: int
aspect_ratio: float # width / height
depth: Optional[np.ndarray]
normal_force: float
shear_x: float
shear_y: float
normal_force_calibrated: float
fps: float
timestamp: float
record_frame: Optional[np.ndarray]
@dataclass
class ForcePlotState:
"""Filtered force series ready for pyqtgraph curves."""
times: np.ndarray # shape (n,)
values: np.ndarray # shape (n, 3) FN, FX, FY
sample_count: int
x_range: Tuple[float, float]
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
def lowpass_filter(
data,
cutoff_hz: float,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
order: int = 2,
) -> np.ndarray:
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D 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)
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
if img is None:
return None
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
return img
def image_shape_meta(img: np.ndarray) -> Tuple[int, int, float]:
"""Return (height, width, width/height) from a display image."""
h, w = int(img.shape[0]), int(img.shape[1])
aspect = float(w) / float(h) if h > 0 else 1.0
return h, w, aspect
def select_display_image(snapshot) -> np.ndarray:
"""Pick flow overlay / processed / raw image for the left panel."""
arrows = getattr(snapshot, "flow_overlay", None)
if arrows is not None:
out = ensure_bgr(arrows)
if out is not None:
return out
for attr in ("processed_image", "image", "raw_image"):
base = getattr(snapshot, attr, None)
out = ensure_bgr(base)
if out is not None:
return out
return np.zeros((400, 400, 3), dtype=np.uint8)
def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> Tuple[np.ndarray, int, int]:
"""Convert BGR uint8 image to contiguous RGB for QImage."""
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
if not rgb.flags["C_CONTIGUOUS"]:
rgb = np.ascontiguousarray(rgb)
h, w = rgb.shape[:2]
return rgb, h, w
def scale_bgr_to_fit(
img_bgr: np.ndarray,
max_w: int,
max_h: int,
) -> np.ndarray:
"""Resize preserving aspect ratio to fit inside (max_w, max_h)."""
if max_w <= 0 or max_h <= 0:
return img_bgr
h, w = img_bgr.shape[:2]
if h <= 0 or w <= 0:
return img_bgr
scale = min(max_w / float(w), max_h / float(h))
if abs(scale - 1.0) < 1e-6:
return img_bgr
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
return cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
def panel_width_for_aspect(
available_height: int,
aspect_ratio: float,
*,
min_size: int = 160,
) -> int:
"""Preferred left-panel width given available height and image aspect."""
h = max(int(available_height), min_size)
w = int(round(h * float(aspect_ratio)))
return max(w, min_size)
class ForceHistory:
"""Ring-style force history buffer with filtering and y-range smoothing."""
def __init__(
self,
npoints: int = PLOT_BUFFER_POINTS,
*,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
lowpass_cutoff_hz: float = 10.0,
y_axis_min_span: float = Y_AXIS_MIN_SPAN,
y_range_alpha: float = 0.5,
):
self.npoints = int(npoints)
self.sample_rate_hz = float(sample_rate_hz)
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = float(y_axis_min_span)
self.y_range_alpha = float(y_range_alpha)
self.window_sec = self.npoints / self.sample_rate_hz
self.reset()
def reset(self) -> None:
self.data = np.full((self.npoints, 2, 3), np.nan, dtype=np.float64)
self.sample_count = 0
self._y_range_smooth = [None, None, None]
def push(self, t: float, fn: float, fx: float, fy: float) -> None:
values = (float(fn), float(fx), float(fy))
if self.sample_count < self.npoints:
idx = self.sample_count
self.data[idx, 0, :] = t
self.data[idx, 1, :] = values
self.sample_count += 1
return
self.data[:-1, 0, :] = self.data[1:, 0, :]
self.data[:-1, 1, :] = self.data[1:, 1, :]
self.data[-1, 0, :] = t
self.data[-1, 1, :] = values
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> Tuple[float, float]:
if self._y_range_smooth[channel] is None:
smoothed = (ymin, ymax)
else:
prev_min, prev_max = self._y_range_smooth[channel]
alpha = self.y_range_alpha
ymin = alpha * ymin + (1.0 - alpha) * prev_min
ymax = alpha * ymax + (1.0 - alpha) * prev_max
smoothed = (ymin, ymax)
span = smoothed[1] - smoothed[0]
if span < self.y_axis_min_span:
mid = 0.5 * (smoothed[0] + smoothed[1])
half = self.y_axis_min_span / 2.0
smoothed = (mid - half, mid + half)
self._y_range_smooth[channel] = smoothed
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
return smoothed[0] - pad, smoothed[1] + pad
def plot_state(self) -> Optional[ForcePlotState]:
n = self.sample_count
if n == 0:
return None
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=self.sample_rate_hz,
)
times = data_plot[:, 0, 0].copy()
values = data_plot[:, 1, :].copy()
y_ranges = tuple(
self._smooth_y_range(i, float(values[:, i].min()), float(values[:, i].max()))
for i in range(3)
)
if n < self.npoints:
x_range = (0.0, self.window_sec)
else:
x0 = float(times[0])
x1 = float(times[-1])
if x1 <= x0:
x1 = x0 + self.window_sec
x_range = (x0, x1)
return ForcePlotState(
times=times,
values=values,
sample_count=n,
x_range=x_range,
y_ranges=y_ranges, # type: ignore[arg-type]
)
class QtViewerBackend:
"""Normalize FrameSnapshot into display images + force history."""
def __init__(
self,
*,
npoints: int = PLOT_BUFFER_POINTS,
lowpass_cutoff_hz: float = 10.0,
y_axis_min_span: float = Y_AXIS_MIN_SPAN,
):
self.force_history = ForceHistory(
npoints=npoints,
lowpass_cutoff_hz=lowpass_cutoff_hz,
y_axis_min_span=y_axis_min_span,
)
self._start_time: Optional[float] = None
self.last_viewer_frame: Optional[ViewerFrame] = None
def reset(self) -> None:
self.force_history.reset()
self._start_time = None
self.last_viewer_frame = None
def ingest(self, snapshot, *, now: Optional[float] = None) -> ViewerFrame:
"""Convert a FrameSnapshot into ViewerFrame and update force history."""
display = select_display_image(snapshot)
h, w, aspect = image_shape_meta(display)
fn = float(getattr(snapshot, "normal_force", 0.0))
fx = float(getattr(snapshot, "shear_x", 0.0))
fy = float(getattr(snapshot, "shear_y", 0.0))
fn_cal = float(getattr(snapshot, "normal_force_calibrated", 0.0))
fps = float(getattr(snapshot, "fps", 0.0))
ts = float(getattr(snapshot, "timestamp", 0.0) or 0.0)
if now is None:
now = ts if ts > 0 else 0.0
if self._start_time is None:
self._start_time = now
t = now - self._start_time
self.force_history.push(t, fn, fx, fy)
record = ensure_bgr(getattr(snapshot, "record_frame", None))
depth = getattr(snapshot, "depth", None)
frame = ViewerFrame(
display_image=display,
image_height=h,
image_width=w,
aspect_ratio=aspect,
depth=depth,
normal_force=fn,
shear_x=fx,
shear_y=fy,
normal_force_calibrated=fn_cal,
fps=fps,
timestamp=ts,
record_frame=record,
)
self.last_viewer_frame = frame
return frame
def plot_state(self) -> Optional[ForcePlotState]:
return self.force_history.plot_state()
+120
View File
@@ -0,0 +1,120 @@
"""Styles for the 2D Qt sensor monitor."""
CURVE_THEME_COLOR = "#00E5FF"
STYLE = """
QMainWindow {
background-color: #000000;
}
/* ---- 图像面板 ---- */
QLabel#arrowsLabel {
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;
}
"""
+491
View File
@@ -0,0 +1,491 @@
"""2D Qt sensor monitor window (image + force curves)."""
from __future__ import annotations
import os
import sys
import time
from datetime import datetime
from pathlib import Path
import cv2
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QFont, QIcon
from PyQt5.QtWidgets import (
QApplication,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
try:
import pyqtgraph as pg
except Exception as exc: # pragma: no cover
raise ImportError(
"PyQtGraph is not installed. Please install it using 'pip install pyqt5 pyqtgraph'."
) from exc
from gui.sensors import create_sensor_adapter
from gui.widgets.log_panel import TeeStream, WidgetLogHandler, make_log_widget
from gui.widgets.qt_pixmap import ndarray_to_pixmap, pixmap_keep_aspect
from .backend import PLOT_BUFFER_POINTS, PLOT_WINDOW_SEC, QtViewerBackend, panel_width_for_aspect
from .styles import CURVE_THEME_COLOR, STYLE
class QtViewerWindow(QMainWindow):
"""2D monitor: flow/image panel + FN/FX/FY curves driven by QtViewerBackend."""
def __init__(self, args, log_file):
super().__init__()
self.setWindowTitle("Orisys SDK")
self.setStyleSheet(STYLE)
logo_path = Path(__file__).resolve().parents[3] / "logo.png"
if logo_path.exists():
self.setWindowIcon(QIcon(str(logo_path)))
self.sensor_adapter = None
self._last_snapshot = None
self._last_viewer_frame = None
self._display_aspect = 1.0
self._source_pixmap = None
self.args = args
self.is_save = False
self.frame_count = 0
self.writer = None
self.save_path = None
self.viewer_backend = QtViewerBackend(
npoints=PLOT_BUFFER_POINTS,
lowpass_cutoff_hz=getattr(args, "cutoff", 10.0),
)
self._setup_ui()
self._create_plots()
self.camera_edit.setText(args.video)
self.config_edit.setText(args.config)
self.cal_edit.setText(args.cal)
self.log_handler = WidgetLogHandler(self.log_widget)
sys.stdout = TeeStream(log_file, self.log_handler)
sys.stderr = sys.stdout
self.timer = QTimer()
self.timer.timeout.connect(self._on_timer)
self._running = False
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)
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(False)
self.arrows_label.setAlignment(Qt.AlignCenter)
left_layout.addWidget(self.arrows_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)
from gui.log_config import LOG_CONFIG
self.log_widget = make_log_widget(LOG_CONFIG)
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 = ["法向力 (raw)", "切向力 X", "切向力 Y"]
self.plots = []
self.curves = []
self.dot_glows = []
self.dot_cores = []
axis_pen = pg.mkPen(color=(38, 38, 38), width=1)
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))
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)
line_pen = pg.mkPen(color=CURVE_THEME_COLOR, width=2)
curve = p.plot(pen=line_pen, fillLevel=0)
dot_glow = p.plot(
[],
[],
pen=None,
symbol="o",
symbolSize=8,
symbolPen=None,
symbolBrush=(0, 229, 255, 76),
)
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_image_panel(self):
"""Size the left panel from the current image aspect ratio."""
top_bar_h = 48
log_h = self.log_widget.height()
spacing = 20
available = max(self.height() - top_bar_h - log_h - spacing, 160)
image_w = panel_width_for_aspect(available, self._display_aspect, min_size=160)
max_w = max(self.width() // 2, 160)
image_w = min(image_w, max_w)
self.h_splitter.setSizes([image_w, max(self.width() - image_w, 200)])
self._render_display_pixmap()
def resizeEvent(self, event):
super().resizeEvent(event)
self._render_display_pixmap()
def _render_display_pixmap(self):
if self._source_pixmap is None or self._source_pixmap.isNull():
return
target = self.arrows_label.size()
if target.width() < 2 or target.height() < 2:
return
self.arrows_label.setPixmap(pixmap_keep_aspect(self._source_pixmap, target))
def _on_timer(self):
if self.sensor_adapter is None:
return
snapshot = self.sensor_adapter.read()
if snapshot is None:
print("Failed to get image")
return
self._last_snapshot = snapshot
viewer_frame = self.viewer_backend.ingest(snapshot, now=time.time())
self._last_viewer_frame = viewer_frame
first_image = self._source_pixmap is None
aspect_changed = abs(viewer_frame.aspect_ratio - self._display_aspect) > 1e-3
self._display_aspect = viewer_frame.aspect_ratio
if self.is_save and self.writer is not None and snapshot.record_frame is not None:
self.frame_count += 1
save_img = snapshot.record_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)
print(
f"FPS={viewer_frame.fps:.2f}, "
f"法向力(raw)={viewer_frame.normal_force:.4f}, "
f"切向力X={viewer_frame.shear_x:.4f}, "
f"切向力Y={viewer_frame.shear_y:.4f}, "
f"法向力(N)={viewer_frame.normal_force_calibrated:.6f}"
)
self._source_pixmap = ndarray_to_pixmap(viewer_frame.display_image, bgr=True)
if first_image or aspect_changed:
self.adjust_image_panel()
else:
self._render_display_pixmap()
plot_state = self.viewer_backend.plot_state()
if plot_state is None:
return
for i, curve in enumerate(self.curves):
x = plot_state.times
y = plot_state.values[:, i]
curve.setData(x, y)
ymin, ymax = plot_state.y_ranges[i]
self.plots[i].setYRange(ymin, ymax)
self.dot_glows[i].setData(x[-1:], y[-1:])
self.dot_cores[i].setData(x[-1:], y[-1:])
x0, x1 = plot_state.x_range
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)
sensor_type = getattr(self.args, "sensor_type", "orisys")
self.sensor_adapter = create_sensor_adapter(
sensor_type,
video=src,
config_name=cfg,
cal_path=cal,
verbose=verbose,
)
self.sensor_adapter.start()
except Exception as e:
self.btn_start.setText("启动")
self.btn_start.setEnabled(True)
self.sensor_adapter = None
print(f"启动失败: {e}")
return
self.viewer_backend.reset()
self._running = True
self.timer.start(10)
self.btn_start.setText("启动")
self.btn_stop.setEnabled(True)
self.btn_reset.setEnabled(True)
print(
f"▶ 启动 — sensor_type={getattr(self.args, 'sensor_type', 'orisys')}, "
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_adapter is not None:
self.sensor_adapter.close()
self.sensor_adapter = None
self._last_snapshot = None
self.btn_start.setEnabled(True)
self.btn_stop.setText("暂停")
self.btn_reset.setEnabled(False)
print("⏸ 已暂停")
def _select_camera(self):
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):
if self.sensor_adapter is None or not self._running:
print("请先启动传感器后再重置追踪器。")
return
self.sensor_adapter.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_adapter is None:
print("请先启动采集")
return
self.save_path = "./data/pulse_test.mp4"
os.makedirs(os.path.dirname(self.save_path), exist_ok=True)
save_img = None
if self._last_snapshot is not None:
save_img = self._last_snapshot.record_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_adapter is not None:
self.sensor_adapter.close()
self.sensor_adapter = None
event.accept()
def run_viewer(args, *, log_dir: str | Path | None = None) -> int:
"""Launch the 2D Qt viewer and block until the window closes."""
from gui.app import get_or_create_qapp
if log_dir is None:
log_dir = Path(__file__).resolve().parents[3] / "scripts" / "logs"
else:
log_dir = Path(log_dir)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"qt_viewer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
log_file = open(log_path, "w", encoding="utf-8")
sys.stdout = log_file
sys.stderr = sys.stdout
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
app = get_or_create_qapp("Orisys SDK")
window = QtViewerWindow(args, log_file)
window.showFullScreen()
QApplication.processEvents()
window.adjust_image_panel()
return app.exec_()