diff --git a/README.md b/README.md index a8549c4..00c13ee 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,11 @@ ## 运行 +GUI 的完整模块说明、操作流程、参数表和故障排查见 +[GUI 使用说明书](docs/GUI_USER_MANUAL.zh-CN.md)。 +正式产品使用说明书(HTML,含界面截图,可打印/导出 PDF)见 +[产品使用说明书](docs/PRODUCT_MANUAL.zh-CN.html)。 + 从项目根目录运行: ```powershell @@ -86,6 +91,17 @@ python build\build_exe.py --profile lite --clean-dist python build\build_exe.py --profile full --clean-dist ``` +需要包含源码和 Python 3.11 虚拟环境的 Windows x64 包时,在 `py311` +环境运行: + +```powershell +conda activate py311 +python build\build_venv_bundle.py +``` + +该包使用精简运行环境,不包含 Torch/CUDA、CuPy 和 Open3D。输出位于 +`release\GripperDemo02-windows-x64-py311-venv-YYYYMMDD.zip`。 + 轻量配置要求传感器配置中的 `tracker.algorithm` 为 `dis`;打包脚本会在 构建前检查并阻止错误的 RAFT 轻量包。 diff --git a/build/build_exe.py b/build/build_exe.py index fd0ecd9..92d687f 100644 --- a/build/build_exe.py +++ b/build/build_exe.py @@ -99,8 +99,8 @@ def validate_lite_sensor_configs(): 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"), + control_config.get("sensor_config1", "./config/ddjx01_q6a.json"), + control_config.get("sensor_config2", "./config/ddjx01_q6a.json"), } for config_name in config_names: config_path = Path(config_name) diff --git a/build/build_venv_bundle.py b/build/build_venv_bundle.py new file mode 100644 index 0000000..d85d0dc --- /dev/null +++ b/build/build_venv_bundle.py @@ -0,0 +1,367 @@ +import argparse +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import venv +import zipfile +from datetime import datetime +from pathlib import Path + + +BUILD_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = BUILD_DIR.parent +DIST_DIR = PROJECT_ROOT / "dist" +RELEASE_DIR = PROJECT_ROOT / "release" +LOCK_FILE = BUILD_DIR / "requirements-venv-win-x64.txt" +RUNTIME_DIRS = ( + "gripper", + "config", + "sdk", + "crt_ctag2f90d_gripper_visualization", + "docs", +) +RUNTIME_FILES = ( + "README.md", + "requirements.txt", + "requirements-dev.txt", +) + + +def run(command, *, cwd=None, env=None, capture_output=False): + print("+", subprocess.list2cmdline([str(item) for item in command])) + return subprocess.run( + [str(item) for item in command], + cwd=cwd, + env=env, + check=True, + text=True, + capture_output=capture_output, + ) + + +def require_supported_python(): + if sys.version_info[:2] != (3, 11): + raise SystemExit( + f"This bundle requires CPython 3.11; current interpreter is {sys.version.split()[0]}." + ) + if os.name != "nt" or platform.machine().upper() not in {"AMD64", "X86_64"}: + raise SystemExit( + f"This lock targets Windows x64; current platform is {platform.platform()}." + ) + + +def ensure_generated_path(path, parent): + resolved = path.resolve() + resolved_parent = parent.resolve() + if resolved.parent != resolved_parent: + raise SystemExit(f"Refusing to replace unexpected path: {resolved}") + + +def remove_generated_dir(path, parent): + ensure_generated_path(path, parent) + if path.exists(): + shutil.rmtree(path) + + +def copy_runtime_tree(source, target): + if not source.exists(): + return False + shutil.copytree( + source, + target, + ignore=shutil.ignore_patterns( + "__pycache__", + "*.pyc", + "*.pyo", + "*.tmp", + "*.log", + ".git", + ".agents", + ".codex", + ), + ) + return True + + +def create_venv(venv_dir): + print(f"Creating venv: {venv_dir}") + venv.EnvBuilder( + system_site_packages=False, + clear=True, + symlinks=False, + with_pip=True, + ).create(venv_dir) + python_exe = venv_dir / "Scripts" / "python.exe" + if not python_exe.exists(): + raise SystemExit(f"venv interpreter was not created: {python_exe}") + return python_exe + + +def install_dependencies(python_exe, orisys_source): + run( + [ + python_exe, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "pip==26.1.2", + "setuptools==78.1.0", + "wheel==0.47.0", + ] + ) + run( + [ + python_exe, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--requirement", + LOCK_FILE, + ] + ) + run( + [ + python_exe, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-deps", + orisys_source, + ] + ) + + +def copy_application(bundle_dir): + for name in RUNTIME_DIRS: + source = PROJECT_ROOT / name + if not copy_runtime_tree(source, bundle_dir / name): + print(f"Warning: runtime directory does not exist: {source}") + + for name in RUNTIME_FILES: + source = PROJECT_ROOT / name + if source.exists(): + shutil.copy2(source, bundle_dir / name) + + shutil.copy2(LOCK_FILE, bundle_dir / "requirements-venv-lock.txt") + + +def write_launchers(bundle_dir): + launcher = bundle_dir / "start_gripper.cmd" + launcher.write_text( + "@echo off\n" + "setlocal\n" + "cd /d \"%~dp0\"\n" + "set PYTHONUTF8=1\n" + "set PYTHONNOUSERSITE=1\n" + "if not exist logs mkdir logs\n" + "\".venv\\Scripts\\python.exe\" -m gripper.visualizer --windowed %* " + ">> \"logs\\venv.log\" 2>&1\n" + "if errorlevel 1 (\n" + " echo Start failed. See logs\\venv.log\n" + " pause\n" + ")\n", + encoding="ascii", + newline="\r\n", + ) + + readme = bundle_dir / "README-VENV.txt" + readme.write_text( + "Gripper Demo 02 - Windows x64 Python 3.11 venv bundle\n" + "========================================================\n\n" + "1. Extract the complete archive to a writable directory.\n" + "2. Edit config\\gripper.json: set video1, video2 and port.\n" + "3. Run start_gripper.cmd. Add --dry-run for a serial-free test.\n" + "4. Runtime output is appended to logs\\venv.log.\n\n" + "This package uses a standard Python venv created with --copies. " + "It is intended for the current machine or another Windows x64 " + "machine with CPython 3.11 installed. Standard venv directories " + "are not a fully standalone Python distribution. If the base " + "interpreter differs, recreate .venv with Python 3.11 and install " + "requirements-venv-lock.txt, then install the Orisys SDK.\n\n" + "CUDA, Torch/RAFT, CuPy and Open3D are not included. The default " + "application uses OpenCV DIS optical flow and the CPU Poisson backend.\n", + encoding="utf-8", + ) + + +def verify_bundle(python_exe, bundle_dir): + verify_code = """ +import json +import platform +import sys + +import cv2 +import llvmlite +import minimalmodbus +import numba +import numpy +import OpenGL +import orisys +import pyqtgraph +import scipy +import serial +from PyQt5 import QtCore +from gripper.config.settings import Demo02Config +from gripper.ui.visualizer import GripperDemo02Viewer + +config = Demo02Config.from_args([ + "--control-config", "config/gripper.json", "--dry-run" +]) +print(json.dumps({ + "python": sys.version.split()[0], + "architecture": platform.machine(), + "numpy": numpy.__version__, + "opencv": cv2.__version__, + "numba": numba.__version__, + "llvmlite": llvmlite.__version__, + "scipy": scipy.__version__, + "pyqt": QtCore.PYQT_VERSION_STR, + "qt": QtCore.QT_VERSION_STR, + "pyqtgraph": pyqtgraph.__version__, + "pyopengl": OpenGL.__version__, + "orisys": getattr(orisys, "__version__", "unknown"), + "dry_run": config.dry_run, +}, indent=2)) +""" + env = os.environ.copy() + env["PYTHONNOUSERSITE"] = "1" + result = run( + [python_exe, "-c", verify_code], + cwd=bundle_dir, + env=env, + capture_output=True, + ) + print(result.stdout.rstrip()) + (bundle_dir / "environment.json").write_text( + result.stdout.strip() + "\n", + encoding="utf-8", + ) + + freeze = run( + [python_exe, "-m", "pip", "freeze", "--all"], + cwd=bundle_dir, + env=env, + capture_output=True, + ) + (bundle_dir / "pip-freeze.txt").write_text( + freeze.stdout, + encoding="utf-8", + ) + + +def remove_bytecode(root): + for cache_dir in sorted(root.rglob("__pycache__"), reverse=True): + shutil.rmtree(cache_dir, ignore_errors=True) + for pattern in ("*.pyc", "*.pyo"): + for path in root.rglob(pattern): + try: + path.unlink() + except OSError: + pass + + +def directory_size(path): + return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) + + +def create_zip(bundle_dir, archive_path): + if archive_path.exists(): + archive_path.unlink() + print(f"Creating archive: {archive_path}") + with zipfile.ZipFile( + archive_path, + "w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=6, + allowZip64=True, + ) as archive: + for path in sorted(bundle_dir.rglob("*")): + if path.is_file(): + archive.write(path, Path(bundle_dir.name) / path.relative_to(bundle_dir)) + + +def sha256_file(path): + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--name", + default="GripperDemo02Venv", + help="Staging directory name under dist.", + ) + parser.add_argument( + "--orisys-source", + type=Path, + default=PROJECT_ROOT.parent / "orisys-sdk", + help="Path to the private Orisys SDK source tree.", + ) + parser.add_argument( + "--no-archive", + action="store_true", + help="Create and verify the directory without writing a release zip.", + ) + args = parser.parse_args() + + require_supported_python() + if not LOCK_FILE.exists(): + raise SystemExit(f"Dependency lock does not exist: {LOCK_FILE}") + orisys_source = args.orisys_source.resolve() + if not (orisys_source / "pyproject.toml").exists(): + raise SystemExit(f"Orisys SDK source was not found: {orisys_source}") + + DIST_DIR.mkdir(parents=True, exist_ok=True) + RELEASE_DIR.mkdir(parents=True, exist_ok=True) + bundle_dir = DIST_DIR / args.name + remove_generated_dir(bundle_dir, DIST_DIR) + bundle_dir.mkdir(parents=True) + + venv_dir = bundle_dir / ".venv" + python_exe = create_venv(venv_dir) + install_dependencies(python_exe, orisys_source) + copy_application(bundle_dir) + write_launchers(bundle_dir) + verify_bundle(python_exe, bundle_dir) + remove_bytecode(bundle_dir) + + unpacked_mb = directory_size(bundle_dir) / (1024 * 1024) + print(f"Unpacked bundle size: {unpacked_mb:.1f} MiB") + + if args.no_archive: + print(f"Bundle ready: {bundle_dir}") + return 0 + + stamp = datetime.now().strftime("%Y%m%d") + archive_path = RELEASE_DIR / f"GripperDemo02-windows-x64-py311-venv-{stamp}.zip" + create_zip(bundle_dir, archive_path) + digest = sha256_file(archive_path) + checksum_path = archive_path.with_suffix(archive_path.suffix + ".sha256") + checksum_path.write_text(f"{digest} {archive_path.name}\n", encoding="ascii") + archive_mb = archive_path.stat().st_size / (1024 * 1024) + + report = { + "bundle": str(bundle_dir), + "archive": str(archive_path), + "checksum": digest, + "unpacked_mib": round(unpacked_mb, 1), + "archive_mib": round(archive_mb, 1), + } + print(json.dumps(report, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/build/capture_gui_screenshots.py b/build/capture_gui_screenshots.py new file mode 100644 index 0000000..8305036 --- /dev/null +++ b/build/capture_gui_screenshots.py @@ -0,0 +1,143 @@ +"""Capture product-manual screenshots using the real visualizer entry path. + +Equivalent to: python -m gripper.visualizer +(fullscreen by default, user config/gripper.json, start_control) +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +OUT = REPO / "docs" / "images" / "manual" +OUT.mkdir(parents=True, exist_ok=True) +sys.path.insert(0, str(REPO)) + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QApplication, QFrame + +from gripper.ui.visualizer import GripperDemo02Viewer, _load_company_icon + + +def process(app, ms=200): + end = time.monotonic() + ms / 1000.0 + while time.monotonic() < end: + app.processEvents() + time.sleep(0.02) + + +def save(widget, name: str): + path = OUT / name + pix = widget.grab() + if pix.isNull() or not pix.save(str(path), "PNG"): + raise RuntimeError(f"save failed: {name}") + print(f"saved {path.relative_to(REPO)}") + return path + + +def wait_ready(app, viewer, timeout=60.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + process(app, 80) + with viewer.status_lock: + st = viewer.latest_status + if not st: + continue + left = st.get("left_sample") or {} + right = st.get("right_sample") or {} + if left and right and st.get("state") != "error": + print( + f"ready state={st.get('state')} action={st.get('action')} " + f"fps=({(left or {}).get('fps')}, {(right or {}).get('fps')})" + ) + return st + if st.get("state") == "error": + raise RuntimeError(f"controller error: {st.get('action')}") + raise TimeoutError("sensors not ready") + + +def main() -> int: + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + app = QApplication(sys.argv[:1]) + app.setWindowIcon(_load_company_icon(64)) + + # Same path as gripper.visualizer.main (no --windowed → fullscreen) + viewer = GripperDemo02Viewer([]) + restored = viewer.restore_saved_layout(force_windowed=False) + if not restored: + viewer.showFullScreen() + viewer.start_control() + + print("waiting for live sensors...") + wait_ready(app, viewer, timeout=75.0) + # let curves accumulate + process(app, 4000) + + save(viewer, "01-main-overview.png") + + viewer.left_flow.raise_() + viewer.right_flow.raise_() + process(app, 200) + save(viewer, "02-flow-windows.png") + + viewer.plot_panel.raise_() + process(app, 200) + save(viewer, "03-force-plots.png") + + viewer.gripper_control_panel.raise_() + process(app, 200) + save(viewer, "04-gripper-control-smart.png") + + viewer.set_control_mode("manual") + process(app, 300) + save(viewer, "05-gripper-control-manual.png") + viewer.set_control_mode("smart") + process(app, 200) + + viewer.sensor_control_panel.raise_() + process(app, 200) + save(viewer, "06-sensor-control.png") + + top = viewer.findChild(QFrame, "TopBar") + if top is not None: + save(top, "07-top-bar.png") + + viewer.open_visual_settings() + process(app, 500) + dialog = viewer.visual_settings_dialog + save(dialog, "08-settings-visual.png") + + dialog.nav.setCurrentRow(1) + process(app, 200) + save(dialog, "09-settings-layout.png") + + for row in range(dialog.nav.count()): + title = dialog.nav.item(row).text() + if title == "串口 / 设备": + dialog.nav.setCurrentRow(row) + process(app, 200) + save(dialog, "11-settings-serial.png") + elif title == "触发 / 夹持": + dialog.nav.setCurrentRow(row) + process(app, 200) + save(dialog, "10-settings-trigger.png") + + dialog.close() + process(app, 300) + # live hold_check may not be active; still save current live frame + save(viewer, "12-hold-check-state.png") + + print("stopping...") + viewer.stop_control() + process(app, 2000) + viewer.close() + app.processEvents() + print(f"done → {OUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/capture_live_window.py b/build/capture_live_window.py new file mode 100644 index 0000000..114ddf0 --- /dev/null +++ b/build/capture_live_window.py @@ -0,0 +1,189 @@ +"""Capture live Gripper Demo 02 window using screen mouse clicks + PrintWindow.""" + +from __future__ import annotations + +import ctypes +import sys +import time +from ctypes import wintypes +from pathlib import Path + +from PIL import Image + +REPO = Path(__file__).resolve().parents[1] +OUT = REPO / "docs" / "images" / "manual" +OUT.mkdir(parents=True, exist_ok=True) + +user32 = ctypes.windll.user32 +gdi32 = ctypes.windll.gdi32 +user32.SetProcessDPIAware() +PW_RENDERFULLCONTENT = 0x00000002 + + +class RECT(ctypes.Structure): + _fields_ = [("left", wintypes.LONG), ("top", wintypes.LONG), + ("right", wintypes.LONG), ("bottom", wintypes.LONG)] + + +class POINT(ctypes.Structure): + _fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)] + + +def find_hwnd(title="Gripper Demo 02"): + found = [] + + @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + def cb(hwnd, _): + if not user32.IsWindowVisible(hwnd): + return True + n = user32.GetWindowTextLengthW(hwnd) + if n <= 0: + return True + buf = ctypes.create_unicode_buffer(n + 1) + user32.GetWindowTextW(hwnd, buf, n + 1) + if title in buf.value: + found.append(hwnd) + return True + + user32.EnumWindows(cb, 0) + return found[0] if found else None + + +def capture(hwnd, path: Path): + rect = RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + w, h = rect.right - rect.left, rect.bottom - rect.top + hdc = user32.GetWindowDC(hwnd) + mem = gdi32.CreateCompatibleDC(hdc) + bmp = gdi32.CreateCompatibleBitmap(hdc, w, h) + old = gdi32.SelectObject(mem, bmp) + if not user32.PrintWindow(hwnd, mem, PW_RENDERFULLCONTENT): + screen = user32.GetDC(0) + gdi32.BitBlt(mem, 0, 0, w, h, screen, rect.left, rect.top, 0x00CC0020) + user32.ReleaseDC(0, screen) + + class BIH(ctypes.Structure): + _fields_ = [ + ("biSize", wintypes.DWORD), ("biWidth", wintypes.LONG), + ("biHeight", wintypes.LONG), ("biPlanes", wintypes.WORD), + ("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD), + ("biSizeImage", wintypes.DWORD), ("biXPelsPerMeter", wintypes.LONG), + ("biYPelsPerMeter", wintypes.LONG), ("biClrUsed", wintypes.DWORD), + ("biClrImportant", wintypes.DWORD), + ] + + class BI(ctypes.Structure): + _fields_ = [("bmiHeader", BIH), ("bmiColors", wintypes.DWORD * 3)] + + bi = BI() + bi.bmiHeader.biSize = ctypes.sizeof(BIH) + bi.bmiHeader.biWidth = w + bi.bmiHeader.biHeight = -h + bi.bmiHeader.biPlanes = 1 + bi.bmiHeader.biBitCount = 32 + buf = (ctypes.c_ubyte * (w * h * 4))() + gdi32.GetDIBits(mem, bmp, 0, h, buf, ctypes.byref(bi), 0) + gdi32.SelectObject(mem, old) + gdi32.DeleteObject(bmp) + gdi32.DeleteDC(mem) + user32.ReleaseDC(hwnd, hdc) + Image.frombuffer("RGBA", (w, h), bytes(buf), "raw", "BGRA", 0, 1).convert("RGB").save(path) + print(f"saved {path.name} ({w}x{h})") + return w, h + + +def click_screen(x, y): + user32.SetCursorPos(int(x), int(y)) + time.sleep(0.05) + user32.mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN + time.sleep(0.04) + user32.mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP + + +def main(): + hwnd = find_hwnd() + if not hwnd: + print("window not found", file=sys.stderr) + return 1 + + user32.ShowWindow(hwnd, 9) # SW_RESTORE + user32.SetForegroundWindow(hwnd) + time.sleep(0.5) + + rect = RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + left, top = rect.left, rect.top + w, h = rect.right - rect.left, rect.bottom - rect.top + print(f"window rect=({left},{top}) {w}x{h}") + + # 1) main overview + capture(hwnd, OUT / "01-main-overview.png") + full = Image.open(OUT / "01-main-overview.png") + + # panel crops from live layout + crops = { + "02-flow-windows.png": (0, int(h * 0.06), w, int(h * 0.55)), + "03-force-plots.png": (int(w * 0.55), int(h * 0.08), w - 8, int(h * 0.72)), + "04-gripper-control-smart.png": (8, int(h * 0.62), int(w * 0.32), h - 8), + "06-sensor-control.png": (int(w * 0.68), int(h * 0.68), w - 8, h - 8), + "07-top-bar.png": (0, 0, w, max(70, int(h * 0.065))), + "12-hold-check-state.png": (0, 0, w, h), + } + for name, box in crops.items(): + full.crop(box).save(OUT / name) + print(f"saved {name}") + + # 2) switch to manual mode — button near bottom-left of gripper panel + # "手动模式" is left of "智能模式" in gripper control panel + manual_x = left + int(w * 0.06) + manual_y = top + int(h * 0.68) + print(f"click manual mode @ ({manual_x},{manual_y})") + click_screen(manual_x, manual_y) + time.sleep(0.6) + capture(hwnd, OUT / "05-gripper-control-manual.png") + + # switch back to smart + smart_x = left + int(w * 0.14) + smart_y = top + int(h * 0.68) + click_screen(smart_x, smart_y) + time.sleep(0.4) + + # 3) open settings — measured from top-bar: first icon ~x=1640 + settings_x = left + 1640 + settings_y = top + 32 + print(f"click settings @ ({settings_x},{settings_y})") + click_screen(settings_x, settings_y) + time.sleep(1.5) + capture(hwnd, OUT / "08-settings-visual.png") + + # embedded settings panel (~72% of window, centered) + # nav list is left column inside the panel + dlg_left = left + int(w * 0.14) + dlg_top = top + int(h * 0.11) + nav_x = dlg_left + 90 + # nav rows: 画面(0), 布局(1), 相机(2), 串口(3), ... 触发(7) + row_y0 = dlg_top + 95 + row_h = 34 + + click_screen(nav_x, row_y0 + 1 * row_h) # 布局 + time.sleep(0.6) + capture(hwnd, OUT / "09-settings-layout.png") + + click_screen(nav_x, row_y0 + 3 * row_h) # 串口 / 设备 + time.sleep(0.6) + capture(hwnd, OUT / "11-settings-serial.png") + + click_screen(nav_x, row_y0 + 7 * row_h) # 触发 / 夹持 + time.sleep(0.6) + capture(hwnd, OUT / "10-settings-trigger.png") + + # close settings panel (X on panel header) + click_screen(left + int(w * 0.14) + int(w * 0.72) - 30, dlg_top + 28) + time.sleep(0.3) + + print("done") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/compile_manual_diagrams.py b/build/compile_manual_diagrams.py new file mode 100644 index 0000000..617c274 --- /dev/null +++ b/build/compile_manual_diagrams.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Generate manual diagrams (SVG). Fast path — no Docker / LaTeX.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +GENERATOR = REPO / "build" / "generate_manual_diagrams.py" + + +def main() -> int: + print("Generating manual diagrams via generate_manual_diagrams.py …") + subprocess.run([sys.executable, str(GENERATOR)], check=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/generate_manual_diagrams.py b/build/generate_manual_diagrams.py new file mode 100644 index 0000000..ed9ffd8 --- /dev/null +++ b/build/generate_manual_diagrams.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +"""Generate manual SVG diagrams with correct UTF-8 encoding.""" + +from pathlib import Path + +OUT = Path(__file__).resolve().parents[1] / "docs" / "assets" / "diagrams" +OUT.mkdir(parents=True, exist_ok=True) + +SYSTEM_ARCH = r''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + 感知层 + + 软件处理层 + + 执行与呈现层 + + + + 左触觉传感器 + USB 摄像头 + 敏感面 + + + + 右触觉传感器 + USB 摄像头 + 敏感面 + + + + + 视频帧采集 + + + + 触觉力计算 / 光流处理 + 法向力 · 切向力 · 光流/幅值图像 + Orisys SDK · OpenCV DIS + + + + + + + + 智能夹持状态机 + 触发 · 闭合 · 加力 · 释放 + + + + 力信号 + + + + 夹爪控制器 + Modbus RTU 从站 + + + + + Modbus RTU / 串口 + + + + 智能夹爪(执行机构) + 位置 · 力度 · 开/合/保持 + + + 驱动 + + + + GUI 监控界面 + 3D 模型 · 光流画面 · 力曲线 · 状态 + PyQt5 · PyOpenGL · pyqtgraph + + + + 状态 / 动作 + + 图像 / 力值 + + + 位置反馈 + + + + 数据 / 控制流 + + 反馈 + + +''' + +GRIP_FLOW = r''' + + + + + + + + + + + + + + + + + + + + + + + + + 等待触发 + open_wait + 已就绪 armed + + + 侧边触发 + + + 闭合中 + closing + 寻找物体 + + + 双侧接触 + + + 夹持加力 + gripping + 低力 → 基础力 + + + 到达保持力 + + + 夹持检测 + hold_check + 自适应补力 / 释放检测 + + + 取物释放 + + + 张开恢复 + open_recover + 位置 / 力恢复 + + + + 恢复完成 + 休息时间(rearm)→ 再次就绪 + + + + 闭合超时 close_timeout → 张开 + + + 阶段说明 + ① 等待触发:夹爪张开,单侧力超阈且为新上升沿时进入闭合。 + ② 闭合~夹持:物体接触后低力保持,分步加力至 hold_force,并按切向负载自适应补力。 + ③ 释放~恢复:力变化超阈则张开;满足恢复条件并经过 rearm_seconds 后,方可再次触发。 + +''' + + +def main(): + (OUT / "system-architecture.svg").write_text(SYSTEM_ARCH, encoding="utf-8") + (OUT / "grip-state-flow.svg").write_text(GRIP_FLOW, encoding="utf-8") + print("Wrote", OUT / "system-architecture.svg") + print("Wrote", OUT / "grip-state-flow.svg") + + +if __name__ == "__main__": + main() diff --git a/build/render_manual_diagrams.py b/build/render_manual_diagrams.py new file mode 100644 index 0000000..460a41a --- /dev/null +++ b/build/render_manual_diagrams.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Render manual diagrams as high-resolution PNG. + +LaTeX/TikZ sources: docs/diagrams/tex/*.tex +Compile with: python build/compile_manual_diagrams.py (requires Docker or local XeLaTeX) +""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib.pyplot as plt +from matplotlib.patches import FancyArrowPatch, FancyBboxPatch +from matplotlib import font_manager as fm + +REPO = Path(__file__).resolve().parents[1] +OUT = REPO / "docs" / "assets" / "diagrams" +OUT.mkdir(parents=True, exist_ok=True) + +FONT = next( + (f.name for f in fm.fontManager.ttflist if "YaHei" in f.name or "Noto Sans CJK" in f.name), + "sans-serif", +) +plt.rcParams.update( + { + "font.family": FONT, + "font.sans-serif": [FONT, "Microsoft YaHei", "SimHei", "DejaVu Sans"], + "axes.unicode_minus": False, + } +) + + +def _box(ax, xy, w, h, title, fc, ec, subtitle=None, fs=9): + x, y = xy + ax.add_patch( + FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.02,rounding_size=0.07", + linewidth=1.2, + edgecolor=ec, + facecolor=fc, + ) + ) + if subtitle: + ax.text(x + w / 2, y + h * 0.65, title, ha="center", va="center", fontsize=fs, fontweight="bold") + ax.text(x + w / 2, y + h * 0.3, subtitle, ha="center", va="center", fontsize=fs - 1.5, color="#475569") + else: + ax.text(x + w / 2, y + h / 2, title, ha="center", va="center", fontsize=fs, fontweight="bold") + + +def _arr(ax, p0, p1, text=None, color="#64748b", dashed=False): + style = "Simple,tail_width=0.45,head_width=3.8,head_length=5.5" + ax.add_patch( + FancyArrowPatch( + p0, + p1, + arrowstyle=style, + linewidth=1.0, + linestyle="-" if not dashed else (0, (4, 3)), + color=color, + shrinkA=1, + shrinkB=1, + connectionstyle="arc3,rad=0.0", + ) + ) + if text: + mx, my = (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2 + ax.text(mx, my + 0.08, text, ha="center", va="bottom", fontsize=7.5, color="#334155") + + +def render_system_architecture(path: Path) -> None: + fig, ax = plt.subplots(figsize=(9.2, 5.2), dpi=240) + ax.set_xlim(0, 9.2) + ax.set_ylim(0, 5.2) + ax.axis("off") + + layers = [(3.55, 1.05, "感知层"), (2.05, 1.25, "软件处理层"), (0.35, 1.05, "执行与呈现层")] + for y, h, name in layers: + ax.add_patch(FancyBboxPatch((0.2, y), 8.8, h, boxstyle="round,pad=0.01,rounding_size=0.05", fc="#f8fafc", ec="#d1d5db", lw=0.8)) + ax.text(0.35, y + h - 0.15, name, fontsize=8, fontweight="bold", color="#64748b") + + _box(ax, (0.45, 3.75), 1.85, 0.62, "左触觉传感器", "#eff6ff", "#3b82f6", "USB 摄像头") + _box(ax, (7.0, 3.75), 1.85, 0.62, "右触觉传感器", "#eff6ff", "#3b82f6", "USB 摄像头") + _box(ax, (3.1, 2.35), 3.0, 0.72, "触觉力计算 / 光流", "#eef2ff", "#6366f1", "法向力 · 切向力 · 图像") + _box(ax, (0.45, 2.35), 2.0, 0.62, "智能夹持状态机", "#fff7ed", "#d97706") + _box(ax, (6.85, 2.35), 2.0, 0.62, "夹爪控制器", "#f1f5f9", "#64748b", "Modbus RTU") + _box(ax, (3.1, 0.55), 3.0, 0.62, "GUI 监控界面", "#ecfdf5", "#059669", "3D · 光流 · 曲线 · 状态") + _box(ax, (6.85, 0.55), 2.0, 0.62, "智能夹爪", "#f1f5f9", "#475569") + + _arr(ax, (1.37, 3.75), (3.5, 3.07), "视频帧") + _arr(ax, (7.0, 3.75), (6.7, 3.07), "视频帧") + _arr(ax, (3.1, 2.72), (2.45, 2.68), "力信号") + _arr(ax, (2.45, 2.5), (6.85, 2.66), "串口命令") + _arr(ax, (7.85, 2.35), (7.85, 1.17), "驱动") + _arr(ax, (4.6, 2.35), (4.6, 1.17), "图像/力值") + _arr(ax, (1.45, 2.35), (3.4, 0.9), "状态") + # feedback: gripper → state machine (dotted, via bottom) + ax.plot([7.85, 7.85, 0.15, 0.15], [0.55, 0.22, 0.22, 2.66], color="#94a3b8", lw=0.9, linestyle=(0, (4, 3))) + ax.annotate("", xy=(0.45, 2.66), xytext=(0.15, 2.66), + arrowprops=dict(arrowstyle="-|>", color="#94a3b8", lw=0.9)) + ax.text(4.0, 0.1, "位置反馈(虚线)", fontsize=7.5, color="#94a3b8", ha="center") + ax.add_patch( + FancyArrowPatch( + (6.85, 0.86), + (1.45, 2.2), + arrowstyle="Simple,tail_width=0.4,head_width=3.5,head_length=5", + linewidth=0.9, + linestyle=(0, (4, 3)), + color="#94a3b8", + connectionstyle="arc3,rad=-0.15", + shrinkA=2, + shrinkB=2, + ) + ) + ax.text(4.2, 1.45, "位置反馈", fontsize=7.5, color="#64748b", ha="center") + + fig.savefig(path, bbox_inches="tight", facecolor="white", pad_inches=0.08) + plt.close(fig) + + +def render_grip_flow(path: Path) -> None: + fig, ax = plt.subplots(figsize=(10.5, 2.5), dpi=240) + ax.set_xlim(0, 10.5) + ax.set_ylim(0, 2.5) + ax.axis("off") + + specs = [ + (0.2, "等待触发", "open_wait", "#eff6ff", "#2563eb"), + (2.1, "闭合中", "closing", "#fff7ed", "#d97706"), + (4.0, "夹持加力", "gripping", "#fff7ed", "#d97706"), + (5.9, "夹持检测", "hold_check", "#fdf2f8", "#db2777"), + (7.8, "张开恢复", "open_recover", "#ecfdf5", "#059669"), + ] + w, h, y = 1.55, 0.88, 1.35 + xs = [] + for x, cn, en, fc, ec in specs: + _box(ax, (x, y), w, h, cn, fc, ec, en) + xs.append(x + w / 2) + + for i, label in enumerate(["侧边触发", "接触", "到保持力", "取物"]): + _arr(ax, (xs[i] + w / 2 - 0.02, y + h / 2), (xs[i + 1] - w / 2 + 0.02, y + h / 2), label) + + ax.plot([xs[-1], xs[-1], xs[0], xs[0]], [y, 0.55, 0.55, y], color="#2563eb", lw=1.0) + ax.add_patch(FancyArrowPatch((xs[0], y), (xs[0], y + 0.01), arrowstyle="Simple,head_width=3.5,head_length=5", color="#2563eb")) + ax.annotate("", xy=(xs[0], y), xytext=(xs[0], 0.55), arrowprops=dict(arrowstyle="-|>", color="#2563eb", lw=1.0)) + ax.text(4.9, 0.38, "恢复 + rearm", ha="center", fontsize=7.5, color="#1e40af") + + ax.plot([xs[1], xs[1], xs[-1]], [y, 0.85, 0.85], color="#dc2626", lw=1.0, linestyle=(0, (4, 3))) + ax.annotate("", xy=(xs[-1], 0.85), xytext=(xs[1], 0.85), arrowprops=dict(arrowstyle="-|>", color="#dc2626", lw=1.0, linestyle=(0, (4, 3)))) + ax.text(6.2, 0.72, "闭合超时", ha="center", fontsize=7.5, color="#b91c1c") + + fig.savefig(path, bbox_inches="tight", facecolor="white", pad_inches=0.08) + plt.close(fig) + + +def main() -> None: + render_system_architecture(OUT / "system-architecture.png") + render_grip_flow(OUT / "grip-state-flow.png") + print("Wrote", OUT / "system-architecture.png") + print("Wrote", OUT / "grip-state-flow.png") + + +if __name__ == "__main__": + main() diff --git a/build/requirements-venv-win-x64.txt b/build/requirements-venv-win-x64.txt new file mode 100644 index 0000000..4e15953 --- /dev/null +++ b/build/requirements-venv-win-x64.txt @@ -0,0 +1,22 @@ +# Gripper Demo 02 Windows x64 / CPython 3.11 venv lock. +# Optional CUDA, RAFT, CuPy and Open3D backends are intentionally excluded. + +numpy==2.4.4 +opencv-python==5.0.0.93 +llvmlite==0.48.0 +numba==0.66.0 +scipy==1.17.1 + +PyQt5==5.15.11 +PyQt5-Qt5==5.15.2 +PyQt5_sip==12.18.0 +pyqtgraph==0.14.0 + +PyOpenGL==3.1.10 +PyOpenGL-accelerate==3.1.10 + +minimalmodbus==2.1.1 +pyserial==3.5 + +pyinstaller==6.21.0 + diff --git a/config/compound_eye.png b/config/compound_eye.png index 1f99841..ba0ea4a 100644 Binary files a/config/compound_eye.png and b/config/compound_eye.png differ diff --git a/config/ddjx01_q6a.json b/config/ddjx01_q6a.json new file mode 100644 index 0000000..21b5e96 --- /dev/null +++ b/config/ddjx01_q6a.json @@ -0,0 +1,44 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/config/gripper.json b/config/gripper.json index fa3f333..f8d73a0 100644 --- a/config/gripper.json +++ b/config/gripper.json @@ -1,58 +1,58 @@ { - "video1": "0", + "video1": "2", "video2": "1", - "sensor_config1": "./config/ddjx01.json", - "sensor_config2": "./config/ddjx01.json", + "sensor_config1": "./config/ddjx01_q6a.json", + "sensor_config2": "./config/ddjx01_q6a.json", "sensor_fps": 30.0, "motion_threshold": 0.0, "sensor_cpu": false, - "port": "COM5", + "port": "COM9", "slave_id": 1, "baudrate": 115200, "serial_timeout": 0.2, "open_pos": 0, "close_pos": 9000, - "speed": 50, - "accel": 80, - "decel": 80, + "speed": 55, + "accel": 70, + "decel": 70, "release_open_speed": 50, - "release_open_accel": 120, - "release_open_decel": 120, - "open_force": 20, - "close_force": 20, - "grip_start_force": 20, - "hold_force": 20, - "initial_force": 20, + "release_open_accel": 70, + "release_open_decel": 70, + "open_force": 25, + "close_force": 33, + "grip_start_force": 25, + "hold_force": 33, + "initial_force": 25, "force_min": 20, - "force_max": 80, + "force_max": 99, "control_hz": 30.0, "baseline_seconds": 1.0, "filter_alpha": 0.55, "shear_filter_alpha": 0.65, "trigger_normal_force": 0.1, "trigger_shear_force": 0.1, - "trigger_clear_stable_seconds": 0.2, + "trigger_clear_stable_seconds": 0.15, "open_wait_stable_samples": 12, - "open_wait_stable_range_n": 0.03, + "open_wait_stable_range_n": 0.01, "open_wait_stable_trend_n": 0.015, "grip_normal_force": 0.2, "grip_requires_both": true, - "close_timeout_seconds": 5.0, + "close_timeout_seconds": 3.5, "closing_command_interval": 0.2, - "force_ramp_step": 5, - "force_ramp_interval": 0.02, - "grip_settle_seconds": 0.05, + "force_ramp_step": 1, + "force_ramp_interval": 0.1, + "grip_settle_seconds": 0.15, "adaptive_grip_enabled": true, "adaptive_shear_start_force": 0.1, "adaptive_shear_full_force": 0.6, - "adaptive_force_min": 15, + "adaptive_force_min": 33, "adaptive_force_max": 80, - "adaptive_force_step": 1, - "adaptive_force_interval": 0.05, - "release_arm_delay_seconds": 0.8, - "release_normal_change": 0.2, - "release_shear_change": 0.2, - "rearm_seconds": 0.5, + "adaptive_force_step": 5, + "adaptive_force_interval": 0.03, + "release_arm_delay_seconds": 1.2, + "release_normal_change": 0.35, + "release_shear_change": 0.35, + "rearm_seconds": 0.4, "recover_normal_force": 0.02, "recover_shear_force": 0.02, "recover_stable_seconds": 0.4, diff --git a/docs/GUI_USER_MANUAL.zh-CN.md b/docs/GUI_USER_MANUAL.zh-CN.md new file mode 100644 index 0000000..31b6c22 --- /dev/null +++ b/docs/GUI_USER_MANUAL.zh-CN.md @@ -0,0 +1,749 @@ +# Orisys 夹爪 GUI 使用说明书 + +> **正式产品说明书(HTML,含截图):** [产品使用说明书](PRODUCT_MANUAL.zh-CN.html) + +> 文档版本:V0.1(快速初版) +> 更新日期:2026-07-17 +> 适用对象:现场演示人员、设备调试人员、参数维护人员 +> 对应程序:Orisys 夹爪当前主分支 GUI + +## 目录 + +1. [文档说明](#1-文档说明) +2. [使用前检查](#2-使用前检查) +3. [快速启动](#3-快速启动) +4. [主界面总览](#4-主界面总览) +5. [顶部状态与程序控制栏](#5-顶部状态与程序控制栏) +6. [夹爪 3D 模型模块](#6-夹爪-3d-模型模块) +7. [左右光流窗口](#7-左右光流窗口) +8. [曲线图模块](#8-曲线图模块) +9. [夹爪控制模块](#9-夹爪控制模块) +10. [传感器控制模块](#10-传感器控制模块) +11. [智能夹持标准流程](#11-智能夹持标准流程) +12. [状态与动作说明](#12-状态与动作说明) +13. [设置模块](#13-设置模块) +14. [参数分组说明](#14-参数分组说明) +15. [布局管理](#15-布局管理) +16. [日志说明](#16-日志说明) +17. [常见故障排查](#17-常见故障排查) +18. [快捷键和退出](#18-快捷键和退出) +19. [现场操作检查表](#19-现场操作检查表) + +## 1. 文档说明 + +本说明书介绍 Orisys 夹爪图形界面的启动、监控、智能夹持、手动控制、参数设置、布局管理和故障排查。 + +系统由以下部分组成: + +- 左、右两个视觉触觉传感器及摄像头。 +- 通过 Modbus RTU 串口控制的夹爪。 +- 触觉力计算和自动夹持控制程序。 +- PyQt5 GUI、夹爪 3D 模型、光流画面和六路力曲线。 + +数据和控制关系如下: + +```text +左/右摄像头 -> Orisys 触觉计算 -> 法向力/切向力/光流画面 + | + v + 智能夹持状态机 + | + v +串口 <-------------------------- 夹爪控制器 + | + v + GUI 状态、3D 模型、曲线 +``` + +## 2. 使用前检查 + +### 2.1 硬件检查 + +1. 确认夹爪电源、控制器和机械连接正常。 +2. 确认左、右触觉传感器摄像头均已连接。 +3. 确认 USB 串口已经识别,Windows 下记下 `COM` 端口号。 +4. 清理夹爪开合范围内的物体,首次调试先使用较低速度和力度。 +5. 启动和重新标定基准时,让两侧触觉传感器保持空载、静止。 + +### 2.2 关键文件 + +打包版程序目录中的关键内容如下,运行时应保留完整目录结构: + +```text +GripperDemo02/ +├─ GripperDemo02.exe +├─ _internal/ +├─ config/ +│ ├─ gripper.json +│ ├─ ddjx01_q6a.json +│ ├─ compound_eye.png +│ └─ gui_layout.json # 保存布局后生成 +├─ sdk/ +├─ crt_ctag2f90d_gripper_visualization/ +└─ logs/ # EXE 首次运行后生成 +``` + +主要配置文件: + +- `config/gripper.json`:摄像头、串口、位置、速度、力度和状态机参数。 +- `config/ddjx01_q6a.json`:触觉传感器和光流算法参数。 +- `config/gui_layout.json`:窗口、浮动面板、3D 视角和画面方向。 + +## 3. 快速启动 + +### 3.1 Windows 打包版 + +1. 解压整个发布包,不要只取出 EXE。 +2. 打开 `config/gripper.json`。 +3. 将 `video1`、`video2` 设置为左右摄像头编号,默认是 `"0"`、`"1"`。 +4. 将 `port` 设置为实际串口,例如 `"COM5"`。 +5. 双击 `GripperDemo02.exe`。 +6. GUI 显示后会自动启动控制程序,并默认进入智能模式。 +7. 启动阶段夹爪先张开,随后进行传感器基准标定。此时保持两侧传感器空载。 +8. 确认左右传感器状态点有数据、FPS 正常,并等待顶部显示 `open-wait-armed(已就绪)`。 + +程序窗口出现后通常不需要再次点击顶部“启动”图标。只有控制程序已经停止并回到空闲状态时,才使用该图标重新启动。 + +### 3.2 源码运行 + +Windows: + +```powershell +conda activate py311 +python -m gripper.visualizer +``` + +窗口模式: + +```powershell +python -m gripper.visualizer --windowed +``` + +只跳过真实夹爪串口动作、保留传感器和 GUI: + +```powershell +python -m gripper.visualizer --windowed --dry-run +``` + +Linux: + +```bash +source .venv/bin/activate +python -m gripper.visualizer --windowed +``` + +Linux 下串口通常为 `/dev/ttyUSB0`,摄像头通常对应 `/dev/video0`、`/dev/video1`。Windows、Linux x86_64 和 Linux ARM64 使用各自平台构建的程序,Windows EXE 不跨平台运行。 + +## 4. 主界面总览 + +主界面分为以下模块: + +| 模块 | 主要用途 | +| --- | --- | +| 顶部状态与程序控制栏 | 查看状态,打开设置,启动、停止或重启控制程序,管理窗口 | +| 夹爪 3D 模型 | 查看夹爪实时开合位置、力比例和控制状态 | +| 左右光流窗口 | 查看两个传感器的光流箭头图或幅值图 | +| 曲线图区域 | 查看左右法向力、切向力 X/Y 的最近趋势 | +| 夹爪控制区域 | 切换智能/手动模式,设置速度、最大力和位置,执行手动动作 | +| 传感器控制区域 | 查看左右传感器状态、FPS、力值和夹爪运行参数,重启单侧传感器 | + +光流窗口、曲线图、夹爪控制和传感器控制都是 3D 工作区上的浮动面板,可以移动和调整大小。 + +## 5. 顶部状态与程序控制栏 + +### 5.1 状态显示 + +顶部左侧显示两个字段: + +- `state`:控制状态,例如等待触发、闭合中、夹持检测。 +- `action`:当前正在执行的细分动作,例如等待稳定、检测到物体、释放张开。 + +界面同时保留英文状态码和中文说明,便于和日志内容对应。 + +### 5.2 顶部图标 + +从设置图标开始,常用图标功能如下: + +| 图标 | 功能 | 说明 | +| --- | --- | --- | +| 设置 | 打开设置 | 调整画面、布局和 `gripper.json` 参数 | +| 启动 | 启动控制程序 | 空闲状态可用;重新创建传感器、串口和控制器 | +| 停止 | 停止控制程序 | 运行状态可用;停止过程需要等待设备释放 | +| 重启 | 重启控制程序 | 先停止并释放旧控制器,再重新连接和标定 | +| 最小化 | 最小化主窗口 | 不停止控制程序 | +| 全屏 | 全屏/窗口切换 | 只改变显示方式 | +| 关闭 | 退出程序 | 停止控制线程并关闭主窗口 | + +鼠标悬停在图标上可查看提示。启动、停止、重启过程中,相关按钮会自动锁定,避免重复操作。 + +注意:顶部“停止”是软件控制流程的停止按钮,不是独立的硬件急停装置。 + +## 6. 夹爪 3D 模型模块 + +### 6.1 显示内容 + +3D 模型根据实时夹爪位置切换开合姿态。区域内还显示: + +- 当前控制状态。 +- 左、右法向力。 +- 当前夹爪位置。 +- 当前力比例 `Force`。 +- 自适应目标力比例 `Target`。 + +### 6.2 视角操作 + +- 鼠标左键拖动:旋转观察视角。 +- 鼠标中键拖动:平移观察中心。 +- 鼠标滚轮:缩放模型。 + +保存当前布局时,3D 相机中心、距离、俯仰角、方位角和视场角会一并保存。 + +### 6.3 3D 模型异常 + +若该区域显示依赖或模型错误提示,依次检查: + +1. `PyOpenGL` 和 `pyqtgraph.opengl` 是否存在。 +2. 显卡驱动和 OpenGL 是否正常。 +3. `crt_ctag2f90d_gripper_visualization` 目录及 STL/URDF 文件是否完整。 +4. EXE 是否仍位于完整打包目录内。 + +3D 模型异常不会改变配置文件,但会影响夹爪姿态的可视化观察。 + +## 7. 左右光流窗口 + +### 7.1 光流与幅值 + +左右窗口分别对应两个触觉传感器。点击窗口右上角的模式按钮,可在以下画面间切换: + +- `光流`:显示光流方向箭头,用于观察触觉表面的位移方向。 +- `幅值`:显示光流变化强度,用于观察形变集中区域。 + +按钮文字表示当前显示模式。幅值画面暂时无数据时,窗口会回退显示光流画面。 + +### 7.2 移动和缩放 + +- 拖动窗口标题栏或画面区域可移动窗口。 +- 将鼠标移到窗口右下角约 18 像素区域,拖动可改变大小。 +- 图像始终保持原始宽高比,不会为了填满窗口而拉伸变形。 +- 窗口会被限制在 3D 工作区内,防止完全移出可视区域。 + +### 7.3 旋转和镜像 + +进入“设置 -> 画面”,左右画面可分别执行: + +- `旋转`:每次顺时针旋转 90 度。 +- `水平`:切换水平镜像。 +- `垂直`:切换垂直镜像。 +- `重置`:恢复 0 度并关闭水平、垂直镜像。 + +画面变化立即生效。需要下次启动继续保留时,再进入“设置 -> 布局”执行“保存当前布局”。 + +## 8. 曲线图模块 + +### 8.1 曲线布局 + +曲线采用 3 行 x 2 列排列: + +| 行 | 左列 | 右列 | +| --- | --- | --- | +| 第 1 行 | 左法向力 | 右法向力 | +| 第 2 行 | 左切向力 X | 右切向力 X | +| 第 3 行 | 左切向力 Y | 右切向力 Y | + +横轴为时间,单位秒;纵轴为力,单位 N。 + +### 8.2 曲线显示规则 + +- 显示最近约 6 秒数据,最多保留 180 个 GUI 刷新点。 +- 默认 Y 轴范围为 `0~1 N`。 +- 数据超过当前范围后,Y 轴自动向上扩展。 +- 启动或重新启动控制程序时,曲线历史和 Y 轴范围重置。 +- 曲线经过平滑处理,适合观察趋势。 +- GUI 将小于 `0.05 N` 的显示值归零,负值也按 0 绘制。 +- 曲线区禁用了单图鼠标缩放和右键菜单,避免六张图的比例被分别改乱。 + +移动曲线面板时拖动“曲线图区域”标题,调整面板大小时拖动面板右下角。 + +## 9. 夹爪控制模块 + +### 9.1 智能模式 + +程序启动后默认选择智能模式。智能模式由触觉信号自动完成触发闭合、接触检测、逐步加力、自适应夹持和取物释放。 + +智能模式下,速度、最大力、位置滑条和手动动作按钮被锁定,避免手动命令干扰自动状态机。 + +### 9.2 手动模式 + +控制程序处于运行状态时,点击“手动模式”后可操作: + +- 速度。 +- 最大力。 +- 夹爪位置。 +- 保持/释放。 +- 夹爪张开。 +- 夹爪闭合。 + +控制程序停止时,手动控件保持禁用。 + +### 9.3 速度 + +- 范围:`0~100%`。 +- 左右箭头步进:`5%`。 +- 拖动或点击调整后,本次运行的控制器速度会更新。 +- 运行时调整不会自动写回 `gripper.json`。 + +### 9.4 最大力 + +- 范围:`0~100%`。 +- 左右箭头步进:`5%`。 +- 调整滑条本身不会立即驱动夹爪。 +- 下一次执行张开、闭合、保持或指定位置动作时,该值随命令发送。 +- 实际执行值仍受 `force_min` 和 `force_max` 限制。 + +### 9.5 夹爪位置 + +- 可调范围由 `open_pos` 与 `close_pos` 的较小值和较大值确定。 +- 左右箭头步进:`100`。 +- 拖动滑条时只更新目标显示,释放滑条后发送位置命令。 +- 点击左右箭头后立即发送新的位置命令。 +- GUI 收到位置到达反馈后恢复跟随实际位置;长时间无到达反馈时约 3 秒后恢复跟随。 + +### 9.6 手动动作按钮 + +| 按钮 | 行为 | +| --- | --- | +| 夹爪张开 | 以当前速度、最大力移动到 `open_pos` | +| 夹爪闭合 | 以当前速度、最大力移动到 `close_pos` | +| 保持 | 读取当前位置,以当前最大力保持该位置;按钮文字变为“释放” | +| 释放 | 取消手动保持状态,回到手动待机 | + +“释放”不等同于发送张开命令。需要物理张开夹爪时,点击“夹爪张开”。 + +## 10. 传感器控制模块 + +### 10.1 传感器状态 + +左右传感器分别显示状态点和 FPS: + +- 绿色:当前状态包中存在该侧传感器样本。 +- 红色:当前没有收到该侧传感器样本。 +- `--`:尚未获得有效状态或 FPS。 + +FPS 文本约每秒更新一次。短时间红点可能来自相机启动、重启或临时取帧中断,应结合日志判断。 + +### 10.2 单侧重启 + +左右传感器各有一个重启图标,仅在控制程序运行时启用。点击后会停止该侧旧采集进程、清理队列并重新拉起。 + +单侧重启不会重新执行整个控制程序的力基准标定。相机恢复后若力值零点明显漂移,应使用顶部“重启控制程序”,并在标定阶段保持传感器空载。 + +### 10.3 数据卡 + +| 数据 | 含义 | +| --- | --- | +| 左/右法向力 | 垂直触觉表面的力,单位 N | +| 左/右切向力 | 触觉表面平面内的合力,单位 N | +| 夹爪当前力比例 | 当前控制状态使用的力百分比 | +| 夹爪目标力比例 | 自适应夹持计算出的目标力百分比 | +| 速度 | 当前控制器速度百分比 | + +## 11. 智能夹持标准流程 + +1. 启动控制程序后,夹爪先移动到张开位置。 +2. 系统在 `baseline_seconds` 内标定传感器基准,此时保持两侧空载。 +3. 状态进入 `open_wait(等待触发)`,先采集稳定样本。 +4. 顶部动作显示 `open-wait-armed(已就绪)` 后,用物体从任意一侧轻扫触觉传感器。 +5. 法向力或切向力变化达到阈值后,动作显示 `side-trigger-close`,夹爪开始闭合。 +6. 将物体移动到夹爪中间。默认配置要求左右两侧都达到接触阈值。 +7. 检测到物体后,夹爪先用较低力度保持当前位置,再逐步加到基础保持力。 +8. 系统根据切向力估计负载,按小步进提高目标夹紧力。 +9. 自适应加力稳定后,系统重新记录释放参考值并进入释放检测。 +10. 取走或移动物体造成法向力、切向力变化达到释放阈值时,夹爪张开。 +11. 状态进入 `open_recover(张开恢复)`,等待位置到位或残余力清除。 +12. 满足恢复和休息时间后重新进入等待触发。 + +若闭合超过 `close_timeout_seconds` 仍未检测到物体,系统会执行超时张开。 + +## 12. 状态与动作说明 + +### 12.1 state 状态 + +| state | 中文显示 | 含义 | +| --- | --- | --- | +| `open_wait` | 等待触发 | 建立稳定基准或等待新的侧边触发 | +| `closing` | 闭合中 | 已触发闭合,正在寻找物体 | +| `gripping` | 夹持加力 | 已检测到物体,正在逐步增加夹持力 | +| `hold_check` | 夹持检测 | 自适应加力、保持及释放检测 | +| `open_recover` | 张开恢复 | 已张开,等待位置或力信号恢复 | +| `manual_close` | 手动闭合 | 手动闭合状态 | +| `manual_hold` | 手动保持 | 手动保持当前位置 | +| `manual_position` | 手动到位 | 手动移动到指定位置 | +| `error` | 错误 | 控制线程发生异常,结合日志排查 | + +### 12.2 常见 action 动作 + +| action | 含义 | +| --- | --- | +| `waiting-for-sensor-samples` | 等待左右传感器数据 | +| `open-wait-stabilizing` | 正在建立稳定触发基准 | +| `open-wait-armed` | 触发检测已经就绪 | +| `side-trigger-close` | 侧边触发后开始闭合 | +| `closing-to-grip` | 闭合寻找物体 | +| `closing-continue` | 按间隔继续发送闭合命令 | +| `object-detected-low-force` | 已检测到物体,切换到低力保持 | +| `close-timeout-open` | 闭合超时,执行张开 | +| `grip-ramp-up` | 正在逐步增加基础夹持力 | +| `grip-hold` | 基础夹持力保持 | +| `hold-reference-armed` | 已记录夹持参考值 | +| `adaptive-grip-ramp` | 正在自适应增加夹持力 | +| `adaptive-grip-wait` | 等待下一次自适应加力 | +| `release-arm-delay` | 等待释放检测稳定 | +| `release-reference-armed` | 已重新记录释放参考值 | +| `hold-check` | 正在监测保持状态 | +| `force-change-release-open` | 检测到取物力变化并执行张开 | +| `open-recover-position` | 正在等待张开位置恢复 | +| `open-recover-position-stabilizing` | 张开位置已接近目标,等待稳定 | +| `open-recover-wait-force-clear` | 等待残余触觉力清除 | +| `open-recover-stabilizing` | 恢复条件已满足,等待稳定时间 | +| `open-ready` / `open-ready-position` | 恢复完成,准备重新等待触发 | +| `manual-*` | 当前执行的手动动作 | +| `left-sensor-restart` / `right-sensor-restart` | 对应传感器正在重启 | +| `*-sensor-restart-failed` | 对应传感器重启失败 | + +## 13. 设置模块 + +点击顶部设置图标打开设置。窗口模式下,设置作为独立窗口显示;全屏模式下,设置面板嵌入主界面并可拖动。 + +### 13.1 画面页 + +分别调整左、右光流画面的旋转和镜像。修改立即生效,保存布局后跨次启动保留。 + +### 13.2 布局页 + +#### 保存当前布局 + +保存以下内容到 `config/gui_layout.json`: + +- 主窗口普通状态的位置和大小。 +- 保存时的全屏或最大化状态。 +- 左右光流、曲线、夹爪控制、传感器控制面板的位置和大小。 +- 3D 相机中心、距离、俯仰角、方位角和视场角。 +- 左右画面的旋转和镜像。 + +#### 应用已保存布局 + +在当前会话中恢复: + +- 浮动面板的位置和大小。 +- 3D 相机视角。 +- 左右画面的旋转和镜像。 + +该按钮不会改变当前主窗口大小、位置或全屏状态。完整窗口状态会在下次启动时恢复;使用 `--windowed` 启动时会保持窗口模式。 + +保存布局不包含曲线历史、当前光流/幅值选择、智能/手动模式、滑条临时值和控制运行状态。 + +### 13.3 参数页 + +设置界面按功能显示当前 `gripper.json` 中已有的参数: + +- 相机 / 传感器。 +- 串口 / 设备。 +- 位置 / 运动。 +- 力度。 +- 控制 / 滤波。 +- 触发 / 夹持。 +- 自适应 / 释放。 +- 恢复。 +- 标定。 +- 其他未归类字段。 + +控件规则: + +- 布尔值使用复选框。 +- 字典、数组使用 JSON 文本编辑框。 +- 数字和字符串使用单行输入框。 + +底部按钮: + +- `重新载入`:重新读取磁盘配置,覆盖当前未保存编辑。 +- `保存到 gripper.json`:检查数据类型并写入配置文件。 +- `关闭`:关闭设置窗口。 + +保存配置后,点击顶部“重启控制程序”让全部参数重新加载。保存参数与保存布局是两个独立操作。 + +## 14. 参数分组说明 + +以下默认值来自当前 `config/gripper.json`。百分比参数通常使用 `0~100` 整数,力学阈值单位通常为 N,时间单位为秒。 + +### 14.1 相机 / 传感器 + +| 参数 | 默认值 | 作用 | +| --- | ---: | --- | +| `video1` | `"0"` | 左摄像头编号或设备路径 | +| `video2` | `"1"` | 右摄像头编号或设备路径 | +| `sensor_config1` | `./config/ddjx01_q6a.json` | 左触觉传感器配置 | +| `sensor_config2` | `./config/ddjx01_q6a.json` | 右触觉传感器配置 | +| `sensor_fps` | `30.0` | 传感器目标采集频率 | +| `motion_threshold` | `0.0` | Orisys 形变/运动检测阈值 | +| `sensor_cpu` | `false` | 是否要求传感器 SDK 使用 CPU 模式 | + +### 14.2 串口 / 设备 + +| 参数 | 默认值 | 作用 | +| --- | ---: | --- | +| `port` | `COM5` | 夹爪串口号;Linux 示例 `/dev/ttyUSB0` | +| `slave_id` | `1` | Modbus 从站 ID | +| `baudrate` | `115200` | 串口波特率 | +| `serial_timeout` | `0.2` | 单次串口操作超时时间 | + +### 14.3 位置 / 运动 + +| 参数 | 默认值 | 作用 | +| --- | ---: | --- | +| `open_pos` | `0` | 完全张开目标位置 | +| `close_pos` | `9000` | 完全闭合目标位置 | +| `speed` | `50` | 默认运动速度百分比 | +| `accel` | `80` | 默认加速度 | +| `decel` | `80` | 默认减速度 | +| `release_open_speed` | `50` | 检测到释放时的张开速度 | +| `release_open_accel` | `120` | 释放张开的加速度 | +| `release_open_decel` | `120` | 释放张开的减速度 | + +### 14.4 力度 + +| 参数 | 默认值 | 作用 | +| --- | ---: | --- | +| `open_force` | `20` | 张开动作使用的力比例 | +| `close_force` | `20` | 普通闭合动作使用的力比例 | +| `grip_start_force` | `20` | 检测到物体后先使用的低力 | +| `hold_force` | `20` | 基础保持目标力 | +| `initial_force` | `20` | 配置展示字段;当前运行值由 `open_force` 生成 | +| `force_min` | `20` | 所有夹爪力命令的下限 | +| `force_max` | `80` | 所有夹爪力命令的上限 | + +### 14.5 控制 / 滤波 + +| 参数 | 默认值 | 作用 | +| --- | ---: | --- | +| `control_hz` | `30.0` | 状态机目标控制频率 | +| `baseline_seconds` | `1.0` | 启动时空载基准标定时间 | +| `filter_alpha` | `0.55` | 法向力指数滤波权重;越大响应越快、平滑越弱 | +| `shear_filter_alpha` | `0.65` | 切向力指数滤波权重 | +| `dry_run` | `false` | 跳过真实夹爪串口动作,仅记录动作日志 | +| `open_at_end` | `true` | 控制循环结束时尝试发送张开命令 | +| `trigger_force_update` | `false` | 更新目标力后是否额外触发一次运动命令 | + +### 14.6 触发 / 夹持 + +| 参数 | 默认值 | 作用 | +| --- | ---: | --- | +| `trigger_normal_force` | `0.1` | 相对稳定基准的法向触发阈值 | +| `trigger_shear_force` | `0.1` | 相对稳定基准的切向触发阈值 | +| `trigger_clear_stable_seconds` | `0.2` | 兼容配置字段;当前重新就绪主要由稳定样本和恢复参数控制 | +| `open_wait_stable_samples` | `12` | 等待触发前所需稳定样本数 | +| `open_wait_stable_range_n` | `0.03` | 稳定窗口允许的力值范围 | +| `open_wait_stable_trend_n` | `0.015` | 稳定窗口允许的首尾趋势变化 | +| `grip_normal_force` | `0.2` | 判断触碰到物体的法向力阈值 | +| `grip_requires_both` | `true` | 是否要求左右两侧同时接触 | +| `close_timeout_seconds` | `5.0` | 闭合寻找物体的最长时间 | +| `closing_command_interval` | `0.2` | 闭合过程中重发命令的间隔 | +| `force_ramp_step` | `5` | 基础夹持每次增加的力比例 | +| `force_ramp_interval` | `0.02` | 基础夹持两次加力间隔 | +| `grip_settle_seconds` | `0.05` | 到达基础保持力后的稳定时间 | + +### 14.7 自适应 / 释放 + +| 参数 | 默认值 | 作用 | +| --- | ---: | --- | +| `adaptive_grip_enabled` | `true` | 启用按切向负载自适应加力 | +| `adaptive_shear_start_force` | `0.1` | 开始提高目标力的切向力 | +| `adaptive_shear_full_force` | `0.6` | 映射到最大目标力的切向力 | +| `adaptive_force_min` | `15` | 自适应目标力下限 | +| `adaptive_force_max` | `80` | 自适应目标力上限 | +| `adaptive_force_step` | `1` | 自适应每次增加的力比例 | +| `adaptive_force_interval` | `0.05` | 自适应两次加力间隔 | +| `release_arm_delay_seconds` | `0.8` | 停止加力后启用释放检测前的等待时间 | +| `release_normal_change` | `0.2` | 相对释放参考值的法向变化阈值 | +| `release_shear_change` | `0.2` | 相对释放参考值的切向变化阈值 | + +### 14.8 恢复 + +| 参数 | 默认值 | 作用 | +| --- | ---: | --- | +| `rearm_seconds` | `0.5` | 张开后重新允许触发前的最短休息时间 | +| `recover_normal_force` | `0.02` | 力恢复路径允许的最大法向残余力 | +| `recover_shear_force` | `0.02` | 力恢复路径允许的最大切向残余力 | +| `recover_stable_seconds` | `0.4` | 残余力满足阈值后的稳定时间 | +| `open_position_recover_enabled` | `true` | 启用按张开位置判断恢复 | +| `open_position_tolerance` | `100` | 实际位置接近 `open_pos` 的允许误差 | +| `open_position_stable_seconds` | `0.1` | 张开位置到位后的稳定时间 | +| `open_position_check_interval` | `0.1` | 读取张开位置的时间间隔 | +| `hold_fallback_pos` | `null` | 读取不到当前位置时使用的保持位置;`null` 表示不指定 | + +### 14.9 标定 + +`normal_force_calibration` 和 `shear_force_calibration` 是 JSON 对象,用于把传感器原始量映射到 N。常用字段: + +- `enabled`:是否启用该标定。 +- `method`:当前使用分段线性映射 `piecewise_linear`。 +- `extrapolate`:是否允许超出标定点后继续外推。 +- `clamp_output_min`:输出下限。 +- `points`:原始值与真实力值的标定点列表。 +- `signed`:切向分量是否保留方向符号。 +- `input`:切向标定输入形式,当前为 `components`。 + +标定点会直接影响界面力值、触发阈值、接触判断和释放检测。修改前应备份 `gripper.json`,并使用测量数据核对单位和方向。 + +## 15. 布局管理 + +### 15.1 推荐保存步骤 + +1. 调整主窗口到需要的窗口或全屏状态。 +2. 移动并缩放左右光流窗口。 +3. 移动并缩放曲线、夹爪控制、传感器控制面板。 +4. 调整 3D 模型视角。 +5. 在“设置 -> 画面”调整左右画面方向。 +6. 在“设置 -> 布局”点击“保存当前布局”。 + +浮动面板使用相对于 3D 工作区的比例保存。窗口分辨率变化后,程序按比例恢复并把面板限制在可视区域内。 + +### 15.2 恢复默认布局 + +关闭程序后,将 `config/gui_layout.json` 备份并删除,再启动 GUI,即可使用程序默认布局。布局文件损坏、版本不匹配或不存在时,程序也会使用默认布局。 + +## 16. 日志说明 + +### 16.1 打包版日志 + +EXE 日志位于程序同级 `logs` 目录: + +```text +logs/ +├─ latest.txt +└─ YYYYMMDD_HHMMSS_PID/ + ├─ main.log + ├─ worker_PID.log + └─ worker_PID.log +``` + +- `latest.txt`:最近一次运行的会话目录名。 +- `main.log`:GUI、控制器、串口、性能信息和异常堆栈。 +- `worker_*.log`:左右传感器进程日志,也包含 OpenCV 原生警告。 +- 程序自动保留最近 10 次运行会话。 + +### 16.2 源码运行日志 + +源码方式运行时,日志主要输出在启动程序的终端窗口,不自动使用 EXE 的会话日志重定向。 + +### 16.3 推荐查看顺序 + +1. 打开 `logs/latest.txt`。 +2. 进入对应会话目录。 +3. 先看 `main.log` 最后的异常和 `state=error` 原因。 +4. 相机或光流问题再看两个 `worker_*.log`。 +5. 将 `main.log` 和 worker 日志一起保留,便于复现启动顺序和设备状态。 + +## 17. 常见故障排查 + +### 17.1 左右传感器没有画面或 FPS 为 `--` + +检查顺序: + +1. 检查摄像头 USB 连接和系统相机权限。 +2. 检查 `video1`、`video2` 是否对应实际编号,并确认没有填成同一个相机。 +3. 关闭其他正在占用摄像头的软件。 +4. 点击对应传感器的单侧重启图标。 +5. 仍未恢复时点击顶部“重启控制程序”。 +6. 查看对应 `worker_*.log`。 + +Windows 日志连续出现 `CvCapture_MSMF::grabFrame ... can't grab frame`,通常表示相机已经被打开,但系统未能继续取到视频帧。重点检查相机占用、USB 连接、带宽和设备编号。 + +### 17.2 串口连接失败 + +检查: + +- `port` 是否为当前实际端口。 +- 夹爪电源是否开启。 +- `slave_id`、`baudrate` 是否与设备一致。 +- CH341 等串口驱动是否安装。 +- 串口是否被其他软件占用。 +- Linux 用户是否有 `dialout` 权限。 + +在 `main.log` 搜索 `serial`、`Modbus`、`motor is not connected` 和异常堆栈。 + +### 17.3 GUI 打开后立即进入 error + +1. 查看顶部 `action` 中的异常摘要。 +2. 打开最近会话的 `main.log`。 +3. 检查两个传感器进程是否都成功输出样本。 +4. 检查串口配置和 Orisys SDK。 +5. 使用 `--dry-run` 区分夹爪串口问题和传感器问题。 + +### 17.4 力值漂移或空载不归零 + +1. 移开两侧传感器上的全部负载。 +2. 点击顶部“重启控制程序”。 +3. 在 `baseline_seconds` 标定阶段保持设备静止。 +4. 检查光照、摄像头固定和触觉表面是否发生变化。 +5. 核对标定 JSON 和传感器配置。 + +### 17.5 FPS 较低或 GUI 卡顿 + +1. 检查 `config/ddjx01_q6a.json` 中光流算法是否为 `dis`。 +2. 检查是否错误使用了完整 CUDA/RAFT 打包环境。 +3. 比较 `sensor_cpu=true` 与 `false` 的实际性能。 +4. 适当降低 `sensor_fps` 或传感器分辨率。 +5. 更新显卡驱动并检查 3D OpenGL 是否正常。 +6. 查看 `main.log` 中 `[perf]` 性能记录和 worker 日志。 + +### 17.6 保存参数后没有生效 + +1. 确认点击的是“保存到 gripper.json”。 +2. 确认保存提示显示成功。 +3. 关闭设置窗口。 +4. 点击顶部“重启控制程序”。 +5. 检查设置标题显示的配置文件路径是否是当前程序正在使用的文件。 + +### 17.7 布局异常或面板位置不合适 + +- 当前窗口内恢复:设置 -> 布局 -> 应用已保存布局。 +- 恢复默认布局:关闭程序,备份并删除 `config/gui_layout.json`,重新启动。 +- 更换显示器后,程序会尽量将主窗口和面板限制在可见区域,但不同 DPI 下不保证像素级一致。 + +## 18. 快捷键和退出 + +| 快捷键 | 功能 | +| --- | --- | +| `Esc` | 在全屏与普通窗口之间切换 | +| `Q` | 退出 GUI | + +正常退出会请求停止控制器,并等待控制线程结束。`open_at_end=true` 时,控制器结束流程会尝试让夹爪回到张开位置;设备断电、串口异常或控制器尚未成功连接时,以现场设备实际状态为准。 + +## 19. 现场操作检查表 + +### 启动前 + +- [ ] 左右摄像头已经识别。 +- [ ] 夹爪电源和串口已经识别。 +- [ ] `video1`、`video2`、`port` 配置正确。 +- [ ] 夹爪开合区域已经清理。 +- [ ] 两侧传感器表面无预加载。 + +### 启动后 + +- [ ] 左右传感器有 FPS,状态点正常。 +- [ ] 3D 模型和夹爪实际位置基本一致。 +- [ ] 顶部没有 `state=error`。 +- [ ] 智能模式等待到 `open-wait-armed` 后再触发。 +- [ ] 首次手动测试使用较低速度和最大力。 + +### 参数或布局修改后 + +- [ ] 参数已保存到 `gripper.json`。 +- [ ] 已重启控制程序加载新参数。 +- [ ] 布局已单独执行“保存当前布局”。 +- [ ] 最近一次运行日志可以正常生成和定位。 + +--- + +本版本为快速初版,后续版本可补充带编号的主界面截图、设备接线图、参数调试示例和现场验收记录表。 diff --git a/docs/PRODUCT_MANUAL.zh-CN.html b/docs/PRODUCT_MANUAL.zh-CN.html new file mode 100644 index 0000000..1ed5a53 --- /dev/null +++ b/docs/PRODUCT_MANUAL.zh-CN.html @@ -0,0 +1,920 @@ + + + + + + + Orisys 夹爪产品使用说明书 + + + + +
+ + + +
+
+ + +
+

用户手册 · User Manual

+

Orisys 夹爪

+

图形化监控与智能夹持控制软件

+ + + + + + + +
文档编号GD02-UM-001
文档版本V1.0
发布日期2026-07-21
适用软件Orisys 夹爪 GUI(Release 20260717 及同系列版本)
适用平台Windows x64 / Linux x86_64
文档密级随产品交付资料
+ +
+ + +
+
+ DOCUMENT CONTROL +

版本履历

+
+ + + + + + + + + + + + + + + + + + +
表 0-1 文档修订记录
版本日期修订人修订说明
V1.02026-07-21—首版发布。含完整界面说明、操作流程、参数设置、故障排除及现场检查表。
+ + + + + + + + + + + +
表 0-2 适用读者
读者角色建议阅读章节
现场演示人员第 2、5、6、8 章及附录 A
设备调试人员第 4、7、9、10、11、12 章
参数维护人员第 10、11、12 章及附录 B
技术支持人员全文及《GUI 详细技术说明》
+
+ 说明 +

本手册描述软件操作与配置方法,不替代夹爪、传感器硬件厂商提供的机械安装与安全规范。硬件安装、电气接线须按对应硬件说明书执行。

+
+
+ + + + + +
+
+ CHAPTER 1 +

引言

+
+ +

1.1 编写目的

+

本手册用于指导用户正确安装、配置、操作和维护 Orisys 夹爪软件(以下简称「本软件」)。用户应在使用本软件前通读第 2 章安全须知,并按本手册规定步骤进行操作。

+ +

1.2 产品简介

+

Orisys 夹爪是一套集成视觉触觉感知、智能夹持控制与图形化监控的演示系统。系统通过左右两个视觉触觉传感器采集法向力与切向力信息,经 Modbus RTU 串口驱动夹爪执行动作,并在 PyQt5 图形界面中同步显示 3D 模型、光流画面、力曲线及运行状态。

+

本软件主要实现以下功能:

+
    +
  • 单侧触觉触发后自动闭合、接触检测、分步加力、自适应夹持及取物释放;
  • +
  • 实时显示夹爪三维姿态、左右传感器光流/幅值图像及六路力值曲线;
  • +
  • 提供智能模式与手动模式,支持速度、力度、位置等参数调节;
  • +
  • 支持运行参数在线编辑、工作区布局保存及运行日志记录。
  • +
+ +

1.3 术语与缩略语

+ + + + + + + + + + + + + + + +
表 1-1 术语定义
术语含义
法向力垂直于触觉敏感表面的力分量,单位 N
切向力沿触觉敏感表面平面内的合力,单位 N
光流基于图像序列估计的表面位移矢量场,用于可视化触觉形变
智能模式由状态机根据触觉信号自动控制夹爪,用户不可干预运动参数
手动模式用户通过界面直接下发张开、闭合、保持及位置命令
state控制状态机当前所处状态(如等待触发、闭合中、夹持检测等)
action当前控制周期内正在执行的具体动作描述
dry-run跳过真实串口动作、仅记录控制逻辑的调试模式
+ +

1.4 引用文件

+ + + + + + + + + + + +
表 1-2 相关文档
文档名称说明
config/gripper.json主运行配置文件
config/ddjx01_q6a.json触觉传感器及光流算法配置
docs/GUI_USER_MANUAL.zh-CN.mdGUI 详细技术说明(完整参数表及深度故障排查)
README.md开发与打包说明
+
+ + +
+
+ CHAPTER 2 +

安全须知

+
+ +
+ 警告 +

夹爪为运动部件,调试及演示过程中可能造成挤压伤害。操作前必须确认开合范围内无人员肢体及无关物体。首次运行须使用较低速度与力度。

+
+ +
+ 注意 +

本软件顶部「停止」按钮仅停止软件控制流程,不是独立的硬件急停装置。发生异常时应立即切断夹爪电源或执行硬件急停(如设备具备)。

+
+ +

2.1 一般安全要求

+
    +
  1. 仅经培训人员方可操作本系统;
  2. +
  3. 运行前检查夹爪安装牢固、电源及通信线缆连接可靠;
  4. +
  5. 传感器标定及系统重启标定阶段,须保持两侧触觉传感器空载且静止;
  6. +
  7. 修改力度、速度及触发阈值前,应备份 config/gripper.json;
  8. +
  9. 程序异常退出后,以夹爪实际物理状态为准,必要时手动断电复位。
  10. +
+ +

2.2 电气与设备安全

+
    +
  • 串口通信期间不得热插拔通信线缆(除非硬件支持);
  • +
  • USB 摄像头及串口设备应连接至稳定供电的 USB 端口,避免共用易过载的 USB Hub;
  • +
  • Linux 环境下运行用户须具备 video、dialout 设备组权限。
  • +
+
+ + +
+
+ CHAPTER 3 +

产品说明

+
+ +

3.1 系统组成

+

本系统由感知层、软件处理层及执行与呈现层构成。左、右触觉传感器采集视频帧并计算触觉力;智能夹持状态机根据力信号经 Modbus RTU 串口驱动夹爪;GUI 同步显示 3D 姿态、光流图像、力曲线及运行状态。

+
+ Orisys 夹爪 系统组成与数据流示意图 +
图 3-1 系统组成与数据流示意图。实线为主要控制/数据流;虚线为位置反馈。
+
+ +

3.2 配置清单

+ + + + + + + + + + + +
表 3-1 硬件配置参考清单
序号名称数量备注
1智能夹爪及驱动控制器1 套Modbus RTU,USB 转串口连接
2视觉触觉传感器组件2 套左、右各一,含独立 USB 摄像头
3通信及电源线缆若干按现场安装要求配置
4运行计算机1 台算力版
+ +

3.3 软件目录结构

+

发布包解压后须保持完整目录结构。禁止仅复制可执行文件至其他路径单独运行。

+
GripperDemo02/
+├── GripperDemo02.exe              # 主程序(Linux 无 .exe 后缀)
+├── _internal/                     # 运行时依赖库(禁止删除)
+├── config/
+│   ├── gripper.json               # 主配置文件
+│   ├── ddjx01_q6a.json                # 传感器配置文件
+│   ├── compound_eye.png           # 传感器资源
+│   └── gui_layout.json            # 界面布局(保存后生成)
+├── sdk/                           # 夹爪通信 SDK
+├── crt_ctag2f90d_gripper_visualization/   # 3D 模型资源
+└── logs/                          # 运行日志(首次运行后自动生成)
+
+ + +
+
+ CHAPTER 4 +

安装与配置

+
+ +

4.1 安装前检查

+ + + + + + + + + + + + + +
表 4-1 安装前检查项目
检查项检查方法合格标准
夹爪机械安装目视及手动检查安装牢固,开合路径无干涉
夹爪电源上电指示控制器正常上电
串口识别设备管理器 / dmesg端口已识别(如 COM5、/dev/ttyUSB0)
摄像头识别系统相机应用或设备列表两路摄像头均可独立打开
工作区域目视夹爪开合范围内无杂物
传感器状态目视触觉敏感面无预加载、无遮挡
+ +

4.2 基本配置

+

使用文本编辑器打开 config/gripper.json,至少须正确配置以下参数后方可运行。

+ + + + + + + + + + + + +
表 4-2 必配参数
参数名说明Windows 示例Linux 示例
video1左摄像头设备编号或路径"0""0" 或 "/dev/video0"
video2右摄像头设备编号或路径"1""1" 或 "/dev/video1"
port夹爪串口"COM5""/dev/ttyUSB0"
baudrate串口波特率115200(须与设备一致)
slave_idModbus 从站地址1(须与设备一致)
+ +
+ 说明 +

video1 与 video2 必须对应不同的物理摄像头。配置错误将导致单侧无数据或数据重复。

+
+ +

4.3 系统要求

+

本系统默认随算力版主机一并交付,出厂已完成操作系统、驱动及软件环境配置,用户开箱连接夹爪与传感器即可使用。若用户自备运行计算机,须满足表 4-3 所列环境要求,并自行完成摄像头、串口及依赖库配置。

+ + + + + + + + + + + + +
表 4-3 自备计算机运行环境(可选)
项目要求
部署方式算力版(默认交付)或用户自备计算机
操作系统Windows 10/11 x64,或 Ubuntu 20.04+ x86_64
显示分辨率 ≥ 1920×1080,支持 OpenGL 3.0+
内存≥ 8 GB
USB独立端口连接两路摄像头及串口设备
+
+ + +
+
+ CHAPTER 5 +

启动与停止

+
+ +

5.1 正常启动程序

+
    +
  1. 确认第 4 章检查项目均合格;
  2. +
  3. 双击运行 GripperDemo02.exe(Linux:./GripperDemo02);
  4. +
  5. 程序启动后自动创建控制线程,夹爪执行张开动作;
  6. +
  7. 系统在 baseline_seconds(默认 1.0 s)内完成传感器空载基准标定,此期间保持两侧传感器无接触;
  8. +
  9. 待顶部状态栏显示 state=open_wait(等待触发),且 action=open-wait-armed(已就绪);
  10. +
  11. 确认左右传感器状态指示为绿色,FPS 显示正常数值(非 --)。
  12. +
+ +
+ 说明 +

正常启动后无需再次点击顶部「启动」图标。仅当控制程序处于空闲(已停止)状态时,方使用该按钮重新建立设备连接。

+
+ +

5.2 正常停止与退出

+ + + + + + + + + + +
表 5-1 停止方式说明
操作作用范围说明
顶部「停止」控制线程停止状态机及传感器采集,释放串口与摄像头资源
顶部「关闭」或快捷键 Q整个程序先请求停止控制线程,等待结束后关闭窗口
顶部「重启」控制线程等效于先停止再启动,重新标定传感器基准
+

当配置项 open_at_end=true(默认)时,控制流程结束时会尝试发送夹爪张开命令。若设备已断电或通信异常,以夹爪实际状态为准。

+ +

5.3 源码方式运行(调试人员)

+
# Windows
+conda activate py311
+python -m gripper.visualizer --windowed
+
+# 跳过真实串口(传感器与 GUI 仍运行)
+python -m gripper.visualizer --windowed --dry-run
+
+ + +
+
+ CHAPTER 6 +

主界面说明

+
+ +

本软件主界面由顶部状态控制栏、中央 3D 工作区及若干浮动功能面板组成。浮动面板可拖动、调整大小;布局可通过设置模块保存并在下次启动时恢复。

+ +
+
+ Orisys 夹爪 主界面 +
A +
B +
C左光流 +
C右光流 +
D +
E +
F +
+
图 6-1 主界面总览。彩色框标注各功能区域,对应表 6-1 编号。
+
    +
  • A 顶部状态与控制栏
  • +
  • B 夹爪 3D 模型工作区
  • +
  • C 左/右光流窗口
  • +
  • D 六路力曲线图
  • +
  • E 夹爪控制区域
  • +
  • F 传感器控制区域
  • +
+
+ + + + + + + + + + + + + + +
表 6-1 主界面区域功能说明
区域编号名称功能说明
A顶部状态与控制栏显示 state、action;提供设置、启停、重启及窗口管理
B夹爪 3D 模型工作区显示夹爪实时开合姿态、当前状态及力/位置摘要
C左/右光流窗口分别显示左、右传感器光流或幅值图像
D曲线图区域六路力值(法向力、切向力 X/Y)实时趋势
E夹爪控制区域智能/手动模式切换;速度、力度、位置及动作命令
F传感器控制区域传感器在线状态、FPS、力值读数及单侧重启
+ +
+
+ 顶部状态与控制栏 +
1Logo +
2state / action +
3设置 +
4 +
5 +
6 +
7 +
+
图 6-2 顶部状态与控制栏局部放大标注。
+
    +
  • 1 品牌 Logo
  • +
  • 2 运行状态(state / action)
  • +
  • 3 设置
  • +
  • 4 启动控制
  • +
  • 5 停止控制
  • +
  • 6 重启控制程序
  • +
  • 7 最小化 / 全屏 / 关闭
  • +
+
+
+ + +
+
+ CHAPTER 7 +

功能模块操作

+
+ +

7.1 夹爪 3D 模型

+

3D 模型区域根据实时夹爪位置在预缓存的开合档位间切换,显示当前机械姿态。区域下方叠加显示左/右法向力、夹爪位置、当前力百分比及自适应目标力百分比。

+ + + + + + + + + + +
表 7-1 3D 视图操作
输入操作
鼠标左键拖动旋转观察视角
鼠标中键拖动平移观察中心
鼠标滚轮缩放
+
+
+ 夹持检测状态界面 +
1state=hold_check +
23D 开合姿态 +
3力/位置摘要 +
+
图 7-1 夹持检测(hold_check)状态示例:标注顶部状态行、3D 模型区及底部实时摘要。
+
    +
  • 1 状态栏显示 hold_check / 当前动作
  • +
  • 2 3D 模型反映当前开合档位
  • +
  • 3 左/右法向力、位置、力比例摘要
  • +
+
+ +

7.2 光流窗口

+

左、右光流窗口为独立浮动面板,分别对应两路触觉传感器。用户可通过右上角模式按钮在「光流」与「幅值」显示模式间切换。

+
+
+ 左右光流窗口 +
1左侧传感器 +
2右侧传感器 +
3光流/幅值切换 +
+
图 7-2 光流窗口特写。标注左右面板及模式切换按钮位置。
+
    +
  • 1 左传感器光流/幅值画面
  • +
  • 2 右传感器光流/幅值画面
  • +
  • 3 「光流 / 幅值」模式切换按钮
  • +
+
+ + + + + + + + + + + +
表 7-2 光流窗口操作
操作方法
切换显示模式点击窗口右上角「光流 / 幅值」按钮
移动窗口拖动标题栏或图像区域
调整大小拖动窗口右下角约 18 px 区域
旋转 / 镜像设置 → 画面 → 对应侧按钮(见 10.1 节)
+ +

7.3 力曲线图

+

曲线区域以 3 行 × 2 列布局显示六路力值,横轴为时间(秒),纵轴为力(牛顿)。默认显示最近约 6 s 数据;显示值经平滑处理,绝对值低于 0.05 N 时归零显示。

+
+
+ 六路力曲线图 +
D曲线图区域 +
1法向力 +
2 +
3 +
+
图 7-3 力曲线区域特写。三行分别对应法向力、切向力 X、切向力 Y。
+
    +
  • D 曲线图浮动面板整体
  • +
  • 1 第 1 行:左/右法向力
  • +
  • 2 第 2 行:左/右切向力 X
  • +
  • 3 第 3 行:左/右切向力 Y
  • +
+
+ +

7.4 传感器控制区域

+
+
+ 传感器控制区域 +
F +
1在线状态 +
2 +
3力值 / FPS +
+
图 7-4 传感器控制区域特写。标注在线指示、FPS 及力值读数位置。
+
    +
  • F 传感器控制浮动面板
  • +
  • 1 绿/红状态点(是否收到样本)
  • +
  • 2 左/右 FPS 显示
  • +
  • 3 法向/切向力读数及重启按钮
  • +
+
+ + + + + + + + + + + +
表 7-3 传感器区域显示项
显示项说明
左/右法向力、切向力经标定后的实时力值,单位 N
夹爪当前力比例 / 目标力比例控制器输出力百分比及自适应目标力百分比
速度当前控制器速度百分比
重启图标重启单侧传感器采集进程(不触发全程序基准重标定)
+
+ 注意 +

单侧重启仅恢复该侧相机采集。若力值零点出现明显漂移,应执行顶部「重启控制程序」,并在标定阶段保持传感器空载。

+
+
+ + +
+
+ CHAPTER 8 +

智能夹持标准流程

+
+ +

智能模式为本软件默认工作模式。该模式下,夹爪动作由触觉信号驱动的状态机自动控制,用户不应在演示过程中切换至手动模式或干预运动参数。

+ +
+ 智能夹持状态流转图 +
图 8-1 智能夹持状态流转图。蓝色回环:恢复后重新就绪;红色虚线:闭合超时路径。
+
+ +

8.1 标准演示步骤

+ + + + + + + + + + + + + + +
表 8-1 现场演示操作步骤
步骤操作预期现象
1按 5.1 节完成启动,等待就绪action=open-wait-armed(已就绪)
2用物体从任意一侧轻扫触觉传感器力值上升;action=side-trigger-close
3夹爪开始闭合,将物体移至钳口中央state=closing → gripping
4系统检测接触后低力保持,再分步加力3D 模型闭合;力比例逐步上升
5自适应补力完成后进入释放检测state=hold_check
6取走或移动物体法向/切向力变化超阈;夹爪张开
7等待恢复完成返回 open_wait,可进行下一次演示
+ +

8.2 运行约束

+
    +
  • 默认配置要求左右两侧均达到接触阈值(grip_requires_both=true)方判定夹持成功;
  • +
  • 闭合超时(默认 5 s,参数 close_timeout_seconds)未检测到物体时,系统自动张开;
  • +
  • 释放后须满足位置/力恢复条件并经过最短休息时间(rearm_seconds,默认 0.5 s),方可再次触发;
  • +
  • 休息期间即便传感器检测到扫过,也不会触发闭合。
  • +
+
+ + +
+
+ CHAPTER 9 +

手动控制操作

+
+ +

控制程序处于运行状态时,用户可点击「手动模式」切换至手动控制。手动模式下,用户可直接设定速度、最大力及目标位置,并执行张开、闭合、保持等命令。

+ +
+
+ 智能模式夹爪控制面板 +
E +
1智能模式(锁定) +
2滑条禁用 +
3 +
+
图 9-1 智能模式:模式切换、滑条与动作按钮均为灰色锁定状态。
+
    +
  • E 夹爪控制面板
  • +
  • 1 「智能模式」高亮,「手动模式」不可切换干预
  • +
  • 2 速度 / 最大力 / 位置滑条禁用
  • +
  • 3 张开 / 闭合 / 保持按钮禁用
  • +
+
+ +
+
+ 手动模式夹爪控制面板 +
E +
1手动模式 +
2动作按钮可用 +
+
图 9-2 手动模式:滑条与动作按钮均已启用,可直接下发命令。
+
    +
  • E 夹爪控制面板
  • +
  • 1 「手动模式」激活,可调整速度/力度/位置
  • +
  • 2 保持、张开、闭合按钮可点击
  • +
+
+ + + + + + + + + + + +
表 9-1 手动控制参数
参数范围步进生效方式
速度0~100%5%调整后立即更新控制器速度
最大力0~100%5%随下一次动作命令发送;受 force_min、force_max 约束
夹爪位置open_pos~close_pos100释放滑条或点击步进按钮后发送位置命令
+ + + + + + + + + + + + +
表 9-2 手动动作按钮
按钮功能
夹爪张开以当前速度、最大力运动至 open_pos
夹爪闭合以当前速度、最大力运动至 close_pos
保持读取当前位置并以当前最大力保持;按钮文字变为「释放」
释放取消手动保持状态,返回手动待机;不自动执行张开动作
+
+ + +
+
+ CHAPTER 10 +

参数配置与设置

+
+ +

点击顶部设置图标打开设置窗口。设置窗口分为「画面」「布局」及若干参数分组页,参数分组内容直接映射 config/gripper.json 文件字段。

+ +
+ 设置窗口-画面页 +
图 10-1 设置 — 画面页。可分别对左、右光流画面执行旋转(90° 步进)、水平镜像、垂直镜像及重置。
+
+ +
+ 设置窗口-布局页 +
图 10-2 设置 — 布局页。可将当前窗口尺寸、浮动面板位置、3D 视角及画面方向保存至 config/gui_layout.json。
+
+ +
+ 设置窗口-串口参数 +
图 10-3 设置 — 串口 / 设备页:配置 port、baudrate、slave_id 等通信参数。
+
+ +
+ 设置窗口-触发夹持参数 +
图 10-4 设置 — 触发 / 夹持页:触发阈值、接触判定、闭合超时及分步加力参数。
+
+ +

10.1 参数保存程序

+

在设置窗口中修改参数后,须按以下顺序操作,否则运行中的控制程序不会加载新值:

+
    +
  1. 点击「保存到 gripper.json」,确认保存成功提示;
  2. +
  3. 关闭设置窗口;
  4. +
  5. 点击顶部「重启控制程序」。
  6. +
+ +
+ 注意 +

「保存当前布局」与「保存到 gripper.json」为相互独立操作。布局文件 gui_layout.json 不包含运行参数;参数文件不包含曲线历史及当前运行状态。

+
+ +

10.2 常用参数速查

+ + + + + + + + + + + + + + + +
表 10-1 常用运行参数
参数默认值说明
trigger_normal_force0.1 N单侧法向力触发阈值
trigger_shear_force0.1 N单侧切向力触发阈值
grip_normal_force0.2 N判定接触的法向力阈值
grip_requires_bothtrue是否要求双侧同时接触
hold_force20%基础保持目标力百分比
adaptive_force_max80%自适应夹持力上限
release_normal_change0.2 N释放检测法向力变化阈值
rearm_seconds0.5 s张开后再次允许触发的最短休息时间
+

完整参数分组及默认值见《GUI 详细技术说明》(docs/GUI_USER_MANUAL.zh-CN.md)第 14 章。

+
+ + +
+
+ CHAPTER 11 +

运行状态与日志

+
+ +

11.1 状态码说明

+ + + + + + + + + + + + + +
表 11-1 主要 state 状态
state界面显示含义
open_wait等待触发夹爪张开,等待触觉触发
closing闭合中已触发,正在闭合并寻找物体
gripping夹持加力已检测到物体,正在增加夹持力
hold_check夹持检测自适应补力、保持及释放检测
open_recover张开恢复已张开,等待位置或力信号恢复
error错误控制线程异常,须查看日志
+ +

11.2 日志文件

+

打包版程序运行日志保存在程序目录下 logs/ 文件夹。程序自动保留最近 10 次运行会话。

+
logs/
+├── latest.txt                      # 最近一次会话目录名
+└── YYYYMMDD_HHMMSS_PID/
+    ├── main.log                    # GUI、控制器、串口及异常信息
+    └── worker_*.log                # 左、右传感器进程日志
+

建议故障排查顺序:读取 latest.txt → 打开对应会话目录 → 检查 main.log 末尾异常 → 必要时对照 worker_*.log。

+ +

11.3 快捷键

+ + + + + + + + + +
表 11-2 键盘快捷键
快捷键功能
Esc全屏与窗口模式切换
Q退出程序
+
+ + +
+
+ CHAPTER 12 +

故障排除

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
表 12-1 常见故障及处理
序号故障现象可能原因处理措施
1传感器无画面,FPS 显示 --摄像头未连接;设备编号错误;被其他程序占用检查 USB 连接及 video1/video2;关闭占用程序;执行单侧或全部重启
2串口连接失败端口号错误;驱动未安装;端口被占用;从站地址/波特率不匹配核对 port、baudrate、slave_id;检查 main.log 中 Modbus 相关报错
3启动后 state=error传感器或串口初始化失败;配置文件格式错误查看 main.log 异常栈;可用 --dry-run 区分串口与传感器故障
4空载力值不归零 / 漂移标定阶段有负载;光照或摄像头松动;标定参数不当移开负载后重启控制程序;标定阶段保持静止;检查标定 JSON
5修改参数未生效未保存文件或未重启控制程序确认保存成功并执行「重启控制程序」
63D 模型无法显示OpenGL 不可用;模型资源缺失更新显卡驱动;确认 crt_ctag2f90d_gripper_visualization 目录完整
7界面卡顿、FPS 低光流算法配置不当;分辨率或帧率过高确认 ddjx01_q6a.json 中算法为 dis;适当降低 sensor_fps
+
+ + +
+
+ CHAPTER 13 +

维护与定期检查

+
+ +

13.1 日常维护

+
    +
  • 保持触觉敏感面清洁,避免油污及硬质颗粒;
  • +
  • 定期检查 USB 及串口线缆有无松动或外皮破损;
  • +
  • 长期停用后重新启用前,执行一次完整重启并完成空载标定;
  • +
  • 重要参数变更前备份 config/gripper.json 及 config/gui_layout.json。
  • +
+ +

13.2 标定参数维护

+

normal_force_calibration 与 shear_force_calibration 用于将传感器原始量映射至牛顿单位,直接影响触发、接触及释放判断。修改标定点须有实测数据支持,修改后须重新验证全流程。

+
+ + +
+
+ APPENDIX +

附录

+
+ +

附录 A 现场操作检查表

+

启动前检查

+
    +
  • 左右摄像头已被操作系统识别
  • +
  • 夹爪电源及串口通信正常
  • +
  • video1、video2、port 配置正确
  • +
  • 夹爪开合范围内无人员及杂物
  • +
  • 两侧触觉传感器无预加载
  • +
+

启动后确认

+
    +
  • 左右传感器 FPS 正常,状态点为绿色
  • +
  • 3D 模型姿态与夹爪实际位置一致
  • +
  • 顶部状态无 error
  • +
  • 已等待 open-wait-armed(已就绪) 后再进行演示
  • +
+ +

附录 B 插图维护说明

+

本手册插图由软件界面自动截取生成。软件界面变更后,可在项目根目录执行以下命令重新生成插图:

+
conda activate py311
+python build/capture_gui_screenshots.py
+python build/generate_manual_diagrams.py
+

截图输出路径:docs/images/manual/;矢量示意图输出路径:docs/assets/diagrams/。

+ +
+

+ — 文档结束 · GD02-UM-001 V1.0 —
+ Orisys 夹爪产品使用说明书 +

+
+ +
+
+
+ + + + + + + diff --git a/docs/PRODUCT_MANUAL.zh-CN.md b/docs/PRODUCT_MANUAL.zh-CN.md new file mode 100644 index 0000000..359c163 --- /dev/null +++ b/docs/PRODUCT_MANUAL.zh-CN.md @@ -0,0 +1,29 @@ +# Orisys 夹爪产品使用说明书 + +> **正式版文档已迁移为 HTML 格式。** + +请打开以下文件查阅完整产品使用说明书(含封面、版本履历、规范图号表号及全部界面截图): + +**[`PRODUCT_MANUAL.zh-CN.html`](PRODUCT_MANUAL.zh-CN.html)** + +## 使用方式 + +1. 在文件管理器中双击 `docs/PRODUCT_MANUAL.zh-CN.html`,使用浏览器打开; +2. 点击右下角「打印 / 导出 PDF」或使用浏览器打印功能(Ctrl+P)导出 PDF; +3. 左侧导航栏可快速跳转章节。 + +## 相关文档 + +| 文档 | 说明 | +| --- | --- | +| [PRODUCT_MANUAL.zh-CN.html](PRODUCT_MANUAL.zh-CN.html) | 正式产品使用说明书(推荐) | +| [GUI_USER_MANUAL.zh-CN.md](GUI_USER_MANUAL.zh-CN.md) | 详细技术说明与完整参数表 | +| [assets/manual.css](assets/manual.css) | 说明书样式表 | +| [images/manual/](images/manual/) | 界面截图资源 | + +## 更新截图 + +```powershell +conda activate py311 +python build/capture_gui_screenshots.py +``` diff --git a/docs/assets/diagrams/grip-state-flow.png b/docs/assets/diagrams/grip-state-flow.png new file mode 100644 index 0000000..617a623 Binary files /dev/null and b/docs/assets/diagrams/grip-state-flow.png differ diff --git a/docs/assets/diagrams/grip-state-flow.svg b/docs/assets/diagrams/grip-state-flow.svg new file mode 100644 index 0000000..36cfe6e --- /dev/null +++ b/docs/assets/diagrams/grip-state-flow.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 等待触发 + open_wait + 已就绪 armed + + + 侧边触发 + + + 闭合中 + closing + 寻找物体 + + + 双侧接触 + + + 夹持加力 + gripping + 低力 → 基础力 + + + 到达保持力 + + + 夹持检测 + hold_check + 自适应补力 / 释放检测 + + + 取物释放 + + + 张开恢复 + open_recover + 位置 / 力恢复 + + + + 恢复完成 + 休息时间(rearm)→ 再次就绪 + + + + 闭合超时 close_timeout → 张开 + + + 阶段说明 + ① 等待触发:夹爪张开,单侧力超阈且为新上升沿时进入闭合。 + ② 闭合~夹持:物体接触后低力保持,分步加力至 hold_force,并按切向负载自适应补力。 + ③ 释放~恢复:力变化超阈则张开;满足恢复条件并经过 rearm_seconds 后,方可再次触发。 + diff --git a/docs/assets/diagrams/system-architecture.png b/docs/assets/diagrams/system-architecture.png new file mode 100644 index 0000000..c1d86e5 Binary files /dev/null and b/docs/assets/diagrams/system-architecture.png differ diff --git a/docs/assets/diagrams/system-architecture.svg b/docs/assets/diagrams/system-architecture.svg new file mode 100644 index 0000000..d19bb09 --- /dev/null +++ b/docs/assets/diagrams/system-architecture.svg @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 感知层 + + 软件处理层 + + 执行与呈现层 + + + + 左触觉传感器 + USB 摄像头 + 敏感面 + + + + 右触觉传感器 + USB 摄像头 + 敏感面 + + + + + 视频帧采集 + + + + 触觉力计算 / 光流处理 + 法向力 · 切向力 · 光流/幅值图像 + Orisys SDK · OpenCV DIS + + + + + + + + 智能夹持状态机 + 触发 · 闭合 · 加力 · 释放 + + + + 力信号 + + + + 夹爪控制器 + Modbus RTU 从站 + + + + + Modbus RTU / 串口 + + + + 智能夹爪(执行机构) + 位置 · 力度 · 开/合/保持 + + + 驱动 + + + + GUI 监控界面 + 3D 模型 · 光流画面 · 力曲线 · 状态 + PyQt5 · PyOpenGL · pyqtgraph + + + + 状态 / 动作 + + 图像 / 力值 + + + 位置反馈 + + + + 数据 / 控制流 + + 反馈 + + diff --git a/docs/assets/manual.css b/docs/assets/manual.css new file mode 100644 index 0000000..53a1f26 --- /dev/null +++ b/docs/assets/manual.css @@ -0,0 +1,1016 @@ +/* Orisys 夹爪 — Product User Manual stylesheet */ + +:root { + --ink: #1a1a1a; + --ink-muted: #4b5563; + --ink-light: #6b7280; + --border: #d1d5db; + --border-light: #e5e7eb; + --surface: #ffffff; + --surface-alt: #f8fafc; + --surface-nav: #f1f5f9; + --accent: #1e40af; + --accent-dark: #1e3a8a; + --warn-bg: #fffbeb; + --warn-border: #f59e0b; + --danger-bg: #fef2f2; + --danger-border: #dc2626; + --note-bg: #eff6ff; + --note-border: #3b82f6; + --sidebar-w: 280px; + --content-max: 920px; + --font-body: "Source Han Sans SC", "Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif; + --font-mono: "Cascadia Code", "Consolas", "Microsoft YaHei UI", monospace; + --line-height: 1.75; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + font-size: 16px; +} + +body { + margin: 0; + color: var(--ink); + font-family: var(--font-body); + line-height: var(--line-height); + background: var(--surface-alt); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Layout */ + +.manual-shell { + display: flex; + min-height: 100vh; +} + +.manual-nav { + position: fixed; + top: 0; + left: 0; + width: var(--sidebar-w); + height: 100vh; + overflow-y: auto; + padding: 1.25rem 0 2rem; + background: var(--surface); + border-right: 1px solid var(--border); + z-index: 100; +} + +.manual-nav__brand { + padding: 0 1.25rem 1rem; + border-bottom: 1px solid var(--border-light); + margin-bottom: 0.75rem; +} + +.manual-nav__brand h2 { + margin: 0 0 0.25rem; + font-size: 0.95rem; + font-weight: 700; + color: var(--accent-dark); + letter-spacing: 0.02em; +} + +.manual-nav__brand p { + margin: 0; + font-size: 0.75rem; + color: var(--ink-light); + line-height: 1.4; +} + +.manual-nav ul { + list-style: none; + margin: 0; + padding: 0; +} + +.manual-nav > ul > li > a { + display: block; + padding: 0.45rem 1.25rem; + font-size: 0.8125rem; + color: var(--ink-muted); + border-left: 3px solid transparent; +} + +.manual-nav > ul > li > a:hover, +.manual-nav > ul > li > a.active { + color: var(--accent-dark); + background: var(--surface-nav); + border-left-color: var(--accent); + text-decoration: none; +} + +.manual-nav .nav-group { + padding: 0.65rem 1.25rem 0.2rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-light); +} + +.manual-main { + flex: 1; + margin-left: var(--sidebar-w); + padding: 2rem 2.5rem 4rem; +} + +.manual-content { + max-width: var(--content-max); + margin: 0 auto; +} + +/* Cover */ + +.cover { + min-height: calc(100vh - 4rem); + display: flex; + flex-direction: column; + justify-content: center; + padding: 3rem 2rem; + margin-bottom: 3rem; + background: var(--surface); + border: 1px solid var(--border); + page-break-after: always; +} + +.cover__doc-type { + font-size: 0.8125rem; + letter-spacing: 0.25em; + text-transform: uppercase; + color: var(--ink-light); + margin-bottom: 1.5rem; +} + +.cover__title { + margin: 0 0 0.5rem; + font-size: 2.125rem; + font-weight: 700; + line-height: 1.25; + color: var(--accent-dark); +} + +.cover__subtitle { + margin: 0 0 2.5rem; + font-size: 1.25rem; + font-weight: 400; + color: var(--ink-muted); +} + +.cover__meta { + width: 100%; + max-width: 520px; + border-collapse: collapse; + font-size: 0.9375rem; +} + +.cover__meta th, +.cover__meta td { + padding: 0.55rem 0.75rem; + border: 1px solid var(--border); + text-align: left; +} + +.cover__meta th { + width: 38%; + background: var(--surface-alt); + font-weight: 600; + color: var(--ink-muted); +} + +.cover__footer { + margin-top: auto; + padding-top: 3rem; + font-size: 0.8125rem; + color: var(--ink-light); +} + +/* Document control */ + +.doc-section { + margin-bottom: 3rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border-light); +} + +.doc-section:last-child { + border-bottom: none; +} + +.section-head { + margin: 0 0 1.25rem; + padding-bottom: 0.65rem; + border-bottom: 2px solid var(--accent); +} + +.section-head .section-num { + display: block; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--accent); + margin-bottom: 0.25rem; +} + +.section-head h1, +.section-head h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--ink); + line-height: 1.35; +} + +h3 { + margin: 1.75rem 0 0.75rem; + font-size: 1.0625rem; + font-weight: 700; + color: var(--ink); +} + +h4 { + margin: 1.25rem 0 0.5rem; + font-size: 0.9375rem; + font-weight: 700; + color: var(--ink-muted); +} + +p { + margin: 0 0 1rem; + text-align: justify; +} + +ul, +ol { + margin: 0 0 1rem; + padding-left: 1.5rem; +} + +li { + margin-bottom: 0.35rem; +} + +li > ul, +li > ol { + margin-top: 0.35rem; + margin-bottom: 0.35rem; +} + +/* Tables */ + +.data-table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0 1.5rem; + font-size: 0.875rem; + background: var(--surface); +} + +.data-table caption { + caption-side: top; + text-align: left; + font-size: 0.8125rem; + font-weight: 600; + color: var(--ink-muted); + padding: 0 0 0.5rem; +} + +.data-table th, +.data-table td { + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + vertical-align: top; +} + +.data-table th { + background: var(--surface-alt); + font-weight: 600; + color: var(--ink); + white-space: nowrap; +} + +.data-table td code, +.data-table th code { + font-family: var(--font-mono); + font-size: 0.8125rem; + background: #f3f4f6; + padding: 0.1rem 0.35rem; + border-radius: 3px; +} + +.data-table--compact th, +.data-table--compact td { + padding: 0.45rem 0.6rem; +} + +/* Figures */ + +.figure { + margin: 1.5rem 0 2rem; + background: var(--surface); + border: 1px solid var(--border); + page-break-inside: avoid; +} + +.figure img { + display: block; + width: 100%; + height: auto; + border-bottom: 1px solid var(--border-light); +} + +.figure__caption { + padding: 0.75rem 1rem; + font-size: 0.8125rem; + color: var(--ink-muted); + line-height: 1.55; + background: var(--surface-alt); +} + +.figure__caption strong { + color: var(--ink); + font-weight: 700; +} + +.figure--borderless { + border: none; + background: transparent; +} + +.figure--borderless img { + border: 1px solid var(--border); +} + +/* Screenshot annotations */ + +.figure-annotate { + position: relative; + line-height: 0; +} + +.figure-annotate img { + display: block; + width: 100%; + height: auto; +} + +.figure-annotate .anno-box { + position: absolute; + border: 2.5px solid; + border-radius: 4px; + background: transparent; + pointer-events: none; + z-index: 2; +} + +.figure-annotate .anno-tag { + position: absolute; + z-index: 3; + min-width: 1.35rem; + height: 1.35rem; + padding: 0 0.3rem; + border-radius: 999px; + font: 700 0.68rem/1.35rem Consolas, "Microsoft YaHei", sans-serif; + text-align: center; + color: #fff; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25); + pointer-events: none; +} + +.figure-annotate .anno-label { + position: absolute; + z-index: 3; + max-width: 11rem; + padding: 0.18rem 0.45rem; + border-radius: 4px; + font: 600 0.68rem/1.35 "Microsoft YaHei", "Noto Sans SC", sans-serif; + color: #fff; + white-space: nowrap; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.22); + pointer-events: none; +} + +.figure-annotate .anno-label--dark { + color: #0f172a; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(15, 23, 42, 0.12); +} + +/* Colored borders only — no dark overlay */ +.figure--overview .anno-box, +.figure--flow .anno-box, +.figure--topbar .anno-box, +.figure--hold .anno-box, +.figure--plots .anno-box, +.figure--grip-smart .anno-box, +.figure--grip-manual .anno-box, +.figure--sensor .anno-box, +.figure--settings .anno-box { + box-shadow: none; +} + +.figure__legend { + list-style: none; + margin: 0; + padding: 0.55rem 1rem 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1rem; + background: var(--surface-alt); + border-top: 1px solid var(--border-light); + font-size: 0.75rem; + color: var(--ink-muted); +} + +.figure__legend li { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.legend-tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.15rem; + height: 1.15rem; + padding: 0 0.25rem; + border-radius: 999px; + font: 700 0.62rem/1 Consolas, sans-serif; + color: #fff; +} + +.legend-tag--a, .anno-tag--a, .anno-box--a { border-color: #2563eb; background: #2563eb; } +.legend-tag--b, .anno-tag--b, .anno-box--b { border-color: #7c3aed; background: #7c3aed; } +.legend-tag--c, .anno-tag--c, .anno-box--c { border-color: #0891b2; background: #0891b2; } +.legend-tag--d, .anno-tag--d, .anno-box--d { border-color: #059669; background: #059669; } +.legend-tag--e, .anno-tag--e, .anno-box--e { border-color: #d97706; background: #d97706; } +.legend-tag--f, .anno-tag--f, .anno-box--f { border-color: #db2777; background: #db2777; } +.legend-tag--g, .anno-tag--g, .anno-box--g { border-color: #475569; background: #475569; } +.legend-tag--h, .anno-tag--h, .anno-box--h { border-color: #dc2626; background: #dc2626; } + +.anno-box--a, .anno-box--b, .anno-box--c, .anno-box--d, +.anno-box--e, .anno-box--f, .anno-box--g, .anno-box--h { + background: transparent; +} + +.anno-label--a { background: #2563eb; } +.anno-label--b { background: #7c3aed; } +.anno-label--c { background: #0891b2; } +.anno-label--d { background: #059669; } +.anno-label--e { background: #d97706; } +.anno-label--f { background: #db2777; } +.anno-label--g { background: #475569; } +.anno-label--h { background: #dc2626; } + +/* Positions calibrated to 1920×1080 screenshots + gui_layout.json */ + +/* Main overview */ +.figure--overview .anno-box--a { top: 0.9%; left: 0.5%; width: 99%; height: 6.5%; } +.figure--overview .anno-tag--a { top: 1.2%; left: 1%; } +.figure--overview .anno-box--b { top: 38%; left: 30%; width: 40%; height: 32%; } +.figure--overview .anno-tag--b { top: 39%; left: 31%; } +.figure--overview .anno-box--c { top: 12.4%; left: 18.2%; width: 10.6%; height: 20.4%; } +.figure--overview .anno-tag--c { top: 12.8%; left: 18.6%; } +.figure--overview .anno-label--c { top: 12.8%; left: 21%; } +.figure--overview .anno-box--c-right { top: 12.4%; left: 58.4%; width: 11.4%; height: 21.4%; border-color: #0891b2; } +.figure--overview .anno-tag--c-right { top: 12.8%; left: 58.8%; background: #0891b2; } +.figure--overview .anno-label--c-right { top: 12.8%; left: 61.2%; background: #0891b2; } +.figure--overview .anno-box--d { top: 12.4%; left: 74.5%; width: 24.8%; height: 63.5%; } +.figure--overview .anno-tag--d { top: 12.8%; left: 75%; } +.figure--overview .anno-box--e { top: 72.4%; left: 1%; width: 25.1%; height: 23%; } +.figure--overview .anno-tag--e { top: 72.8%; left: 1.4%; } +.figure--overview .anno-box--f { top: 76.5%; left: 74.4%; width: 24.8%; height: 18.9%; } +.figure--overview .anno-tag--f { top: 76.9%; left: 74.8%; } + +/* Top bar crop 1900×66 */ +.figure--topbar .anno-box--a { top: 8%; left: 0.4%; width: 3.2%; height: 84%; } +.figure--topbar .anno-tag--a { top: 12%; left: 0.6%; } +.figure--topbar .anno-label--a { top: 12%; left: 4%; } +.figure--topbar .anno-box--b { top: 8%; left: 4%; width: 28%; height: 84%; } +.figure--topbar .anno-tag--b { top: 12%; left: 4.4%; } +.figure--topbar .anno-label--b { top: 12%; left: 7%; } +.figure--topbar .anno-box--c { top: 8%; left: 78.5%; width: 3%; height: 84%; } +.figure--topbar .anno-tag--c { top: 12%; left: 78.7%; } +.figure--topbar .anno-label--c { top: 55%; left: 78.7%; writing-mode: vertical-rl; letter-spacing: 0.05em; } +.figure--topbar .anno-box--d { top: 8%; left: 81.8%; width: 3%; height: 84%; } +.figure--topbar .anno-tag--d { top: 12%; left: 82%; } +.figure--topbar .anno-box--e { top: 8%; left: 85%; width: 3%; height: 84%; } +.figure--topbar .anno-tag--e { top: 12%; left: 85.2%; } +.figure--topbar .anno-box--f { top: 8%; left: 88.2%; width: 3%; height: 84%; } +.figure--topbar .anno-tag--f { top: 12%; left: 88.4%; } +.figure--topbar .anno-box--g { top: 8%; left: 91.5%; width: 8%; height: 84%; } +.figure--topbar .anno-tag--g { top: 12%; left: 91.7%; } + +/* Module focus shots — inner boxes without dim */ +.figure--flow .anno-box--c, +.figure--plots .anno-box--b, +.figure--plots .anno-box--c, +.figure--plots .anno-box--d, +.figure--grip-smart .anno-box--b, +.figure--grip-smart .anno-box--c, +.figure--grip-smart .anno-box--d, +.figure--grip-manual .anno-box--b, +.figure--grip-manual .anno-box--c, +.figure--sensor .anno-box--b, +.figure--sensor .anno-box--c, +.figure--sensor .anno-box--d, +.figure--hold .anno-box--a, +.figure--hold .anno-box--c, +.figure--settings-visual .anno-box--c, +.figure--settings-visual .anno-box--d, +.figure--settings-layout .anno-box--c, +.figure--settings-layout .anno-box--d, +.figure--settings-serial .anno-box--c, +.figure--settings-trigger .anno-box--c, +.figure--settings-trigger .anno-box--d, +.figure--settings-trigger .anno-box--e, +.figure--settings-trigger .anno-box--f { + box-shadow: none; +} + +/* Flow windows — left ~18%, right ~58% (NOT the plot panel) */ +.figure--flow .anno-box--a { top: 12.4%; left: 18.2%; width: 10.6%; height: 20.4%; } +.figure--flow .anno-tag--a { top: 12.8%; left: 18.6%; } +.figure--flow .anno-label--a { top: 12.8%; left: 21%; } +.figure--flow .anno-box--b { top: 12.4%; left: 58.4%; width: 11.4%; height: 21.4%; } +.figure--flow .anno-tag--b { top: 12.8%; left: 58.8%; } +.figure--flow .anno-label--b { top: 12.8%; left: 61.2%; } +.figure--flow .anno-box--c { top: 12.8%; left: 23.5%; width: 4.8%; height: 3.2%; } +.figure--flow .anno-tag--c { top: 9.2%; left: 23.5%; } +.figure--flow .anno-label--c { top: 9.2%; left: 26%; } + +/* Force plots */ +.figure--plots .anno-box--a { top: 12.4%; left: 74.5%; width: 24.8%; height: 63.5%; } +.figure--plots .anno-tag--a { top: 12.8%; left: 75%; } +.figure--plots .anno-label--a { top: 12.8%; left: 77.5%; } +.figure--plots .anno-box--b { top: 15%; left: 75%; width: 11.5%; height: 17%; } +.figure--plots .anno-tag--b { top: 15.4%; left: 75.4%; } +.figure--plots .anno-label--b { top: 15.4%; left: 77.5%; } +.figure--plots .anno-box--c { top: 33.5%; left: 75%; width: 11.5%; height: 17%; } +.figure--plots .anno-tag--c { top: 33.9%; left: 75.4%; } +.figure--plots .anno-box--d { top: 52%; left: 75%; width: 11.5%; height: 17%; } +.figure--plots .anno-tag--d { top: 52.4%; left: 75.4%; } + +/* Gripper control — smart */ +.figure--grip-smart .anno-box--a { top: 72.4%; left: 1%; width: 25.1%; height: 23%; } +.figure--grip-smart .anno-tag--a { top: 72.8%; left: 1.4%; } +.figure--grip-smart .anno-box--b { top: 74.2%; left: 1.6%; width: 23.5%; height: 4.5%; } +.figure--grip-smart .anno-tag--b { top: 74.5%; left: 2%; } +.figure--grip-smart .anno-label--b { top: 74.5%; left: 4.5%; } +.figure--grip-smart .anno-box--c { top: 79.5%; left: 1.6%; width: 14%; height: 9%; } +.figure--grip-smart .anno-tag--c { top: 79.8%; left: 2%; } +.figure--grip-smart .anno-label--c { top: 79.8%; left: 4.5%; } +.figure--grip-smart .anno-box--d { top: 89.5%; left: 1.6%; width: 23.5%; height: 5%; } +.figure--grip-smart .anno-tag--d { top: 89.8%; left: 2%; } + +/* Gripper control — manual */ +.figure--grip-manual .anno-box--a { top: 72.4%; left: 1%; width: 25.1%; height: 23%; } +.figure--grip-manual .anno-tag--a { top: 72.8%; left: 1.4%; } +.figure--grip-manual .anno-box--b { top: 74.2%; left: 1.6%; width: 23.5%; height: 4.5%; } +.figure--grip-manual .anno-tag--b { top: 74.5%; left: 2%; } +.figure--grip-manual .anno-label--b { top: 74.5%; left: 4.5%; } +.figure--grip-manual .anno-box--c { top: 89.5%; left: 1.6%; width: 23.5%; height: 5%; } +.figure--grip-manual .anno-tag--c { top: 89.8%; left: 2%; } +.figure--grip-manual .anno-label--c { top: 89.8%; left: 4.5%; } + +/* Sensor control */ +.figure--sensor .anno-box--a { top: 76.5%; left: 74.4%; width: 24.8%; height: 18.9%; } +.figure--sensor .anno-tag--a { top: 76.9%; left: 74.8%; } +.figure--sensor .anno-box--b { top: 79%; left: 75%; width: 11%; height: 4.5%; } +.figure--sensor .anno-tag--b { top: 79.3%; left: 75.4%; } +.figure--sensor .anno-label--b { top: 79.3%; left: 78%; } +.figure--sensor .anno-box--c { top: 79%; left: 86.5%; width: 11.5%; height: 4.5%; } +.figure--sensor .anno-tag--c { top: 79.3%; left: 86.9%; } +.figure--sensor .anno-box--d { top: 85%; left: 75%; width: 23.5%; height: 8.5%; } +.figure--sensor .anno-tag--d { top: 85.3%; left: 75.4%; } +.figure--sensor .anno-label--d { top: 85.3%; left: 78%; } + +/* Hold-check state */ +.figure--hold .anno-box--a { top: 0.9%; left: 0.5%; width: 99%; height: 6.5%; } +.figure--hold .anno-tag--a { top: 1.2%; left: 1%; } +.figure--hold .anno-label--a { top: 1.2%; left: 3%; } +.figure--hold .anno-box--b { top: 38%; left: 30%; width: 40%; height: 32%; } +.figure--hold .anno-tag--b { top: 39%; left: 31%; } +.figure--hold .anno-label--b { top: 39%; left: 33.5%; } +.figure--hold .anno-box--c { top: 70%; left: 30%; width: 40%; height: 4.5%; } +.figure--hold .anno-tag--c { top: 70.3%; left: 31%; } +.figure--hold .anno-label--c { top: 70.3%; left: 33.5%; } + +/* Settings dialogs unused (no boxes) */ +.figure--settings .anno-box--a { top: 12%; left: 1%; width: 18%; height: 76%; } +.figure--settings .anno-tag--a { top: 13%; left: 1.5%; } +.figure--settings .anno-label--a { top: 13%; left: 4%; } +.figure--settings .anno-box--b { top: 12%; left: 20%; width: 78%; height: 76%; } +.figure--settings .anno-tag--b { top: 13%; left: 20.5%; } + +.figure--settings-visual .anno-box--c { top: 18%; left: 22%; width: 74%; height: 34%; } +.figure--settings-visual .anno-tag--c { top: 18.5%; left: 22.5%; } +.figure--settings-visual .anno-label--c { top: 18.5%; left: 25%; } +.figure--settings-visual .anno-box--d { top: 54%; left: 22%; width: 74%; height: 34%; } +.figure--settings-visual .anno-tag--d { top: 54.5%; left: 22.5%; } +.figure--settings-visual .anno-label--d { top: 54.5%; left: 25%; } + +.figure--settings-layout .anno-box--c { top: 18%; left: 22%; width: 40%; height: 10%; } +.figure--settings-layout .anno-tag--c { top: 18.5%; left: 22.5%; } +.figure--settings-layout .anno-label--c { top: 18.5%; left: 25%; } +.figure--settings-layout .anno-box--d { top: 30%; left: 22%; width: 40%; height: 10%; } +.figure--settings-layout .anno-tag--d { top: 30.5%; left: 22.5%; } +.figure--settings-layout .anno-label--d { top: 30.5%; left: 25%; } + +.figure--settings-serial .anno-box--c { top: 18%; left: 22%; width: 74%; height: 58%; } +.figure--settings-serial .anno-tag--c { top: 18.5%; left: 22.5%; } +.figure--settings-serial .anno-label--c { top: 18.5%; left: 25%; } + +.figure--settings-trigger .anno-box--c { top: 18%; left: 22%; width: 74%; height: 58%; } +.figure--settings-trigger .anno-tag--c { top: 18.5%; left: 22.5%; } +.figure--settings-trigger .anno-label--c { top: 18.5%; left: 25%; } +.figure--settings-trigger .anno-box--d { top: 20%; left: 24%; width: 70%; height: 8%; } +.figure--settings-trigger .anno-tag--d { top: 20.5%; left: 24.5%; } +.figure--settings-trigger .anno-box--e { top: 38%; left: 24%; width: 70%; height: 8%; } +.figure--settings-trigger .anno-tag--e { top: 38.5%; left: 24.5%; } +.figure--settings-trigger .anno-box--f { top: 56%; left: 24%; width: 70%; height: 8%; } +.figure--settings-trigger .anno-tag--f { top: 56.5%; left: 24.5%; } + +/* Print */ + +@media print { + body { + background: #fff; + font-size: 10.5pt; + line-height: 1.45; + color: #111; + } + + .manual-nav, + .toolbar-hint { + display: none !important; + } + + .manual-main { + margin-left: 0; + padding: 0; + } + + .manual-content { + max-width: none; + } + + .cover { + min-height: 0; + height: auto; + justify-content: flex-start; + padding: 1.25rem 0.5rem 1.5rem; + margin-bottom: 0; + border: none; + page-break-after: always; + break-after: page; + } + + .cover__doc-type { + margin-bottom: 0.75rem; + } + + .cover__title { + font-size: 22pt; + } + + .cover__subtitle { + margin-bottom: 1.25rem; + font-size: 12pt; + } + + .cover__footer { + margin-top: 1.5rem; + padding-top: 1rem; + } + + #revision { + page-break-after: avoid; + break-after: avoid; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + } + + .toc { + page-break-before: avoid; + break-before: avoid; + page-break-after: always; + break-after: page; + page-break-inside: avoid; + break-inside: avoid; + margin-bottom: 0; + padding: 0.85rem 1rem; + } + + .toc ol { + columns: 2; + column-gap: 1.25rem; + } + + .toc li { + margin-bottom: 0.2rem; + font-size: 9.5pt; + } + + .doc-section { + margin-bottom: 1.25rem; + padding-bottom: 0.85rem; + page-break-inside: auto; + break-inside: auto; + page-break-before: auto; + } + + .section-head { + page-break-after: avoid; + break-after: avoid; + margin-bottom: 0.75rem; + } + + h3 { + page-break-after: avoid; + break-after: avoid; + } + + .figure { + margin: 0.75rem 0 1rem; + max-width: 100%; + page-break-inside: avoid; + break-inside: avoid; + } + + .figure img, + .figure-annotate img { + max-height: 105mm; + width: auto; + max-width: 100%; + margin: 0 auto; + object-fit: contain; + } + + .figure--borderless img, + .figure--topbar img { + max-height: 28mm; + } + + .figure--diagram img { + max-height: 90mm; + padding: 0.4rem !important; + } + + .figure__caption { + padding: 0.4rem 0.65rem; + font-size: 8.5pt; + } + + .figure__legend { + padding: 0.35rem 0.65rem 0.5rem; + font-size: 8pt; + gap: 0.2rem 0.75rem; + } + + .figure-annotate .anno-box { + box-shadow: none !important; + border-width: 2px; + } + + .figure-annotate .anno-tag, + .figure-annotate .anno-label { + font-size: 7.5pt; + box-shadow: none; + } + + .data-table { + margin: 0.6rem 0 1rem; + font-size: 9pt; + } + + .data-table th, + .data-table td { + padding: 0.3rem 0.45rem; + } + + .callout { + margin: 0.65rem 0; + padding: 0.5rem 0.75rem; + page-break-inside: avoid; + } + + pre, + .code-block { + margin: 0.5rem 0 0.85rem; + padding: 0.55rem 0.75rem; + font-size: 8pt; + page-break-inside: avoid; + } + + a[href]::after { + content: none; + } + + @page { + margin: 12mm 12mm 14mm; + @bottom-center { + content: counter(page); + font-size: 8pt; + color: #666; + } + } +} + +.callout { + margin: 1.25rem 0; + padding: 0.85rem 1rem 0.85rem 1rem; + border-left: 4px solid; + font-size: 0.875rem; + page-break-inside: avoid; +} + +.callout__label { + display: block; + font-weight: 700; + font-size: 0.75rem; + letter-spacing: 0.06em; + margin-bottom: 0.35rem; +} + +.callout--note { + background: var(--note-bg); + border-color: var(--note-border); +} + +.callout--caution { + background: var(--warn-bg); + border-color: var(--warn-border); +} + +.callout--danger { + background: var(--danger-bg); + border-color: var(--danger-border); +} + +.callout p:last-child { + margin-bottom: 0; +} + +/* Code / preformatted */ + +pre, +.code-block { + margin: 1rem 0 1.5rem; + padding: 1rem 1.15rem; + background: #111827; + color: #e5e7eb; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.6; + overflow-x: auto; + border-radius: 4px; + white-space: pre; +} + +code { + font-family: var(--font-mono); + font-size: 0.875em; +} + +:not(pre) > code { + background: #f3f4f6; + padding: 0.12rem 0.35rem; + border-radius: 3px; +} + +/* TOC */ + +.toc { + background: var(--surface); + border: 1px solid var(--border); + padding: 1.5rem 1.75rem; + margin-bottom: 2rem; +} + +.toc h2 { + margin: 0 0 1rem; + font-size: 1.125rem; + border-bottom: 1px solid var(--border-light); + padding-bottom: 0.5rem; +} + +.toc ol { + margin: 0; + padding-left: 1.25rem; + columns: 2; + column-gap: 2rem; +} + +.toc li { + margin-bottom: 0.4rem; + font-size: 0.875rem; + break-inside: avoid; +} + +.toc a { + color: var(--ink); +} + +.toc a:hover { + color: var(--accent); +} + +.figure--diagram { + background: var(--surface); +} + +.figure--diagram img { + border-bottom: none; + padding: 1.25rem 1.5rem 0.75rem; + background: linear-gradient(180deg, #fafbfc 0%, #ffffff 100%); +} + +.figure--diagram .figure__caption { + border-top: 1px solid var(--border-light); +} + +/* Legacy ASCII diagrams removed — use SVG in assets/diagrams/ */ + + +.checklist { + list-style: none; + padding-left: 0; +} + +.checklist li { + position: relative; + padding-left: 1.75rem; + margin-bottom: 0.5rem; +} + +.checklist li::before { + content: "☐"; + position: absolute; + left: 0; + color: var(--ink-light); +} + +/* Revision */ + +.revision-table td:nth-child(1), +.revision-table td:nth-child(3) { + white-space: nowrap; +} + +/* Toolbar hint for screen */ + +.toolbar-hint { + position: fixed; + bottom: 1rem; + right: 1rem; + padding: 0.5rem 0.85rem; + background: rgba(30, 64, 175, 0.92); + color: #fff; + font-size: 0.75rem; + border-radius: 4px; + z-index: 200; + cursor: pointer; + border: none; + font-family: inherit; +} + +.toolbar-hint:hover { + background: var(--accent-dark); +} + +@media (max-width: 960px) { + .manual-nav { + display: none; + } + + .manual-main { + margin-left: 0; + padding: 1rem; + } + + .toc ol { + columns: 1; + } + + .cover__title { + font-size: 1.625rem; + } +} diff --git a/docs/diagrams/tex/grip-state-flow.tex b/docs/diagrams/tex/grip-state-flow.tex new file mode 100644 index 0000000..b0d335c --- /dev/null +++ b/docs/diagrams/tex/grip-state-flow.tex @@ -0,0 +1,38 @@ +\documentclass[tikz,border=8pt]{standalone} +\usepackage{ctex} +\usepackage{tikz} +\usetikzlibrary{positioning,arrows.meta,fit,backgrounds} + +\tikzset{ + st/.style={draw, rounded corners=2pt, align=center, font=\small, + minimum width=2.55cm, minimum height=1.15cm, inner sep=2pt}, + idle/.style={st, fill=blue!8, draw=blue!55}, + act/.style={st, fill=orange!10, draw=orange!60!black}, + hold/.style={st, fill=magenta!6, draw=magenta!55}, + rec/.style={st, fill=teal!8, draw=teal!60!black}, + arr/.style={-{Stealth[length=2.2mm]}, semithick, draw=gray!70}, + darr/.style={-{Stealth[length=2.2mm]}, semithick, draw=red!65, dashed}, + loop/.style={-{Stealth[length=2.2mm]}, semithick, draw=blue!60}, + ed/.style={font=\scriptsize, fill=white, inner sep=1pt, text=gray!25!black}, +} + +\begin{document} +\begin{tikzpicture}[node distance=7mm] + + \node[idle] (w) {等待触发\\\scriptsize open\_wait}; + \node[act, right=of w] (c) {闭合中\\\scriptsize closing}; + \node[act, right=of c] (g) {夹持加力\\\scriptsize gripping}; + \node[hold, right=of g] (h) {夹持检测\\\scriptsize hold\_check}; + \node[rec, right=of h] (o) {张开恢复\\\scriptsize open\_recover}; + + \draw[arr] (w) -- node[ed, above] {侧边触发} (c); + \draw[arr] (c) -- node[ed, above] {接触} (g); + \draw[arr] (g) -- node[ed, above] {到保持力} (h); + \draw[arr] (h) -- node[ed, above] {取物} (o); + + \draw[loop] (o.south) -- ++(0,-0.75) -| node[ed, pos=0.25, below] {恢复 + rearm} (w.south); + + \draw[darr] (c.south) -- ++(0,-0.55) -| node[ed, pos=0.12, below] {超时} (o.south); + +\end{tikzpicture} +\end{document} diff --git a/docs/diagrams/tex/system-architecture.tex b/docs/diagrams/tex/system-architecture.tex new file mode 100644 index 0000000..30c95c2 --- /dev/null +++ b/docs/diagrams/tex/system-architecture.tex @@ -0,0 +1,49 @@ +\documentclass[tikz,border=8pt]{standalone} +\usepackage{ctex} +\usepackage{tikz} +\usetikzlibrary{positioning,fit,arrows.meta,calc,backgrounds} + +\tikzset{ + base/.style={draw, rounded corners=2pt, align=center, font=\small}, + sensor/.style={base, fill=blue!8, draw=blue!55, minimum width=3.0cm, minimum height=1.05cm}, + process/.style={base, fill=violet!8, draw=violet!55, minimum width=4.2cm, minimum height=1.15cm}, + control/.style={base, fill=orange!10, draw=orange!60!black, minimum width=3.0cm, minimum height=1.0cm}, + device/.style={base, fill=gray!10, draw=gray!60, minimum width=3.0cm, minimum height=1.0cm}, + gui/.style={base, fill=teal!8, draw=teal!60!black, minimum width=4.2cm, minimum height=1.05cm}, + layer/.style={draw=gray!35, rounded corners=3pt, inner sep=10pt}, + arr/.style={-{Stealth[length=2.2mm]}, semithick, draw=gray!70}, + darr/.style={-{Stealth[length=2.2mm]}, semithick, draw=gray!55, dashed}, + lbl/.style={font=\scriptsize, fill=white, inner sep=1pt, text=gray!30!black}, +} + +\begin{document} +\begin{tikzpicture}[node distance=8mm and 10mm] + + \node[sensor] (L) {左触觉传感器\\{\scriptsize USB 摄像头}}; + \node[sensor, right=5.8cm of L] (R) {右触觉传感器\\{\scriptsize USB 摄像头}}; + + \node[process, below=1.35cm of $(L)!0.5!(R)$] (P) {触觉力计算 / 光流\\{\scriptsize 法向力 · 切向力 · 图像}}; + + \node[control, below left=1.1cm and 0.2cm of P] (SM) {智能夹持\\状态机}; + \node[device, below right=1.1cm and 0.2cm of P] (GC) {夹爪控制器\\{\scriptsize Modbus RTU}}; + + \node[device, below=1.0cm of GC] (G) {智能夹爪}; + \node[gui, below=1.0cm of P] (UI) {GUI 监控界面\\{\scriptsize 3D · 光流 · 曲线 · 状态}}; + + \begin{scope}[on background layer] + \node[layer, fit=(L)(R), label={[font=\scriptsize\bfseries]above left:感知层}] {}; + \node[layer, fit=(P)(SM)(GC), label={[font=\scriptsize\bfseries]above left:软件处理层}] {}; + \node[layer, fit=(G)(UI), label={[font=\scriptsize\bfseries]above left:执行与呈现层}] {}; + \end{scope} + + \draw[arr] (L) -- node[lbl, above] {视频帧} (P); + \draw[arr] (R) -- node[lbl, above] {视频帧} (P); + \draw[arr] (P) -- node[lbl, left] {力信号} (SM); + \draw[arr] (SM) -- node[lbl, above] {串口命令} (GC); + \draw[arr] (GC) -- node[lbl, right] {驱动} (G); + \draw[arr] (P) -- node[lbl, right] {图像/力值} (UI); + \draw[arr] (SM) -- node[lbl, left] {状态} (UI); + \draw[darr] (G.west) -| node[lbl, near start, below] {位置反馈} (SM.south); + +\end{tikzpicture} +\end{document} diff --git a/docs/images/manual/01-main-overview.png b/docs/images/manual/01-main-overview.png new file mode 100644 index 0000000..efe73a5 Binary files /dev/null and b/docs/images/manual/01-main-overview.png differ diff --git a/docs/images/manual/02-flow-windows.png b/docs/images/manual/02-flow-windows.png new file mode 100644 index 0000000..0e0e89d Binary files /dev/null and b/docs/images/manual/02-flow-windows.png differ diff --git a/docs/images/manual/03-force-plots.png b/docs/images/manual/03-force-plots.png new file mode 100644 index 0000000..41d9fa7 Binary files /dev/null and b/docs/images/manual/03-force-plots.png differ diff --git a/docs/images/manual/04-gripper-control-smart.png b/docs/images/manual/04-gripper-control-smart.png new file mode 100644 index 0000000..feb1bcd Binary files /dev/null and b/docs/images/manual/04-gripper-control-smart.png differ diff --git a/docs/images/manual/05-gripper-control-manual.png b/docs/images/manual/05-gripper-control-manual.png new file mode 100644 index 0000000..dd763c1 Binary files /dev/null and b/docs/images/manual/05-gripper-control-manual.png differ diff --git a/docs/images/manual/06-sensor-control.png b/docs/images/manual/06-sensor-control.png new file mode 100644 index 0000000..f55a4a8 Binary files /dev/null and b/docs/images/manual/06-sensor-control.png differ diff --git a/docs/images/manual/07-top-bar.png b/docs/images/manual/07-top-bar.png new file mode 100644 index 0000000..443a422 Binary files /dev/null and b/docs/images/manual/07-top-bar.png differ diff --git a/docs/images/manual/08-settings-visual.png b/docs/images/manual/08-settings-visual.png new file mode 100644 index 0000000..212c741 Binary files /dev/null and b/docs/images/manual/08-settings-visual.png differ diff --git a/docs/images/manual/09-settings-layout.png b/docs/images/manual/09-settings-layout.png new file mode 100644 index 0000000..04b6fce Binary files /dev/null and b/docs/images/manual/09-settings-layout.png differ diff --git a/docs/images/manual/10-settings-trigger.png b/docs/images/manual/10-settings-trigger.png new file mode 100644 index 0000000..bfaed9c Binary files /dev/null and b/docs/images/manual/10-settings-trigger.png differ diff --git a/docs/images/manual/11-settings-serial.png b/docs/images/manual/11-settings-serial.png new file mode 100644 index 0000000..eacea94 Binary files /dev/null and b/docs/images/manual/11-settings-serial.png differ diff --git a/docs/images/manual/12-hold-check-state.png b/docs/images/manual/12-hold-check-state.png new file mode 100644 index 0000000..c044a77 Binary files /dev/null and b/docs/images/manual/12-hold-check-state.png differ diff --git a/gripper/config/settings.py b/gripper/config/settings.py index 74170f0..a98b1a1 100644 --- a/gripper/config/settings.py +++ b/gripper/config/settings.py @@ -32,8 +32,8 @@ class Demo02Config: video2: str = "1" rotation1: dict | str = field(default_factory=lambda: copy.deepcopy(DEFAULT_ROTATION1)) rotation2: dict | str = field(default_factory=lambda: copy.deepcopy(DEFAULT_ROTATION2)) - sensor_config1: str = "./config/ddjx01.json" - sensor_config2: str = "./config/ddjx01.json" + sensor_config1: str = "./config/ddjx01_q6a.json" + sensor_config2: str = "./config/ddjx01_q6a.json" sensor_fps: float = 30.0 motion_threshold: float = 0.0 sensor_cpu: bool = False diff --git a/gripper/sensors/camera_discovery.py b/gripper/sensors/camera_discovery.py new file mode 100644 index 0000000..53f2b94 --- /dev/null +++ b/gripper/sensors/camera_discovery.py @@ -0,0 +1,162 @@ +"""Discover USB cameras without coupling discovery to the sensor runtime.""" + +from __future__ import annotations + +import contextlib +import os +from pathlib import Path +import sys +from dataclasses import dataclass + +import cv2 + + +# Most USB camera stacks expose devices from 0 upward. Keeping this bounded +# prevents a search from spending seconds probing empty DirectShow devices. +DEFAULT_MAX_CAMERA_INDEX = 3 + + +@dataclass(frozen=True) +class CameraInfo: + index: int + backend_name: str + backend_api: int + width: int + height: int + + @property + def source(self): + if sys.platform.startswith("linux"): + return f"/dev/video{self.index}" + return str(self.index) + + def label(self): + device = self.source if sys.platform.startswith("linux") else f"相机 {self.index}" + return f"{device} {self.width}x{self.height} [{self.backend_name}]" + + +@contextlib.contextmanager +def _suppress_opencv_noise(): + previous_level = None + set_level = None + try: + logging = getattr(getattr(cv2, "utils", None), "logging", None) + if logging is not None: + set_level = getattr(logging, "setLogLevel", None) + get_level = getattr(logging, "getLogLevel", None) + silent = getattr(logging, "LOG_LEVEL_SILENT", 0) + if set_level is not None: + if get_level is not None: + previous_level = get_level() + set_level(silent) + except Exception: + previous_level = None + + devnull = open(os.devnull, "w") + try: + with contextlib.redirect_stderr(devnull): + try: + stderr_fd = sys.stderr.fileno() + saved_fd = os.dup(stderr_fd) + except (AttributeError, OSError): + yield + else: + try: + os.dup2(devnull.fileno(), stderr_fd) + yield + finally: + os.dup2(saved_fd, stderr_fd) + os.close(saved_fd) + finally: + devnull.close() + if set_level is not None and previous_level is not None: + try: + set_level(previous_level) + except Exception: + pass + + +def _backend_candidates(): + candidates = [] + if sys.platform == "win32": + if hasattr(cv2, "CAP_DSHOW"): + candidates.append(("DSHOW", cv2.CAP_DSHOW)) + if hasattr(cv2, "CAP_MSMF"): + candidates.append(("MSMF", cv2.CAP_MSMF)) + elif sys.platform.startswith("linux") and hasattr(cv2, "CAP_V4L2"): + candidates.append(("V4L2", cv2.CAP_V4L2)) + return candidates or [("ANY", cv2.CAP_ANY)] + + +def camera_source_index(source): + """Return the numeric index for ``0`` or ``/dev/video0`` sources.""" + text = str(source or "").strip() + if text.isdigit(): + return int(text) + if text.startswith("/dev/video"): + suffix = text[len("/dev/video"):] + if suffix.isdigit(): + return int(suffix) + return None + + +def _candidate_indices(max_index): + if sys.platform.startswith("linux"): + indices = [] + for path in Path("/dev").glob("video*"): + index = camera_source_index(str(path)) + if index is not None: + indices.append(index) + if indices: + return sorted(set(indices)) + return list(range(max_index + 1)) + + +def _try_open(index, backend_name, backend_api): + capture = cv2.VideoCapture(index, backend_api) + try: + if not capture.isOpened(): + return None + ok, frame = capture.read() + width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) + height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) + if ok and frame is not None: + frame_height, frame_width = frame.shape[:2] + width = width or int(frame_width) + height = height or int(frame_height) + elif width <= 0 or height <= 0: + return None + return CameraInfo(index, backend_name, backend_api, width, height) + finally: + capture.release() + + +def list_cameras( + max_index=DEFAULT_MAX_CAMERA_INDEX, + skip_indices=None, + fallback_backends=False, +): + """Probe camera indices and return one working backend for each index.""" + found = [] + seen_indices = set() + for source in skip_indices or (): + index = camera_source_index(source) + if index is not None: + seen_indices.add(index) + backends = _backend_candidates() + if not fallback_backends: + backends = backends[:1] + with _suppress_opencv_noise(): + # Probe one backend across all indices before falling back to another + # backend. On Windows this avoids opening every empty index twice when + # DirectShow is available. + for backend_name, backend_api in backends: + for index in _candidate_indices(max_index): + if index in seen_indices: + continue + info = _try_open(index, backend_name, backend_api) + if info is not None: + found.append(info) + seen_indices.add(index) + found.sort(key=lambda camera: camera.index) + return found diff --git a/gripper/sensors/force_reader.py b/gripper/sensors/force_reader.py index e246997..fc39037 100644 --- a/gripper/sensors/force_reader.py +++ b/gripper/sensors/force_reader.py @@ -272,8 +272,8 @@ class TwoSensorForceReader: self, video1=0, video2=1, - config1="./config/ddjx01.json", - config2="./config/ddjx01.json", + config1="./config/ddjx01_q6a.json", + config2="./config/ddjx01_q6a.json", rotation1=None, rotation2=None, target_fps=30, @@ -433,8 +433,8 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--video1", "-v1", type=str, default="0") parser.add_argument("--video2", "-v2", type=str, default="1") - parser.add_argument("--config1", type=str, default="./config/ddjx01.json") - parser.add_argument("--config2", type=str, default="./config/ddjx01.json") + parser.add_argument("--config1", type=str, default="./config/ddjx01_q6a.json") + parser.add_argument("--config2", type=str, default="./config/ddjx01_q6a.json") parser.add_argument("--rotation1", "-r1", type=str, default=None) parser.add_argument("--rotation2", "-r2", type=str, default=None) parser.add_argument("--target-fps", type=float, default=30) diff --git a/gripper/ui/visualizer.py b/gripper/ui/visualizer.py index 431d077..2624e26 100644 --- a/gripper/ui/visualizer.py +++ b/gripper/ui/visualizer.py @@ -18,6 +18,11 @@ if sys.platform.startswith("linux") and os.environ.get("WSL_DISTRO_NAME"): import cv2 import numpy as np +try: + from serial.tools import list_ports as serial_list_ports +except Exception: + serial_list_ports = None + def _fix_qt_plugin_path(): try: @@ -42,6 +47,7 @@ try: QApplication, QButtonGroup, QCheckBox, + QComboBox, QDialog, QFrame, QGraphicsOpacityEffect, @@ -71,7 +77,7 @@ except Exception as exc: 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 + QButtonGroup = QCheckBox = QComboBox = QDialog = QFrame = QGraphicsOpacityEffect = QLineEdit = QListWidget = QMessageBox = QMainWindow = object QProgressBar = QScrollArea = QSlider = QSpinBox = QStackedWidget = QTextEdit = None try: @@ -92,6 +98,7 @@ else: from ..app import build_controller from ..common.paths import PACKAGE_DIR, REPO_ROOT +from ..sensors.camera_discovery import camera_source_index, list_cameras PLOT_POINTS = 180 @@ -599,7 +606,7 @@ QSpinBox { border-radius: 4px; padding: 3px 5px; } -QLineEdit, QTextEdit { +QLineEdit, QTextEdit, QComboBox { background-color: #ffffff; color: #1f2933; border: 1px solid #d7dee8; @@ -608,7 +615,7 @@ QLineEdit, QTextEdit { font-family: "Cascadia Code", "Consolas"; font-size: 12px; } -QLineEdit:focus, QTextEdit:focus { +QLineEdit:focus, QTextEdit:focus, QComboBox:focus { border: 1px solid #7aa7e8; } QListWidget { @@ -733,6 +740,12 @@ def _draw_top_icon(kind, color="#344054", size=22): painter.setBrush(QColor("#ffffff")) painter.drawEllipse(QPointF(knob_x, y), size * 0.075, size * 0.075) painter.setBrush(Qt.NoBrush) + elif kind == "search": + painter.drawEllipse(QRectF(size * 0.25, size * 0.23, size * 0.40, size * 0.40)) + painter.drawLine( + QPointF(size * 0.60, size * 0.60), + QPointF(size * 0.76, size * 0.76), + ) elif kind == "minimize": painter.drawLine( QPointF(size * 0.32, size * 0.62), @@ -932,6 +945,7 @@ class ImageBox(QFrame): layout.setContentsMargins(6, 6, 6, 6) layout.setSpacing(5) header = QHBoxLayout() + self.header_layout = header header.setSpacing(6) self.title_label = QLabel(title) self.title_label.setObjectName("SectionTitle") @@ -994,6 +1008,10 @@ class FloatingImageBox(ImageBox): self.image_label.setScaledContents(False) self.mode_toggle_button.setObjectName("FlowModeButton") self.mode_toggle_button.setText("光流") + self.camera_search_button = TopIconButton("search", "搜索并设置左右摄像头") + self.camera_search_button.setFixedSize(24, 24) + self.camera_search_button.setIconSize(QSize(17, 17)) + self.header_layout.insertWidget(self.header_layout.count() - 1, self.camera_search_button) self._drag_mode = None self._drag_start_global = None self._start_geometry = None @@ -2203,6 +2221,141 @@ class SliderControl(QFrame): return int(self.slider.value()) +class CameraDiscoveryDialog(QDialog): + def __init__(self, viewer): + super().__init__(viewer) + self.viewer = viewer + self._scan_id = 0 + self._scan_result = None + self.setWindowTitle("双摄像头设置") + self.setWindowIcon(_load_company_icon(64)) + # Keep the main window responsive while OpenCV probes devices in the + # worker thread. A modeless child also avoids a full-screen compositor + # flash caused by switching into a nested modal event loop. + self.setModal(False) + self.setMinimumWidth(520) + + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(10) + self.status_label = QLabel("正在搜索摄像头...") + self.status_label.setObjectName("MetricLabel") + layout.addWidget(self.status_label) + + camera_grid = QGridLayout() + camera_grid.setHorizontalSpacing(10) + camera_grid.setVerticalSpacing(8) + self.camera_combos = {} + for row, (side, title) in enumerate((("left", "左侧摄像头"), ("right", "右侧摄像头"))): + label = QLabel(title) + label.setObjectName("MetricLabel") + combo = QComboBox() + combo.setMinimumHeight(32) + self.camera_combos[side] = combo + camera_grid.addWidget(label, row, 0) + camera_grid.addWidget(combo, row, 1) + camera_grid.setColumnStretch(1, 1) + layout.addLayout(camera_grid) + + actions = QHBoxLayout() + self.refresh_button = AnimatedButton("重新搜索") + self.refresh_button.setObjectName("ManualButton") + self.refresh_button.clicked.connect(self._start_scan) + self.apply_button = AnimatedButton("应用左右摄像头") + self.apply_button.setObjectName("OpenActionButton") + self.apply_button.clicked.connect(self._apply) + cancel_button = AnimatedButton("取消") + cancel_button.setObjectName("ManualButton") + cancel_button.clicked.connect(self.reject) + actions.addWidget(self.refresh_button) + actions.addStretch(1) + actions.addWidget(self.apply_button) + actions.addWidget(cancel_button) + layout.addLayout(actions) + + self.poll_timer = QTimer(self) + self.poll_timer.setInterval(100) + self.poll_timer.timeout.connect(self._poll_scan) + self._start_scan() + + def _start_scan(self): + self._scan_id += 1 + scan_id = self._scan_id + self._scan_result = None + self.status_label.setText("正在快速搜索摄像头(编号 0-3)...") + current_sources = self.viewer.camera_sources() + skip_indices = set() + for side, combo in self.camera_combos.items(): + combo.clear() + current = current_sources.get(side, "") + if current: + combo.addItem(f"当前配置 {current}(可能正在使用)", current) + combo.setEnabled(True) + current_index = camera_source_index(current) + if current_index is not None: + skip_indices.add(current_index) + else: + combo.setEnabled(False) + self.refresh_button.setEnabled(False) + self.apply_button.setEnabled( + all(combo.count() > 0 for combo in self.camera_combos.values()) + ) + + def scan(): + try: + self._scan_result = ( + scan_id, + list_cameras(skip_indices=skip_indices), + None, + ) + except Exception as exc: + self._scan_result = (scan_id, [], str(exc)) + + threading.Thread(target=scan, daemon=True).start() + self.poll_timer.start() + + def _poll_scan(self): + result = self._scan_result + if result is None: + return + scan_id, cameras, error = result + if scan_id != self._scan_id: + return + self.poll_timer.stop() + self.refresh_button.setEnabled(True) + current_sources = self.viewer.camera_sources() + found_sources = set() + for side, combo in self.camera_combos.items(): + combo.clear() + for camera in cameras: + combo.addItem(camera.label(), camera.source) + found_sources.add(camera.source) + current = current_sources.get(side, "") + if current and current not in found_sources: + combo.addItem(f"当前配置 {current}(可能正在使用)", current) + current_index = combo.findData(current) + if current_index >= 0: + combo.setCurrentIndex(current_index) + if error: + self.status_label.setText(f"搜索失败:{error}") + elif cameras: + self.status_label.setText(f"找到 {len(cameras)} 个可用摄像头") + else: + self.status_label.setText("未发现空闲摄像头;正在使用的当前配置仍可保留") + has_both = all(combo.count() > 0 for combo in self.camera_combos.values()) + for combo in self.camera_combos.values(): + combo.setEnabled(combo.count() > 0) + self.apply_button.setEnabled(has_both) + + def _apply(self): + left = self.camera_combos["left"].currentData() + right = self.camera_combos["right"].currentData() + if left is None or right is None: + return + if self.viewer.apply_camera_sources(str(left), str(right)): + self.accept() + + class VisualSettingsDialog(QDialog): CONFIG_GROUPS = [ ( @@ -2595,6 +2748,21 @@ class VisualSettingsDialog(QDialog): return scroll def _make_control(self, key, value): + if key == "port": + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + control = QComboBox() + control.setEditable(True) + control.addItem(str(value), str(value)) + control.setToolTip("port") + search_button = TopIconButton("search", "搜索可用串口") + search_button.clicked.connect(self._search_serial_ports) + layout.addWidget(control, 1) + layout.addWidget(search_button) + self.controls[key] = control + return container if isinstance(value, bool): control = QCheckBox() control.setChecked(value) @@ -2612,6 +2780,11 @@ class VisualSettingsDialog(QDialog): def _control_value(self, key, original): control = self.controls[key] + if isinstance(control, QComboBox): + selected = control.currentData() + if control.currentIndex() >= 0 and selected is not None: + return str(selected) + return control.currentText().strip() if isinstance(original, bool): return bool(control.isChecked()) if isinstance(original, (dict, list)): @@ -2636,11 +2809,50 @@ class VisualSettingsDialog(QDialog): return if isinstance(control, QCheckBox): control.setChecked(bool(value)) + elif isinstance(control, QComboBox): + text = "" if value is None else str(value) + index = control.findData(text) + if index < 0: + control.addItem(text, text) + index = control.count() - 1 + control.setCurrentIndex(index) elif isinstance(control, QTextEdit): control.setPlainText(json.dumps(value, ensure_ascii=False, indent=2)) elif isinstance(control, QLineEdit): control.setText("null" if value is None else str(value)) + def _search_serial_ports(self): + control = self.controls.get("port") + if not isinstance(control, QComboBox): + return + current = self._control_value("port", self.config_data.get("port", "")) + if serial_list_ports is None: + QMessageBox.warning(self, "搜索失败", "PySerial 串口枚举模块未安装。") + return + try: + ports = list(serial_list_ports.comports()) + except Exception as exc: + QMessageBox.warning(self, "搜索失败", f"无法枚举串口:\n{exc}") + return + + control.clear() + selected_index = -1 + for port in ports: + device = str(port.device) + description = str(port.description or "未知设备") + control.addItem(f"{device} [{description}]", device) + if device == current: + selected_index = control.count() - 1 + if current and selected_index < 0: + control.addItem(f"{current} [当前配置]", current) + selected_index = control.count() - 1 + if selected_index >= 0: + control.setCurrentIndex(selected_index) + if not ports: + QMessageBox.information(self, "搜索串口", "未发现可用串口,已保留当前配置。") + return + control.showPopup() + def _reload(self): self._load_config() for key, value in self.config_data.items(): @@ -2665,12 +2877,26 @@ class VisualSettingsDialog(QDialog): QMessageBox.warning(self, "保存失败", f"无法写入配置文件:\n{exc}") return + try: + layout_path = self.viewer.save_current_layout() + except Exception as exc: + QMessageBox.warning(self, "画面保存失败", f"参数已写入,但画面旋转无法保存:\n{exc}") + return + self.config_data = updated - QMessageBox.information(self, "已保存", "配置已写入 gripper.json。重启控制程序后完整生效。") + QMessageBox.information( + self, + "已保存", + f"配置已写入 gripper.json,画面旋转已写入:\n{layout_path}\n重启控制程序后完整生效。", + ) def _adjust(self, side, action): self.viewer.adjust_visual_transform(side, action) self._refresh_labels() + try: + self.viewer.save_current_layout() + except Exception as exc: + QMessageBox.warning(self, "保存失败", f"画面已调整,但无法保存旋转配置:\n{exc}") def _refresh_labels(self): for side, title in (("left", "左侧光流"), ("right", "右侧光流")): @@ -2726,7 +2952,7 @@ class GripperDemo02Viewer(QMainWindow): if pg is None: raise RuntimeError(f"pyqtgraph import failed: {_PYQTGRAPH_IMPORT_ERROR}") - self.setWindowTitle("Gripper Demo 02") + self.setWindowTitle("Orisys 夹爪") self.setWindowIcon(_load_company_icon(64)) self.setStyleSheet(STYLE) self.controller = None @@ -2745,6 +2971,7 @@ class GripperDemo02Viewer(QMainWindow): self.argv = argv self.layout_path = GUI_LAYOUT_PATH self.visual_settings_dialog = None + self.camera_discovery_dialog = None self._pending_runtime_speed = None self._pending_position_target = None self._pending_position_since = 0.0 @@ -2898,6 +3125,91 @@ class GripperDemo02Viewer(QMainWindow): config_path = REPO_ROOT / config_path return config_path + def camera_sources(self): + sources = {"left": "", "right": ""} + try: + with open(self._control_config_path(), "r", encoding="utf-8") as f: + data = json.load(f) + sources["left"] = str(data.get("video1", "")) + sources["right"] = str(data.get("video2", "")) + except Exception: + pass + return sources + + def open_camera_search(self): + existing = self.camera_discovery_dialog + if existing is not None and existing.isVisible(): + existing.raise_() + existing.activateWindow() + return + dialog = CameraDiscoveryDialog(self) + self.camera_discovery_dialog = dialog + dialog.finished.connect(lambda _result: setattr(self, "camera_discovery_dialog", None)) + dialog.show() + dialog.raise_() + + def _update_camera_argv(self, config_key, source): + args = list(self.argv or []) + option_names = { + "video1": ("--video1", "-v1"), + "video2": ("--video2", "-v2"), + }[config_key] + for index, arg in enumerate(args): + if arg in option_names and index + 1 < len(args): + args[index + 1] = source + self.argv = args + return + for option in option_names: + if str(arg).startswith(option + "="): + args[index] = option + "=" + source + self.argv = args + return + + def apply_camera_sources(self, left_source, right_source): + if left_source == right_source: + QMessageBox.warning(self, "摄像头冲突", "左右两侧需要选择不同的摄像头。") + return False + sources = self.camera_sources() + if left_source == sources.get("left") and right_source == sources.get("right"): + return True + + config_path = self._control_config_path() + temp_path = config_path.with_suffix(config_path.suffix + ".tmp") + try: + with open(config_path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError("配置文件根节点不是 JSON 对象") + data["video1"] = left_source + data["video2"] = right_source + config_path.parent.mkdir(parents=True, exist_ok=True) + 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, config_path) + except Exception as exc: + try: + if temp_path.exists(): + temp_path.unlink() + except OSError: + pass + QMessageBox.warning(self, "保存失败", f"无法保存摄像头配置:\n{exc}") + return False + + self._update_camera_argv("video1", left_source) + self._update_camera_argv("video2", right_source) + if self.visual_settings_dialog is not None: + for config_key, source in (("video1", left_source), ("video2", right_source)): + self.visual_settings_dialog.config_data[config_key] = source + self.visual_settings_dialog._set_control_value(config_key, source) + QMessageBox.information( + self, + "双摄像头已切换", + f"左侧摄像头: {left_source}\n右侧摄像头: {right_source}\n控制程序将自动重启。", + ) + self.restart_control() + return True + def _load_position_config(self): open_pos = 0 close_pos = 9000 @@ -3007,6 +3319,8 @@ class GripperDemo02Viewer(QMainWindow): self.right_flow = self.gripper_stage.right_flow self.left_flow.set_transform_label(self.visual_transforms["left"]) self.right_flow.set_transform_label(self.visual_transforms["right"]) + self.left_flow.camera_search_button.clicked.connect(self.open_camera_search) + self.right_flow.camera_search_button.clicked.connect(self.open_camera_search) visual_layout.addWidget(self.gripper_stage, 0, 0) visual_layout.setColumnStretch(0, 1) center_splitter.addWidget(visual_panel) diff --git a/release/GripperDemo02-ubuntu26.04-x86_64-lite-20260717.tar.gz b/release/GripperDemo02-ubuntu26.04-x86_64-lite-20260717.tar.gz new file mode 100644 index 0000000..026ffdb Binary files /dev/null and b/release/GripperDemo02-ubuntu26.04-x86_64-lite-20260717.tar.gz differ diff --git a/release/GripperDemo02-ubuntu26.04-x86_64-lite-20260717.tar.gz.sha256 b/release/GripperDemo02-ubuntu26.04-x86_64-lite-20260717.tar.gz.sha256 new file mode 100644 index 0000000..0f6ead7 --- /dev/null +++ b/release/GripperDemo02-ubuntu26.04-x86_64-lite-20260717.tar.gz.sha256 @@ -0,0 +1 @@ +620677510d421d76418c98366a4b57df7df930cda60e72f4efb214d96b4f05c7 GripperDemo02-ubuntu26.04-x86_64-lite-20260717.tar.gz diff --git a/release/GripperDemo02-windows-x64-lite-20260715.zip b/release/GripperDemo02-windows-x64-lite-20260715.zip deleted file mode 100644 index 165d5e4..0000000 --- a/release/GripperDemo02-windows-x64-lite-20260715.zip +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3099df00132a5f515fde42617969397dcf71655df13cd28c196002435ca6cd7b -size 195519254 diff --git a/release/GripperDemo02-windows-x64-py311-venv-20260717.zip b/release/GripperDemo02-windows-x64-py311-venv-20260717.zip new file mode 100644 index 0000000..c8e01ed --- /dev/null +++ b/release/GripperDemo02-windows-x64-py311-venv-20260717.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:042c1f87a8c194239ccbd373ca55425091234e8540f995bc7efe6b2eb74779fa +size 233662612 diff --git a/release/GripperDemo02-windows-x64-py311-venv-20260717.zip.sha256 b/release/GripperDemo02-windows-x64-py311-venv-20260717.zip.sha256 new file mode 100644 index 0000000..163946f --- /dev/null +++ b/release/GripperDemo02-windows-x64-py311-venv-20260717.zip.sha256 @@ -0,0 +1 @@ +042c1f87a8c194239ccbd373ca55425091234e8540f995bc7efe6b2eb74779fa GripperDemo02-windows-x64-py311-venv-20260717.zip diff --git a/release/SHA256SUMS.txt b/release/SHA256SUMS.txt deleted file mode 100644 index 84eeca0..0000000 --- a/release/SHA256SUMS.txt +++ /dev/null @@ -1 +0,0 @@ -3099df00132a5f515fde42617969397dcf71655df13cd28c196002435ca6cd7b GripperDemo02-windows-x64-lite-20260715.zip