1. tuned arrow mesh drawing

2. centralixzed arrow config in layout.py
3. seperate mesh render and display resolution to speed up (~40 fps)
This commit is contained in:
ykcheungab
2026-06-19 14:28:28 +08:00
parent 3fd9b839a9
commit 2e5c047d1e
19 changed files with 980 additions and 147 deletions
+24 -22
View File
@@ -66,6 +66,7 @@ class ForceChartWidget:
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._time_origin: float | None = None
self._y_range_smooth = [None, None, None]
def _create_plots(self) -> None:
@@ -134,6 +135,9 @@ class ForceChartWidget:
plot.setXRange(0, self._plot_window_sec, padding=0)
def update_frame(self, frame: FingerFrameView) -> None:
if self._time_origin is None:
self._time_origin = frame.timestamp
if self._plot_sample_count < self.npoints:
idx = self._plot_sample_count
self.data[idx, 0, :] = frame.timestamp
@@ -151,36 +155,36 @@ class ForceChartWidget:
self.data[-1, 1, 2] = frame.fsheary
n = self._plot_sample_count
if n == 0:
if n == 0 or self._time_origin is None:
return
layout = self._layout
data_plot = self.data[:n].copy()
times = data_plot[:, 0, 0] - float(data_plot[0, 0, 0])
data_plot = self.data[:n]
times = data_plot[:, 0, 0] - self._time_origin
x_end = float(times[-1])
if x_end < self._plot_window_sec:
x_start = 0.0
x_end = self._plot_window_sec
else:
x_start = x_end - self._plot_window_sec
for channel in range(3):
data_plot[:, 1, channel] = lowpass_filter(
data_plot[:, 1, channel],
y = lowpass_filter(
data_plot[:, 1, channel].copy(),
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 = self.curves[channel]
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)
if self._y_range_smooth[channel] is None:
self._y_range_smooth[channel] = (ymin, ymax)
else:
prev_min, prev_max = self._y_range_smooth[i]
prev_min, prev_max = self._y_range_smooth[channel]
alpha = self._y_range_alpha
ymin = alpha * ymin + (1 - alpha) * prev_min
ymax = alpha * ymax + (1 - alpha) * prev_max
@@ -190,13 +194,11 @@ class ForceChartWidget:
half = layout.y_axis_min_span / 2
ymin = mid - half
ymax = mid + half
self._y_range_smooth[i] = (ymin, ymax)
self._y_range_smooth[channel] = (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)
self.plots[channel].setYRange(ymin - pad, ymax + pad)
self.dot_glows[channel].setData(times[-1:], y[-1:])
self.dot_cores[channel].setData(times[-1:], y[-1:])
for plot in self.plots:
plot.setXRange(x_start, x_end, padding=0)
+45 -7
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from PyQt5.QtGui import QFont
from PyQt5.QtGui import QFont, QTextCursor
from PyQt5.QtWidgets import QPlainTextEdit
@@ -22,30 +22,68 @@ class TeeStream:
class WidgetLogHandler:
"""Mirror print output into a QPlainTextEdit."""
"""Mirror print output into a QPlainTextEdit with a live status line at the bottom."""
def __init__(self, widget: QPlainTextEdit):
self.widget = widget
self.buf = ""
self._has_status_line = False
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)
self._append_log_line(line)
def flush(self):
if self.buf:
self.widget.appendPlainText(self.buf)
self._append_log_line(self.buf)
self.buf = ""
def set_status_line(self, text: str) -> None:
"""Update the pinned bottom line (FPS / forces) without scrolling the log."""
text = text.rstrip("\n")
if self._has_status_line:
cursor = QTextCursor(self.widget.document().lastBlock())
cursor.select(QTextCursor.BlockUnderCursor)
cursor.insertText(text)
else:
self.widget.appendPlainText(text)
self._has_status_line = True
scrollbar = self.widget.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def make_log_widget() -> QPlainTextEdit:
def clear_status_line(self) -> None:
if not self._has_status_line:
return
cursor = QTextCursor(self.widget.document().lastBlock())
cursor.select(QTextCursor.BlockUnderCursor)
cursor.removeSelectedText()
self._has_status_line = False
def _append_log_line(self, line: str) -> None:
status = None
if self._has_status_line:
status = self.widget.document().lastBlock().text()
cursor = QTextCursor(self.widget.document().lastBlock())
cursor.select(QTextCursor.BlockUnderCursor)
cursor.removeSelectedText()
self._has_status_line = False
self.widget.appendPlainText(line)
if status is not None:
self.set_status_line(status)
def make_log_widget(config=None) -> QPlainTextEdit:
if config is None:
from ..layout import LOG_CONFIG
config = LOG_CONFIG
widget = QPlainTextEdit()
widget.setReadOnly(True)
widget.setMaximumBlockCount(2000)
font = QFont("Consolas", 12)
widget.setMaximumBlockCount(config.max_lines)
font = QFont(config.font_family, config.font_pt)
font.setStyleHint(QFont.Monospace)
widget.setFont(font)
return widget
+47 -10
View File
@@ -2,10 +2,13 @@
from __future__ import annotations
import sys
from pipeline.frame import FingerFrameView
from viz.offscreen_mesh import OffscreenFingerMeshRenderer
from ..layout import DashboardLayout, LAYOUT
from ..mesh_render_worker import MeshRenderWorker
from .qt_pixmap import ndarray_to_pixmap
from .image_panel import ImagePanel
@@ -16,14 +19,15 @@ class MeshViewportWidget(ImagePanel):
def __init__(self, parent=None, *, layout: DashboardLayout = LAYOUT):
self._layout = layout
self._renderer: OffscreenFingerMeshRenderer | None = None
self._mesh_worker: MeshRenderWorker | None = None
self._mesh_map = "depth"
self._threshold = 0.0
self._enable_3d = True
slot = layout.top_slot("mesh")
display_w, display_h = layout.mesh_display_size()
super().__init__(
title=layout.mesh_panel_title,
object_name="meshViewport",
default_side=min(slot.width_px, slot.height_px),
default_side=min(display_w, display_h),
parent=parent,
)
@@ -31,6 +35,7 @@ class MeshViewportWidget(ImagePanel):
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
self._stop_worker()
if self._renderer is not None:
self._renderer.close()
self._renderer = None
@@ -42,28 +47,58 @@ class MeshViewportWidget(ImagePanel):
return
self.label.setText("")
self._init_renderer(pipeline)
if self._layout.mesh_async_render and sys.platform != "win32":
self._mesh_worker = MeshRenderWorker(self)
self._mesh_worker.rgb_ready.connect(self._on_rgb_ready)
self._mesh_worker.set_renderer(self._renderer)
def _init_renderer(self, pipeline) -> None:
if not self._enable_3d or pipeline.uv_mesh is None:
return
side = self.side_length
render_w, render_h = self._layout.mesh_render_size()
self._renderer = OffscreenFingerMeshRenderer(
pipeline.uv_mesh,
width=side,
height=side,
width=render_w,
height=render_h,
threshold=self._threshold,
color_mode=self._mesh_map,
world_to_cam=pipeline.world_to_cam,
lookat=pipeline.mesh_lookat,
mesh_lighting=self._layout.mesh_lighting,
contact_arrow_style=self._layout.mesh_contact_arrow,
)
def on_side_length_changed(self, side: int) -> None:
if self._renderer is not None:
self._renderer.resize(side, side)
def _stop_worker(self) -> None:
if self._mesh_worker is not None:
self._mesh_worker.stop_worker()
self._mesh_worker = None
def update_frame(self, frame: FingerFrameView) -> None:
def on_side_length_changed(self, side: int) -> None:
"""Display widget resized; offscreen render size stays on layout.mesh_render_*."""
del side # display size only; ImagePanel upscales the pixmap to fit the label
def _on_rgb_ready(self, rgb) -> None:
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
def update_frame(self, frame: FingerFrameView, *, async_render: bool | None = None) -> float:
"""Update mesh colors. Returns render wall time (0 when async submit only)."""
if not self._enable_3d or self._renderer is None:
return
return 0.0
use_async = self._layout.mesh_async_render if async_render is None else async_render
if use_async and self._mesh_worker is not None:
self._mesh_worker.submit(
flow=frame.flow,
scalar_map=frame.depth_map,
centroid=frame.centroid,
centroid_found=frame.centroid_found,
fnormal=frame.fnormal,
)
return 0.0
import time
t0 = time.perf_counter()
rgb = self._renderer.render(
flow=frame.flow,
scalar_map=frame.depth_map,
@@ -72,8 +107,10 @@ class MeshViewportWidget(ImagePanel):
fnormal=frame.fnormal,
)
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
return (time.perf_counter() - t0) * 1000.0
def close_renderer(self) -> None:
self._stop_worker()
if self._renderer is not None:
self._renderer.close()
self._renderer = None