diff --git a/.gitignore b/.gitignore index 580720e..5af6002 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,88 @@ -# Python caches +# Python bytecode and caches __pycache__/ *.py[cod] *$py.class +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyre/ +.pytype/ +.hypothesis/ +.nox/ +.tox/ -# Virtual environments +# Virtual environments and local Python tooling .venv/ venv/ env/ ENV/ +.conda/ +.python-version +pip-wheel-metadata/ - -# Test and coverage output -.pytest_cache/ -.coverage -htmlcov/ -debug/ - -# PyInstaller output +# Python packaging and PyInstaller output dist/ +wheelhouse/ +*.egg-info/ +.eggs/ build/_pyinstaller/ build/*.spec +*.manifest -# Runtime logs and local generated files +# Test, coverage, and profiling output +.coverage +.coverage.* +coverage.xml +htmlcov/ +prof/ +*.prof +debug/ + +# Runtime logs and machine-local state *.log log/ logs/ debug.txt +config/gui_layout.json +config/gui_layout.json.tmp +test.json -# IDE/editor files +# Local launcher and shell wrappers +*.bat +*.cmd +*.ps1 +*.sh + +# Temporary and backup files +*.tmp +*.temp +*.bak +*.orig +*.rej +*~ + +# IDE and editor metadata .idea/ .vscode/ +*.code-workspace +*.sublime-project +*.sublime-workspace *.swp *.swo -# OS files +# Jupyter +.ipynb_checkpoints/ + +# Operating-system metadata .DS_Store +._* Thumbs.db +ehthumbs.db desktop.ini # Local agent/tool metadata .agents/ .codex/ -# Hardware vendor tool runtime logs +# Hardware vendor runtime output 知行夹爪/**/log/ diff --git a/README.md b/README.md index 66c20fc..a8549c4 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,27 @@ pip install pyinstaller PyOpenGL PyOpenGL_accelerate 然后从项目根目录运行: ```powershell -build\build_exe.bat +python build\build_exe.py ``` +默认使用轻量打包配置,使用 OpenCV DIS 光流和 CPU Poisson 后端,不打包 +Torch/CUDA、CuPy 和 Orisys 可选的 Open3D 查看器。项目自己的 PyOpenGL +夹爪 3D 模型仍然可用。当前参考产物约为 340 MiB: + +```powershell +python build\build_exe.py --profile lite --clean-dist +``` + +只有需要 RAFT/CUDA 或 CuPy 后端时才使用完整配置。完整配置会包含 CUDA +运行库,产物通常为数 GB: + +```powershell +python build\build_exe.py --profile full --clean-dist +``` + +轻量配置要求传感器配置中的 `tracker.algorithm` 为 `dis`;打包脚本会在 +构建前检查并阻止错误的 RAFT 轻量包。 + 输出文件在: ```text @@ -86,6 +104,35 @@ dist\GripperDemo02\sdk 现场修改参数时,直接改 exe 同级的 `config\gripper.json`。 +窗口版 EXE 的运行日志保存在同级 `logs` 目录。`logs\latest.txt` 记录最近 +一次运行的会话目录;其中 `main.log` 是 GUI/控制日志,`worker_*.log` 是 +左右传感器进程日志,并包含 OpenCV 原生警告和异常 traceback。程序自动保留 +最近 10 次运行。 + +### Linux + +Windows 生成的 `.exe` 仅用于 Windows。Linux 需要在 Linux 环境使用同一份 +源码重新安装依赖和打包,推荐 Python 3.11: + +```bash +python3.11 -m venv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +pip install -e ../orisys-sdk +python build/build_exe.py --profile lite --clean-dist +``` + +Linux 产物没有 `.exe` 后缀,位于 `dist/GripperDemo02/GripperDemo02`。直接 +运行源码可使用: + +```bash +python -m gripper.visualizer --windowed +``` + +将 `config/gripper.json` 的串口从 `COM*` 改为实际设备,例如 +`/dev/ttyUSB0`;摄像头通常对应 `/dev/video0` 和 `/dev/video1`。运行用户需 +拥有 `video` 与 `dialout` 设备组权限。WSL 还需要先透传 USB 摄像头和串口。 + ## 关键配置 - `trigger_normal_force`: 单侧法向力触发阈值。 diff --git a/build/build_exe.bat b/build/build_exe.bat deleted file mode 100644 index 0bed110..0000000 --- a/build/build_exe.bat +++ /dev/null @@ -1,19 +0,0 @@ -@echo off -setlocal -cd /d "%~dp0.." -call conda activate py311 -if errorlevel 1 ( - echo Failed to activate conda environment: py311 - pause - exit /b 1 -) -python "%~dp0build_exe.py" %* -if errorlevel 1 ( - echo. - echo Build failed. - pause - exit /b 1 -) -echo. -echo Build finished. -pause diff --git a/build/build_exe.ps1 b/build/build_exe.ps1 deleted file mode 100644 index 1eb730c..0000000 --- a/build/build_exe.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -param( - [string]$CondaEnv = "py311" -) - -$ErrorActionPreference = "Stop" - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$ProjectRoot = Split-Path -Parent $ScriptDir - -Set-Location $ProjectRoot - -Write-Host "Project root: $ProjectRoot" -Write-Host "Conda env: $CondaEnv" - -$ForwardArgs = $args -join " " -cmd /d /s /c "call conda activate $CondaEnv && python `"$ScriptDir\build_exe.py`" $ForwardArgs" -if ($LASTEXITCODE -ne 0) { - throw "build failed" -} diff --git a/build/build_exe.py b/build/build_exe.py index e637e64..fd0ecd9 100644 --- a/build/build_exe.py +++ b/build/build_exe.py @@ -1,5 +1,6 @@ import argparse import importlib.util +import json import shutil import subprocess import sys @@ -13,6 +14,33 @@ LOGO_FILES = ( PROJECT_ROOT / "gripper" / "logo.png", PROJECT_ROOT / "gripper" / "logo_topbar.png", ) +LITE_EXCLUDES = ( + # RAFT and its CUDA runtime. The default application uses OpenCV DIS. + "torch", + "torchvision", + "torchaudio", + "torchgen", + # Optional Orisys GPU Poisson backends. + "cupy", + "cupyx", + "cupy_backends", + "pyopencl", + "pyvkfft", + # Optional Orisys mesh viewer and its notebook/web dependencies. + "open3d", + "dash", + "flask", + "plotly", + "IPython", + "jedi", + "parso", + "nbformat", + "notebook", + # Transitive data-science packages not used by this GUI. + "matplotlib", + "pandas", + "sklearn", +) def module_exists(name): @@ -25,10 +53,6 @@ def require_module(name, install_hint): raise SystemExit(f"Missing module '{name}'. Install it first: {install_hint}") -def data_arg(source, target): - return f"{source};{target}" - - def copy_runtime_dir(source, app_dir, name): target = app_dir / name if target.exists(): @@ -36,13 +60,69 @@ def copy_runtime_dir(source, app_dir, name): shutil.copytree( source, target, - ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ignore=shutil.ignore_patterns( + "__pycache__", + "*.pyc", + "gui_layout.json", + "gui_layout.json.tmp", + ), ) return target -def build(app_name, clean_dist): +def directory_size(path): + return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) + + +def print_size_report(app_dir, budget_mb): + total_bytes = directory_size(app_dir) + total_mb = total_bytes / (1024 * 1024) + print(f"Package size: {total_mb:.1f} MiB ({total_bytes / 1_000_000:.1f} MB)") + if total_mb > budget_mb: + print(f"Warning: package exceeds the {budget_mb:.0f} MiB target.") + + largest = sorted( + (item for item in app_dir.rglob("*") if item.is_file()), + key=lambda item: item.stat().st_size, + reverse=True, + )[:10] + print("Largest packaged files:") + for item in largest: + print(f" {item.stat().st_size / (1024 * 1024):8.1f} MiB {item.relative_to(app_dir)}") + + +def validate_lite_sensor_configs(): + control_path = PROJECT_ROOT / "config" / "gripper.json" + try: + control_config = json.loads(control_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise SystemExit(f"Cannot validate lite build config '{control_path}': {exc}") from exc + + config_names = { + control_config.get("sensor_config1", "./config/ddjx01.json"), + control_config.get("sensor_config2", "./config/ddjx01.json"), + } + for config_name in config_names: + config_path = Path(config_name) + if not config_path.is_absolute(): + config_path = PROJECT_ROOT / config_path + try: + sensor_config = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise SystemExit(f"Cannot validate lite sensor config '{config_path}': {exc}") from exc + algorithm = str(sensor_config.get("tracker", {}).get("algorithm", "dis")).lower() + if algorithm == "raft": + raise SystemExit( + f"Lite profile cannot package RAFT configured in '{config_path}'. " + "Set tracker.algorithm to 'dis' or build with --profile full." + ) + + +def build(app_name, clean_dist, profile, size_budget_mb): require_module("PyInstaller", "pip install pyinstaller") + require_module("numba", "pip install numba") + if profile == "lite": + validate_lite_sensor_configs() dist_dir = PROJECT_ROOT / "dist" work_dir = BUILD_DIR / "_pyinstaller" @@ -73,10 +153,6 @@ def build(app_name, clean_dist): str(PROJECT_ROOT), "--paths", str(PROJECT_ROOT / "sdk"), - "--add-data", - data_arg(PROJECT_ROOT / "config", "config"), - "--add-data", - data_arg(PROJECT_ROOT / "sdk", "sdk"), "--hidden-import", "changingtek_p_rtu_Servo", "--hidden-import", @@ -87,26 +163,19 @@ def build(app_name, clean_dist): "cv2", "--hidden-import", "numpy", - "--collect-all", - "PyQt5", - "--collect-all", - "pyqtgraph", + "--hidden-import", + "pyqtgraph.opengl", ] - if VISUALIZATION_DIR.exists(): - args.extend( - [ - "--add-data", - data_arg(VISUALIZATION_DIR, "crt_ctag2f90d_gripper_visualization"), - ] - ) - else: + if profile == "lite": + for module_name in LITE_EXCLUDES: + args.extend(["--exclude-module", module_name]) + + if not VISUALIZATION_DIR.exists(): print(f"Warning: visualization assets were not found: {VISUALIZATION_DIR}") for logo_file in LOGO_FILES: - if logo_file.exists(): - args.extend(["--add-data", data_arg(logo_file, "gripper")]) - else: + if not logo_file.exists(): print(f"Warning: logo asset was not found: {logo_file}") if module_exists("OpenGL"): @@ -125,7 +194,8 @@ def build(app_name, clean_dist): subprocess.run(args, cwd=PROJECT_ROOT, check=True) - exe_path = dist_dir / app_name / f"{app_name}.exe" + executable_name = f"{app_name}.exe" if sys.platform == "win32" else app_name + exe_path = dist_dir / app_name / executable_name if not exe_path.exists(): raise SystemExit(f"Build finished but exe was not found: {exe_path}") @@ -154,14 +224,28 @@ def build(app_name, clean_dist): print(f"Runtime 3D model copied to: {visualization_dir}") for logo_path in copied_logos: print(f"Runtime logo copied to: {logo_path}") + print(f"Build profile: {profile}") + print_size_report(app_dir, size_budget_mb) def main(): parser = argparse.ArgumentParser() parser.add_argument("--name", default="GripperDemo02", help="Output exe/app folder name.") parser.add_argument("--clean-dist", action="store_true", help="Delete dist/ before building.") + parser.add_argument( + "--profile", + choices=("lite", "full"), + default="lite", + help="lite excludes RAFT/CUDA/CuPy/Open3D; full bundles installed optional backends.", + ) + parser.add_argument( + "--size-budget-mb", + type=float, + default=500.0, + help="Print a warning when the onedir package exceeds this many MiB.", + ) args = parser.parse_args() - build(args.name, args.clean_dist) + build(args.name, args.clean_dist, args.profile, args.size_budget_mb) if __name__ == "__main__": diff --git a/build/run_visualizer.py b/build/run_visualizer.py index 29e050f..08bf933 100644 --- a/build/run_visualizer.py +++ b/build/run_visualizer.py @@ -7,6 +7,10 @@ from pathlib import Path if getattr(sys, "frozen", False): os.chdir(Path(sys.executable).resolve().parent) +from gripper.common.runtime_logging import configure_frozen_logging + +configure_frozen_logging() + from gripper.visualizer import main diff --git a/gripper/common/runtime_logging.py b/gripper/common/runtime_logging.py new file mode 100644 index 0000000..feaa75f --- /dev/null +++ b/gripper/common/runtime_logging.py @@ -0,0 +1,105 @@ +import atexit +import os +import shutil +import sys +import traceback +from datetime import datetime +from pathlib import Path + + +_LOG_STREAM = None + + +def _cleanup_old_sessions(log_root, keep_sessions): + sessions = sorted( + (path for path in log_root.iterdir() if path.is_dir()), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + for old_session in sessions[max(1, int(keep_sessions)) :]: + try: + shutil.rmtree(old_session) + except OSError: + pass + + +def _redirect_standard_streams(stream): + for descriptor in (1, 2): + try: + os.dup2(stream.fileno(), descriptor) + except OSError: + pass + sys.stdout = stream + sys.stderr = stream + + +def _install_exception_hooks(stream): + def handle_exception(exc_type, exc_value, exc_traceback): + print("\n[unhandled exception]", file=stream) + traceback.print_exception(exc_type, exc_value, exc_traceback, file=stream) + stream.flush() + + sys.excepthook = handle_exception + + try: + import threading + + def handle_thread_exception(args): + print(f"\n[thread exception] name={args.thread.name}", file=stream) + traceback.print_exception( + args.exc_type, + args.exc_value, + args.exc_traceback, + file=stream, + ) + stream.flush() + + threading.excepthook = handle_thread_exception + except (AttributeError, ImportError): + pass + + +def configure_frozen_logging(app_name="GripperDemo02", keep_sessions=10): + global _LOG_STREAM + if _LOG_STREAM is not None: + return Path(_LOG_STREAM.name) + if not getattr(sys, "frozen", False): + return None + + app_dir = Path(sys.executable).resolve().parent + log_root = app_dir / "logs" + log_root.mkdir(parents=True, exist_ok=True) + + session_env = f"{app_name.upper()}_LOG_SESSION" + session_name = os.environ.get(session_env) + is_main_process = not session_name + if is_main_process: + session_name = datetime.now().strftime("%Y%m%d_%H%M%S") + f"_{os.getpid()}" + os.environ[session_env] = session_name + _cleanup_old_sessions(log_root, keep_sessions) + + session_dir = log_root / session_name + session_dir.mkdir(parents=True, exist_ok=True) + log_name = "main.log" if is_main_process else f"worker_{os.getpid()}.log" + log_path = session_dir / log_name + _LOG_STREAM = open(log_path, "a", encoding="utf-8", buffering=1) + _redirect_standard_streams(_LOG_STREAM) + _install_exception_hooks(_LOG_STREAM) + + if is_main_process: + try: + (log_root / "latest.txt").write_text(session_name + "\n", encoding="utf-8") + except OSError: + pass + + print("=" * 72) + print(f"{app_name} runtime log") + print(f"time={datetime.now().isoformat(timespec='seconds')}") + print(f"pid={os.getpid()} role={'main' if is_main_process else 'worker'}") + print(f"executable={sys.executable}") + print(f"log={log_path}") + print(f"argv={sys.argv!r}") + print("=" * 72) + + atexit.register(_LOG_STREAM.flush) + return log_path diff --git a/gripper/control/controller.py b/gripper/control/controller.py index bdc9a47..3bf8588 100644 --- a/gripper/control/controller.py +++ b/gripper/control/controller.py @@ -1,5 +1,6 @@ import time import threading +from collections import deque from ..common.filters import clamp @@ -22,7 +23,7 @@ class SideTriggerGripController: self.status_callback = status_callback self._stop_event = threading.Event() self._manual_lock = threading.Lock() - self._manual_command = None + self._manual_commands = deque() self._mode_lock = threading.Lock() self._control_mode = "smart" @@ -47,7 +48,13 @@ class SideTriggerGripController: "position": position, } with self._manual_lock: - self._manual_command = payload + if command == "move_position": + self._manual_commands = deque( + item + for item in self._manual_commands + if item.get("command") != "move_position" + ) + self._manual_commands.append(payload) def set_control_mode(self, mode): if mode not in {"smart", "manual"}: @@ -61,9 +68,9 @@ class SideTriggerGripController: def _pop_manual_command(self): with self._manual_lock: - command = self._manual_command - self._manual_command = None - return command + if not self._manual_commands: + return None + return self._manual_commands.popleft() def run(self): self._stop_event.clear() @@ -828,9 +835,20 @@ class SideTriggerGripController: action, trigger_armed, ): + left_fps = 0.0 + right_fps = 0.0 + try: + if len(self.reader.latest) >= 2: + left_sample = self.reader.latest[0] or {} + right_sample = self.reader.latest[1] or {} + left_fps = float(left_sample.get("fps", 0.0) or 0.0) + right_fps = float(right_sample.get("fps", 0.0) or 0.0) + except (AttributeError, TypeError, ValueError): + pass print( f"L={feedback.left_normal:7.3f}{self.normal_unit} " f"R={feedback.right_normal:7.3f}{self.normal_unit} " + f"fps=({left_fps:5.1f},{right_fps:5.1f}) " f"maxN={feedback.max_normal:7.3f}{self.normal_unit} " f"shear={feedback.max_shear:7.3f}{self.shear_unit} " f"trigger={int(trigger)} armed={int(trigger_armed)} " diff --git a/gripper/sensors/force_reader.py b/gripper/sensors/force_reader.py index 9eb7b29..e246997 100644 --- a/gripper/sensors/force_reader.py +++ b/gripper/sensors/force_reader.py @@ -6,6 +6,7 @@ import inspect import multiprocessing as mp import queue import time +import traceback from multiprocessing import Process, Queue import cv2 @@ -248,6 +249,8 @@ def _sensor_force_worker( time.sleep(sleep_time) except Exception as exc: + print(f"sensor {sensor_id} worker failed: {exc}") + traceback.print_exc() _put_latest( result_queue, { diff --git a/gripper/ui/visualizer.py b/gripper/ui/visualizer.py index 561d09b..431d077 100644 --- a/gripper/ui/visualizer.py +++ b/gripper/ui/visualizer.py @@ -8,8 +8,13 @@ import struct import sys import threading import time +import traceback import xml.etree.ElementTree as ET +if sys.platform.startswith("linux") and os.environ.get("WSL_DISTRO_NAME"): + os.environ.setdefault("QT_XCB_GL_INTEGRATION", "xcb_glx") + os.environ.setdefault("PYOPENGL_PLATFORM", "glx") + import cv2 import numpy as np @@ -31,8 +36,8 @@ _fix_qt_plugin_path() _QT_IMPORT_ERROR = None try: - from PyQt5.QtCore import QEasingCurve, QEvent, QPointF, QRectF, QSize, QPropertyAnimation, Qt, QTimer - from PyQt5.QtGui import QColor, QFont, QIcon, QImage, QLinearGradient, QPainter, QPen, QPixmap, QPolygonF, QRadialGradient + from PyQt5.QtCore import QEasingCurve, QEvent, QPointF, QRect, QRectF, QSize, QPropertyAnimation, Qt, QTimer + from PyQt5.QtGui import QColor, QFont, QIcon, QImage, QLinearGradient, QPainter, QPen, QPixmap, QPolygonF, QRadialGradient, QVector3D from PyQt5.QtWidgets import ( QApplication, QButtonGroup, @@ -62,8 +67,8 @@ try: ) except Exception as exc: _QT_IMPORT_ERROR = exc - Qt = QTimer = QPropertyAnimation = QEasingCurve = QEvent = QPointF = QRectF = QSize = QFont = QIcon = QImage = QPixmap = QApplication = None - QColor = QLinearGradient = QPainter = QPen = QPolygonF = QRadialGradient = None + Qt = QTimer = QPropertyAnimation = QEasingCurve = QEvent = QPointF = QRect = QRectF = QSize = QFont = QIcon = QImage = QPixmap = QApplication = None + QColor = QLinearGradient = QPainter = QPen = QPolygonF = QRadialGradient = QVector3D = None QGridLayout = QHBoxLayout = QLabel = QPushButton = QSizeGrip = QSizePolicy = QSplitter = None QVBoxLayout = QWidget = None QButtonGroup = QCheckBox = QDialog = QFrame = QGraphicsOpacityEffect = QLineEdit = QListWidget = QMessageBox = QMainWindow = object @@ -98,6 +103,8 @@ PLOT_AXIS_FONT_SIZE = 8 FORCE_DISPLAY_ZERO_THRESHOLD_N = 0.05 CURVE_COLOR = "#536dce" PLOT_SMOOTH_ALPHA = 0.28 +GUI_LAYOUT_VERSION = 1 +GUI_LAYOUT_PATH = REPO_ROOT / "config" / "gui_layout.json" def cv2_to_pixmap(img_bgr): @@ -314,6 +321,29 @@ QFrame#DarkPanel { border: 1px solid #e3e8ef; border-radius: 6px; } +QFrame#FlowWindow { + background-color: #ffffff; + border: 1px solid #cfd7e3; + border-radius: 6px; +} +QLabel#FlowCanvas { + background-color: #111418; + border: 0; + border-radius: 4px; +} +QPushButton#FlowModeButton { + background-color: #f2f4f7; + color: #344054; + border: 1px solid #d7dee8; + border-radius: 4px; + padding: 3px 8px; + min-width: 42px; + max-height: 22px; +} +QPushButton#FlowModeButton:hover { + background-color: #e9edf3; + border-color: #b8c2cc; +} QFrame#GripperStage { background-color: #ffffff; border: 1px solid #e3e8ef; @@ -324,6 +354,102 @@ QFrame#ControlPanel { border: 1px solid #e3e8ef; border-radius: 6px; } +QFrame#GripperControlPanel { + background-color: #ffffff; + border: 1px solid #ddd7d8; + border-bottom: 2px solid #cfc5c7; + border-radius: 6px; +} +QFrame#ControlRow { + background-color: #faf8f8; + border: 1px solid #ebe4e5; + border-radius: 4px; +} +QLabel#ControlSectionTitle { + color: #2f2526; + background-color: transparent; + border: 0; + border-left: 3px solid #c83b43; + padding-left: 7px; + font-size: 13px; + font-weight: 700; +} +QLabel#ControlLabel { + color: #5f5556; + font-size: 11px; + font-weight: 600; +} +QLabel#ControlValue { + color: #9f2630; + font-family: "Cascadia Code", "Consolas"; + font-size: 12px; + font-weight: 700; +} +QPushButton#ControlModeButton { + background-color: #f7f4f4; + color: #5f5556; + border: 1px solid #ddd6d7; + border-radius: 4px; + padding: 4px 10px; +} +QPushButton#ControlModeButton:hover { + background-color: #eee9ea; + border-color: #b9adaf; +} +QPushButton#ControlModeButton:checked { + background-color: #fff0f0; + color: #9f2630; + border: 1px solid #e7a9ad; + font-weight: 700; +} +QPushButton#OpenActionButton { + background-color: #ffffff; + color: #6f3035; + border: 1px solid #d8b0b3; + font-weight: 700; + min-width: 72px; +} +QPushButton#OpenActionButton:hover { + background-color: #fff5f5; + border-color: #c96a71; +} +QPushButton#OpenActionButton:pressed { + background-color: #ffe3e3; + border-color: #b64149; +} +QPushButton#OpenActionButton:disabled, +QPushButton#CloseActionButton:disabled, +QPushButton#HoldButton:disabled { + background-color: #f4f4f4; + color: #a8a8a8; + border: 1px solid #e0e0e0; +} +QPushButton#CloseActionButton { + background-color: #c83b43; + color: #ffffff; + border: 1px solid #c83b43; + font-weight: 700; + min-width: 72px; +} +QPushButton#CloseActionButton:hover { + background-color: #b52e38; + border-color: #b52e38; +} +QPushButton#CloseActionButton:pressed { + background-color: #8f2029; + border-color: #8f2029; +} +QPushButton#HoldButton { + background-color: #3f3a3b; + color: #ffffff; + border: 1px solid #3f3a3b; + font-weight: 700; + min-width: 72px; +} +QPushButton#HoldButton:hover { + background-color: #565051; + border-color: #565051; +} QDialog#SettingsPanel { background-color: #ffffff; border: 1px solid #d7dee8; @@ -391,7 +517,7 @@ QPushButton#TopIconButton:disabled { background-color: #f7f9fc; border: 1px solid #e3e8ef; } -QPushButton#ManualButton, QPushButton#HoldButton { +QPushButton#ManualButton { background-color: #ffffff; min-width: 72px; } @@ -412,16 +538,22 @@ QPushButton#PositionButton { } QPushButton#StepButton { background-color: #ffffff; - color: #344054; - border: 1px solid #d7dee8; - border-radius: 4px; - padding: 2px 6px; - min-width: 38px; - max-height: 22px; + color: #5f5556; + border: 1px solid #d9cfd0; + border-radius: 5px; + padding: 0; + min-width: 26px; + max-width: 26px; + min-height: 24px; + max-height: 24px; } QPushButton#StepButton:hover { - background-color: #fff1f1; - border: 1px solid #f4a7a7; + background-color: #fff0f0; + border: 1px solid #e7a9ad; +} +QPushButton#StepButton:pressed { + background-color: #ffdcdc; + padding: 0; } QProgressBar { background-color: #eef2f6; @@ -435,27 +567,30 @@ QProgressBar::chunk { background-color: #d92d20; border-radius: 3px; } -QSlider::groove:horizontal { - height: 9px; - background: #eef2f6; - border: 1px solid #d7dee8; - border-radius: 5px; +QSlider#ControlSlider::groove:horizontal { + height: 6px; + background: #e7e1e2; + border: 0; + border-radius: 3px; } -QSlider::sub-page:horizontal { - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #ef4444, stop:1 #b91c1c); - border: 1px solid #ef4444; - border-radius: 5px; +QSlider#ControlSlider::sub-page:horizontal { + background: #c83b43; + border: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } -QSlider::add-page:horizontal { - background: #eef2f6; - border-radius: 5px; +QSlider#ControlSlider::add-page:horizontal { + background: #e7e1e2; + border: 0; + border-radius: 3px; } -QSlider::handle:horizontal { - background: #ffffff; - border: 2px solid #dc2626; - width: 17px; - margin: -6px 0; - border-radius: 9px; +QSlider#ControlSlider::handle:horizontal { + background: transparent; + border: 0; + width: 1px; + margin: -5px 0; } QSpinBox { background-color: #ffffff; @@ -583,19 +718,21 @@ def _draw_top_icon(kind, color="#344054", size=22): painter.setBrush(QColor(color)) painter.drawPolygon(arrow) elif kind == "settings": - center = QPointF(size * 0.50, size * 0.50) - painter.drawEllipse(center, size * 0.13, size * 0.13) - for idx in range(8): - angle = math.tau * idx / 8.0 - inner = QPointF( - center.x() + math.cos(angle) * size * 0.29, - center.y() + math.sin(angle) * size * 0.29, + rows = ( + (0.31, 0.40), + (0.50, 0.64), + (0.69, 0.46), + ) + for y_ratio, knob_ratio in rows: + y = size * y_ratio + knob_x = size * knob_ratio + painter.drawLine( + QPointF(size * 0.24, y), + QPointF(size * 0.76, y), ) - outer = QPointF( - center.x() + math.cos(angle) * size * 0.39, - center.y() + math.sin(angle) * size * 0.39, - ) - painter.drawLine(inner, outer) + painter.setBrush(QColor("#ffffff")) + painter.drawEllipse(QPointF(knob_x, y), size * 0.075, size * 0.075) + painter.setBrush(Qt.NoBrush) elif kind == "minimize": painter.drawLine( QPointF(size * 0.32, size * 0.62), @@ -621,6 +758,31 @@ def _draw_top_icon(kind, color="#344054", size=22): QPointF(size * 0.66, size * 0.34), QPointF(size * 0.34, size * 0.66), ) + elif kind in {"plus", "minus"}: + painter.drawLine( + QPointF(size * 0.30, size * 0.50), + QPointF(size * 0.70, size * 0.50), + ) + if kind == "plus": + painter.drawLine( + QPointF(size * 0.50, size * 0.30), + QPointF(size * 0.50, size * 0.70), + ) + elif kind in {"chevron-left", "chevron-right"}: + if kind == "chevron-left": + xs = (0.34, 0.66, 0.66) + else: + xs = (0.66, 0.34, 0.34) + arrow = QPolygonF( + [ + QPointF(size * xs[0], size * 0.50), + QPointF(size * xs[1], size * 0.28), + QPointF(size * xs[2], size * 0.72), + ] + ) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(color)) + painter.drawPolygon(arrow) painter.end() return QIcon(pixmap) @@ -827,18 +989,20 @@ class ImageBox(QFrame): class FloatingImageBox(ImageBox): def __init__(self, title): super().__init__(title, compact=True) + self.setObjectName("FlowWindow") + self.image_label.setObjectName("FlowCanvas") + self.image_label.setScaledContents(False) + self.mode_toggle_button.setObjectName("FlowModeButton") + self.mode_toggle_button.setText("光流") self._drag_mode = None self._drag_start_global = None self._start_geometry = None self._user_placed = False - self._size_grip = QSizeGrip(self) - self._size_grip.setFixedSize(16, 16) - self._size_grip.raise_() self.setMinimumSize(128, 150) self.setMouseTracking(True) self.title_label.installEventFilter(self) self.image_label.installEventFilter(self) - self._size_grip.installEventFilter(self) + self.setToolTip("拖动标题栏移动窗口,拖动右下角调整大小") def set_default_geometry(self, rect): if self._user_placed: @@ -848,7 +1012,7 @@ class FloatingImageBox(ImageBox): self.raise_() def eventFilter(self, obj, event): - if obj not in (self.title_label, self.image_label, self._size_grip): + if obj not in (self.title_label, self.image_label): return super().eventFilter(obj, event) event_type = event.type() if event_type not in ( @@ -860,8 +1024,6 @@ class FloatingImageBox(ImageBox): mapped_pos = obj.mapTo(self, event.pos()) if event_type == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: - if obj is self._size_grip: - mapped_pos = QPointF(self.width() - 1, self.height() - 1).toPoint() self._begin_drag(mapped_pos, event.globalPos()) return True if event_type == QEvent.MouseMove: @@ -900,11 +1062,40 @@ class FloatingImageBox(ImageBox): def resizeEvent(self, event): super().resizeEvent(event) - self._size_grip.move( - max(0, self.width() - self._size_grip.width() - 3), - max(0, self.height() - self._size_grip.height() - 3), + self._render_image() + + def _render_image(self): + img = self._magnitude_img if self.mode == "magnitude" else self._flow_img + if img is None and self.mode == "magnitude": + img = self._flow_img + pixmap = cv2_to_pixmap(img) + if not pixmap.isNull() and self.image_label.width() > 0 and self.image_label.height() > 0: + pixmap = pixmap.scaled( + self.image_label.size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + self.image_label.setPixmap(pixmap) + + def set_mode(self, mode): + self.mode = mode + self.mode_toggle_button.setText("幅值" if mode == "magnitude" else "光流") + self.mode_toggle_button.setToolTip( + "切换到光流" if mode == "magnitude" else "切换到幅值" ) - self._size_grip.raise_() + self._render_image() + + def set_transform_label(self, transform): + angle = int(transform.get("rotation_angle", 0)) % 360 + flip_x = bool(transform.get("flip_x")) + flip_y = bool(transform.get("flip_y")) + self.title_label.setText(self.title) + details = [f"旋转 {angle} 度"] + if flip_x: + details.append("水平翻转") + if flip_y: + details.append("垂直翻转") + self.title_label.setToolTip(",".join(details)) def _is_resize_pos(self, pos): return pos.x() >= self.width() - 18 and pos.y() >= self.height() - 18 @@ -934,6 +1125,9 @@ class FloatingImageBox(ImageBox): self._drag_start_global = None self._start_geometry = None self.setCursor(Qt.ArrowCursor) + parent = self.parentWidget() + if parent is not None and hasattr(parent, "remember_overlay_geometry"): + parent.remember_overlay_geometry(self) def _keep_inside_parent(self): parent = self.parentWidget() @@ -954,9 +1148,6 @@ class FloatingPanel(QFrame): self._user_placed = False self.setMouseTracking(True) self.setMinimumSize(280, 96) - self._size_grip = QSizeGrip(self) - self._size_grip.setFixedSize(16, 16) - self._size_grip.raise_() def set_drag_handle(self, widget): widget.installEventFilter(self) @@ -1010,14 +1201,6 @@ class FloatingPanel(QFrame): return super().mouseReleaseEvent(event) - def resizeEvent(self, event): - super().resizeEvent(event) - self._size_grip.move( - max(0, self.width() - self._size_grip.width() - 3), - max(0, self.height() - self._size_grip.height() - 3), - ) - self._size_grip.raise_() - def _is_resize_pos(self, pos): return pos.x() >= self.width() - 18 and pos.y() >= self.height() - 18 @@ -1046,6 +1229,9 @@ class FloatingPanel(QFrame): self._drag_start_global = None self._start_geometry = None self.setCursor(Qt.ArrowCursor) + parent = self.parentWidget() + if parent is not None and hasattr(parent, "remember_overlay_geometry"): + parent.remember_overlay_geometry(self) def _keep_inside_parent(self): parent = self.parentWidget() @@ -1098,6 +1284,7 @@ class GripperStage(QFrame): self._force_label = None self._gl_error_text = "" self._floating_control_panels = [] + self._last_overlay_area = None self.left_flow = FloatingImageBox("左侧传感器") self.right_flow = FloatingImageBox("右侧传感器") self.setMinimumSize(240, 300) @@ -1709,6 +1896,114 @@ class GripperStage(QFrame): self._floating_control_panels.append((panel, role)) self._layout_overlays() + def _overlay_area(self): + if hasattr(self, "gl_view"): + return self.gl_view.geometry() + if hasattr(self, "gl_placeholder"): + return self.gl_placeholder.geometry() + return self.rect().adjusted(10, 40, -10, -28) + + def _layout_widgets(self): + widgets = { + "left_flow": self.left_flow, + "right_flow": self.right_flow, + } + widgets.update({role: panel for panel, role in self._floating_control_panels}) + return widgets + + @staticmethod + def _normalized_overlay_geometry(widget, area): + area_w = max(1, area.width()) + area_h = max(1, area.height()) + geom = widget.geometry() + return { + "x": round((geom.x() - area.x()) / area_w, 6), + "y": round((geom.y() - area.y()) / area_h, 6), + "width": round(geom.width() / area_w, 6), + "height": round(geom.height() / area_h, 6), + } + + def remember_overlay_geometry(self, widget): + area = self._overlay_area() + if area.width() <= 0 or area.height() <= 0: + return + widget._layout_anchor = self._normalized_overlay_geometry(widget, area) + + @staticmethod + def _apply_overlay_anchor(widget, anchor, area): + try: + width = max(widget.minimumWidth(), round(float(anchor["width"]) * area.width())) + height = max(widget.minimumHeight(), round(float(anchor["height"]) * area.height())) + width = min(width, area.width()) + height = min(height, area.height()) + x = area.x() + round(float(anchor["x"]) * area.width()) + y = area.y() + round(float(anchor["y"]) * area.height()) + x = max(area.left(), min(x, area.right() - width + 1)) + y = max(area.top(), min(y, area.bottom() - height + 1)) + except (KeyError, TypeError, ValueError): + return False + widget.setGeometry(x, y, width, height) + return True + + def capture_workspace_layout(self): + area = self._overlay_area() + overlays = { + name: self._normalized_overlay_geometry(widget, area) + for name, widget in self._layout_widgets().items() + } + + result = {"overlays": overlays} + if hasattr(self, "gl_view"): + opts = self.gl_view.opts + center = opts.get("center") + if center is not None: + result["camera"] = { + "center": [float(center.x()), float(center.y()), float(center.z())], + "distance": float(opts.get("distance", 0.42)), + "elevation": float(opts.get("elevation", 23.0)), + "azimuth": float(opts.get("azimuth", -38.0)), + "fov": float(opts.get("fov", 60.0)), + } + return result + + def restore_workspace_layout(self, data): + if not isinstance(data, dict): + return False + area = self._overlay_area() + if area.width() <= 0 or area.height() <= 0: + return False + + overlays = data.get("overlays", {}) + restored = False + for name, widget in self._layout_widgets().items(): + saved = overlays.get(name) + if not isinstance(saved, dict): + continue + if not self._apply_overlay_anchor(widget, saved, area): + continue + widget._user_placed = True + widget._layout_anchor = dict(saved) + widget.show() + widget.raise_() + restored = True + + camera = data.get("camera") + if hasattr(self, "gl_view") and isinstance(camera, dict): + try: + center = camera.get("center", [0.0, 0.0, 0.0]) + self.gl_view.setCameraPosition( + pos=QVector3D(float(center[0]), float(center[1]), float(center[2])), + distance=float(camera.get("distance", 0.42)), + elevation=float(camera.get("elevation", 23.0)), + azimuth=float(camera.get("azimuth", -38.0)), + ) + self.gl_view.opts["fov"] = float(camera.get("fov", 60.0)) + self.gl_view.update() + restored = True + except (IndexError, TypeError, ValueError): + pass + return restored + def _log_perf_if_due(self): now = time.perf_counter() if now - self._perf["last_log"] < 1.0: @@ -1753,14 +2048,31 @@ class GripperStage(QFrame): area = self.gl_placeholder.geometry() else: area = self.rect().adjusted(10, 40, -10, -28) + previous_area = self._last_overlay_area + + def place_overlay(widget, default_rect): + if not getattr(widget, "_user_placed", False): + widget.setGeometry(default_rect) + widget.raise_() + return + anchor = getattr(widget, "_layout_anchor", None) + if not isinstance(anchor, dict) and previous_area is not None: + anchor = self._normalized_overlay_geometry(widget, previous_area) + widget._layout_anchor = anchor + if isinstance(anchor, dict) and self._apply_overlay_anchor(widget, anchor, area): + widget.raise_() + return + widget._keep_inside_parent() panel_w = max(140, min(230, int(area.width() * 0.23))) panel_h = max(138, min(200, int(area.height() * 0.34))) margin = 12 - self.left_flow.set_default_geometry( + place_overlay( + self.left_flow, QRectF(area.left() + margin, area.top() + margin, panel_w, panel_h).toRect() ) - self.right_flow.set_default_geometry( + place_overlay( + self.right_flow, QRectF( area.right() - panel_w - margin, area.top() + margin, @@ -1776,11 +2088,10 @@ class GripperStage(QFrame): gripper_w = max(280, int(available_w * 0.50)) sensor_w = max(280, int(available_w - gripper_w)) y = max(area.top() + margin, area.bottom() - control_h - margin) - plot_w = max(340, min(500, int(area.width() * 0.36))) - plot_h = max(300, min(440, int(area.height() * 0.52))) + plot_w = max(420, min(760, int(area.width() * 0.46))) plot_y = area.top() + panel_h + margin * 2 - if plot_y + plot_h > y - margin: - plot_h = max(260, y - margin - plot_y) + plot_available_h = max(140, y - margin - plot_y) + plot_h = min(520, int(area.height() * 0.55), plot_available_h) for panel, role in self._floating_control_panels: if role == "gripper": x = area.left() + margin @@ -1791,8 +2102,8 @@ class GripperStage(QFrame): else: x = area.right() - plot_w - margin rect = QRectF(x, plot_y, plot_w, plot_h).toRect() - panel.set_default_geometry(rect) - panel.raise_() + place_overlay(panel, rect) + self._last_overlay_area = QRect(area) def paintEvent(self, event): if self._gl_ready: @@ -1815,7 +2126,7 @@ class GripperStage(QFrame): class SliderControl(QFrame): def __init__(self, title, minimum, maximum, value, step=1, suffix=""): super().__init__() - self.setObjectName("Panel") + self.setObjectName("ControlRow") self.suffix = suffix self.step = max(1, int(step)) layout = QGridLayout(self) @@ -1823,14 +2134,23 @@ class SliderControl(QFrame): layout.setHorizontalSpacing(6) layout.setVerticalSpacing(2) self.title_label = QLabel(title) - self.title_label.setObjectName("MetricLabel") + self.title_label.setObjectName("ControlLabel") self.value_label = QLabel("--") - self.value_label.setObjectName("SmallValue") - self.minus_button = AnimatedButton(f"-{self.step}") + self.value_label.setObjectName("ControlValue") + self.minus_button = AnimatedButton() self.minus_button.setObjectName("StepButton") - self.plus_button = AnimatedButton(f"+{self.step}") + self.minus_button.setIcon(_draw_top_icon("chevron-left", "#b52e38", 18)) + self.minus_button.setIconSize(QSize(18, 18)) + self.minus_button.setToolTip(f"减少 {self.step}{self.suffix}") + self.minus_button.setAccessibleName(f"减少 {self.step}{self.suffix}") + self.plus_button = AnimatedButton() self.plus_button.setObjectName("StepButton") + self.plus_button.setIcon(_draw_top_icon("chevron-right", "#b52e38", 18)) + self.plus_button.setIconSize(QSize(18, 18)) + self.plus_button.setToolTip(f"增加 {self.step}{self.suffix}") + self.plus_button.setAccessibleName(f"增加 {self.step}{self.suffix}") self.slider = QSlider(Qt.Horizontal) + self.slider.setObjectName("ControlSlider") self.slider.setRange(minimum, maximum) self.slider.setValue(value) self.slider.setTracking(False) @@ -2131,6 +2451,7 @@ class VisualSettingsDialog(QDialog): body.addWidget(self.stack, 1) self._add_page("画面", self._build_visual_page()) + self._add_page("布局", self._build_layout_page()) used_keys = set() for title, keys in self.CONFIG_GROUPS: present = [key for key in keys if key in self.config_data] @@ -2204,6 +2525,44 @@ class VisualSettingsDialog(QDialog): layout.addStretch(1) return page + def _build_layout_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + heading = QLabel("工作区布局") + heading.setObjectName("SectionTitle") + save_layout_button = AnimatedButton("保存当前布局") + save_layout_button.setObjectName("OpenActionButton") + save_layout_button.clicked.connect(self._save_current_layout) + apply_layout_button = AnimatedButton("应用已保存布局") + apply_layout_button.setObjectName("ManualButton") + apply_layout_button.clicked.connect(self._apply_saved_layout) + actions = QHBoxLayout() + actions.setSpacing(8) + actions.addWidget(save_layout_button) + actions.addWidget(apply_layout_button) + actions.addStretch(1) + layout.addWidget(heading) + layout.addLayout(actions) + layout.addStretch(1) + return page + + def _save_current_layout(self): + try: + path = self.viewer.save_current_layout() + except Exception as exc: + QMessageBox.warning(self, "保存失败", f"无法保存当前布局:\n{exc}") + return + QMessageBox.information(self, "已保存", f"当前布局已保存到:\n{path}") + + def _apply_saved_layout(self): + if self.viewer._load_saved_layout() is None: + QMessageBox.warning(self, "应用失败", "尚未保存布局配置。") + return + self.close() + QTimer.singleShot(0, self.viewer.apply_saved_workspace_layout) + def _build_config_page(self, title, keys): scroll = QScrollArea() scroll.setWidgetResizable(True) @@ -2384,8 +2743,11 @@ class GripperDemo02Viewer(QMainWindow): self.start_time = time.time() self.force_history = [] self.argv = argv + self.layout_path = GUI_LAYOUT_PATH self.visual_settings_dialog = None self._pending_runtime_speed = None + self._pending_position_target = None + self._pending_position_since = 0.0 self._runtime_speed_timer = QTimer(self) self._runtime_speed_timer.setSingleShot(True) self._runtime_speed_timer.timeout.connect(self._apply_pending_runtime_speed) @@ -2401,6 +2763,127 @@ class GripperDemo02Viewer(QMainWindow): self.timer.timeout.connect(self._refresh) self.timer.start(33) + def _load_saved_layout(self): + try: + with open(self.layout_path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + return None + if int(data.get("version", 0)) != GUI_LAYOUT_VERSION: + return None + return data + except (OSError, TypeError, ValueError, json.JSONDecodeError): + return None + + @staticmethod + def _rect_to_dict(rect): + return { + "x": int(rect.x()), + "y": int(rect.y()), + "width": int(rect.width()), + "height": int(rect.height()), + } + + def save_current_layout(self): + normal_geometry = self.normalGeometry() + if not self.isFullScreen() and not self.isMaximized(): + normal_geometry = self.geometry() + data = { + "version": GUI_LAYOUT_VERSION, + "window": { + "geometry": self._rect_to_dict(normal_geometry), + "fullscreen": bool(self.isFullScreen()), + "maximized": bool(self.isMaximized()), + }, + "workspace": self.gripper_stage.capture_workspace_layout(), + "visual_transforms": self.visual_transforms, + } + + self.layout_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = self.layout_path.with_suffix(self.layout_path.suffix + ".tmp") + try: + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + os.replace(temp_path, self.layout_path) + finally: + try: + if temp_path.exists(): + temp_path.unlink() + except OSError: + pass + return self.layout_path + + def restore_saved_layout(self, force_windowed=False): + data = self._load_saved_layout() + if data is None: + return False + + saved_window = data.get("window", {}) + geometry = saved_window.get("geometry", {}) + try: + width = max(640, int(geometry["width"])) + height = max(480, int(geometry["height"])) + x = int(geometry["x"]) + y = int(geometry["y"]) + saved_rect = QRect(x, y, width, height) + screens = QApplication.screens() + if screens and not any( + saved_rect.intersects(screen.availableGeometry()) for screen in screens + ): + available = QApplication.primaryScreen().availableGeometry() + x = available.x() + max(0, (available.width() - width) // 2) + y = available.y() + max(0, (available.height() - height) // 2) + self.setGeometry(x, y, width, height) + except (KeyError, TypeError, ValueError): + self.resize(1500, 900) + + if force_windowed: + self.show() + elif saved_window.get("fullscreen"): + self.showFullScreen() + elif saved_window.get("maximized"): + self.showMaximized() + else: + self.show() + + transforms = data.get("visual_transforms") + if isinstance(transforms, dict): + for side in ("left", "right"): + transform = transforms.get(side) + if isinstance(transform, dict): + self.visual_transforms[side] = { + "rotation_angle": int(transform.get("rotation_angle", 0)) % 360, + "flip_x": bool(transform.get("flip_x", False)), + "flip_y": bool(transform.get("flip_y", False)), + } + self.left_flow.set_transform_label(self.visual_transforms["left"]) + self.right_flow.set_transform_label(self.visual_transforms["right"]) + + workspace = data.get("workspace", {}) + QTimer.singleShot(0, lambda: self.gripper_stage.restore_workspace_layout(workspace)) + return True + + def apply_saved_workspace_layout(self): + data = self._load_saved_layout() + if data is None: + return False + + transforms = data.get("visual_transforms") + if isinstance(transforms, dict): + for side in ("left", "right"): + transform = transforms.get(side) + if isinstance(transform, dict): + self.visual_transforms[side] = { + "rotation_angle": int(transform.get("rotation_angle", 0)) % 360, + "flip_x": bool(transform.get("flip_x", False)), + "flip_y": bool(transform.get("flip_y", False)), + } + self.left_flow.set_transform_label(self.visual_transforms["left"]) + self.right_flow.set_transform_label(self.visual_transforms["right"]) + + return self.gripper_stage.restore_workspace_layout(data.get("workspace", {})) + def _control_config_path(self): config_path = REPO_ROOT / "config" / "gripper.json" args = list(self.argv or []) @@ -2431,6 +2914,18 @@ class GripperDemo02Viewer(QMainWindow): position_max = position_min + 1 return open_pos, close_pos, position_min, position_max + def _load_manual_control_config(self): + speed = 25 + force = 10 + try: + with open(self._control_config_path(), "r", encoding="utf-8") as f: + data = json.load(f) + speed = int(data.get("speed", speed)) + force = int(data.get("close_force", force)) + except Exception: + pass + return max(0, min(100, speed)), max(0, min(100, force)) + def _build_ui(self): root = QWidget() self.setCentralWidget(root) @@ -2469,10 +2964,10 @@ class GripperDemo02Viewer(QMainWindow): self.restart_button = TopIconButton("restart", "重启控制程序") self.restart_button.clicked.connect(self.restart_control) self.manual_open_button = AnimatedButton("夹爪张开") - self.manual_open_button.setObjectName("ManualButton") + self.manual_open_button.setObjectName("OpenActionButton") self.manual_open_button.clicked.connect(lambda: self.send_manual_command("open")) self.manual_close_button = AnimatedButton("夹爪闭合") - self.manual_close_button.setObjectName("ManualButton") + self.manual_close_button.setObjectName("CloseActionButton") self.manual_close_button.clicked.connect(lambda: self.send_manual_command("close")) self.manual_hold_button = AnimatedButton("保持") self.manual_hold_button.setObjectName("HoldButton") @@ -2517,7 +3012,7 @@ class GripperDemo02Viewer(QMainWindow): center_splitter.addWidget(visual_panel) plot_panel = FloatingPanel(self.gripper_stage) - plot_panel.setMinimumSize(360, 260) + plot_panel.setMinimumSize(320, 140) plot_layout = QVBoxLayout(plot_panel) plot_layout.setContentsMargins(8, 6, 8, 8) plot_layout.setSpacing(4) @@ -2531,32 +3026,34 @@ class GripperDemo02Viewer(QMainWindow): pg.setConfigOption("foreground", "#667085") pg.setConfigOptions(antialias=True) self.plot_widget = pg.GraphicsLayoutWidget() - self.plot_widget.ci.setSpacing(10) - self.plot_widget.ci.setContentsMargins(4, 2, 4, 4) - self.plot_widget.setMinimumHeight(220) + self.plot_widget.ci.setSpacing(6) + self.plot_widget.ci.setContentsMargins(2, 2, 2, 2) + self.plot_widget.setMinimumHeight(100) plot_layout.addWidget(self.plot_widget, 1) self.plot_panel = plot_panel center_splitter.setStretchFactor(0, 1) center_splitter.setSizes([1200]) gripper_control = FloatingPanel(self.gripper_stage) + gripper_control.setObjectName("GripperControlPanel") gripper_layout = QGridLayout(gripper_control) gripper_layout.setContentsMargins(8, 6, 8, 8) gripper_layout.setHorizontalSpacing(8) gripper_layout.setVerticalSpacing(6) gripper_title = QLabel("夹爪控制区域") - gripper_title.setObjectName("SectionTitle") + gripper_title.setObjectName("ControlSectionTitle") gripper_title.setAlignment(Qt.AlignLeft | Qt.AlignTop) gripper_control.set_drag_handle(gripper_title) - self.speed_control = SliderControl("速度", 0, 100, 0, step=5, suffix="%") - self.force_control = SliderControl("最大力", 0, 100, 0, step=5, suffix="%") + manual_speed, manual_force = self._load_manual_control_config() + self.speed_control = SliderControl("速度", 0, 100, manual_speed, step=5, suffix="%") + self.force_control = SliderControl("最大力", 0, 100, manual_force, step=5, suffix="%") self.speed_control.slider.valueChanged.connect(self.schedule_runtime_speed) self.manual_mode_button = AnimatedButton("手动模式") - self.manual_mode_button.setObjectName("ViewToggle") + self.manual_mode_button.setObjectName("ControlModeButton") self.manual_mode_button.setCheckable(True) self.manual_mode_button.setToolTip("切换到手动控制") self.smart_mode_button = AnimatedButton("智能模式") - self.smart_mode_button.setObjectName("ViewToggle") + self.smart_mode_button.setObjectName("ControlModeButton") self.smart_mode_button.setCheckable(True) self.smart_mode_button.setToolTip("切换到自动智能控制") self.mode_button_group = QButtonGroup(self) @@ -2672,20 +3169,21 @@ class GripperDemo02Viewer(QMainWindow): plot.titleLabel.setFont(title_font) plot.titleLabel.setFixedHeight(18) plot.titleLabel.setText(title, color="#1f2937") - plot.setContentsMargins(6, 6, 6, 6) + plot.setContentsMargins(3, 3, 3, 3) plot.setMenuEnabled(False) plot.setMouseEnabled(x=False, y=False) plot.showAxis("top", False) plot.showAxis("right", False) plot.showGrid(x=True, y=True, alpha=0.22) plot.hideButtons() - plot.setLabel( - "bottom", - "时间", - units="s", - color="#596579", - **{"font-size": f"{PLOT_AXIS_FONT_SIZE}pt"}, - ) + if row == 2: + plot.setLabel( + "bottom", + "时间", + units="s", + color="#596579", + **{"font-size": f"{PLOT_AXIS_FONT_SIZE}pt"}, + ) plot.setLabel( "left", "力", @@ -2702,8 +3200,9 @@ class GripperDemo02Viewer(QMainWindow): axis.setPen(pg.mkPen("#d8e0eb", width=1)) axis.setTextPen(pg.mkPen("#667085")) axis.setStyle(tickFont=axis_font, tickTextOffset=3, showValues=True) - plot.getAxis("left").setWidth(46) - plot.getAxis("bottom").setHeight(34) + plot.getAxis("left").setWidth(42) + plot.getAxis("bottom").setStyle(showValues=row == 2) + plot.getAxis("bottom").setHeight(30 if row == 2 else 10) plot.getAxis("bottom").setTickSpacing(major=1.0, minor=1.0) plot.setYRange(PLOT_Y_DEFAULT_MIN_N, PLOT_Y_DEFAULT_MAX_N, padding=0) plot.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False) @@ -2792,7 +3291,7 @@ class GripperDemo02Viewer(QMainWindow): self.restart_button.setEnabled(restart_enabled) mode_enabled = state in ("idle", "running") manual_enabled = state == "running" and self.control_mode == "manual" - action_enabled = state == "running" + action_enabled = manual_enabled sensor_restart_enabled = state == "running" self.manual_open_button.setEnabled(action_enabled) self.manual_close_button.setEnabled(action_enabled) @@ -2917,6 +3416,8 @@ class GripperDemo02Viewer(QMainWindow): def send_manual_command(self, command, position=None): if self.controller is None or self.control_thread is None or not self.control_thread.is_alive(): return + if command in {"open", "close"}: + self._pending_position_target = None speed_pct = self.speed_control.value() force_pct = self.force_control.value() self.apply_runtime_speed(speed_pct) @@ -2936,6 +3437,8 @@ class GripperDemo02Viewer(QMainWindow): min(self.position_control.slider.maximum(), int(position)), ) self.position_control.set_value(position, force=True) + self._pending_position_target = position + self._pending_position_since = time.monotonic() self.send_manual_command("move_position", position=position) def move_to_target_position(self): @@ -3014,6 +3517,8 @@ class GripperDemo02Viewer(QMainWindow): try: self.controller.run() except Exception as exc: + print(f"controller failed: {exc}") + traceback.print_exc() self._on_status({"state": "error", "action": str(exc), "timestamp": time.time()}) finally: self.controller_finished = True @@ -3070,9 +3575,16 @@ class GripperDemo02Viewer(QMainWindow): ) if hasattr(self, "position_control"): gripper_position = status.get("gripper_position") - self.position_control.set_value(gripper_position) - self.speed_control.set_value(status.get("speed_pct", 0) or 0) - self.force_control.set_value(status.get("force_pct", 0) or 0) + pending_target = self._pending_position_target + if pending_target is not None and gripper_position is not None: + tolerance = max(1, self.position_control.step // 2) + reached = abs(int(gripper_position) - int(pending_target)) <= tolerance + timed_out = time.monotonic() - self._pending_position_since >= 3.0 + if reached or timed_out: + self._pending_position_target = None + self.position_control.set_value(gripper_position) + elif pending_target is None: + self.position_control.set_value(gripper_position) self.gripper_stage.update_state(status) left_sample = status.get("left_sample") or {} @@ -3219,10 +3731,11 @@ def main(argv=None): app = QApplication(sys.argv[:1]) app.setWindowIcon(_load_company_icon(64)) window = GripperDemo02Viewer(controller_argv) - if ui_args.windowed: + restored = window.restore_saved_layout(force_windowed=ui_args.windowed) + if not restored and ui_args.windowed: window.resize(1500, 900) window.show() - else: + elif not restored: window.showFullScreen() window.start_control() return app.exec_() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..325d108 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# 开发 / 打包依赖 +-r requirements.txt + +# PyInstaller 打包 exe +pyinstaller>=6.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6767856 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,28 @@ +# Gripper Demo 02 运行依赖 +# Python: 3.11(推荐 conda 环境 py311) +# +# 安装: +# conda activate py311 +# pip install -r requirements.txt +# +# 说明: +# - orisys 是触觉传感器私有 SDK,不在本文件中;需自行安装到当前环境 +# - 3D 模型需要 PyOpenGL;不装也能跑界面,但夹爪实体模型不可用 +# - 打包 exe 额外看 requirements-dev.txt + +# 数值 / 图像 +numpy>=1.24 +opencv-python>=4.8 +numba>=0.60 + +# GUI +PyQt5>=5.15 +pyqtgraph>=0.13 + +# 3D 夹爪模型(OpenGL) +PyOpenGL>=3.1 +PyOpenGL-accelerate>=3.1 + +# 夹爪串口控制(Modbus RTU) +minimalmodbus>=2.1 +pyserial>=3.5 diff --git a/start_gripper.bat b/start_gripper.bat deleted file mode 100644 index 841313a..0000000 --- a/start_gripper.bat +++ /dev/null @@ -1,11 +0,0 @@ -@echo off -setlocal -cd /d "%~dp0" -call conda activate py311 -if errorlevel 1 ( - echo Failed to activate conda environment: py311 - pause - exit /b 1 -) -python -m gripper.visualizer %* -pause