added mesh surface arrows

This commit is contained in:
ykcheungab
2026-06-19 21:22:28 +08:00
parent 2e5c047d1e
commit 258948bcb7
20 changed files with 951 additions and 194 deletions
+72 -33
View File
@@ -6,7 +6,7 @@ import sys
import time
from pathlib import Path
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtCore import QSize, Qt, QTimer
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
QApplication,
@@ -31,6 +31,7 @@ from .layout import (
top_row_splitter_sizes,
)
from .styles import STYLE
from .transport_icons import transport_icon
from .widgets import (
ArrowPanelWidget,
ForceChartWidget,
@@ -41,6 +42,15 @@ from .widgets import (
)
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
_PLAY_PAUSE_ICON_PX = 18
_PLAY_PAUSE_ICON_COLORS = {
"run": "#4caf84",
"starting": "#555555",
"pause": "#ff9800",
}
class FingerDashboardWindow(QMainWindow):
"""Single-window finger demo: 3D mesh | UV arrows | force charts."""
@@ -88,14 +98,14 @@ class FingerDashboardWindow(QMainWindow):
bar = QHBoxLayout()
bar.setSpacing(layout.toolbar_spacing_px)
self.btn_camera = QPushButton("摄像机")
self.btn_camera = QPushButton("Sensor")
self.btn_camera.clicked.connect(self._select_camera)
bar.addWidget(self.btn_camera)
self.camera_edit = QLineEdit("0")
self.camera_edit.setPlaceholderText("摄像头编号 或 视频路径")
self.camera_edit.setPlaceholderText("Camera ID or Video Path")
bar.addWidget(self.camera_edit, 1)
self.btn_config = QPushButton("配置")
self.btn_config = QPushButton("Config")
self.btn_config.clicked.connect(self._select_config)
bar.addWidget(self.btn_config)
self.config_edit = QLineEdit("./config/dd02-ov.json")
@@ -104,34 +114,38 @@ class FingerDashboardWindow(QMainWindow):
self.cal_edit = QLineEdit("")
bar.addWidget(self.cal_edit, 1)
self.btn_reset = QPushButton("重置")
self.btn_reset = QPushButton("Reset")
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)
self.btn_play_pause = QPushButton()
self.btn_play_pause.setObjectName(_PLAY_PAUSE_BUTTON_NAME)
self.btn_play_pause.setFixedSize(36, 32)
self.btn_play_pause.setIconSize(QSize(_PLAY_PAUSE_ICON_PX, _PLAY_PAUSE_ICON_PX))
self.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
self._set_play_pause_button("run")
bar.addWidget(self.btn_play_pause)
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)
arrows_slot = layout.slot("arrows")
self.arrow_panel: ArrowPanelWidget | None = None
if arrows_slot is not None and arrows_slot.enabled:
self.arrow_panel = ArrowPanelWidget(
flow_threshold=layout.arrow_flow_threshold,
background=layout.arrow_background,
layout=layout,
)
self.force_charts = ForceChartWidget(layout=layout, lowpass_cutoff_hz=self.config.lowpass_cutoff_hz)
self.log_widget = make_log_widget(layout.log_config)
self._panel_widgets: dict[str, QWidget] = {}
self._panel_widgets["mesh"] = self.mesh_viewport
self._panel_widgets["arrows"] = self.arrow_panel
if self.arrow_panel is not None:
self._panel_widgets["arrows"] = self.arrow_panel
self._panel_widgets["charts"] = self.force_charts.widget
self._panel_widgets["log"] = self.log_widget
@@ -194,11 +208,40 @@ class FingerDashboardWindow(QMainWindow):
sys.stdout = TeeStream(*streams)
sys.stderr = sys.stdout
def _set_play_pause_button(self, state: str) -> None:
"""``run`` = green play, ``starting`` = grey disabled play, ``pause`` = orange pause."""
btn = self.btn_play_pause
if state == "run":
btn.setToolTip("Run")
btn.setEnabled(True)
elif state == "starting":
btn.setToolTip("Starting…")
btn.setEnabled(False)
elif state == "pause":
btn.setToolTip("Pause")
btn.setEnabled(True)
else:
raise ValueError(f"unknown play/pause state: {state!r}")
kind = "pause" if state == "pause" else "play"
btn.setText("")
btn.setIcon(transport_icon(kind, _PLAY_PAUSE_ICON_COLORS[state]))
btn.setProperty("playState", state)
style = btn.style()
style.unpolish(btn)
style.polish(btn)
btn.update()
def _on_play_pause_clicked(self) -> None:
if self._running:
self._on_stop()
else:
self._on_start()
def _on_start(self) -> None:
if self._running:
return
self.btn_start.setText("启动中...")
self.btn_start.setEnabled(False)
self._set_play_pause_button("starting")
QApplication.processEvents()
self.config.video = self.camera_edit.text().strip()
@@ -214,8 +257,7 @@ class FingerDashboardWindow(QMainWindow):
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)
self._set_play_pause_button("run")
print(f"启动失败: {exc}")
return
@@ -223,8 +265,7 @@ class FingerDashboardWindow(QMainWindow):
self._frame_index = 0
self._wall_fps = 0.0
self.timer.start(self.layout.timer_interval_ms)
self.btn_start.setText("启动")
self.btn_stop.setEnabled(True)
self._set_play_pause_button("pause")
self.btn_reset.setEnabled(True)
print(
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
@@ -237,17 +278,14 @@ class FingerDashboardWindow(QMainWindow):
self._running = False
self.timer.stop()
self.log_handler.clear_status_line()
self.btn_stop.setText("停止中...")
self.btn_stop.setEnabled(False)
self._set_play_pause_button("starting")
QApplication.processEvents()
self.mesh_viewport.close_renderer()
self.pipeline.close()
self.btn_start.setEnabled(True)
self.btn_stop.setText("暂停")
self._set_play_pause_button("run")
self.btn_reset.setEnabled(False)
print("⏸ 已暂停")
def _on_reset(self) -> None:
if not self._running:
@@ -278,9 +316,10 @@ class FingerDashboardWindow(QMainWindow):
profile.record("mesh", mesh_ms)
arrows_ms = 0.0
t0 = time.perf_counter()
self.arrow_panel.update_frame(frame)
arrows_ms = (time.perf_counter() - t0) * 1000.0
if self.arrow_panel is not None:
t0 = time.perf_counter()
self.arrow_panel.update_frame(frame)
arrows_ms = (time.perf_counter() - t0) * 1000.0
if profile is not None:
profile.record("arrows", arrows_ms)
+47 -8
View File
@@ -19,6 +19,8 @@ MeshLighting (below) controls the 3D mesh panel sun light:
intensity — Filament sun strength (typical 30000–120000)
ambient/diffuse — Windows viewer shading weights (0–1)
MESH_BACKGROUND (below) sets the 3D mesh panel clear color (RGB 0–255).
MESH_CONTACT_ARROW (below) controls the yellow 3D contact arrow on the mesh panel:
reference_normal_force — Fn at which arrow reaches full size (log/power/linear curve)
force_strength_mode — "log" | "power" | "linear"
@@ -34,7 +36,7 @@ Performance (3D dashboard; edit DashboardLayout defaults):
chart_update_stride — update force charts every N frames
mesh_async_render — background mesh render (Linux only; Windows uses sync)
Edit TOP_ROW.slots to reorder or resize panels.
Edit TOP_ROW.slots to reorder or resize panels; set ``enabled=False`` to hide a panel (e.g. arrows).
Edit PANEL_SETUP to change top vs log proportions.
Edit LOG_CONFIG for live status line and log panel appearance.
"""
@@ -46,6 +48,8 @@ from dataclasses import dataclass
from typing import Literal
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
from viz.flow_arrow import FlowArrowStyle
from viz.render_2d import ArrowBackground, ArrowCanvasColors
PanelId = Literal["mesh", "arrows", "charts", "log"]
@@ -84,7 +88,7 @@ class PanelSetup:
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("arrows", "UV Unrolled", width_px=500, height_px=500, enabled=False),
PanelSlot("charts", "Force charts", width_px=500, height_px=500),
),
)
@@ -99,8 +103,8 @@ PANEL_SETUP = PanelSetup(
)
# Open3D offscreen resolution for the mesh panel (independent of on-screen widget size).
MESH_RENDER_WIDTH_PX = 256
MESH_RENDER_HEIGHT_PX = 256
MESH_RENDER_WIDTH_PX = 500
MESH_RENDER_HEIGHT_PX = 500
@dataclass(frozen=True)
@@ -123,13 +127,23 @@ class MeshLighting:
MESH_LIGHTING = MeshLighting(
azimuth_deg=-45.0,
azimuth_deg=0,
elevation_deg=0,
intensity=75000.0,
ambient=0.5,
diffuse=0.5,
)
@dataclass(frozen=True)
class MeshBackground:
"""3D mesh panel clear color — edit MESH_BACKGROUND or DashboardLayout.mesh_background."""
color: tuple[int, int, int] = (0, 0, 0)
MESH_BACKGROUND = MeshBackground(color=(0, 0, 0))
# 3D mesh contact arrow — edit here for dashboard tuning (see ContactArrowStyle in viz/contact_arrow.py).
MESH_CONTACT_ARROW = ContactArrowStyle(
reference_normal_force=10000.0,
@@ -143,6 +157,26 @@ MESH_CONTACT_ARROW = ContactArrowStyle(
color=(1.0, 0.92, 0.10),
)
# 3D mesh flow line arrows — tail_color/head_color are RGB 0–255 (see FlowArrowStyle).
MESH_FLOW_ARROW = FlowArrowStyle(
threshold=2.0,
grid_spacing=20,
arrow_scale=0.5,
length_margin=1.0,
tail_color=(40, 100, 220),
head_color=(40, 100, 220),
head_fraction=0.5,
opacity=1,
line_width=15,
head_line_width=30.0,
)
# UV arrow synthetic canvas — BGR tuples (outside valid_mask, inside valid_mask).
ARROW_CANVAS_COLORS = ArrowCanvasColors(
outside_bgr=(0, 0, 0),
uv_bgr=(128, 0, 0),
)
@dataclass(frozen=True)
class LogConfig:
@@ -150,7 +184,7 @@ class LogConfig:
status_enabled: bool = True
max_lines: int = 2000
font_family: str = "Consolas"
font_family: str = "Cascadia Mono"
font_pt: int = 12
show_flow: bool = True
show_mask: bool = True
@@ -191,7 +225,7 @@ class DashboardLayout:
panel_setup: PanelSetup = PANEL_SETUP
window_title: str = "Orisys Finger Dashboard"
window_width: int = 1500
window_width: int = 1000
window_height: int = 600
root_margin_px: int = 6
@@ -203,15 +237,20 @@ class DashboardLayout:
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_axis_min_span: float = 10000.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
# "unrolled" = sensor texture; "white" = solid canvas (tune colors via arrow_canvas_colors)
arrow_background: ArrowBackground = "white"
arrow_canvas_colors: ArrowCanvasColors = ARROW_CANVAS_COLORS
mesh_lighting: MeshLighting = MESH_LIGHTING
mesh_background: MeshBackground = MESH_BACKGROUND
mesh_contact_arrow: ContactArrowStyle = MESH_CONTACT_ARROW
mesh_flow_arrow: FlowArrowStyle = MESH_FLOW_ARROW
log_config: LogConfig = LOG_CONFIG
+3 -1
View File
@@ -28,7 +28,7 @@ class MeshRenderWorker(QThread):
self._renderer = None
self._pending = None
def submit(self, *, flow, scalar_map, centroid, centroid_found, fnormal) -> None:
def submit(self, *, flow, scalar_map, centroid, centroid_found, fnormal, valid_mask=None) -> None:
with QMutexLocker(self._mutex):
self._pending = {
"flow": flow,
@@ -36,6 +36,7 @@ class MeshRenderWorker(QThread):
"centroid": centroid,
"centroid_found": centroid_found,
"fnormal": fnormal,
"valid_mask": valid_mask,
}
self._wake = True
if not self.isRunning():
@@ -66,6 +67,7 @@ class MeshRenderWorker(QThread):
centroid=job["centroid"],
centroid_found=job["centroid_found"],
fnormal=job["fnormal"],
valid_mask=job["valid_mask"],
)
self.rgb_ready.emit(np.ascontiguousarray(rgb))
+31 -22
View File
@@ -1,5 +1,10 @@
"""Qt stylesheet for the finger dashboard."""
UI_FONT_STACK = '"Segoe UI Variable", "Segoe UI", "Inter", "Microsoft YaHei UI", sans-serif'
LOG_FONT_STACK = '"Cascadia Mono", "JetBrains Mono", "IBM Plex Mono", "Consolas", monospace'
UI_FONT_PRIMARY = "Segoe UI"
LOG_FONT_PRIMARY = "Cascadia Mono"
STYLE = """
QMainWindow {
background-color: #000000;
@@ -17,7 +22,7 @@ QPushButton {
border: 1px solid #333333;
border-radius: 4px;
padding: 6px 16px;
font-family: "SimHei";
font-family: __UI_FONT__;
font-size: 13px;
}
QPushButton:hover {
@@ -25,33 +30,36 @@ QPushButton:hover {
color: #cccccc;
border: 1px solid #555555;
}
QPushButton#btnStart {
QPushButton#btnPlayPause {
background-color: #0d2818;
color: #4caf84;
border: 1px solid #1a4d30;
padding: 4px 8px;
font-size: 15px;
}
QPushButton#btnStart:hover {
QPushButton#btnPlayPause:hover {
background-color: #143d24;
color: #6fd4a0;
}
QPushButton#btnStart:disabled {
background-color: #0f0f0f;
color: #333333;
border: 1px solid #222222;
QPushButton#btnPlayPause:disabled {
background-color: #1a1a1a;
color: #555555;
border: 1px solid #333333;
}
QPushButton#btnStop {
background-color: #2b1010;
color: #e05555;
border: 1px solid #4d1a1a;
QPushButton#btnPlayPause[playState="pause"] {
background-color: #3d2808;
color: #ff9800;
border: 1px solid #6d4400;
}
QPushButton#btnStop:hover {
background-color: #3d1818;
color: #f07070;
QPushButton#btnPlayPause[playState="pause"]:hover {
background-color: #523208;
color: #ffb74d;
border: 1px solid #8a5a00;
}
QPushButton#btnStop:disabled {
background-color: #0f0f0f;
color: #333333;
border: 1px solid #222222;
QPushButton#btnPlayPause[playState="pause"]:disabled {
background-color: #1a1a1a;
color: #555555;
border: 1px solid #333333;
}
QLineEdit {
background-color: #0d0d0d;
@@ -59,6 +67,7 @@ QLineEdit {
border: 1px solid #333333;
border-radius: 4px;
padding: 5px 10px;
font-family: __UI_FONT__;
font-size: 13px;
}
@@ -69,11 +78,11 @@ GraphicsLayoutWidget {
QPlainTextEdit {
background-color: #0b0b0b;
color: #777777;
color: #999999;
border: 1px solid #262626;
border-radius: 4px;
font-family: "Cascadia Code", "Consolas", "Courier New", monospace;
font-size: 13px;
font-family: __LOG_FONT__;
font-size: 12px;
padding: 8px;
selection-background-color: #1a3a4a;
selection-color: #cccccc;
@@ -111,4 +120,4 @@ QSplitter::handle:vertical {
QSplitter::handle:horizontal {
width: 1px;
}
"""
""".replace("__UI_FONT__", UI_FONT_STACK).replace("__LOG_FONT__", LOG_FONT_STACK)
+50
View File
@@ -0,0 +1,50 @@
"""Monochrome play/pause icons for the dashboard toolbar (avoids Windows emoji glyphs)."""
from __future__ import annotations
from typing import Literal
from PyQt5.QtCore import QPointF, QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPixmap, QPolygonF
TransportKind = Literal["play", "pause"]
_DEFAULT_ICON_PX = 18
def transport_icon(kind: TransportKind, color: str, *, size: int = _DEFAULT_ICON_PX) -> QIcon:
"""Build a flat play triangle or pause bars in ``color`` (#rrggbb)."""
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setBrush(QColor(color))
painter.setPen(Qt.NoPen)
if kind == "play":
w = size * 0.52
h = size * 0.60
x0 = (size - w) * 0.58
y0 = (size - h) / 2.0
painter.drawPolygon(
QPolygonF(
[
QPointF(x0, y0),
QPointF(x0, y0 + h),
QPointF(x0 + w, y0 + h / 2.0),
]
)
)
else:
bar_w = size * 0.17
bar_h = size * 0.60
gap = size * 0.14
y0 = (size - bar_h) / 2.0
x0 = (size - (2.0 * bar_w + gap)) / 2.0
radius = max(1.0, size * 0.06)
painter.drawRoundedRect(QRectF(x0, y0, bar_w, bar_h), radius, radius)
painter.drawRoundedRect(QRectF(x0 + bar_w + gap, y0, bar_w, bar_h), radius, radius)
painter.end()
return QIcon(pixmap)
+8 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import cv2
from pipeline.frame import FingerFrameView
from viz.render_2d import render_uv_arrows
from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows
from ..layout import DashboardLayout, LAYOUT
from .image_panel import ImagePanel
@@ -19,6 +19,8 @@ class ArrowPanelWidget(ImagePanel):
self,
*,
flow_threshold: float = 0.5,
background: ArrowBackground = "unrolled",
canvas_colors: ArrowCanvasColors | None = None,
layout: DashboardLayout = LAYOUT,
parent=None,
):
@@ -30,15 +32,18 @@ class ArrowPanelWidget(ImagePanel):
parent=parent,
)
self.flow_threshold = flow_threshold
self.background = background
self.canvas_colors = canvas_colors if canvas_colors is not None else layout.arrow_canvas_colors
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,
frame,
flow_threshold=self.flow_threshold,
background=self.background,
canvas_colors=self.canvas_colors,
)
arrows = cv2.rotate(arrows, cv2.ROTATE_180)
self.set_source_pixmap(ndarray_to_pixmap(arrows, bgr=True))
+6 -5
View File
@@ -7,6 +7,7 @@ from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import QSizePolicy
from gui.layout import DashboardLayout, LAYOUT
from gui.styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
from pipeline.frame import FingerFrameView
from viz.signal_filters import lowpass_filter
@@ -72,21 +73,21 @@ class ForceChartWidget:
def _create_plots(self) -> None:
pg = self._pg
layout = self._layout
titles = ["法向力", "切向力 X", "切向力 Y"]
titles = ["Normal Deformation", "X Deformation", "Y Deformation"]
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)
label_font = QFont(UI_FONT_PRIMARY, 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.titleLabel.setFont(QFont(UI_FONT_PRIMARY, title_pt))
plot.setLabel("bottom", "Time", units="s")
plot.getAxis("bottom").label.setFont(label_font)
plot.showAxis("top", False)
plot.showAxis("right", False)
@@ -94,7 +95,7 @@ class ForceChartWidget:
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 = QFont(LOG_FONT_PRIMARY, layout.chart_tick_font_pt)
tick_font.setStyleHint(QFont.Monospace)
plot.getAxis("left").setTickFont(tick_font)
plot.getAxis("bottom").setTickFont(tick_font)
+4
View File
@@ -65,7 +65,9 @@ class MeshViewportWidget(ImagePanel):
world_to_cam=pipeline.world_to_cam,
lookat=pipeline.mesh_lookat,
mesh_lighting=self._layout.mesh_lighting,
mesh_background=self._layout.mesh_background.color,
contact_arrow_style=self._layout.mesh_contact_arrow,
flow_arrow_style=self._layout.mesh_flow_arrow,
)
def _stop_worker(self) -> None:
@@ -93,6 +95,7 @@ class MeshViewportWidget(ImagePanel):
centroid=frame.centroid,
centroid_found=frame.centroid_found,
fnormal=frame.fnormal,
valid_mask=frame.valid_mask,
)
return 0.0
@@ -105,6 +108,7 @@ class MeshViewportWidget(ImagePanel):
centroid=frame.centroid,
centroid_found=frame.centroid_found,
fnormal=frame.fnormal,
valid_mask=frame.valid_mask,
)
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
return (time.perf_counter() - t0) * 1000.0