GUI for finger v1
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""Public GUI entry points for finger examples."""
|
||||
|
||||
from .dashboard import FingerDashboardWindow, run_dashboard
|
||||
from .layout import (
|
||||
DashboardLayout,
|
||||
LAYOUT,
|
||||
LOG_PANEL,
|
||||
PANEL_SETUP,
|
||||
TOP_ROW,
|
||||
PanelRow,
|
||||
PanelSetup,
|
||||
PanelSlot,
|
||||
top_row_splitter_sizes,
|
||||
top_row_slot_index,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DashboardLayout",
|
||||
"FingerDashboardWindow",
|
||||
"LAYOUT",
|
||||
"LOG_PANEL",
|
||||
"PANEL_SETUP",
|
||||
"TOP_ROW",
|
||||
"PanelRow",
|
||||
"PanelSetup",
|
||||
"PanelSlot",
|
||||
"run_dashboard",
|
||||
"top_row_splitter_sizes",
|
||||
"top_row_slot_index",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Qt application bootstrap for Orisys example dashboards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def fix_qt_plugin_path() -> None:
|
||||
"""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)
|
||||
|
||||
|
||||
def get_or_create_qapp(app_name: str = "Orisys Finger Dashboard"):
|
||||
fix_qt_plugin_path()
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName(app_name)
|
||||
return app
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Unified Qt dashboard for the finger tactile example."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from pipeline.config import PipelineConfig
|
||||
from pipeline.service import FingerPipeline
|
||||
|
||||
from .layout import (
|
||||
DashboardLayout,
|
||||
LAYOUT,
|
||||
PANEL_SETUP,
|
||||
PanelSetup,
|
||||
top_row_splitter_sizes,
|
||||
)
|
||||
from .styles import STYLE
|
||||
from .widgets import (
|
||||
ArrowPanelWidget,
|
||||
ForceChartWidget,
|
||||
MeshViewportWidget,
|
||||
TeeStream,
|
||||
WidgetLogHandler,
|
||||
make_log_widget,
|
||||
)
|
||||
|
||||
|
||||
class FingerDashboardWindow(QMainWindow):
|
||||
"""Single-window finger demo: 3D mesh | UV arrows | force charts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: PipelineConfig,
|
||||
*,
|
||||
layout: DashboardLayout = LAYOUT,
|
||||
log_file=None,
|
||||
):
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.layout = layout
|
||||
self.pipeline = FingerPipeline(config)
|
||||
self._running = False
|
||||
|
||||
self.setWindowTitle(layout.window_title)
|
||||
self.setStyleSheet(STYLE)
|
||||
|
||||
logo_path = Path(__file__).resolve().parents[2] / "logo.png"
|
||||
if logo_path.is_file():
|
||||
self.setWindowIcon(QIcon(str(logo_path)))
|
||||
|
||||
self._setup_ui()
|
||||
self._wire_logging(log_file)
|
||||
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self._on_timer)
|
||||
|
||||
self.camera_edit.setText(str(config.video))
|
||||
self.config_edit.setText(config.config)
|
||||
self.cal_edit.setText(config.calib or "")
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
layout = self.layout
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root_layout = QVBoxLayout(central)
|
||||
root_layout.setContentsMargins(layout.root_margin_px, layout.root_margin_px, layout.root_margin_px, layout.root_margin_px)
|
||||
root_layout.setSpacing(layout.root_spacing_px)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(layout.toolbar_spacing_px)
|
||||
|
||||
self.btn_camera = QPushButton("摄像机")
|
||||
self.btn_camera.clicked.connect(self._select_camera)
|
||||
bar.addWidget(self.btn_camera)
|
||||
self.camera_edit = QLineEdit("0")
|
||||
self.camera_edit.setPlaceholderText("摄像头编号 或 视频路径")
|
||||
bar.addWidget(self.camera_edit, 1)
|
||||
|
||||
self.btn_config = QPushButton("配置")
|
||||
self.btn_config.clicked.connect(self._select_config)
|
||||
bar.addWidget(self.btn_config)
|
||||
self.config_edit = QLineEdit("./config/dd02-ov.json")
|
||||
bar.addWidget(self.config_edit, 1)
|
||||
|
||||
self.cal_edit = QLineEdit("")
|
||||
bar.addWidget(self.cal_edit, 1)
|
||||
|
||||
self.btn_reset = QPushButton("重置")
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_reset.clicked.connect(self._on_reset)
|
||||
bar.addWidget(self.btn_reset)
|
||||
|
||||
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)
|
||||
|
||||
setup = layout.panel_setup
|
||||
self._panel_widgets: dict[str, QWidget] = {}
|
||||
|
||||
self.mesh_viewport = MeshViewportWidget(layout=layout)
|
||||
self.arrow_panel = ArrowPanelWidget(flow_threshold=layout.arrow_flow_threshold, layout=layout)
|
||||
self.force_charts = ForceChartWidget(layout=layout, lowpass_cutoff_hz=self.config.lowpass_cutoff_hz)
|
||||
self.log_widget = make_log_widget()
|
||||
|
||||
self._panel_widgets["mesh"] = self.mesh_viewport
|
||||
self._panel_widgets["arrows"] = self.arrow_panel
|
||||
self._panel_widgets["charts"] = self.force_charts.widget
|
||||
self._panel_widgets["log"] = self.log_widget
|
||||
|
||||
# Top row: mesh | arrows | charts (order from layout.PANEL_SETUP.top_row)
|
||||
h_splitter = QSplitter(Qt.Horizontal)
|
||||
for slot in setup.top_row.slots:
|
||||
if not slot.enabled:
|
||||
continue
|
||||
h_splitter.addWidget(self._panel_widgets[slot.id])
|
||||
h_splitter.setStretchFactor(h_splitter.count() - 1, slot.stretch)
|
||||
h_splitter.setChildrenCollapsible(setup.top_row.collapsible)
|
||||
self.h_splitter = h_splitter
|
||||
|
||||
# Vertical: top panel | log panel
|
||||
v_splitter = QSplitter(Qt.Vertical)
|
||||
v_splitter.addWidget(h_splitter)
|
||||
if setup.log_panel.enabled:
|
||||
v_splitter.addWidget(self.log_widget)
|
||||
v_splitter.setStretchFactor(0, setup.top_stretch)
|
||||
v_splitter.setStretchFactor(1, setup.log_stretch)
|
||||
else:
|
||||
v_splitter.setStretchFactor(0, 1)
|
||||
self.v_splitter = v_splitter
|
||||
|
||||
root_layout.addWidget(v_splitter)
|
||||
|
||||
self.resize(layout.window_width, layout.window_height)
|
||||
QTimer.singleShot(0, self._apply_panel_layout)
|
||||
|
||||
def _apply_panel_layout(self) -> None:
|
||||
setup = self.layout.panel_setup
|
||||
|
||||
for slot in setup.top_row.slots:
|
||||
if not slot.enabled:
|
||||
continue
|
||||
if slot.id in ("mesh", "arrows"):
|
||||
self._panel_widgets[slot.id].set_fixed_size(slot.width_px, slot.height_px)
|
||||
elif slot.id == "charts":
|
||||
self.force_charts.set_panel_size(slot.width_px, slot.height_px)
|
||||
|
||||
if setup.log_panel.enabled and setup.log_panel.height_px > 0:
|
||||
self.log_widget.setFixedHeight(setup.log_panel.height_px)
|
||||
|
||||
self.h_splitter.setSizes(list(top_row_splitter_sizes(self.layout)))
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
QTimer.singleShot(0, self._apply_panel_layout)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
QTimer.singleShot(0, self._apply_panel_layout)
|
||||
|
||||
def _wire_logging(self, log_file) -> None:
|
||||
streams = []
|
||||
if log_file is not None:
|
||||
streams.append(log_file)
|
||||
self.log_handler = WidgetLogHandler(self.log_widget)
|
||||
streams.append(self.log_handler)
|
||||
sys.stdout = TeeStream(*streams)
|
||||
sys.stderr = sys.stdout
|
||||
|
||||
def _on_start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self.btn_start.setText("启动中...")
|
||||
self.btn_start.setEnabled(False)
|
||||
QApplication.processEvents()
|
||||
|
||||
self.config.video = self.camera_edit.text().strip()
|
||||
self.config.config = self.config_edit.text().strip()
|
||||
cal = self.cal_edit.text().strip()
|
||||
self.config.calib = cal or None
|
||||
|
||||
try:
|
||||
self.pipeline = FingerPipeline(self.config)
|
||||
self.pipeline.open()
|
||||
self.mesh_viewport.configure(self.pipeline)
|
||||
self.force_charts.reset()
|
||||
QTimer.singleShot(0, self._apply_panel_layout)
|
||||
except Exception as exc:
|
||||
self.mesh_viewport.close_renderer()
|
||||
self.btn_start.setText("启动")
|
||||
self.btn_start.setEnabled(True)
|
||||
print(f"启动失败: {exc}")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self.timer.start(self.layout.timer_interval_ms)
|
||||
self.btn_start.setText("启动")
|
||||
self.btn_stop.setEnabled(True)
|
||||
self.btn_reset.setEnabled(True)
|
||||
print(
|
||||
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
|
||||
f"标定={self.config.calib or '(none)'}"
|
||||
)
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
self.timer.stop()
|
||||
self.btn_stop.setText("停止中...")
|
||||
self.btn_stop.setEnabled(False)
|
||||
QApplication.processEvents()
|
||||
|
||||
self.mesh_viewport.close_renderer()
|
||||
self.pipeline.close()
|
||||
|
||||
self.btn_start.setEnabled(True)
|
||||
self.btn_stop.setText("暂停")
|
||||
self.btn_reset.setEnabled(False)
|
||||
print("⏸ 已暂停")
|
||||
|
||||
def _on_reset(self) -> None:
|
||||
if not self._running:
|
||||
print("请先启动传感器后再重置追踪器。")
|
||||
return
|
||||
self.pipeline.reset()
|
||||
print("追踪器已重置。")
|
||||
|
||||
def _on_timer(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
frame = self.pipeline.tick()
|
||||
if frame is None:
|
||||
print("采集结束或读取失败。")
|
||||
self._on_stop()
|
||||
return
|
||||
|
||||
flow_mag = 0.0
|
||||
if frame.flow.size:
|
||||
flow_mag = float((frame.flow[:, :, 0] ** 2 + frame.flow[:, :, 1] ** 2).mean() ** 0.5)
|
||||
print(
|
||||
f"FPS={frame.fps:.2f} flow_rms={flow_mag:.2f} "
|
||||
f"mask={frame.mask_coverage:.2f} normal={frame.fnormal:.2f} "
|
||||
f"shear=({frame.fshearx:.2f}, {frame.fsheary:.2f})",
|
||||
end="\r",
|
||||
)
|
||||
|
||||
self.mesh_viewport.update_frame(frame)
|
||||
self.arrow_panel.update_frame(frame)
|
||||
self.force_charts.update_frame(frame)
|
||||
|
||||
def _select_camera(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"选择视频文件",
|
||||
"data",
|
||||
"视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
|
||||
)
|
||||
if path:
|
||||
self.camera_edit.setText(path)
|
||||
|
||||
def _select_config(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"选择配置文件",
|
||||
"config",
|
||||
"JSON (*.json);;所有文件 (*)",
|
||||
)
|
||||
if path:
|
||||
self.config_edit.setText(path)
|
||||
|
||||
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:
|
||||
if self.pipeline.start_recording():
|
||||
print("开始录像。")
|
||||
elif key == Qt.Key_E:
|
||||
self.pipeline.stop_recording()
|
||||
print("录像结束。")
|
||||
elif key == Qt.Key_R:
|
||||
self._on_reset()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.timer.stop()
|
||||
self.mesh_viewport.close_renderer()
|
||||
self.pipeline.close()
|
||||
event.accept()
|
||||
|
||||
|
||||
def run_dashboard(config: PipelineConfig, *, layout: DashboardLayout = LAYOUT) -> int:
|
||||
"""Launch the unified finger dashboard and block until the window closes."""
|
||||
from .app import get_or_create_qapp
|
||||
|
||||
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
|
||||
|
||||
app = get_or_create_qapp()
|
||||
window = FingerDashboardWindow(config, layout=layout)
|
||||
window.show()
|
||||
QTimer.singleShot(0, window._apply_panel_layout)
|
||||
return app.exec_()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Finger dashboard layout — edit this file to define the panel hierarchy and sizes.
|
||||
|
||||
Structure:
|
||||
|
||||
┌─ toolbar ─────────────────────────────────────────────┐
|
||||
├─ TOP (stretch: top_stretch) ──────────────────────────┤
|
||||
│ ┌ mesh ─┬─ arrows ─┬─ charts ─┐ (left → right) │
|
||||
│ └───────┴───────────┴─────────┘ │
|
||||
├─ LOG (stretch: log_stretch) ──────────────────────────┤
|
||||
└───────────────────────────────────────────────────────┘
|
||||
|
||||
Each panel uses explicit width_px and height_px.
|
||||
top_stretch / log_stretch control how extra vertical space is shared on resize.
|
||||
|
||||
Edit TOP_ROW.slots to reorder or resize panels.
|
||||
Edit PANEL_SETUP to change top vs log proportions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
PanelId = Literal["mesh", "arrows", "charts", "log"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelSlot:
|
||||
"""One panel in the dashboard."""
|
||||
|
||||
id: PanelId
|
||||
title: str
|
||||
width_px: int
|
||||
height_px: int
|
||||
stretch: int = 0
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelRow:
|
||||
"""Horizontal row of panels (the top visual strip)."""
|
||||
|
||||
slots: tuple[PanelSlot, ...]
|
||||
collapsible: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelSetup:
|
||||
"""Full panel hierarchy: top row + bottom log."""
|
||||
|
||||
top_row: PanelRow
|
||||
log_panel: PanelSlot
|
||||
top_stretch: int = 5
|
||||
log_stretch: int = 1
|
||||
|
||||
|
||||
# --- Top strip: mesh | arrows | charts (edit order and sizes here) ---
|
||||
TOP_ROW = PanelRow(
|
||||
slots=(
|
||||
PanelSlot("mesh", "3D mesh", width_px=500, height_px=500),
|
||||
PanelSlot("arrows", "UV Unrolled", width_px=500, height_px=500),
|
||||
PanelSlot("charts", "Force charts", width_px=500, height_px=500),
|
||||
),
|
||||
)
|
||||
|
||||
LOG_PANEL = PanelSlot("log", "Log", width_px=0, height_px=100)
|
||||
|
||||
PANEL_SETUP = PanelSetup(
|
||||
top_row=TOP_ROW,
|
||||
log_panel=LOG_PANEL,
|
||||
top_stretch=5,
|
||||
log_stretch=1,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardLayout:
|
||||
"""Window chrome, chart styling, and panel hierarchy."""
|
||||
|
||||
panel_setup: PanelSetup = PANEL_SETUP
|
||||
|
||||
window_title: str = "Orisys Finger Dashboard"
|
||||
window_width: int = 1500
|
||||
window_height: int = 600
|
||||
|
||||
root_margin_px: int = 6
|
||||
root_spacing_px: int = 6
|
||||
toolbar_spacing_px: int = 8
|
||||
|
||||
plot_buffer_points: int = 120
|
||||
plot_sample_rate_hz: float = 30.0
|
||||
chart_title_font_pt: int = 11
|
||||
chart_axis_label_font_pt: int = 9
|
||||
chart_tick_font_pt: int = 9
|
||||
y_axis_min_span: float = 20000.0
|
||||
y_range_smooth_alpha: float = 0.5
|
||||
curve_color: str = "#00E5FF"
|
||||
|
||||
# UV arrow panel: min flow magnitude to draw an arrow (display only; not dd02-ov.json contact.flow_threshold)
|
||||
arrow_flow_threshold: float = 1.0
|
||||
|
||||
timer_interval_ms: int = 10
|
||||
|
||||
def slot(self, panel_id: PanelId) -> PanelSlot | None:
|
||||
if self.panel_setup.log_panel.id == panel_id:
|
||||
return self.panel_setup.log_panel
|
||||
for slot in self.panel_setup.top_row.slots:
|
||||
if slot.id == panel_id:
|
||||
return slot
|
||||
return None
|
||||
|
||||
def top_slot(self, panel_id: PanelId) -> PanelSlot:
|
||||
for slot in self.panel_setup.top_row.slots:
|
||||
if slot.id == panel_id:
|
||||
return slot
|
||||
raise KeyError(f"panel {panel_id!r} not in top row")
|
||||
|
||||
@property
|
||||
def mesh_panel_title(self) -> str:
|
||||
return self.top_slot("mesh").title
|
||||
|
||||
@property
|
||||
def arrows_panel_title(self) -> str:
|
||||
return self.top_slot("arrows").title
|
||||
|
||||
|
||||
LAYOUT = DashboardLayout()
|
||||
|
||||
|
||||
def top_row_splitter_sizes(layout: DashboardLayout = LAYOUT) -> tuple[int, ...]:
|
||||
"""Horizontal splitter widths from panel setup."""
|
||||
return tuple(slot.width_px for slot in layout.panel_setup.top_row.slots if slot.enabled)
|
||||
|
||||
|
||||
def top_row_slot_index(panel_id: PanelId, layout: DashboardLayout = LAYOUT) -> int:
|
||||
"""Index of a panel in the horizontal top splitter."""
|
||||
for i, slot in enumerate(layout.panel_setup.top_row.slots):
|
||||
if slot.id == panel_id:
|
||||
return i
|
||||
raise KeyError(f"panel {panel_id!r} not in top row")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Qt stylesheet for the finger dashboard."""
|
||||
|
||||
STYLE = """
|
||||
QMainWindow {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
QLabel#panelLabel, QLabel#meshViewport {
|
||||
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,20 @@
|
||||
"""Qt widgets for the finger dashboard."""
|
||||
|
||||
from .force_charts import ForceChartWidget
|
||||
from .log_panel import TeeStream, WidgetLogHandler, make_log_widget
|
||||
from .mesh_viewport import MeshViewportWidget
|
||||
from .arrow_image import ArrowPanelWidget
|
||||
from .qt_pixmap import ndarray_to_pixmap, pixmap_keep_aspect
|
||||
from .image_panel import ImagePanel
|
||||
|
||||
__all__ = [
|
||||
"ArrowPanelWidget",
|
||||
"ForceChartWidget",
|
||||
"MeshViewportWidget",
|
||||
"ImagePanel",
|
||||
"TeeStream",
|
||||
"WidgetLogHandler",
|
||||
"make_log_widget",
|
||||
"ndarray_to_pixmap",
|
||||
"pixmap_keep_aspect",
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""UV flow-arrow panel for the Qt dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.render_2d import render_uv_arrows
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from .image_panel import ImagePanel
|
||||
from .qt_pixmap import ndarray_to_pixmap
|
||||
|
||||
|
||||
class ArrowPanelWidget(ImagePanel):
|
||||
"""UV unrolled flow-arrow panel."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
flow_threshold: float = 0.5,
|
||||
layout: DashboardLayout = LAYOUT,
|
||||
parent=None,
|
||||
):
|
||||
slot = layout.top_slot("arrows")
|
||||
super().__init__(
|
||||
title=layout.arrows_panel_title,
|
||||
object_name="panelLabel",
|
||||
default_side=min(slot.width_px, slot.height_px),
|
||||
parent=parent,
|
||||
)
|
||||
self.flow_threshold = flow_threshold
|
||||
|
||||
def set_flow_threshold(self, threshold: float) -> None:
|
||||
self.flow_threshold = threshold
|
||||
|
||||
def update_frame(self, frame: FingerFrameView) -> None:
|
||||
arrows = render_uv_arrows(
|
||||
frame.unrolled,
|
||||
frame.vnormal,
|
||||
flow_threshold=self.flow_threshold,
|
||||
)
|
||||
arrows = cv2.rotate(arrows, cv2.ROTATE_180)
|
||||
self.set_source_pixmap(ndarray_to_pixmap(arrows, bgr=True))
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Embedded PyQtGraph force charts for the Qt dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtWidgets import QSizePolicy
|
||||
|
||||
from gui.layout import DashboardLayout, LAYOUT
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.signal_filters import lowpass_filter
|
||||
|
||||
|
||||
def _require_pyqtgraph():
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"PyQtGraph is not installed. Install with: pip install pyqt5 pyqtgraph"
|
||||
) from exc
|
||||
return pg
|
||||
|
||||
|
||||
class ForceChartWidget:
|
||||
"""Rolling normal/shear force charts as an embeddable QWidget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
layout: DashboardLayout = LAYOUT,
|
||||
lowpass_cutoff_hz: float = 5.0,
|
||||
parent=None,
|
||||
):
|
||||
pg = _require_pyqtgraph()
|
||||
pg.setConfigOption("background", "#000000")
|
||||
pg.setConfigOption("foreground", "#777777")
|
||||
pg.setConfigOptions(antialias=True)
|
||||
|
||||
self._layout = layout
|
||||
self._pg = pg
|
||||
self.npoints = layout.plot_buffer_points
|
||||
self.sample_rate_hz = layout.plot_sample_rate_hz
|
||||
self.lowpass_cutoff_hz = lowpass_cutoff_hz
|
||||
self._plot_window_sec = self.npoints / self.sample_rate_hz
|
||||
self._plot_sample_count = 0
|
||||
self._y_range_alpha = layout.y_range_smooth_alpha
|
||||
self._y_range_smooth: list[tuple[float, float] | None] = [None, None, None]
|
||||
|
||||
self.widget = pg.GraphicsLayoutWidget(parent=parent)
|
||||
charts = layout.top_slot("charts")
|
||||
self.set_panel_size(charts.width_px, charts.height_px)
|
||||
self._init_plot_buffer()
|
||||
self._create_plots()
|
||||
|
||||
def set_panel_size(self, width: int, height: int) -> None:
|
||||
"""Apply layout width/height; height 0 means fill top-row height."""
|
||||
width = max(int(width), 64)
|
||||
self.widget.setFixedWidth(width)
|
||||
if height > 0:
|
||||
self.widget.setFixedHeight(height)
|
||||
self.widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
else:
|
||||
self.widget.setMaximumHeight(16777215)
|
||||
self.widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
|
||||
def _init_plot_buffer(self) -> None:
|
||||
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]
|
||||
|
||||
def _create_plots(self) -> None:
|
||||
pg = self._pg
|
||||
layout = self._layout
|
||||
titles = ["法向力", "切向力 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", layout.chart_axis_label_font_pt)
|
||||
title_pt = layout.chart_title_font_pt
|
||||
|
||||
for i, title in enumerate(titles):
|
||||
plot = self.widget.addPlot(title=None)
|
||||
plot.setTitle(title, color="#999999", size=f"{title_pt}pt")
|
||||
plot.titleLabel.setFont(QFont("SimHei", title_pt))
|
||||
plot.setLabel("bottom", "时间", units="s")
|
||||
plot.getAxis("bottom").label.setFont(label_font)
|
||||
plot.showAxis("top", False)
|
||||
plot.showAxis("right", False)
|
||||
plot.getAxis("left").setPen(axis_pen)
|
||||
plot.getAxis("bottom").setPen(axis_pen)
|
||||
plot.showGrid(x=False, y=True, alpha=0.12)
|
||||
|
||||
tick_font = QFont("Consolas", layout.chart_tick_font_pt)
|
||||
tick_font.setStyleHint(QFont.Monospace)
|
||||
plot.getAxis("left").setTickFont(tick_font)
|
||||
plot.getAxis("bottom").setTickFont(tick_font)
|
||||
|
||||
line_pen = pg.mkPen(color=layout.curve_color, width=2)
|
||||
curve = plot.plot(pen=line_pen, fillLevel=0)
|
||||
dot_glow = plot.plot(
|
||||
[],
|
||||
[],
|
||||
pen=None,
|
||||
symbol="o",
|
||||
symbolSize=8,
|
||||
symbolPen=None,
|
||||
symbolBrush=(0, 229, 255, 76),
|
||||
)
|
||||
dot_core = plot.plot(
|
||||
[],
|
||||
[],
|
||||
pen=None,
|
||||
symbol="o",
|
||||
symbolSize=4,
|
||||
symbolPen=None,
|
||||
symbolBrush=(255, 255, 255, 255),
|
||||
)
|
||||
plot.setXRange(0, self._plot_window_sec, padding=0)
|
||||
plot.enableAutoRange(x=False, y=False)
|
||||
|
||||
self.plots.append(plot)
|
||||
self.curves.append(curve)
|
||||
self.dot_glows.append(dot_glow)
|
||||
self.dot_cores.append(dot_core)
|
||||
if i < 2:
|
||||
self.widget.nextRow()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._init_plot_buffer()
|
||||
for plot in self.plots:
|
||||
plot.setXRange(0, self._plot_window_sec, padding=0)
|
||||
|
||||
def update_frame(self, frame: FingerFrameView) -> None:
|
||||
if self._plot_sample_count < self.npoints:
|
||||
idx = self._plot_sample_count
|
||||
self.data[idx, 0, :] = frame.timestamp
|
||||
self.data[idx, 1, 0] = frame.fnormal
|
||||
self.data[idx, 1, 1] = frame.fshearx
|
||||
self.data[idx, 1, 2] = frame.fsheary
|
||||
self._plot_sample_count += 1
|
||||
else:
|
||||
for channel in range(3):
|
||||
self.data[:-1, 0, channel] = self.data[1:, 0, channel]
|
||||
self.data[:-1, 1, channel] = self.data[1:, 1, channel]
|
||||
self.data[-1, 0, :] = frame.timestamp
|
||||
self.data[-1, 1, 0] = frame.fnormal
|
||||
self.data[-1, 1, 1] = frame.fshearx
|
||||
self.data[-1, 1, 2] = frame.fsheary
|
||||
|
||||
n = self._plot_sample_count
|
||||
if n == 0:
|
||||
return
|
||||
|
||||
layout = self._layout
|
||||
data_plot = self.data[:n].copy()
|
||||
times = data_plot[:, 0, 0] - float(data_plot[0, 0, 0])
|
||||
for channel in range(3):
|
||||
data_plot[:, 1, channel] = lowpass_filter(
|
||||
data_plot[:, 1, channel],
|
||||
self.lowpass_cutoff_hz,
|
||||
sample_rate_hz=self.sample_rate_hz,
|
||||
)
|
||||
|
||||
x_end = float(times[-1])
|
||||
x_start = max(0.0, x_end - self._plot_window_sec)
|
||||
if x_end - x_start < self._plot_window_sec:
|
||||
x_start = 0.0
|
||||
x_end = max(self._plot_window_sec, x_end)
|
||||
|
||||
for i, curve in enumerate(self.curves):
|
||||
y = data_plot[:, 1, i]
|
||||
curve.setData(times, y)
|
||||
finite = np.isfinite(y)
|
||||
if not np.any(finite):
|
||||
continue
|
||||
ymin, ymax = float(np.min(y[finite])), float(np.max(y[finite]))
|
||||
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 < layout.y_axis_min_span:
|
||||
mid = (ymin + ymax) / 2
|
||||
half = layout.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 = times[-1:]
|
||||
last_y = y[-1:]
|
||||
self.dot_glows[i].setData(last_x, last_y)
|
||||
self.dot_cores[i].setData(last_x, last_y)
|
||||
|
||||
for plot in self.plots:
|
||||
plot.setXRange(x_start, x_end, padding=0)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Square 1:1 image panel host for dashboard viewports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from .qt_pixmap import pixmap_keep_aspect
|
||||
|
||||
|
||||
class ImagePanel(QWidget):
|
||||
"""Fixed square widget that displays an image at 1:1 aspect ratio."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
title: str = "",
|
||||
object_name: str = "panelLabel",
|
||||
default_side: int = 256,
|
||||
min_side: int = 64,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._side = default_side
|
||||
self._min_side = min_side
|
||||
self._source_pixmap: QPixmap | None = None
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.label = QLabel(title)
|
||||
self.label.setObjectName(object_name)
|
||||
self.label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.label)
|
||||
self.set_side_length(self._side)
|
||||
|
||||
def set_side_length(self, side: int) -> None:
|
||||
self.set_fixed_size(side, side)
|
||||
|
||||
def set_fixed_size(self, width: int, height: int) -> None:
|
||||
width = max(int(width), self._min_side)
|
||||
height = max(int(height), self._min_side)
|
||||
if width == self.width() and height == self.height() and width == self._side:
|
||||
return
|
||||
self._side = width
|
||||
self.setFixedSize(width, height)
|
||||
self.label.setFixedSize(width, height)
|
||||
self.on_side_length_changed(min(width, height))
|
||||
self._refresh_pixmap()
|
||||
|
||||
@property
|
||||
def side_length(self) -> int:
|
||||
return self._side
|
||||
|
||||
def on_side_length_changed(self, side: int) -> None:
|
||||
"""Hook for subclasses (e.g. resize an offscreen renderer)."""
|
||||
|
||||
def set_source_pixmap(self, pixmap: QPixmap | None) -> None:
|
||||
self._source_pixmap = pixmap
|
||||
self._refresh_pixmap()
|
||||
|
||||
def _refresh_pixmap(self) -> None:
|
||||
if self._source_pixmap is None or self._source_pixmap.isNull():
|
||||
return
|
||||
self.label.setPixmap(pixmap_keep_aspect(self._source_pixmap, self.label.size()))
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._refresh_pixmap()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Logging helpers for the finger dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtWidgets import QPlainTextEdit
|
||||
|
||||
|
||||
class TeeStream:
|
||||
"""Write to multiple text streams."""
|
||||
|
||||
def __init__(self, *streams):
|
||||
self.streams = streams
|
||||
|
||||
def write(self, text):
|
||||
for stream in self.streams:
|
||||
stream.write(text)
|
||||
|
||||
def flush(self):
|
||||
for stream in self.streams:
|
||||
stream.flush()
|
||||
|
||||
|
||||
class WidgetLogHandler:
|
||||
"""Mirror print output into a 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 = ""
|
||||
|
||||
|
||||
def make_log_widget() -> QPlainTextEdit:
|
||||
widget = QPlainTextEdit()
|
||||
widget.setReadOnly(True)
|
||||
widget.setMaximumBlockCount(2000)
|
||||
font = QFont("Consolas", 12)
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
widget.setFont(font)
|
||||
return widget
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Embedded offscreen 3D mesh viewport for the Qt dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.offscreen_mesh import OffscreenFingerMeshRenderer
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from .qt_pixmap import ndarray_to_pixmap
|
||||
from .image_panel import ImagePanel
|
||||
|
||||
|
||||
class MeshViewportWidget(ImagePanel):
|
||||
"""Render the finger mesh offscreen in a square 1:1 viewport."""
|
||||
|
||||
def __init__(self, parent=None, *, layout: DashboardLayout = LAYOUT):
|
||||
self._layout = layout
|
||||
self._renderer: OffscreenFingerMeshRenderer | None = None
|
||||
self._mesh_map = "depth"
|
||||
self._threshold = 0.0
|
||||
self._enable_3d = True
|
||||
slot = layout.top_slot("mesh")
|
||||
super().__init__(
|
||||
title=layout.mesh_panel_title,
|
||||
object_name="meshViewport",
|
||||
default_side=min(slot.width_px, slot.height_px),
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
def configure(self, pipeline) -> None:
|
||||
self._mesh_map = pipeline.config.mesh_map
|
||||
self._threshold = pipeline.config.flow_threshold
|
||||
self._enable_3d = pipeline.config.enable_3d and pipeline.uv_mesh is not None
|
||||
if self._renderer is not None:
|
||||
self._renderer.close()
|
||||
self._renderer = None
|
||||
if not self._enable_3d:
|
||||
self.label.setText(
|
||||
"3D mesh disabled" if not pipeline.config.enable_3d else "3D mesh unavailable"
|
||||
)
|
||||
self.set_source_pixmap(None)
|
||||
return
|
||||
self.label.setText("")
|
||||
self._init_renderer(pipeline)
|
||||
|
||||
def _init_renderer(self, pipeline) -> None:
|
||||
if not self._enable_3d or pipeline.uv_mesh is None:
|
||||
return
|
||||
side = self.side_length
|
||||
self._renderer = OffscreenFingerMeshRenderer(
|
||||
pipeline.uv_mesh,
|
||||
width=side,
|
||||
height=side,
|
||||
threshold=self._threshold,
|
||||
color_mode=self._mesh_map,
|
||||
world_to_cam=pipeline.world_to_cam,
|
||||
lookat=pipeline.mesh_lookat,
|
||||
)
|
||||
|
||||
def on_side_length_changed(self, side: int) -> None:
|
||||
if self._renderer is not None:
|
||||
self._renderer.resize(side, side)
|
||||
|
||||
def update_frame(self, frame: FingerFrameView) -> None:
|
||||
if not self._enable_3d or self._renderer is None:
|
||||
return
|
||||
rgb = self._renderer.render(
|
||||
flow=frame.flow,
|
||||
scalar_map=frame.depth_map,
|
||||
centroid=frame.centroid,
|
||||
centroid_found=frame.centroid_found,
|
||||
fnormal=frame.fnormal,
|
||||
)
|
||||
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
|
||||
|
||||
def close_renderer(self) -> None:
|
||||
if self._renderer is not None:
|
||||
self._renderer.close()
|
||||
self._renderer = None
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Qt pixmap conversion helpers (no widget dependencies)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QImage, QPixmap
|
||||
|
||||
|
||||
def ndarray_to_pixmap(image: np.ndarray, *, bgr: bool = True) -> QPixmap:
|
||||
"""Convert a numpy image to QPixmap for QLabel display."""
|
||||
arr = np.ascontiguousarray(image)
|
||||
if arr.ndim == 2:
|
||||
arr = cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR)
|
||||
bgr = True
|
||||
if arr.ndim == 3 and arr.shape[2] == 4:
|
||||
arr = arr[:, :, :3]
|
||||
if bgr:
|
||||
rgb = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB)
|
||||
else:
|
||||
rgb = arr
|
||||
h, w, ch = rgb.shape
|
||||
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888)
|
||||
return QPixmap.fromImage(qimg.copy())
|
||||
|
||||
|
||||
def pixmap_keep_aspect(pixmap: QPixmap, size) -> QPixmap:
|
||||
"""Scale a pixmap to fit ``size`` without changing aspect ratio."""
|
||||
if size.width() <= 0 or size.height() <= 0:
|
||||
return pixmap
|
||||
return pixmap.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
Reference in New Issue
Block a user