split example visualizaiton pipeline

This commit is contained in:
ykcheungab
2026-06-18 13:47:16 +08:00
parent fbee1c37ed
commit f19f092b64
42 changed files with 3067 additions and 702 deletions
+20
View File
@@ -0,0 +1,20 @@
"""Shared visualization helpers for Orisys example scripts."""
from .finger_panels import show_finger_uv_panels
from .force_charts import ForceChartWindow
from .mesh_viewer import UVMeshViewer, build_uv_surface_mesh
from .opencv_maps import depth_colormap, draw_magnitude_map, frame_to_bgr
from .recording import FrameRecorder
from .signal_filters import lowpass_filter
__all__ = [
"ForceChartWindow",
"FrameRecorder",
"UVMeshViewer",
"build_uv_surface_mesh",
"depth_colormap",
"draw_magnitude_map",
"frame_to_bgr",
"lowpass_filter",
"show_finger_uv_panels",
]
+29
View File
@@ -0,0 +1,29 @@
"""Finger-specific OpenCV panels for example scripts."""
from __future__ import annotations
import cv2
import orisys
from .opencv_maps import depth_colormap
def show_finger_uv_panels(
unrolled,
vnormal,
depth_map,
*,
flow_threshold: float = 0.4,
grid_spacing: int = 20,
arrow_scale: float = 1.0,
) -> None:
"""Show depth colormap and UV unrolled flow arrows."""
cv2.imshow("Depth", depth_colormap(depth_map))
arrows = orisys.util.draw_arrows(
unrolled,
vnormal,
threshold=flow_threshold,
grid_spacing=grid_spacing,
arrow_scale=arrow_scale,
)
cv2.imshow("UV Unrolled", arrows)
+69
View File
@@ -0,0 +1,69 @@
"""PyQtGraph real-time force chart window for examples."""
from __future__ import annotations
import numpy as np
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 ForceChartWindow:
"""Rolling normal/shear force charts backed by PyQtGraph."""
def __init__(
self,
*,
npoints: int = 100,
sample_rate_hz: float = 30.0,
title: str = "Orisys 实时力曲线",
app_name: str = "Orisys Plot Example",
) -> None:
pg = _require_pyqtgraph()
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
self._pg = pg
self.npoints = npoints
self.sample_rate_hz = sample_rate_hz
self.app = pg.mkQApp(app_name)
self.win = pg.GraphicsLayoutWidget(show=True, title=title)
self.win.setWindowTitle(title)
self.data = np.zeros((npoints, 2, 3), dtype=np.float64)
for channel in range(3):
self.data[:, 0, channel] = np.linspace(-npoints, 0, npoints) / sample_rate_hz
p1 = self.win.addPlot(title="法向力")
p1.setLabel("bottom", "时间", units="s")
self.win.nextRow()
p2 = self.win.addPlot(title="切向力 X")
p2.setLabel("bottom", "时间", units="s")
self.win.nextRow()
p3 = self.win.addPlot(title="切向力 Y")
p3.setLabel("bottom", "时间", units="s")
self._plots = (p1, p2, p3)
self._curves = (p1.plot(pen="k"), p2.plot(pen="k"), p3.plot(pen="k"))
def push(self, timestamp: float, fn: float, fx: float, fy: float) -> None:
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, :] = timestamp
self.data[-1, 1, 0] = fn
self.data[-1, 1, 1] = fx
self.data[-1, 1, 2] = fy
def refresh(self, display_data: np.ndarray | None = None) -> None:
plot_data = self.data if display_data is None else display_data
for channel, (plot, curve) in enumerate(zip(self._plots, self._curves)):
curve.setData(plot_data[:, :, channel])
plot.setYRange(float(plot_data[:, 1, channel].min()), float(plot_data[:, 1, channel].max()))
+4
View File
@@ -0,0 +1,4 @@
"""Re-exports for finger 3D mesh examples; implementation lives in orisys.finger."""
from orisys.finger.mesh import * # noqa: F403
from orisys.finger.viewer import * # noqa: F403
+57
View File
@@ -0,0 +1,57 @@
"""OpenCV visualization helpers for example scripts."""
from __future__ import annotations
import cv2
import numpy as np
def depth_colormap(depth_map, colormap=cv2.COLORMAP_JET) -> np.ndarray:
"""Normalize a scalar map to uint8 and apply an OpenCV colormap."""
values = np.asarray(depth_map)
if values.size == 0:
return np.zeros((1, 1, 3), dtype=np.uint8)
if values.max() > values.min():
normalized = ((values - values.min()) / (values.max() - values.min()) * 255).astype(np.uint8)
else:
normalized = np.zeros_like(values, dtype=np.uint8)
return cv2.applyColorMap(normalized, colormap)
def draw_magnitude_map(
vfield,
threshold=3.0,
colormap=cv2.COLORMAP_JET,
output_size=(400, 400),
) -> np.ndarray:
"""Render a smoothed optical-flow magnitude map as a BGR pseudo-color image."""
magnitude = np.sqrt(vfield[:, :, 0] ** 2 + vfield[:, :, 1] ** 2)
downsample_factor = 10
h, w = magnitude.shape
magnitude_downsampled = magnitude[::downsample_factor, ::downsample_factor]
magnitude_smoothed = cv2.resize(magnitude_downsampled, (w, h), interpolation=cv2.INTER_CUBIC)
magnitude_thresholded = magnitude_smoothed.copy()
magnitude_thresholded[magnitude_smoothed < threshold] = 0
mag_min, mag_max = magnitude_thresholded.min(), magnitude_thresholded.max()
if mag_max > mag_min:
magnitude_norm = ((magnitude_thresholded - mag_min) / (mag_max - mag_min) * 255).astype(np.uint8)
else:
magnitude_norm = np.zeros_like(magnitude_thresholded, dtype=np.uint8)
magnitude_colored = cv2.applyColorMap(magnitude_norm, colormap)
out_w, out_h = output_size
return cv2.resize(magnitude_colored, (out_w, out_h), interpolation=cv2.INTER_CUBIC)
def frame_to_bgr(frame: np.ndarray) -> np.ndarray:
"""Convert a grayscale or single-channel frame to BGR for display/recording."""
if frame is None:
raise ValueError("frame is None")
if len(frame.shape) == 2:
return cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
if len(frame.shape) == 3 and frame.shape[2] == 1:
return cv2.cvtColor(frame.squeeze(2), cv2.COLOR_GRAY2BGR)
return frame
+71
View File
@@ -0,0 +1,71 @@
"""Simple frame recording helper for example scripts."""
from __future__ import annotations
import os
from pathlib import Path
import cv2
from .opencv_maps import frame_to_bgr
class FrameRecorder:
"""Write camera frames to disk on demand (start/stop via keyboard hooks)."""
def __init__(self, save_path: str | Path = "./data/pulse_test.mp4", fps: int = 30) -> None:
self.save_path = Path(save_path)
self.fps = fps
self._writer: cv2.VideoWriter | None = None
self._is_recording = False
self._frame_count = 0
@property
def is_recording(self) -> bool:
return self._is_recording
def write_if_recording(self, frame) -> None:
if not self._is_recording or self._writer is None or frame is None:
return
self._writer.write(frame_to_bgr(frame))
self._frame_count += 1
def start(self, frame) -> bool:
if frame is None:
print("当前没有可用帧,无法确定录像尺寸。")
return False
self.save_path.parent.mkdir(parents=True, exist_ok=True)
bgr = frame_to_bgr(frame)
height, width = bgr.shape[:2]
writer = cv2.VideoWriter(
str(self.save_path),
cv2.VideoWriter_fourcc(*"mp4v"),
self.fps,
(width, height),
)
if not writer.isOpened():
print(f"无法创建录像文件:{self.save_path}")
return False
self._writer = writer
self._is_recording = True
self._frame_count = 0
print(f"开始录像:{self.save_path}")
return True
def stop(self) -> tuple[Path | None, int]:
self._is_recording = False
if self._writer is not None:
self._writer.release()
self._writer = None
path = self.save_path if self._frame_count else None
if path is not None:
print(f"录像结束:{path},总帧数:{self._frame_count}")
else:
print(f"录像结束,总帧数:{self._frame_count}")
count = self._frame_count
self._frame_count = 0
return path, count
+27
View File
@@ -0,0 +1,27 @@
"""Signal filtering helpers for example force charts."""
from __future__ import annotations
import numpy as np
from scipy.signal import butter, filtfilt
def lowpass_filter(data, cutoff_hz, sample_rate_hz=30, order=2) -> np.ndarray:
"""Zero-phase Butterworth low-pass filter 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)