added dd06 test support, organized 2d viewer and 3d viewer code organization.
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
@@ -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_()
|
||||
Reference in New Issue
Block a user