split example visualizaiton pipeline
This commit is contained in:
@@ -1,228 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from importlib.machinery import EXTENSION_SUFFIXES
|
||||
|
||||
INCLUDE_LAYOUT = {
|
||||
"configs": {
|
||||
"source_dir": Path("config"),
|
||||
"target_subdir": Path("configs"),
|
||||
"patterns": ["*.json"],
|
||||
},
|
||||
"examples": {
|
||||
"source_dir": Path("examples"),
|
||||
"target_subdir": Path("examples"),
|
||||
"files": [
|
||||
Path("examples/basic_pipeline.py"),
|
||||
Path("examples/two_sensor_pipeline.py"),
|
||||
Path("examples/plot_data.py"),
|
||||
],
|
||||
},
|
||||
"docs": {
|
||||
"source_dir": Path("docs"),
|
||||
"target_subdir": Path("docs"),
|
||||
"patterns": ["*.md", "*.pdf"],
|
||||
},
|
||||
}
|
||||
|
||||
def install_nuitka():
|
||||
print("Checking Nuitka installation...")
|
||||
try:
|
||||
import nuitka # noqa: F401
|
||||
print("Nuitka is installed.")
|
||||
except ImportError:
|
||||
print("Installing Nuitka...")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "nuitka", "ordered-set", "zstandard"]
|
||||
)
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
init_file = Path("src/orisys/__init__.py")
|
||||
if not init_file.exists():
|
||||
return "0.3.1"
|
||||
for line in init_file.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("__version__"):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return "0.3.1"
|
||||
|
||||
|
||||
def find_compiled_extension(output_dir: Path, module_name: str) -> Path:
|
||||
candidates = []
|
||||
for suffix in EXTENSION_SUFFIXES:
|
||||
candidates.extend(output_dir.glob(f"{module_name}*{suffix}"))
|
||||
if not candidates:
|
||||
all_files = ", ".join(p.name for p in output_dir.iterdir()) if output_dir.exists() else "(none)"
|
||||
raise RuntimeError(
|
||||
f"Nuitka did not produce a compiled extension for {module_name}. Found: {all_files}"
|
||||
)
|
||||
return sorted(candidates, key=lambda p: len(p.name))[0]
|
||||
|
||||
|
||||
def move_wheel_to_sdk(wheel_path: Path, version: str) -> Path:
|
||||
sdk_version_dir = Path("SDK") / version
|
||||
sdk_version_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_wheel = sdk_version_dir / wheel_path.name
|
||||
if dest_wheel.exists():
|
||||
dest_wheel.unlink()
|
||||
shutil.move(str(wheel_path), str(dest_wheel))
|
||||
return dest_wheel
|
||||
|
||||
|
||||
def compile_orisys(build_root: Path, env: dict[str, str]) -> Path:
|
||||
module_output_dir = build_root / "orisys"
|
||||
if module_output_dir.exists():
|
||||
shutil.rmtree(module_output_dir)
|
||||
module_output_dir.mkdir(parents=True)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"nuitka",
|
||||
"--module",
|
||||
"--include-package=orisys",
|
||||
"--output-dir=" + str(module_output_dir),
|
||||
"--remove-output",
|
||||
"--no-pyi-file",
|
||||
"src/orisys",
|
||||
]
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
subprocess.check_call(cmd, env=env)
|
||||
|
||||
ext_file = find_compiled_extension(module_output_dir, "orisys")
|
||||
print(f"Generated compiled extension for orisys: {ext_file}")
|
||||
return ext_file
|
||||
|
||||
|
||||
def copy_compiled_orisys(pkg_dir: Path, ext_file: Path) -> str:
|
||||
ext_suffix = "".join(ext_file.suffixes)
|
||||
dest_ext = pkg_dir / f"__init__{ext_suffix}"
|
||||
shutil.copy(ext_file, dest_ext)
|
||||
print(f"Copied compiled module orisys to {dest_ext}")
|
||||
return ext_suffix
|
||||
|
||||
|
||||
def copy_included_files(pkg_dir: Path) -> dict[str, int]:
|
||||
copied_counts = {}
|
||||
for group_name, group_cfg in INCLUDE_LAYOUT.items():
|
||||
src_dir = group_cfg["source_dir"]
|
||||
dst_dir = pkg_dir / group_cfg["target_subdir"]
|
||||
dst_dir.mkdir(exist_ok=True)
|
||||
|
||||
copied = 0
|
||||
if "files" in group_cfg:
|
||||
for file_path in group_cfg["files"]:
|
||||
if file_path.exists():
|
||||
shutil.copy(file_path, dst_dir)
|
||||
copied += 1
|
||||
print(f"Copied {group_name}: {file_path.name}")
|
||||
else:
|
||||
for pattern in group_cfg.get("patterns", []):
|
||||
for src_file in src_dir.glob(pattern):
|
||||
shutil.copy(src_file, dst_dir)
|
||||
copied += 1
|
||||
print(f"Copied {group_name}: {src_file.name}")
|
||||
|
||||
if group_name == "examples":
|
||||
(dst_dir / "__init__.py").write_text("# Examples for orisys package\n", encoding="utf-8")
|
||||
|
||||
copied_counts[group_name] = copied
|
||||
return copied_counts
|
||||
|
||||
|
||||
def get_package_data_entries(ext_suffix: str) -> dict[str, list[str]]:
|
||||
package_data = {
|
||||
"orisys": [f"*{ext_suffix}"],
|
||||
"orisys.examples": ["*.py"],
|
||||
}
|
||||
for group_name, group_cfg in INCLUDE_LAYOUT.items():
|
||||
if group_name == "examples":
|
||||
continue
|
||||
target_subdir = str(group_cfg["target_subdir"]).replace("\\", "/")
|
||||
for pattern in group_cfg.get("patterns", []):
|
||||
package_data["orisys"].append(f"{target_subdir}/{pattern}")
|
||||
return package_data
|
||||
|
||||
|
||||
def build():
|
||||
install_nuitka()
|
||||
|
||||
version = get_version()
|
||||
output_dir = Path("build_nuitka")
|
||||
dist_dir = Path("dist_nuitka")
|
||||
|
||||
if output_dir.exists():
|
||||
shutil.rmtree(output_dir)
|
||||
if dist_dir.exists():
|
||||
shutil.rmtree(dist_dir)
|
||||
output_dir.mkdir()
|
||||
dist_dir.mkdir()
|
||||
|
||||
print("\n=== Compiling with Nuitka ===")
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = "src" + os.pathsep + env.get("PYTHONPATH", "")
|
||||
ext_file = compile_orisys(output_dir, env)
|
||||
|
||||
print("\n=== Preparing Wheel Structure ===")
|
||||
wheel_root = dist_dir / "wheel_root"
|
||||
pkg_dir = wheel_root / "orisys"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
|
||||
ext_suffix = copy_compiled_orisys(pkg_dir, ext_file)
|
||||
|
||||
copied_counts = copy_included_files(pkg_dir)
|
||||
print(f"Copied {copied_counts['configs']} JSON config files")
|
||||
print(f"Copied {copied_counts['examples']} example files to orisys/examples/")
|
||||
print(f"Copied {copied_counts['docs']} doc files to orisys/docs/")
|
||||
package_data = get_package_data_entries(ext_suffix)
|
||||
|
||||
setup_py_path = dist_dir / "setup.py"
|
||||
setup_content = f"""
|
||||
from setuptools import setup, Distribution
|
||||
|
||||
class BinaryDistribution(Distribution):
|
||||
def has_ext_modules(self):
|
||||
return True
|
||||
|
||||
setup(
|
||||
name="orisys",
|
||||
version="{version}",
|
||||
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
|
||||
author="Orisys",
|
||||
packages=['orisys', 'orisys.examples'],
|
||||
package_dir={{'': 'wheel_root'}},
|
||||
package_data={package_data},
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
"numpy>=1.24.4",
|
||||
"opencv-python>=4.10.0.84",
|
||||
"scipy>=1.10.1",
|
||||
],
|
||||
extras_require={{
|
||||
"cuda12": ["cupy-cuda12x>=12.3.0"],
|
||||
"cuda13": ["cupy-cuda13x>=13.0.0"],
|
||||
"cuda": ["cupy-cuda12x>=12.3.0"],
|
||||
}},
|
||||
distclass=BinaryDistribution,
|
||||
zip_safe=False,
|
||||
)
|
||||
"""
|
||||
setup_py_path.write_text(setup_content, encoding="utf-8")
|
||||
|
||||
print("\n=== Building Wheel ===")
|
||||
subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"], cwd=dist_dir)
|
||||
|
||||
wheels = sorted((dist_dir / "dist").glob("*.whl"))
|
||||
if wheels:
|
||||
final_wheel = move_wheel_to_sdk(wheels[0], version)
|
||||
print(f"\n[SUCCESS] Wheel created and moved to: {final_wheel}")
|
||||
else:
|
||||
print("\n[ERROR] Wheel not found.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
|
||||
@@ -0,0 +1,858 @@
|
||||
"""
|
||||
Orisys SDK 实时监控 — Qt 统一界面
|
||||
|
||||
将所有可视化和曲线整合到一个窗口中,适合销售演示使用。
|
||||
SDK version: 0.3.1
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
def _fix_qt_plugin_path():
|
||||
"""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)
|
||||
_fix_qt_plugin_path()
|
||||
import orisys
|
||||
from orisys.finger import FingerRuntime, UVMeshViewer, build_uv_surface_mesh
|
||||
import numpy as np
|
||||
import time
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from scipy.signal import butter, filtfilt
|
||||
|
||||
# Force curve sampling rate (Hz), matches typical sensor loop ~30 FPS.
|
||||
FORCE_SAMPLE_RATE_HZ = 30.0
|
||||
PLOT_BUFFER_POINTS = 120
|
||||
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ # 4 s window
|
||||
# Minimum y-axis span so autoscale does not over-zoom on small fluctuations.
|
||||
Y_AXIS_MIN_SPAN = 20000
|
||||
CURVE_THEME_COLOR = '#00E5FF'
|
||||
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
||||
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
|
||||
QLabel, QApplication, QSplitter, QPlainTextEdit,
|
||||
QPushButton, QFileDialog, QLineEdit,
|
||||
)
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5 import QtGui
|
||||
from PyQt5.QtGui import QImage, QPixmap, QFont, QIcon
|
||||
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except Exception:
|
||||
print("PyQtGraph is not installed. Please install it using 'pip install pyqt5 pyqtgraph'.")
|
||||
sys.exit(1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cv2_to_pixmap(img_bgr: np.ndarray) -> QPixmap:
|
||||
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||
h, w, ch = rgb.shape
|
||||
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888)
|
||||
return QPixmap.fromImage(qimg)
|
||||
|
||||
def draw_magnitude_map(vfield, threshold=3, colormap=cv2.COLORMAP_JET) -> np.ndarray:
|
||||
flow_field = vfield
|
||||
magnitude = np.sqrt(flow_field[:, :, 0] ** 2 + flow_field[:, :, 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_img = cv2.resize(magnitude_colored, (400, 400), interpolation=cv2.INTER_CUBIC)
|
||||
return out_img
|
||||
|
||||
|
||||
def lowpass_filter(data, cutoff_hz, sample_rate_hz=FORCE_SAMPLE_RATE_HZ, order=2):
|
||||
"""
|
||||
Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : array-like
|
||||
Input samples acquired at `sample_rate_hz` (default 30 Hz).
|
||||
cutoff_hz : float
|
||||
-3 dB cutoff frequency in Hz; use 0 to skip filtering (return raw data).
|
||||
Otherwise must satisfy 0 < cutoff_hz < sample_rate_hz / 2.
|
||||
sample_rate_hz : float
|
||||
Sampling rate of `data` in Hz.
|
||||
order : int
|
||||
Butterworth filter order.
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
Filtered 1-D array with the same length as `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)
|
||||
|
||||
|
||||
def calibrate_normal_force(raw_value):
|
||||
"""Convert raw normal-force reading to Newtons using y = mx + c."""
|
||||
return NORMAL_FORCE_CAL_SLOPE * raw_value + NORMAL_FORCE_CAL_OFFSET
|
||||
|
||||
|
||||
class TeeStream:
|
||||
"""同时写入多个流"""
|
||||
def __init__(self, *streams):
|
||||
self.streams = streams
|
||||
|
||||
def write(self, text):
|
||||
for s in self.streams:
|
||||
s.write(text)
|
||||
|
||||
def flush(self):
|
||||
for s in self.streams:
|
||||
s.flush()
|
||||
|
||||
|
||||
class WidgetLogHandler:
|
||||
"""将 print 输出实时显示到 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 = ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 样式表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STYLE = """
|
||||
QMainWindow {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
/* ---- 图像面板 ---- */
|
||||
QLabel#arrowsLabel {
|
||||
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;
|
||||
}
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主窗口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, args, log_file):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Orisys SDK")
|
||||
self.setStyleSheet(STYLE)
|
||||
|
||||
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
|
||||
if os.path.exists(logo_path):
|
||||
self.setWindowIcon(QIcon(logo_path))
|
||||
|
||||
self.sensor = None
|
||||
self.mesh_viewer = None
|
||||
self.start_time = None
|
||||
self.args = args
|
||||
|
||||
# 录像状态
|
||||
self.is_save = False
|
||||
self.frame_count = 0
|
||||
self.writer = None
|
||||
self.save_path = None
|
||||
|
||||
# 曲线数据缓冲(先填满 npoints 再滚动;120 点 @ 30 Hz = 4 s)
|
||||
self.npoints = PLOT_BUFFER_POINTS
|
||||
self.lowpass_cutoff_hz = getattr(args, "cutoff", 5.0)
|
||||
self._plot_sample_count = 0
|
||||
self._init_plot_buffer()
|
||||
|
||||
self._setup_ui()
|
||||
self._create_plots()
|
||||
|
||||
# 同步参数到 UI
|
||||
self.camera_edit.setText(args.video)
|
||||
self.config_edit.setText(args.config)
|
||||
self.cal_edit.setText(getattr(args, "cal", ""))
|
||||
|
||||
# stdout → 日志文件 + 界面日志面板
|
||||
self.log_handler = WidgetLogHandler(self.log_widget)
|
||||
sys.stdout = TeeStream(log_file, self.log_handler)
|
||||
sys.stderr = sys.stdout
|
||||
|
||||
self._y_range_alpha = 0.5
|
||||
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self._on_timer)
|
||||
self._running = False
|
||||
|
||||
def _init_plot_buffer(self):
|
||||
"""Empty buffer; x-axis grows until npoints real samples, then scrolls."""
|
||||
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]
|
||||
|
||||
# ---- UI 构建 ----
|
||||
|
||||
def _setup_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root_layout = QVBoxLayout(central)
|
||||
root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
root_layout.setSpacing(6)
|
||||
|
||||
# ---- 顶栏 ----
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(8)
|
||||
|
||||
self.btn_camera = QPushButton("摄像机")
|
||||
self.btn_camera.setObjectName("btnCamera")
|
||||
self.btn_camera.clicked.connect(self._select_camera)
|
||||
bar.addWidget(self.btn_camera)
|
||||
self.camera_edit = QLineEdit("1")
|
||||
self.camera_edit.setPlaceholderText("摄像头编号 或 视频路径")
|
||||
bar.addWidget(self.camera_edit, 1)
|
||||
|
||||
self.btn_config = QPushButton("配置")
|
||||
self.btn_config.setObjectName("btnConfig")
|
||||
self.btn_config.clicked.connect(self._select_config)
|
||||
bar.addWidget(self.btn_config)
|
||||
self.config_edit = QLineEdit("./config/ddjx01.json")
|
||||
bar.addWidget(self.config_edit, 1)
|
||||
|
||||
self.btn_reset = QPushButton("重置")
|
||||
self.btn_reset.setObjectName("btnReset")
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_reset.clicked.connect(self._on_reset)
|
||||
bar.addWidget(self.btn_reset)
|
||||
self.cal_edit = QLineEdit("./config/ddjx01.npy")
|
||||
bar.addWidget(self.cal_edit, 1)
|
||||
|
||||
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)
|
||||
|
||||
# ---- 外层纵向 splitter:主区域 / 日志 ----
|
||||
v_splitter = QSplitter(Qt.Vertical)
|
||||
|
||||
# ---- 主区域:图像 (左) | 曲线 (右) ----
|
||||
h_splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# 左侧图像面板
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
left_layout.setSpacing(6)
|
||||
|
||||
self.arrows_label = QLabel()
|
||||
self.arrows_label.setObjectName("arrowsLabel")
|
||||
self.arrows_label.setMinimumSize(160, 160)
|
||||
self.arrows_label.setAlignment(Qt.AlignCenter)
|
||||
self.arrows_label.setWordWrap(True)
|
||||
self.arrows_label.setText(self._mesh_viewer_status_text())
|
||||
left_layout.addWidget(self.arrows_label)
|
||||
|
||||
h_splitter.addWidget(left)
|
||||
|
||||
# 右侧力曲线
|
||||
pg.setConfigOption('background', '#000000')
|
||||
pg.setConfigOption('foreground', '#777777')
|
||||
pg.setConfigOptions(antialias=True)
|
||||
self.graph_widget = pg.GraphicsLayoutWidget()
|
||||
self.graph_widget.setMinimumWidth(300)
|
||||
h_splitter.addWidget(self.graph_widget)
|
||||
|
||||
self.h_splitter = h_splitter
|
||||
|
||||
v_splitter.addWidget(h_splitter)
|
||||
|
||||
# ---- 底部日志面板 ----
|
||||
self.log_widget = QPlainTextEdit()
|
||||
self.log_widget.setReadOnly(True)
|
||||
self.log_widget.setMaximumBlockCount(2000)
|
||||
font = QFont("Consolas", 12)
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
self.log_widget.setFont(font)
|
||||
v_splitter.addWidget(self.log_widget)
|
||||
|
||||
v_splitter.setStretchFactor(0, 4)
|
||||
v_splitter.setStretchFactor(1, 1)
|
||||
|
||||
root_layout.addWidget(v_splitter)
|
||||
|
||||
def _create_plots(self):
|
||||
titles = ["法向力 (N)", "切向力 X", "切向力 Y"]
|
||||
self.plots = []
|
||||
self.curves = []
|
||||
self.dot_glows = []
|
||||
self.dot_cores = []
|
||||
|
||||
axis_pen = pg.mkPen(color=(38, 38, 38), width=1) # #262626
|
||||
label_font = QFont("SimHei", 9)
|
||||
|
||||
for i, title in enumerate(titles):
|
||||
p = self.graph_widget.addPlot(title=None)
|
||||
|
||||
# 标题:黑体
|
||||
p.setTitle(title, color='#999999', size='14pt')
|
||||
p.titleLabel.setFont(QFont("SimHei", 14))
|
||||
|
||||
# X 轴标签
|
||||
p.setLabel('bottom', '时间', units='s')
|
||||
p.getAxis('bottom').label.setFont(label_font)
|
||||
|
||||
# 仅保留左 + 下轴线,隐藏右 + 上边框
|
||||
p.showAxis('top', False)
|
||||
p.showAxis('right', False)
|
||||
p.getAxis('left').setPen(axis_pen)
|
||||
p.getAxis('bottom').setPen(axis_pen)
|
||||
|
||||
# 仅横向网格,极淡
|
||||
p.showGrid(x=False, y=True, alpha=0.12)
|
||||
|
||||
# 等宽刻度字体
|
||||
tick_font = QFont("Consolas", 11)
|
||||
tick_font.setStyleHint(QFont.Monospace)
|
||||
p.getAxis('left').setTickFont(tick_font)
|
||||
p.getAxis('bottom').setTickFont(tick_font)
|
||||
|
||||
# 曲线: 青色描边 + 向下渐变填充(与 demo_wuxi 一致)
|
||||
line_pen = pg.mkPen(color=CURVE_THEME_COLOR, width=2)
|
||||
# grad_brush = make_gradient_brush(CURVE_THEME_COLOR, max_alpha=120)
|
||||
curve = p.plot(pen=line_pen, fillLevel=0)
|
||||
|
||||
# 终点光晕: #00e5ff 30% 透明,半径 8
|
||||
dot_glow = p.plot([], [], pen=None, symbol='o', symbolSize=8,
|
||||
symbolPen=None, symbolBrush=(0, 229, 255, 76))
|
||||
# 终点核心: 纯白,半径 4
|
||||
dot_core = p.plot([], [], pen=None, symbol='o', symbolSize=4,
|
||||
symbolPen=None, symbolBrush=(255, 255, 255, 255))
|
||||
|
||||
p.setXRange(0, PLOT_WINDOW_SEC, padding=0)
|
||||
p.enableAutoRange(x=False, y=False)
|
||||
|
||||
self.plots.append(p)
|
||||
self.curves.append(curve)
|
||||
self.dot_glows.append(dot_glow)
|
||||
self.dot_cores.append(dot_core)
|
||||
|
||||
if i < 2:
|
||||
self.graph_widget.nextRow()
|
||||
|
||||
def adjust_square_images(self):
|
||||
"""根据窗口高度调整左侧面板宽度,使左侧图像保持正方形"""
|
||||
top_bar_h = 48
|
||||
log_h = self.log_widget.height()
|
||||
spacing = 20
|
||||
available = self.height() - top_bar_h - log_h - spacing
|
||||
image_w = max(available, 160)
|
||||
self.h_splitter.setSizes([image_w, self.width() - image_w])
|
||||
|
||||
def _mesh_viewer_status_text(self):
|
||||
if self.mesh_viewer is None:
|
||||
return "3D mesh viewer will open in a separate window\nwhen acquisition starts."
|
||||
return "3D mesh viewer is running in a separate window."
|
||||
|
||||
def _update_mesh_status_label(self):
|
||||
self.arrows_label.setText(self._mesh_viewer_status_text())
|
||||
|
||||
def _init_mesh_viewer(self):
|
||||
if self.sensor is None:
|
||||
self._update_mesh_status_label()
|
||||
return
|
||||
domain = getattr(self.sensor, "domain", None)
|
||||
surface = getattr(domain, "surface", None)
|
||||
if surface is None or getattr(surface, "world_xyz", None) is None:
|
||||
self.arrows_label.setText("3D mesh viewer unavailable.\nUV domain has no surface map.")
|
||||
return
|
||||
try:
|
||||
uv_mesh = build_uv_surface_mesh(domain.surface.world_xyz, domain.valid_mask)
|
||||
self.mesh_viewer = UVMeshViewer(
|
||||
uv_mesh,
|
||||
color_mode="flow",
|
||||
threshold=2.0,
|
||||
)
|
||||
self._update_mesh_status_label()
|
||||
except Exception as exc:
|
||||
self.mesh_viewer = None
|
||||
self.arrows_label.setText(f"3D mesh viewer init failed.\n{exc}")
|
||||
|
||||
def _close_mesh_viewer(self):
|
||||
if self.mesh_viewer is not None:
|
||||
self.mesh_viewer.close()
|
||||
self.mesh_viewer = None
|
||||
self._update_mesh_status_label()
|
||||
|
||||
# ---- 帧处理 ----
|
||||
|
||||
def _on_timer(self):
|
||||
if self.sensor is None:
|
||||
return
|
||||
runtime = self.sensor
|
||||
tactile = runtime.tactile
|
||||
|
||||
# isimg = sensor.get_img()
|
||||
ok, unrolled = runtime.grab_and_process(color=True)
|
||||
if not ok:
|
||||
print("Failed to get image")
|
||||
return
|
||||
|
||||
# 旋转原始图像,使力方向与实际物理方向对齐
|
||||
# sensor.img = cv2.rotate(sensor.img, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
||||
# if sensor.frame is not None:
|
||||
# sensor.frame = cv2.rotate(sensor.frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
||||
|
||||
# 录像写入
|
||||
if self.is_save and self.writer is not None and tactile.frame is not None:
|
||||
self.frame_count += 1
|
||||
save_img = tactile.frame
|
||||
if len(save_img.shape) == 2 or (len(save_img.shape) == 3 and save_img.shape[2] == 1):
|
||||
if len(save_img.shape) == 3:
|
||||
save_img = save_img.squeeze(2)
|
||||
save_img = cv2.cvtColor(save_img, cv2.COLOR_GRAY2BGR)
|
||||
self.writer.write(save_img)
|
||||
|
||||
tactile.compute_deformation(check_motion=True, threshold=1)
|
||||
tactile.compute_contact()
|
||||
|
||||
fps, fn_raw, fx, fy, flow, vnormal, vshear, _ = tactile.read_info(
|
||||
tactile.info.FPS, tactile.info.FNORMAL,
|
||||
tactile.info.FSHEARX, tactile.info.FSHEARY,
|
||||
tactile.info.VRAW, tactile.info.VNORMAL,
|
||||
tactile.info.VSHEAR,
|
||||
tactile.info.DEPTH
|
||||
)
|
||||
fn = calibrate_normal_force(fn_raw)
|
||||
|
||||
|
||||
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
|
||||
# print(f"CUPY Available: {sensor.CUPY_AVAILABLE}")
|
||||
|
||||
if self.mesh_viewer is not None:
|
||||
if not self.mesh_viewer.update(flow=flow):
|
||||
self.mesh_viewer.close()
|
||||
self.mesh_viewer = None
|
||||
self.arrows_label.setText("3D mesh viewer closed.\nRestart acquisition to reopen it.")
|
||||
|
||||
# 曲线:先顺序写入 npoints 个真实样本,满后再左移滚动
|
||||
current_time = time.time() - self.start_time
|
||||
if self._plot_sample_count < self.npoints:
|
||||
idx = self._plot_sample_count
|
||||
self.data[idx, 0, :] = current_time
|
||||
self.data[idx, 1, 0] = fn
|
||||
self.data[idx, 1, 1] = fx
|
||||
self.data[idx, 1, 2] = fy
|
||||
self._plot_sample_count += 1
|
||||
else:
|
||||
for i in range(3):
|
||||
self.data[:-1, 0, i] = self.data[1:, 0, i]
|
||||
self.data[:-1, 1, i] = self.data[1:, 1, i]
|
||||
self.data[-1, 0, :] = current_time
|
||||
self.data[-1, 1, 0] = fn
|
||||
self.data[-1, 1, 1] = fx
|
||||
self.data[-1, 1, 2] = fy
|
||||
|
||||
n = self._plot_sample_count
|
||||
if n == 0:
|
||||
return
|
||||
|
||||
data_plot = self.data[:n].copy()
|
||||
for i in range(3):
|
||||
data_plot[:, 1, i] = lowpass_filter(
|
||||
data_plot[:, 1, i],
|
||||
self.lowpass_cutoff_hz,
|
||||
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||
)
|
||||
|
||||
for i, curve in enumerate(self.curves):
|
||||
x = data_plot[:, 0, i]
|
||||
y = data_plot[:, 1, i]
|
||||
curve.setData(x, y)
|
||||
ymin, ymax = y.min(), y.max()
|
||||
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 < Y_AXIS_MIN_SPAN:
|
||||
mid = (ymin + ymax) / 2
|
||||
half = 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 = x[-1:]
|
||||
last_y = y[-1:]
|
||||
self.dot_glows[i].setData(last_x, last_y)
|
||||
self.dot_cores[i].setData(last_x, last_y)
|
||||
|
||||
if n < self.npoints:
|
||||
for p in self.plots:
|
||||
p.setXRange(0, PLOT_WINDOW_SEC, padding=0)
|
||||
else:
|
||||
x0 = float(data_plot[0, 0, 0])
|
||||
x1 = float(data_plot[-1, 0, 0])
|
||||
if x1 <= x0:
|
||||
x1 = x0 + PLOT_WINDOW_SEC
|
||||
for p in self.plots:
|
||||
p.setXRange(x0, x1, padding=0)
|
||||
|
||||
# ---- 键盘 & 录像 ----
|
||||
|
||||
def _on_start(self):
|
||||
if self._running:
|
||||
return
|
||||
self.btn_start.setText("启动中...")
|
||||
self.btn_start.setEnabled(False)
|
||||
QApplication.processEvents()
|
||||
|
||||
try:
|
||||
src = self.camera_edit.text().strip()
|
||||
cfg = self.config_edit.text()
|
||||
cal = self.cal_edit.text().strip()
|
||||
verbose = getattr(self.args, 'verbose', True)
|
||||
try:
|
||||
video = int(src)
|
||||
except ValueError:
|
||||
video = src
|
||||
# self.sensor = orisys.Sensor(src, config_name=cfg, cal_path=cal, verbose=verbose)
|
||||
self.sensor = FingerRuntime.open(
|
||||
config=cfg,
|
||||
calib=cal or None,
|
||||
source_scale=None,
|
||||
video=video,
|
||||
verbose=verbose,
|
||||
)
|
||||
self.sensor.warmup(color=True)
|
||||
self._init_mesh_viewer()
|
||||
except Exception as e:
|
||||
self._close_mesh_viewer()
|
||||
self.btn_start.setText("启动")
|
||||
self.btn_start.setEnabled(True)
|
||||
print(f"启动失败: {e}")
|
||||
return
|
||||
|
||||
self.start_time = time.time()
|
||||
self._init_plot_buffer()
|
||||
self._running = True
|
||||
self.timer.start(10)
|
||||
self.btn_start.setText("启动")
|
||||
self.btn_stop.setEnabled(True)
|
||||
self.btn_reset.setEnabled(True)
|
||||
print(f"▶ 启动 — 摄像机={src}, 配置={cfg}, 标定={cal}")
|
||||
|
||||
def _on_stop(self):
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
self.timer.stop()
|
||||
self.btn_stop.setText("停止中...")
|
||||
self.btn_stop.setEnabled(False)
|
||||
QApplication.processEvents()
|
||||
|
||||
if self.sensor is not None:
|
||||
self.sensor.close()
|
||||
self.sensor = None
|
||||
self._close_mesh_viewer()
|
||||
|
||||
self.btn_start.setEnabled(True)
|
||||
self.btn_stop.setText("暂停")
|
||||
self.btn_reset.setEnabled(False)
|
||||
print("⏸ 已暂停")
|
||||
|
||||
def _select_camera(self):
|
||||
text = self.camera_edit.text()
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "选择视频文件", "data", "视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
|
||||
)
|
||||
if path:
|
||||
self.camera_edit.setText(path)
|
||||
|
||||
def _select_config(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "选择配置文件", "config", "JSON (*.json);;所有文件 (*)",
|
||||
)
|
||||
if path:
|
||||
self.config_edit.setText(path)
|
||||
|
||||
def _on_reset(self):
|
||||
"""Reset optical flow tracker when drift occurs (same as basic_pipeline 'r' key)."""
|
||||
if self.sensor is None or not self._running:
|
||||
print("请先启动传感器后再重置追踪器。")
|
||||
return
|
||||
self.sensor.tactile.reset()
|
||||
print("追踪器已重置。")
|
||||
|
||||
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:
|
||||
self._start_recording()
|
||||
elif key == Qt.Key_E:
|
||||
self._stop_recording()
|
||||
elif key == Qt.Key_R:
|
||||
self._on_reset()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def _start_recording(self):
|
||||
if self.sensor is None:
|
||||
print("请先启动采集")
|
||||
return
|
||||
self.save_path = "./data/pulse_test.mp4"
|
||||
os.makedirs(os.path.dirname(self.save_path), exist_ok=True)
|
||||
|
||||
save_img = self.sensor.tactile.frame
|
||||
if save_img is None:
|
||||
print("当前没有可用帧,无法确定录像尺寸。")
|
||||
return
|
||||
|
||||
temp_img = save_img.copy()
|
||||
if len(temp_img.shape) == 2 or (len(temp_img.shape) == 3 and temp_img.shape[2] == 1):
|
||||
if len(temp_img.shape) == 3:
|
||||
temp_img = temp_img.squeeze(2)
|
||||
temp_img = cv2.cvtColor(temp_img, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
fh, fw = temp_img.shape[:2]
|
||||
self.writer = cv2.VideoWriter(
|
||||
self.save_path, cv2.VideoWriter_fourcc(*'mp4v'), 30, (fw, fh),
|
||||
)
|
||||
if self.writer.isOpened():
|
||||
self.is_save = True
|
||||
print(f"开始录像:{self.save_path}")
|
||||
else:
|
||||
print(f"无法创建录像文件:{self.save_path}")
|
||||
self.writer = None
|
||||
|
||||
def _stop_recording(self):
|
||||
self.is_save = False
|
||||
if self.writer is not None:
|
||||
self.writer.release()
|
||||
self.writer = None
|
||||
if self.save_path is not None:
|
||||
print(f"录像结束:{self.save_path},总帧数:{self.frame_count}")
|
||||
else:
|
||||
print(f"录像结束,总帧数:{self.frame_count}")
|
||||
self.frame_count = 0
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.timer.stop()
|
||||
if self.writer is not None:
|
||||
self.writer.release()
|
||||
self._close_mesh_viewer()
|
||||
if self.sensor is not None:
|
||||
self.sensor.close()
|
||||
event.accept()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
log_dir = os.path.join(script_dir, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, f"qt_viewer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log")
|
||||
log_file = open(log_path, 'w', encoding='utf-8')
|
||||
# stdout 到文件的临时重定向(创建 MainWindow 后会加上日志面板)
|
||||
sys.stdout = log_file
|
||||
sys.stderr = sys.stdout
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video", "-v", type=str, default="0")
|
||||
parser.add_argument("--config", "-c", type=str, default="./config/dd02-ov.json")
|
||||
parser.add_argument("--cal", type=str, default="")
|
||||
parser.add_argument("--verbose", "-verbose", type=bool, default=True)
|
||||
parser.add_argument(
|
||||
"--cutoff",
|
||||
type=float,
|
||||
default=10.0,
|
||||
help="Low-pass cutoff for force curves (Hz), sample rate 30 Hz",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
|
||||
|
||||
# 高 DPI 适配(笔记本高分屏)
|
||||
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow(args, log_file)
|
||||
window.showFullScreen()
|
||||
QApplication.processEvents()
|
||||
window.adjust_square_images()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user