GUI for finger v1

This commit is contained in:
ykcheungab
2026-06-18 22:18:19 +08:00
parent f19f092b64
commit 3fd9b839a9
42 changed files with 2326 additions and 1186 deletions
+20
View File
@@ -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",
]
+44
View File
@@ -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))
+202
View File
@@ -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)
+71
View File
@@ -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()
+51
View File
@@ -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
+79
View File
@@ -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
+32
View File
@@ -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)