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'''
+
+'''
+
+GRIP_FLOW = r'''
+
+'''
+
+
+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 夹爪
+ 图形化监控与智能夹持控制软件
+
+
+
+
+
+
+
+ DOCUMENT CONTROL
+
版本履历
+
+
+ 表 0-1 文档修订记录
+
+
+ | 版本 |
+ 日期 |
+ 修订人 |
+ 修订说明 |
+
+
+
+
+ | V1.0 |
+ 2026-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.md | GUI 详细技术说明(完整参数表及深度故障排查) |
+ README.md | 开发与打包说明 |
+
+
+
+
+
+
+
+ CHAPTER 2
+
安全须知
+
+
+
+
警告
+
夹爪为运动部件,调试及演示过程中可能造成挤压伤害。操作前必须确认开合范围内无人员肢体及无关物体。首次运行须使用较低速度与力度。
+
+
+
+
注意
+
本软件顶部「停止」按钮仅停止软件控制流程,不是独立的硬件急停装置。发生异常时应立即切断夹爪电源或执行硬件急停(如设备具备)。
+
+
+ 2.1 一般安全要求
+
+ - 仅经培训人员方可操作本系统;
+ - 运行前检查夹爪安装牢固、电源及通信线缆连接可靠;
+ - 传感器标定及系统重启标定阶段,须保持两侧触觉传感器空载且静止;
+ - 修改力度、速度及触发阈值前,应备份
config/gripper.json;
+ - 程序异常退出后,以夹爪实际物理状态为准,必要时手动断电复位。
+
+
+ 2.2 电气与设备安全
+
+ - 串口通信期间不得热插拔通信线缆(除非硬件支持);
+ - USB 摄像头及串口设备应连接至稳定供电的 USB 端口,避免共用易过载的 USB Hub;
+ - Linux 环境下运行用户须具备
video、dialout 设备组权限。
+
+
+
+
+
+
+ CHAPTER 3
+
产品说明
+
+
+ 3.1 系统组成
+ 本系统由感知层、软件处理层及执行与呈现层构成。左、右触觉传感器采集视频帧并计算触觉力;智能夹持状态机根据力信号经 Modbus RTU 串口驱动夹爪;GUI 同步显示 3D 姿态、光流图像、力曲线及运行状态。
+
+
+ 图 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_id | Modbus 从站地址 | 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 正常启动程序
+
+ - 确认第 4 章检查项目均合格;
+ - 双击运行
GripperDemo02.exe(Linux:./GripperDemo02);
+ - 程序启动后自动创建控制线程,夹爪执行张开动作;
+ - 系统在
baseline_seconds(默认 1.0 s)内完成传感器空载基准标定,此期间保持两侧传感器无接触;
+ - 待顶部状态栏显示
state=open_wait(等待触发),且 action=open-wait-armed(已就绪);
+ - 确认左右传感器状态指示为绿色,FPS 显示正常数值(非
--)。
+
+
+
+
说明
+
正常启动后无需再次点击顶部「启动」图标。仅当控制程序处于空闲(已停止)状态时,方使用该按钮重新建立设备连接。
+
+
+ 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 工作区及若干浮动功能面板组成。浮动面板可拖动、调整大小;布局可通过设置模块保存并在下次启动时恢复。
+
+
+
+ 图 6-1 主界面总览。彩色框标注各功能区域,对应表 6-1 编号。
+
+
+
+
+ 表 6-1 主界面区域功能说明
+
+ | 区域编号 | 名称 | 功能说明 |
+
+
+ | A | 顶部状态与控制栏 | 显示 state、action;提供设置、启停、重启及窗口管理 |
+ | B | 夹爪 3D 模型工作区 | 显示夹爪实时开合姿态、当前状态及力/位置摘要 |
+ | C | 左/右光流窗口 | 分别显示左、右传感器光流或幅值图像 |
+ | D | 曲线图区域 | 六路力值(法向力、切向力 X/Y)实时趋势 |
+ | E | 夹爪控制区域 | 智能/手动模式切换;速度、力度、位置及动作命令 |
+ | F | 传感器控制区域 | 传感器在线状态、FPS、力值读数及单侧重启 |
+
+
+
+
+
+ 图 6-2 顶部状态与控制栏局部放大标注。
+
+
+
+
+
+
+
+ CHAPTER 7
+
功能模块操作
+
+
+ 7.1 夹爪 3D 模型
+ 3D 模型区域根据实时夹爪位置在预缓存的开合档位间切换,显示当前机械姿态。区域下方叠加显示左/右法向力、夹爪位置、当前力百分比及自适应目标力百分比。
+
+ 表 7-1 3D 视图操作
+
+ | 输入 | 操作 |
+
+
+ | 鼠标左键拖动 | 旋转观察视角 |
+ | 鼠标中键拖动 | 平移观察中心 |
+ | 鼠标滚轮 | 缩放 |
+
+
+
+
+ 图 7-1 夹持检测(hold_check)状态示例:标注顶部状态行、3D 模型区及底部实时摘要。
+
+
+
+ 7.2 光流窗口
+ 左、右光流窗口为独立浮动面板,分别对应两路触觉传感器。用户可通过右上角模式按钮在「光流」与「幅值」显示模式间切换。
+
+
+ 图 7-2 光流窗口特写。标注左右面板及模式切换按钮位置。
+
+
+
+ 表 7-2 光流窗口操作
+
+ | 操作 | 方法 |
+
+
+ | 切换显示模式 | 点击窗口右上角「光流 / 幅值」按钮 |
+ | 移动窗口 | 拖动标题栏或图像区域 |
+ | 调整大小 | 拖动窗口右下角约 18 px 区域 |
+ | 旋转 / 镜像 | 设置 → 画面 → 对应侧按钮(见 10.1 节) |
+
+
+
+ 7.3 力曲线图
+ 曲线区域以 3 行 × 2 列布局显示六路力值,横轴为时间(秒),纵轴为力(牛顿)。默认显示最近约 6 s 数据;显示值经平滑处理,绝对值低于 0.05 N 时归零显示。
+
+
+ 图 7-3 力曲线区域特写。三行分别对应法向力、切向力 X、切向力 Y。
+
+
+
+ 7.4 传感器控制区域
+
+
+ 图 7-4 传感器控制区域特写。标注在线指示、FPS 及力值读数位置。
+
+
+
+ 表 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
+
手动控制操作
+
+
+ 控制程序处于运行状态时,用户可点击「手动模式」切换至手动控制。手动模式下,用户可直接设定速度、最大力及目标位置,并执行张开、闭合、保持等命令。
+
+
+
+ 图 9-1 智能模式:模式切换、滑条与动作按钮均为灰色锁定状态。
+
+
+
+
+
+ 图 9-2 手动模式:滑条与动作按钮均已启用,可直接下发命令。
+
+
+
+
+ 表 9-1 手动控制参数
+
+ | 参数 | 范围 | 步进 | 生效方式 |
+
+
+ | 速度 | 0~100% | 5% | 调整后立即更新控制器速度 |
+ | 最大力 | 0~100% | 5% | 随下一次动作命令发送;受 force_min、force_max 约束 |
+ | 夹爪位置 | open_pos~close_pos | 100 | 释放滑条或点击步进按钮后发送位置命令 |
+
+
+
+
+ 表 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 参数保存程序
+ 在设置窗口中修改参数后,须按以下顺序操作,否则运行中的控制程序不会加载新值:
+
+ - 点击「保存到 gripper.json」,确认保存成功提示;
+ - 关闭设置窗口;
+ - 点击顶部「重启控制程序」。
+
+
+
+
注意
+
「保存当前布局」与「保存到 gripper.json」为相互独立操作。布局文件 gui_layout.json 不包含运行参数;参数文件不包含曲线历史及当前运行状态。
+
+
+ 10.2 常用参数速查
+
+ 表 10-1 常用运行参数
+
+ | 参数 | 默认值 | 说明 |
+
+
+ trigger_normal_force | 0.1 N | 单侧法向力触发阈值 |
+ trigger_shear_force | 0.1 N | 单侧切向力触发阈值 |
+ grip_normal_force | 0.2 N | 判定接触的法向力阈值 |
+ grip_requires_both | true | 是否要求双侧同时接触 |
+ hold_force | 20% | 基础保持目标力百分比 |
+ adaptive_force_max | 80% | 自适应夹持力上限 |
+ release_normal_change | 0.2 N | 释放检测法向力变化阈值 |
+ rearm_seconds | 0.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 |
+ 修改参数未生效 |
+ 未保存文件或未重启控制程序 |
+ 确认保存成功并执行「重启控制程序」 |
+
+
+ | 6 |
+ 3D 模型无法显示 |
+ 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 @@
+
+
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 @@
+
+
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