稳定度配置条与自定义挡位、独立速度调节;状态文字中文简写与平滑;灵敏挡切向振荡判据收紧;新增重置底图按钮。
- 设置框新增 0~100 稳定度配置条(灵敏 0 / 较稳定 50 / 十分稳定 100 为内置挡位,中间按比例插值),支持滚轮/左键拖动,可另存/删除自定义挡位;速度改为独立滑块。 - 状态栏改为中文简写(悬停显示原文);夹持保护期文字按挡位区分;合并同义子状态并加最短停留,避免逐帧跳动。 - 灵敏挡切向振荡:0.5 s 内换向 4 次,并须持续至少 0.15 s 才张开(新参数 release_shear_oscillation_min_seconds)。 - 传感器控制区新增「重置底图」:左右传感器一起用当前画面更换光流参考底图(orisys Sensor.reset),不断开相机。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import copy
|
||||
# sensitive = 灵敏:跟手释放,自适应夹持力
|
||||
# stable = 较稳定:接触即 70% 恒力夹持,2 s 保护期,Y 拟合偏移 0.6 N 张开
|
||||
# firm = 十分稳定:接触即 100% 恒力夹持,2.5 s 保护期,Y 拟合偏移 0.8 N 张开
|
||||
# 稳定度 0..100:三挡分别位于 0 / 50 / 100,中间值按比例插值(见 grip_behavior_level_profile)。
|
||||
GRIP_BEHAVIOR_MODES = ("sensitive", "stable", "firm")
|
||||
|
||||
GRIP_BEHAVIOR_LABELS = {
|
||||
@@ -74,6 +75,7 @@ GRIP_BEHAVIOR_KEYS = (
|
||||
"release_shear_oscillation_min_flips",
|
||||
"release_shear_oscillation_amplitude",
|
||||
"release_shear_oscillation_confirm_samples",
|
||||
"release_shear_oscillation_min_seconds",
|
||||
"release_side_shear_enabled",
|
||||
"release_side_normal_enabled",
|
||||
"release_shear_onset_enabled",
|
||||
@@ -157,10 +159,11 @@ _SENSITIVE_PROFILE = {
|
||||
"release_settle_normal_only": True,
|
||||
# Intentional rub/twist: oscillating shear opens even before plateau latch.
|
||||
"release_shear_oscillation_open": True,
|
||||
"release_shear_oscillation_window_seconds": 0.4,
|
||||
"release_shear_oscillation_min_flips": 3,
|
||||
"release_shear_oscillation_window_seconds": 0.5,
|
||||
"release_shear_oscillation_min_flips": 4,
|
||||
"release_shear_oscillation_amplitude": 0.15,
|
||||
"release_shear_oscillation_confirm_samples": 2,
|
||||
"release_shear_oscillation_min_seconds": 0.15,
|
||||
"release_side_shear_enabled": True,
|
||||
"release_side_normal_enabled": True,
|
||||
"release_shear_onset_enabled": True,
|
||||
@@ -288,6 +291,103 @@ def grip_behavior_profile(mode: str = "sensitive") -> dict:
|
||||
return _merged_profile(mode)
|
||||
|
||||
|
||||
# Stability level 0..100: the three built-in modes sit at fixed anchors.
|
||||
# Below the stable anchor the sensitive algorithm runs with its numeric params
|
||||
# scaled toward the stable values; from the stable anchor on, the held
|
||||
# algorithm runs, scaled from stable to firm.
|
||||
GRIP_BEHAVIOR_LEVEL_MIN = 0
|
||||
GRIP_BEHAVIOR_LEVEL_MAX = 100
|
||||
GRIP_BEHAVIOR_LEVELS = {"sensitive": 0, "stable": 50, "firm": 100}
|
||||
# Only read by the held (stable/firm) release; stays off on the sensitive side.
|
||||
_HELD_ONLY_KEYS = ("release_shear_displacement",)
|
||||
|
||||
|
||||
def clamp_grip_behavior_level(level) -> int:
|
||||
try:
|
||||
value = int(round(float(level)))
|
||||
except (TypeError, ValueError):
|
||||
value = GRIP_BEHAVIOR_LEVEL_MIN
|
||||
return max(GRIP_BEHAVIOR_LEVEL_MIN, min(GRIP_BEHAVIOR_LEVEL_MAX, value))
|
||||
|
||||
|
||||
def grip_behavior_mode_for_level(level) -> str:
|
||||
level = clamp_grip_behavior_level(level)
|
||||
if level >= GRIP_BEHAVIOR_LEVELS["firm"]:
|
||||
return "firm"
|
||||
if level >= GRIP_BEHAVIOR_LEVELS["stable"]:
|
||||
return "stable"
|
||||
return "sensitive"
|
||||
|
||||
|
||||
def default_grip_behavior_level(mode) -> int:
|
||||
return GRIP_BEHAVIOR_LEVELS[normalize_grip_behavior_mode(mode)]
|
||||
|
||||
|
||||
def builtin_grip_behavior_presets() -> list:
|
||||
return [
|
||||
{
|
||||
"name": GRIP_BEHAVIOR_LABELS[mode],
|
||||
"level": GRIP_BEHAVIOR_LEVELS[mode],
|
||||
"speed": GRIP_BEHAVIOR_PROFILES[mode]["speed"],
|
||||
"builtin": True,
|
||||
}
|
||||
for mode in GRIP_BEHAVIOR_MODES
|
||||
]
|
||||
|
||||
|
||||
def normalize_grip_behavior_presets(presets) -> list:
|
||||
"""User presets as [{name, level[, speed]}], without duplicates of built-in names."""
|
||||
reserved = {GRIP_BEHAVIOR_LABELS[mode] for mode in GRIP_BEHAVIOR_MODES}
|
||||
result = []
|
||||
seen = set()
|
||||
for item in presets if isinstance(presets, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
if not name or name in reserved or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
preset = {"name": name, "level": clamp_grip_behavior_level(item.get("level"))}
|
||||
if item.get("speed") is not None:
|
||||
preset["speed"] = clamp_grip_behavior_level(item.get("speed"))
|
||||
result.append(preset)
|
||||
return result
|
||||
|
||||
|
||||
def _lerp_value(a, b, t):
|
||||
if isinstance(a, bool) or isinstance(b, bool):
|
||||
return copy.deepcopy(a)
|
||||
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
|
||||
return copy.deepcopy(a)
|
||||
value = a + (b - a) * t
|
||||
if isinstance(a, int) and isinstance(b, int):
|
||||
return int(round(value))
|
||||
return round(float(value), 4)
|
||||
|
||||
|
||||
def grip_behavior_level_profile(level) -> dict:
|
||||
"""Merged params for stability *level*; anchors equal the built-in modes."""
|
||||
level = clamp_grip_behavior_level(level)
|
||||
for mode, anchor in GRIP_BEHAVIOR_LEVELS.items():
|
||||
if level == anchor:
|
||||
return _merged_profile(mode)
|
||||
stable_at = GRIP_BEHAVIOR_LEVELS["stable"]
|
||||
if level < stable_at:
|
||||
low, high = _merged_profile("sensitive"), _merged_profile("stable")
|
||||
t = (level - GRIP_BEHAVIOR_LEVELS["sensitive"]) / float(stable_at - GRIP_BEHAVIOR_LEVELS["sensitive"])
|
||||
else:
|
||||
low, high = _merged_profile("stable"), _merged_profile("firm")
|
||||
t = (level - stable_at) / float(GRIP_BEHAVIOR_LEVELS["firm"] - stable_at)
|
||||
fixed = GRIP_TRIGGER_KEYS + (_HELD_ONLY_KEYS if level < stable_at else ())
|
||||
profile = {}
|
||||
for key, value in low.items():
|
||||
if key in fixed:
|
||||
profile[key] = copy.deepcopy(value)
|
||||
else:
|
||||
profile[key] = _lerp_value(value, high.get(key, value), t)
|
||||
return profile
|
||||
|
||||
|
||||
def apply_grip_behavior_profile(target, mode: str = "sensitive") -> str:
|
||||
mode = normalize_grip_behavior_mode(mode)
|
||||
profile = _merged_profile(mode)
|
||||
|
||||
@@ -122,6 +122,8 @@ class Demo02Config:
|
||||
release_shear_oscillation_min_flips: int = 3
|
||||
release_shear_oscillation_amplitude: float = 0.15
|
||||
release_shear_oscillation_confirm_samples: int = 2
|
||||
# Oscillation must persist this long (in addition to confirm samples) before opening.
|
||||
release_shear_oscillation_min_seconds: float = 0.0
|
||||
# Block oscillation/side-touch release during contact hold and servo motion.
|
||||
release_guard_fast_paths: bool = False
|
||||
# Shear axes for release only: "" follows shear_control_axes; "y" | "x" | "xy".
|
||||
@@ -281,6 +283,8 @@ class Demo02Config:
|
||||
default=get("release_shear_oscillation_amplitude", cls.release_shear_oscillation_amplitude))
|
||||
add("--release-shear-oscillation-confirm-samples", type=int,
|
||||
default=get("release_shear_oscillation_confirm_samples", cls.release_shear_oscillation_confirm_samples))
|
||||
add("--release-shear-oscillation-min-seconds", type=float,
|
||||
default=get("release_shear_oscillation_min_seconds", cls.release_shear_oscillation_min_seconds))
|
||||
add("--release-guard-fast-paths", dest="release_guard_fast_paths", action="store_true",
|
||||
default=get("release_guard_fast_paths", cls.release_guard_fast_paths))
|
||||
add("--no-release-guard-fast-paths", dest="release_guard_fast_paths", action="store_false")
|
||||
|
||||
@@ -55,6 +55,7 @@ class SideTriggerGripController:
|
||||
"move_position",
|
||||
"restart_left_sensor",
|
||||
"restart_right_sensor",
|
||||
"reset_sensor_reference",
|
||||
}:
|
||||
raise ValueError(f"unsupported manual command: {command}")
|
||||
payload = {
|
||||
@@ -470,6 +471,14 @@ class SideTriggerGripController:
|
||||
manual_action = "right-sensor-restart"
|
||||
else:
|
||||
manual_action = "right-sensor-restart-failed"
|
||||
elif manual_command == "reset_sensor_reference":
|
||||
if self.reader.reset_reference_maps():
|
||||
release_watch.suspend()
|
||||
reset_open_wait_stability()
|
||||
trigger_armed = False
|
||||
manual_action = "sensor-reference-reset"
|
||||
else:
|
||||
manual_action = "sensor-reference-reset-failed"
|
||||
|
||||
# Non-blocking sample read: never stall the FSM on sensor wait.
|
||||
feedback = self.feedback_processor.read(self.reader, timeout=0.0)
|
||||
|
||||
@@ -243,6 +243,7 @@ class ReleaseWatch:
|
||||
self._candidate = None
|
||||
self._osc_count = 0
|
||||
self._osc_channel = ""
|
||||
self._osc_since = None
|
||||
self._last_fresh = {}
|
||||
self._rates = {}
|
||||
self._curves = {key: ForceCurve(self.config) for key in CHANNELS}
|
||||
@@ -377,14 +378,17 @@ class ReleaseWatch:
|
||||
if not channel:
|
||||
self._osc_count = 0
|
||||
self._osc_channel = ""
|
||||
self._osc_since = None
|
||||
return None
|
||||
needed = max(2, int(getattr(self.config, "release_shear_oscillation_confirm_samples", 2) or 2))
|
||||
min_seconds = max(0.0, float(getattr(self.config, "release_shear_oscillation_min_seconds", 0.0) or 0.0))
|
||||
if self._osc_channel != channel:
|
||||
self._osc_channel = channel
|
||||
self._osc_count = 1
|
||||
self._osc_since = now
|
||||
else:
|
||||
self._osc_count += 1
|
||||
released = self._osc_count >= needed
|
||||
released = self._osc_count >= needed and now - self._osc_since >= min_seconds
|
||||
if released:
|
||||
self.phase = ReleasePhase.RELEASED
|
||||
else:
|
||||
@@ -409,6 +413,7 @@ class ReleaseWatch:
|
||||
self._candidate = None
|
||||
self._osc_count = 0
|
||||
self._osc_channel = ""
|
||||
self._osc_since = None
|
||||
self._watch_since = None
|
||||
self._settle_seen.clear()
|
||||
for curve in self._curves.values():
|
||||
|
||||
@@ -155,6 +155,29 @@ def _make_visuals(orisys_module, img, flow, visual_size):
|
||||
return arrows, magnitude, float(flow_mag.mean()), float(flow_mag.max())
|
||||
|
||||
|
||||
def _drain_commands(command_queue, sensor):
|
||||
"""Apply pending worker commands. Returns True if a reference reset ran."""
|
||||
if command_queue is None:
|
||||
return False
|
||||
reset = False
|
||||
while True:
|
||||
try:
|
||||
command = command_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
if command == "reset":
|
||||
try:
|
||||
if sensor.get_img() is not None:
|
||||
sensor.reset()
|
||||
reset = True
|
||||
except Exception as exc:
|
||||
print(f"sensor reference reset failed: {exc}")
|
||||
traceback.print_exc()
|
||||
return reset
|
||||
|
||||
|
||||
def _sensor_force_worker(
|
||||
sensor_id,
|
||||
vid_src,
|
||||
@@ -169,6 +192,7 @@ def _sensor_force_worker(
|
||||
motion_threshold,
|
||||
include_visuals,
|
||||
visual_size,
|
||||
command_queue=None,
|
||||
):
|
||||
ensure_project_paths()
|
||||
import orisys
|
||||
@@ -190,6 +214,7 @@ def _sensor_force_worker(
|
||||
|
||||
while not stop_event.is_set():
|
||||
t_start = time.perf_counter()
|
||||
_drain_commands(command_queue, sensor)
|
||||
img = sensor.get_img()
|
||||
if img is None:
|
||||
time.sleep(0.01)
|
||||
@@ -309,6 +334,7 @@ class TwoSensorForceReader:
|
||||
self.visual_size = visual_size
|
||||
|
||||
self.result_queues = []
|
||||
self.command_queues = []
|
||||
self.stop_events = []
|
||||
self.processes = []
|
||||
self.latest = [None, None]
|
||||
@@ -318,6 +344,7 @@ class TwoSensorForceReader:
|
||||
return
|
||||
|
||||
self.result_queues = [Queue(maxsize=self.queue_size) for _ in self.sensor_configs]
|
||||
self.command_queues = [Queue(maxsize=8) for _ in self.sensor_configs]
|
||||
self.stop_events = [mp.Event() for _ in self.sensor_configs]
|
||||
self.processes = [None] * len(self.sensor_configs)
|
||||
self.latest = [None] * len(self.sensor_configs)
|
||||
@@ -343,6 +370,7 @@ class TwoSensorForceReader:
|
||||
self.motion_threshold,
|
||||
self.include_visuals,
|
||||
self.visual_size,
|
||||
self.command_queues[idx],
|
||||
),
|
||||
)
|
||||
process.start()
|
||||
@@ -364,11 +392,36 @@ class TwoSensorForceReader:
|
||||
old_process.join(timeout=1.0)
|
||||
|
||||
self.result_queues[idx] = Queue(maxsize=self.queue_size)
|
||||
self.command_queues[idx] = Queue(maxsize=8)
|
||||
self.stop_events[idx] = mp.Event()
|
||||
self.latest[idx] = None
|
||||
self._start_sensor_process(idx)
|
||||
return True
|
||||
|
||||
def reset_reference_maps(self):
|
||||
"""Ask both sensor workers to replace the optical-flow reference frame."""
|
||||
if not self.processes or not self.command_queues:
|
||||
return False
|
||||
sent = 0
|
||||
for idx, command_queue in enumerate(self.command_queues):
|
||||
process = self.processes[idx] if idx < len(self.processes) else None
|
||||
if process is None or not process.is_alive():
|
||||
continue
|
||||
try:
|
||||
command_queue.put_nowait("reset")
|
||||
sent += 1
|
||||
except queue.Full:
|
||||
try:
|
||||
command_queue.get_nowait()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
command_queue.put_nowait("reset")
|
||||
sent += 1
|
||||
except queue.Full:
|
||||
pass
|
||||
return sent > 0
|
||||
|
||||
def update(self):
|
||||
for idx, result_queue in enumerate(self.result_queues):
|
||||
data = None
|
||||
|
||||
+658
-67
@@ -4,6 +4,7 @@ import math
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
@@ -41,7 +42,7 @@ _fix_qt_plugin_path()
|
||||
|
||||
_QT_IMPORT_ERROR = None
|
||||
try:
|
||||
from PyQt5.QtCore import QEasingCurve, QEvent, QPoint, QPointF, QRect, QRectF, QSize, QPropertyAnimation, Qt, QTimer
|
||||
from PyQt5.QtCore import QEasingCurve, QEvent, QPoint, QPointF, QRect, QRectF, QSize, QPropertyAnimation, Qt, QTimer, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QFont, QIcon, QImage, QLinearGradient, QPainter, QPen, QPixmap, QPolygonF, QRadialGradient, QVector3D
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -53,6 +54,7 @@ try:
|
||||
QGraphicsOpacityEffect,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
@@ -78,7 +80,10 @@ except Exception as exc:
|
||||
QGridLayout = QHBoxLayout = QLabel = QPushButton = QSizeGrip = QSizePolicy = QSplitter = None
|
||||
QVBoxLayout = QWidget = None
|
||||
QButtonGroup = QCheckBox = QComboBox = QDialog = QFrame = QGraphicsOpacityEffect = QLineEdit = QListWidget = QMessageBox = QMainWindow = object
|
||||
QProgressBar = QScrollArea = QSlider = QSpinBox = QStackedWidget = QTextEdit = None
|
||||
QProgressBar = QScrollArea = QSlider = QSpinBox = QStackedWidget = QTextEdit = QInputDialog = None
|
||||
|
||||
def pyqtSignal(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
@@ -98,7 +103,18 @@ else:
|
||||
|
||||
from ..app import build_controller
|
||||
from ..common.paths import PACKAGE_DIR, REPO_ROOT
|
||||
from ..config.behavior_profiles import grip_behavior_profile, normalize_grip_behavior_mode
|
||||
from ..config.behavior_profiles import (
|
||||
GRIP_BEHAVIOR_LEVEL_MAX,
|
||||
GRIP_BEHAVIOR_LEVEL_MIN,
|
||||
builtin_grip_behavior_presets,
|
||||
clamp_grip_behavior_level,
|
||||
default_grip_behavior_level,
|
||||
grip_behavior_level_profile,
|
||||
grip_behavior_mode_for_level,
|
||||
grip_behavior_profile,
|
||||
normalize_grip_behavior_mode,
|
||||
normalize_grip_behavior_presets,
|
||||
)
|
||||
from ..sensors.camera_discovery import (
|
||||
default_sensor_camera_sources,
|
||||
list_camera_ids,
|
||||
@@ -218,7 +234,7 @@ ACTION_TEXT = {
|
||||
"open-wait-stabilizing": "等待稳定基准",
|
||||
"open-wait-armed": "已就绪",
|
||||
"side-trigger-close": "侧边触发闭合",
|
||||
"closing-continue": "继续闭合",
|
||||
"closing-continue": "闭合寻找物体",
|
||||
"closing-to-grip": "闭合寻找物体",
|
||||
"object-detected-low-force": "检测到物体",
|
||||
"close-timeout-open": "闭合超时张开",
|
||||
@@ -227,21 +243,25 @@ ACTION_TEXT = {
|
||||
"hold-reference-armed": "记录夹持基准",
|
||||
"adaptive-grip-ramp": "自适应加力",
|
||||
"adaptive-grip-wait": "等待自适应加力",
|
||||
"release-grip-settle": "法向夹稳保护,侧触可释放",
|
||||
"release-self-settle": "自运动保护,侧触可释放",
|
||||
"release-ready": "释放检测就绪",
|
||||
"release-grip-settle": "夹持保护期 · 侧触可张开",
|
||||
"release-self-settle": "自运动保护 · 侧触可张开",
|
||||
"release-ready": "监视外力变化",
|
||||
"release-sudden": "突变外力确认",
|
||||
"release-touch": "轻触变化确认",
|
||||
"release-side_touch": "即时侧触确认",
|
||||
"release-slow_external": "缓推外力确认",
|
||||
"release-slow_internal": "慢变跟随",
|
||||
"release-slow_internal": "监视外力变化",
|
||||
"release-watch": "监视外力变化",
|
||||
"release-waiting-sample": "等待新传感器帧",
|
||||
"release-sample-gap": "采样断流保护",
|
||||
"release-invalid-sample": "传感器数据无效",
|
||||
"release-transient-rejected": "短脉冲已拒绝",
|
||||
"release-settling-turn": "平稳拐点,继续夹持",
|
||||
"release-settling-turn": "监视外力变化",
|
||||
"release-confirm-expired": "外力确认已过期",
|
||||
"release-shear_displacement": "Y 偏移确认",
|
||||
"release-shear_oscillation": "切向振荡确认",
|
||||
"release-object-lost": "物体脱出",
|
||||
"release-force-settling": "等待夹持力稳定",
|
||||
"hold-check": "保持检测",
|
||||
"force-change-release-open": "力变化张开",
|
||||
"open-recover-position": "张开位置恢复",
|
||||
@@ -262,29 +282,93 @@ ACTION_TEXT = {
|
||||
"right-sensor-restart": "右传感器重启",
|
||||
"left-sensor-restart-failed": "左传感器重启失败",
|
||||
"right-sensor-restart-failed": "右传感器重启失败",
|
||||
"sensor-reference-reset": "参考底图已重置",
|
||||
"sensor-reference-reset-failed": "参考底图重置失败",
|
||||
}
|
||||
|
||||
|
||||
def label_with_cn(value, mapping):
|
||||
value = str(value or "--")
|
||||
cn = mapping.get(value)
|
||||
if cn is None:
|
||||
for prefix, text in mapping.items():
|
||||
if value.startswith((f"{prefix} ", f"{prefix}(")):
|
||||
cn = text
|
||||
break
|
||||
if cn is None:
|
||||
return value
|
||||
return f"{value}({cn})"
|
||||
# Stable / firm release: every open path is off during the contact guard
|
||||
# (only object-lost still opens), so side touch cannot open there.
|
||||
HELD_ACTION_TEXT = {
|
||||
**ACTION_TEXT,
|
||||
"release-grip-settle": "夹持保护期 · 暂不张开",
|
||||
"release-self-settle": "自运动保护 · 暂不张开",
|
||||
}
|
||||
|
||||
|
||||
def shorten_status_text(text, max_len=22):
|
||||
text = str(text or "--").strip()
|
||||
def _elide_middle(text, max_len):
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
if max_len <= 1:
|
||||
return text[:max_len]
|
||||
return text[: max_len - 1] + "…"
|
||||
head = (max_len - 1) // 2
|
||||
return f"{text[:head]}…{text[len(text) - (max_len - 1 - head):]}"
|
||||
|
||||
|
||||
def _status_part_text(part, mapping):
|
||||
code, _, detail = part.strip().partition(" ")
|
||||
inner = ""
|
||||
if code.endswith(")") and "(" in code:
|
||||
code, _, inner = code[:-1].partition("(")
|
||||
text = mapping.get(code)
|
||||
if text is None:
|
||||
return _elide_middle(code, 20)
|
||||
if inner:
|
||||
text += f"({mapping.get(f'release-{inner}') or mapping.get(inner) or inner})"
|
||||
confirm = re.search(r"confirm=(\d+)/(\d+)", detail)
|
||||
if confirm and int(confirm.group(1)) > 0:
|
||||
needed = int(confirm.group(2))
|
||||
text += f" {min(int(confirm.group(1)), needed) if needed else confirm.group(1)}/{confirm.group(2)}"
|
||||
pos = re.search(r"pos=(-?\d+)", detail)
|
||||
if pos:
|
||||
text += f" @{pos.group(1)}"
|
||||
return text
|
||||
|
||||
|
||||
def status_short_text(value, mapping):
|
||||
"""Chinese short form of a controller state/action; raw text goes in the tooltip."""
|
||||
value = str(value or "").strip()
|
||||
if not value or value == "--":
|
||||
return "--"
|
||||
return " · ".join(_status_part_text(part, mapping) for part in value.split(";") if part.strip())
|
||||
|
||||
|
||||
class ActionTextSmoother:
|
||||
"""Keeps the action label readable: the controller flips sub-labels every frame."""
|
||||
|
||||
SECONDARY_CODES = ("grip-ramp-up", "adaptive-grip-ramp", "adaptive-grip-wait")
|
||||
URGENT_RE = re.compile(r"confirm=[1-9]|^force-change|^manual-")
|
||||
|
||||
def __init__(self, min_hold=0.6, secondary_hold=1.2):
|
||||
self.min_hold = min_hold
|
||||
self.secondary_hold = secondary_hold
|
||||
self._secondary = {}
|
||||
self._state = None
|
||||
self._shown = "--"
|
||||
self._shown_at = 0.0
|
||||
|
||||
def update(self, state, action, mapping, now=None):
|
||||
now = time.monotonic() if now is None else now
|
||||
primary = []
|
||||
urgent = state != self._state
|
||||
if urgent:
|
||||
self._secondary.clear()
|
||||
for part in str(action or "").split(";"):
|
||||
part = part.strip()
|
||||
if not part or part == "--":
|
||||
continue
|
||||
code = part.partition(" ")[0]
|
||||
if code in self.SECONDARY_CODES:
|
||||
self._secondary[code] = (now + self.secondary_hold, _status_part_text(part, mapping))
|
||||
continue
|
||||
urgent = urgent or bool(self.URGENT_RE.search(part))
|
||||
primary.append(_status_part_text(part, mapping))
|
||||
self._secondary = {k: v for k, v in self._secondary.items() if v[0] > now}
|
||||
texts = primary + [text for _, text in self._secondary.values() if text not in primary]
|
||||
text = " · ".join(texts) or "--"
|
||||
self._state = state
|
||||
if text != self._shown and (urgent or now - self._shown_at >= self.min_hold):
|
||||
self._shown = text
|
||||
self._shown_at = now
|
||||
return self._shown
|
||||
|
||||
|
||||
STYLE = """
|
||||
@@ -2300,6 +2384,291 @@ class SliderControl(QFrame):
|
||||
return int(self.slider.value())
|
||||
|
||||
|
||||
class GripLevelSlider(QSlider or object):
|
||||
"""Press anywhere to jump, hold to drag, wheel steps by one."""
|
||||
|
||||
HANDLE = 18
|
||||
STYLE = """
|
||||
QSlider::groove:horizontal {
|
||||
height: 6px;
|
||||
background: #e7e1e2;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #c83b43;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QSlider::add-page:horizontal {
|
||||
background: #e7e1e2;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
background: #ffffff;
|
||||
border: 2px solid #c83b43;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: -6px 0;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QSlider::handle:horizontal:hover, QSlider::handle:horizontal:pressed {
|
||||
background: #fbe9ea;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Qt.Horizontal)
|
||||
self.setObjectName("GripLevelSlider")
|
||||
self.setStyleSheet(self.STYLE)
|
||||
self.setMinimumHeight(24)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
self._dragging = False
|
||||
self._wheel_accum = 0
|
||||
|
||||
def x_for_value(self, value, width=None):
|
||||
width = self.width() if width is None else width
|
||||
span = max(1, width - self.HANDLE)
|
||||
t = (value - self.minimum()) / float(max(1, self.maximum() - self.minimum()))
|
||||
return self.HANDLE / 2.0 + span * t
|
||||
|
||||
def value_at(self, x):
|
||||
span = max(1, self.width() - self.HANDLE)
|
||||
t = (x - self.HANDLE / 2.0) / span
|
||||
value = self.minimum() + t * (self.maximum() - self.minimum())
|
||||
return int(round(max(self.minimum(), min(self.maximum(), value))))
|
||||
|
||||
def begin_drag(self, x):
|
||||
self._dragging = True
|
||||
self.setSliderDown(True)
|
||||
self.setValue(self.value_at(x))
|
||||
|
||||
def drag_to(self, x):
|
||||
if self._dragging:
|
||||
self.setValue(self.value_at(x))
|
||||
|
||||
def end_drag(self):
|
||||
if self._dragging:
|
||||
self._dragging = False
|
||||
self.setSliderDown(False)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() != Qt.LeftButton:
|
||||
return super().mousePressEvent(event)
|
||||
self.setFocus(Qt.MouseFocusReason)
|
||||
self.begin_drag(event.x())
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if not self._dragging:
|
||||
return super().mouseMoveEvent(event)
|
||||
self.drag_to(event.x())
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if not self._dragging:
|
||||
return super().mouseReleaseEvent(event)
|
||||
self.end_drag()
|
||||
event.accept()
|
||||
|
||||
def wheelEvent(self, event):
|
||||
delta = event.angleDelta().y() or event.angleDelta().x()
|
||||
self._wheel_accum += delta
|
||||
steps = int(self._wheel_accum / 120)
|
||||
if steps:
|
||||
self._wheel_accum -= steps * 120
|
||||
self.setValue(self.value() + steps * self.singleStep())
|
||||
event.accept()
|
||||
|
||||
|
||||
class GripLevelScale(QFrame):
|
||||
"""Preset ticks drawn under the stability slider; click near a tick to jump to it."""
|
||||
|
||||
presetClicked = pyqtSignal(int)
|
||||
|
||||
def __init__(self, slider):
|
||||
super().__init__()
|
||||
self.slider = slider
|
||||
self.presets = []
|
||||
self.setMinimumHeight(40)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
def set_presets(self, presets):
|
||||
self.presets = list(presets)
|
||||
self.update()
|
||||
|
||||
def _x_for(self, level):
|
||||
return self.slider.x_for_value(level, self.width())
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
font = QFont(self.font())
|
||||
font.setPointSizeF(max(7.0, font.pointSizeF() - 1))
|
||||
painter.setFont(font)
|
||||
metrics = painter.fontMetrics()
|
||||
width = self.width()
|
||||
taken = {True: [], False: []}
|
||||
for preset in self.presets:
|
||||
builtin = bool(preset.get("builtin"))
|
||||
x = self._x_for(preset["level"])
|
||||
color = QColor("#b52e38") if builtin else QColor("#667085")
|
||||
painter.setPen(QPen(color, 2 if builtin else 1))
|
||||
painter.drawLine(QPointF(x, 0), QPointF(x, 6 if builtin else 5))
|
||||
text = str(preset["name"])
|
||||
text_width = metrics.horizontalAdvance(text)
|
||||
left = min(max(0.0, x - text_width / 2.0), max(0.0, width - text_width))
|
||||
right = left + text_width
|
||||
if any(left < end + 4 and right + 4 > start for start, end in taken[builtin]):
|
||||
continue
|
||||
taken[builtin].append((left, right))
|
||||
top = 8 if builtin else 8 + metrics.height()
|
||||
painter.setPen(color)
|
||||
painter.drawText(QRectF(left, top, text_width + 1, metrics.height()), Qt.AlignLeft, text)
|
||||
painter.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() != Qt.LeftButton:
|
||||
return super().mousePressEvent(event)
|
||||
if self.presets:
|
||||
nearest = min(self.presets, key=lambda preset: abs(self._x_for(preset["level"]) - event.x()))
|
||||
if abs(self._x_for(nearest["level"]) - event.x()) <= 12:
|
||||
self.presetClicked.emit(int(nearest["level"]))
|
||||
event.accept()
|
||||
return
|
||||
self.slider.begin_drag(event.x())
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
self.slider.drag_to(event.x())
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self.slider.end_drag()
|
||||
event.accept()
|
||||
|
||||
def wheelEvent(self, event):
|
||||
self.slider.wheelEvent(event)
|
||||
|
||||
|
||||
class GripLevelControl(QFrame):
|
||||
"""Stability slider 0..100 with preset ticks, parameter summary and preset buttons."""
|
||||
|
||||
levelChanged = pyqtSignal(int)
|
||||
|
||||
def __init__(self, level):
|
||||
super().__init__()
|
||||
self.setObjectName("ControlRow")
|
||||
layout = QGridLayout(self)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setHorizontalSpacing(8)
|
||||
layout.setVerticalSpacing(4)
|
||||
low_label = QLabel("灵敏")
|
||||
low_label.setObjectName("Subtle")
|
||||
high_label = QLabel("稳定")
|
||||
high_label.setObjectName("Subtle")
|
||||
self.value_label = QLabel("--")
|
||||
self.value_label.setObjectName("ControlValue")
|
||||
self.slider = GripLevelSlider()
|
||||
self.slider.setRange(GRIP_BEHAVIOR_LEVEL_MIN, GRIP_BEHAVIOR_LEVEL_MAX)
|
||||
self.slider.setSingleStep(1)
|
||||
self.slider.setPageStep(5)
|
||||
self.slider.setValue(clamp_grip_behavior_level(level))
|
||||
self.scale = GripLevelScale(self.slider)
|
||||
self.scale.presetClicked.connect(self.slider.setValue)
|
||||
self.summary_label = QLabel()
|
||||
self.summary_label.setObjectName("Subtle")
|
||||
self.summary_label.setWordWrap(True)
|
||||
self.save_button = AnimatedButton("保存为新挡位")
|
||||
self.save_button.setObjectName("ManualButton")
|
||||
self.save_button.setToolTip("把当前拖动位置保存为自定义挡位,立即写入 gripper.json")
|
||||
self.delete_button = AnimatedButton("删除挡位")
|
||||
self.delete_button.setObjectName("ManualButton")
|
||||
self.delete_button.setToolTip("删除当前选中的自定义挡位(默认三挡不能删除)")
|
||||
buttons = QHBoxLayout()
|
||||
buttons.setSpacing(6)
|
||||
buttons.addWidget(self.save_button)
|
||||
buttons.addWidget(self.delete_button)
|
||||
buttons.addStretch(1)
|
||||
layout.addWidget(low_label, 0, 0)
|
||||
layout.addWidget(self.slider, 0, 1)
|
||||
layout.addWidget(high_label, 0, 2)
|
||||
layout.addWidget(self.value_label, 0, 3)
|
||||
layout.addWidget(self.scale, 1, 1)
|
||||
layout.addWidget(self.summary_label, 2, 0, 1, 4)
|
||||
layout.addLayout(buttons, 3, 0, 1, 4)
|
||||
layout.setColumnStretch(1, 1)
|
||||
self.slider.valueChanged.connect(self._on_value_changed)
|
||||
self._update_value_label(self.slider.value())
|
||||
|
||||
def _update_value_label(self, value):
|
||||
self.value_label.setText(f"{int(value)}")
|
||||
|
||||
def _on_value_changed(self, value):
|
||||
self._update_value_label(value)
|
||||
self.levelChanged.emit(int(value))
|
||||
|
||||
def level(self):
|
||||
return int(self.slider.value())
|
||||
|
||||
def set_level(self, level):
|
||||
level = clamp_grip_behavior_level(level)
|
||||
was_blocked = self.slider.blockSignals(True)
|
||||
self.slider.setValue(level)
|
||||
self.slider.blockSignals(was_blocked)
|
||||
self._update_value_label(level)
|
||||
|
||||
def set_presets(self, presets):
|
||||
self.scale.set_presets(presets)
|
||||
|
||||
def set_summary(self, text):
|
||||
self.summary_label.setText(text)
|
||||
|
||||
|
||||
class PercentSliderControl(QFrame):
|
||||
"""0..100% slider with press-to-jump, drag and wheel, for settings pages."""
|
||||
|
||||
valueChanged = pyqtSignal(int)
|
||||
|
||||
def __init__(self, value, minimum=0, maximum=100):
|
||||
super().__init__()
|
||||
self.setObjectName("ControlRow")
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 4, 8, 4)
|
||||
layout.setSpacing(8)
|
||||
self.slider = GripLevelSlider()
|
||||
self.slider.setRange(minimum, maximum)
|
||||
self.slider.setSingleStep(1)
|
||||
self.slider.setPageStep(5)
|
||||
self.value_label = QLabel("--")
|
||||
self.value_label.setObjectName("ControlValue")
|
||||
self.value_label.setMinimumWidth(40)
|
||||
layout.addWidget(self.slider, 1)
|
||||
layout.addWidget(self.value_label)
|
||||
self.set_value(value)
|
||||
self.slider.valueChanged.connect(self._on_value_changed)
|
||||
|
||||
def _on_value_changed(self, value):
|
||||
self.value_label.setText(f"{int(value)}%")
|
||||
self.valueChanged.emit(int(value))
|
||||
|
||||
def value(self):
|
||||
return int(self.slider.value())
|
||||
|
||||
def set_value(self, value):
|
||||
try:
|
||||
value = int(round(float(value)))
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
value = max(self.slider.minimum(), min(self.slider.maximum(), value))
|
||||
was_blocked = self.slider.blockSignals(True)
|
||||
self.slider.setValue(value)
|
||||
self.slider.blockSignals(was_blocked)
|
||||
self.value_label.setText(f"{value}%")
|
||||
|
||||
|
||||
class CameraDiscoveryDialog(QDialog):
|
||||
def __init__(self, viewer):
|
||||
super().__init__(viewer)
|
||||
@@ -2441,11 +2810,15 @@ class CameraDiscoveryDialog(QDialog):
|
||||
|
||||
|
||||
class VisualSettingsDialog(QDialog):
|
||||
# Managed by the stability bar, not shown as a raw field.
|
||||
HIDDEN_CONFIG_KEYS = {"grip_behavior_presets"}
|
||||
CONFIG_GROUPS = [
|
||||
(
|
||||
"行为模式",
|
||||
[
|
||||
"grip_behavior_mode",
|
||||
"grip_behavior_level",
|
||||
"speed",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -2469,7 +2842,6 @@ class VisualSettingsDialog(QDialog):
|
||||
[
|
||||
"open_pos",
|
||||
"close_pos",
|
||||
"speed",
|
||||
"accel",
|
||||
"decel",
|
||||
"release_open_speed",
|
||||
@@ -2558,6 +2930,7 @@ class VisualSettingsDialog(QDialog):
|
||||
"release_shear_oscillation_min_flips",
|
||||
"release_shear_oscillation_amplitude",
|
||||
"release_shear_oscillation_confirm_samples",
|
||||
"release_shear_oscillation_min_seconds",
|
||||
"release_guard_fast_paths",
|
||||
"release_shear_axes",
|
||||
"release_shear_displacement",
|
||||
@@ -2600,7 +2973,7 @@ class VisualSettingsDialog(QDialog):
|
||||
"serial_timeout": "串口超时",
|
||||
"open_pos": "张开位置",
|
||||
"close_pos": "闭合位置",
|
||||
"speed": "默认速度",
|
||||
"speed": "速度",
|
||||
"accel": "加速度",
|
||||
"decel": "减速度",
|
||||
"release_open_speed": "释放张开速度",
|
||||
@@ -2636,6 +3009,7 @@ class VisualSettingsDialog(QDialog):
|
||||
"force_ramp_interval": "加力间隔",
|
||||
"grip_settle_seconds": "夹持稳定时间",
|
||||
"grip_behavior_mode": "开合行为预设",
|
||||
"grip_behavior_level": "稳定度",
|
||||
"adaptive_grip_enabled": "启用自适应夹持",
|
||||
"adaptive_shear_start_force": "自适应起始切向力",
|
||||
"adaptive_shear_full_force": "自适应满量程切向力",
|
||||
@@ -2670,6 +3044,7 @@ class VisualSettingsDialog(QDialog):
|
||||
"release_shear_oscillation_min_flips": "切向振荡最少换向次数",
|
||||
"release_shear_oscillation_amplitude": "切向振荡峰峰值门槛 (N)",
|
||||
"release_shear_oscillation_confirm_samples": "切向振荡确认帧数",
|
||||
"release_shear_oscillation_min_seconds": "切向振荡至少持续 (s)",
|
||||
"release_guard_fast_paths": "夹紧保护期内屏蔽振荡/侧触张开",
|
||||
"release_shear_axes": "张开判断切向轴 (空=跟随控制轴/y/x/xy)",
|
||||
"release_shear_displacement": "Y 拟合偏移张开阈值 (N, 0=关闭)",
|
||||
@@ -2734,6 +3109,14 @@ class VisualSettingsDialog(QDialog):
|
||||
self.config_data = {}
|
||||
if "grip_behavior_mode" not in self.config_data:
|
||||
self.config_data["grip_behavior_mode"] = "sensitive"
|
||||
mode = normalize_grip_behavior_mode(self.config_data.get("grip_behavior_mode"))
|
||||
level = self.config_data.get("grip_behavior_level")
|
||||
if level is None or grip_behavior_mode_for_level(level) != mode:
|
||||
level = default_grip_behavior_level(mode)
|
||||
self.config_data["grip_behavior_level"] = clamp_grip_behavior_level(level)
|
||||
self._custom_presets = normalize_grip_behavior_presets(
|
||||
self.config_data.get("grip_behavior_presets")
|
||||
)
|
||||
|
||||
def _build_ui(self):
|
||||
outer = QVBoxLayout(self)
|
||||
@@ -2779,7 +3162,7 @@ class VisualSettingsDialog(QDialog):
|
||||
other_keys = [
|
||||
key
|
||||
for key in self.config_data
|
||||
if key not in used_keys
|
||||
if key not in used_keys and key not in self.HIDDEN_CONFIG_KEYS
|
||||
]
|
||||
if other_keys:
|
||||
self._add_page("其他", self._build_config_page("其他", other_keys))
|
||||
@@ -2802,6 +3185,7 @@ class VisualSettingsDialog(QDialog):
|
||||
|
||||
self.nav.currentRowChanged.connect(self.stack.setCurrentIndex)
|
||||
self.nav.setCurrentRow(0)
|
||||
self._init_level_widgets()
|
||||
|
||||
def _add_page(self, title, widget):
|
||||
self.nav.addItem(title)
|
||||
@@ -2912,28 +3296,25 @@ class VisualSettingsDialog(QDialog):
|
||||
|
||||
def _make_control(self, key, value):
|
||||
if key == "grip_behavior_mode":
|
||||
from gripper.config.behavior_profiles import (
|
||||
GRIP_BEHAVIOR_LABELS,
|
||||
GRIP_BEHAVIOR_MODES,
|
||||
normalize_grip_behavior_mode,
|
||||
)
|
||||
|
||||
control = QComboBox()
|
||||
try:
|
||||
current = normalize_grip_behavior_mode(value)
|
||||
except Exception:
|
||||
current = "sensitive"
|
||||
for mode in GRIP_BEHAVIOR_MODES:
|
||||
control.addItem(f"{GRIP_BEHAVIOR_LABELS[mode]} ({mode})", mode)
|
||||
index = control.findData(current)
|
||||
control.setCurrentIndex(max(0, index))
|
||||
control.setToolTip(
|
||||
"灵敏:当前跟手调参,更容易开合;"
|
||||
"较稳定:明显提高门槛与确认;"
|
||||
"十分稳定:约高一个数量级,故意大力开合才响应。"
|
||||
"切换后会立刻填入对应预设参数,保存后生效。"
|
||||
"默认三挡:灵敏 / 较稳定 / 十分稳定,另可选自定义挡位。"
|
||||
"选择后稳定度条跳到对应位置并填入参数,保存后生效。"
|
||||
)
|
||||
control.currentIndexChanged.connect(self._on_behavior_mode_changed)
|
||||
self.controls[key] = control
|
||||
return control
|
||||
if key == "grip_behavior_level":
|
||||
control = GripLevelControl(value)
|
||||
control.setToolTip(
|
||||
"拖动整体调整开合行为:0 灵敏,50 较稳定,100 十分稳定。"
|
||||
"50 以下使用灵敏算法并按比例加大力度与门槛;50 及以上使用稳定算法。"
|
||||
"速度单独在下方调节,不随稳定度变化。"
|
||||
)
|
||||
self.controls[key] = control
|
||||
return control
|
||||
if key == "speed":
|
||||
control = PercentSliderControl(value)
|
||||
control.setToolTip("夹爪闭合 / 默认运动速度,独立于稳定度;选择挡位时套用该挡位保存的速度。")
|
||||
self.controls[key] = control
|
||||
return control
|
||||
if key == "port":
|
||||
@@ -2972,6 +3353,12 @@ class VisualSettingsDialog(QDialog):
|
||||
|
||||
def _control_value(self, key, original):
|
||||
control = self.controls[key]
|
||||
if key == "grip_behavior_mode":
|
||||
return grip_behavior_mode_for_level(self._current_level())
|
||||
if isinstance(control, GripLevelControl):
|
||||
return control.level()
|
||||
if isinstance(control, PercentSliderControl):
|
||||
return control.value()
|
||||
if isinstance(control, QComboBox):
|
||||
selected = control.currentData()
|
||||
if control.currentIndex() >= 0 and selected is not None:
|
||||
@@ -2999,7 +3386,15 @@ class VisualSettingsDialog(QDialog):
|
||||
|
||||
def _set_control_value(self, key, value):
|
||||
control = self.controls.get(key)
|
||||
if control is None:
|
||||
if control is None or key == "grip_behavior_mode":
|
||||
return
|
||||
if isinstance(control, GripLevelControl):
|
||||
control.set_level(value)
|
||||
self._refresh_level_widgets()
|
||||
return
|
||||
if isinstance(control, PercentSliderControl):
|
||||
control.set_value(value)
|
||||
self._refresh_level_widgets()
|
||||
return
|
||||
if isinstance(control, QCheckBox):
|
||||
control.setChecked(bool(value))
|
||||
@@ -3017,25 +3412,207 @@ class VisualSettingsDialog(QDialog):
|
||||
elif isinstance(control, QLineEdit):
|
||||
control.setText("null" if value is None else str(value))
|
||||
|
||||
def _on_behavior_mode_changed(self, _index=None):
|
||||
control = self.controls.get("grip_behavior_mode")
|
||||
if not isinstance(control, QComboBox):
|
||||
return
|
||||
mode = control.currentData()
|
||||
if not mode:
|
||||
return
|
||||
from gripper.config.behavior_profiles import grip_behavior_profile
|
||||
def _level_control(self):
|
||||
control = self.controls.get("grip_behavior_level")
|
||||
return control if isinstance(control, GripLevelControl) else None
|
||||
|
||||
def _preset_combo(self):
|
||||
control = self.controls.get("grip_behavior_mode")
|
||||
return control if isinstance(control, QComboBox) else None
|
||||
|
||||
def _current_level(self):
|
||||
control = self._level_control()
|
||||
if control is not None:
|
||||
return control.level()
|
||||
return clamp_grip_behavior_level(self.config_data.get("grip_behavior_level"))
|
||||
|
||||
def _speed_control(self):
|
||||
control = self.controls.get("speed")
|
||||
return control if isinstance(control, PercentSliderControl) else None
|
||||
|
||||
def _current_speed(self):
|
||||
control = self._speed_control()
|
||||
if control is not None:
|
||||
return control.value()
|
||||
try:
|
||||
profile = grip_behavior_profile(mode)
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "模式无效", str(exc))
|
||||
return int(self.config_data.get("speed"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _all_presets(self):
|
||||
return builtin_grip_behavior_presets() + [
|
||||
{**item, "builtin": False} for item in self._custom_presets
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _preset_matches(preset, level, speed):
|
||||
return preset["level"] == level and preset.get("speed") in (None, speed)
|
||||
|
||||
def _init_level_widgets(self):
|
||||
level_control = self._level_control()
|
||||
combo = self._preset_combo()
|
||||
if level_control is None or combo is None:
|
||||
return
|
||||
level_control.levelChanged.connect(self._on_level_changed)
|
||||
level_control.save_button.clicked.connect(self._save_level_preset)
|
||||
level_control.delete_button.clicked.connect(self._delete_level_preset)
|
||||
combo.currentIndexChanged.connect(self._on_preset_selected)
|
||||
speed_control = self._speed_control()
|
||||
if speed_control is not None:
|
||||
speed_control.valueChanged.connect(self._on_speed_changed)
|
||||
self._refresh_level_widgets()
|
||||
|
||||
def _on_speed_changed(self, value):
|
||||
self.config_data["speed"] = int(value)
|
||||
self._refresh_level_widgets()
|
||||
|
||||
def _refresh_level_widgets(self, selected_name=None):
|
||||
"""Rebuild the preset list and select the preset matching the bar."""
|
||||
level_control = self._level_control()
|
||||
combo = self._preset_combo()
|
||||
if level_control is None or combo is None:
|
||||
return
|
||||
presets = self._all_presets()
|
||||
level = level_control.level()
|
||||
speed = self._current_speed()
|
||||
if selected_name is None and combo.currentIndex() >= 0:
|
||||
selected_name = combo.currentData()
|
||||
names = [preset["name"] for preset in presets if self._preset_matches(preset, level, speed)]
|
||||
if selected_name not in names:
|
||||
selected_name = names[0] if names else None
|
||||
was_blocked = combo.blockSignals(True)
|
||||
combo.clear()
|
||||
for preset in presets:
|
||||
suffix = "" if preset["builtin"] else " · 自定义"
|
||||
speed_text = "" if preset.get("speed") is None else f" · 速度 {preset['speed']}%"
|
||||
combo.addItem(f"{preset['name']}({preset['level']}{speed_text}){suffix}", preset["name"])
|
||||
if selected_name is None:
|
||||
# Qt 5.15 does not paint placeholder text on non-editable combos.
|
||||
speed_text = "" if speed is None else f" · 速度 {speed}%"
|
||||
combo.addItem(f"当前位置({level}{speed_text})· 未保存", None)
|
||||
combo.setCurrentIndex(combo.count() - 1)
|
||||
else:
|
||||
combo.setCurrentIndex(combo.findData(selected_name))
|
||||
combo.blockSignals(was_blocked)
|
||||
level_control.set_presets(presets)
|
||||
level_control.delete_button.setEnabled(
|
||||
any(item["name"] == selected_name for item in self._custom_presets)
|
||||
)
|
||||
level_control.set_summary(self._level_summary(level))
|
||||
|
||||
def _level_summary(self, level):
|
||||
profile = grip_behavior_level_profile(level)
|
||||
mode = grip_behavior_mode_for_level(level)
|
||||
speed = self._current_speed()
|
||||
parts = [
|
||||
"灵敏算法" if mode == "sensitive" else "稳定算法(恒力夹持)",
|
||||
f"速度 {profile['speed'] if speed is None else speed}%",
|
||||
]
|
||||
if mode == "sensitive":
|
||||
parts.append(f"夹持力 {profile['adaptive_force_min']}–{profile['adaptive_force_max']}% 自适应")
|
||||
else:
|
||||
parts.append(f"夹持力 {profile['close_force']}% 恒力")
|
||||
parts.append(f"保护期 {profile['release_min_hold_seconds']:.2f} s")
|
||||
if mode == "sensitive":
|
||||
parts.append(f"确认 {profile['release_confirm_samples']} 帧")
|
||||
else:
|
||||
parts.append(f"张开阈值 Y 偏移 {profile['release_shear_displacement']:.2f} N")
|
||||
return " · ".join(parts)
|
||||
|
||||
def _on_level_changed(self, level):
|
||||
profile = grip_behavior_level_profile(level)
|
||||
for key, value in profile.items():
|
||||
if key == "speed":
|
||||
continue
|
||||
if key in self.controls:
|
||||
self._set_control_value(key, value)
|
||||
self.config_data[key] = value
|
||||
self.config_data["grip_behavior_mode"] = mode
|
||||
self.config_data["grip_behavior_mode"] = grip_behavior_mode_for_level(level)
|
||||
self.config_data["grip_behavior_level"] = clamp_grip_behavior_level(level)
|
||||
self._refresh_level_widgets()
|
||||
|
||||
def _on_preset_selected(self, _index=None):
|
||||
combo = self._preset_combo()
|
||||
level_control = self._level_control()
|
||||
if combo is None or level_control is None:
|
||||
return
|
||||
name = combo.currentData()
|
||||
if name is None:
|
||||
return
|
||||
preset = next((item for item in self._all_presets() if item["name"] == name), None)
|
||||
if preset is None:
|
||||
return
|
||||
level_control.set_level(preset["level"])
|
||||
if preset.get("speed") is not None:
|
||||
self._set_control_value("speed", preset["speed"])
|
||||
self.config_data["speed"] = preset["speed"]
|
||||
self._on_level_changed(preset["level"])
|
||||
self._refresh_level_widgets(selected_name=name)
|
||||
|
||||
def _persist_presets(self):
|
||||
"""Write only grip_behavior_presets, keeping other unsaved edits pending."""
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
data = {}
|
||||
data["grip_behavior_presets"] = list(self._custom_presets)
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
self.config_data["grip_behavior_presets"] = list(self._custom_presets)
|
||||
|
||||
def _save_level_preset(self):
|
||||
level = self._current_level()
|
||||
speed = self._current_speed()
|
||||
desc = f"稳定度 {level}" + ("" if speed is None else f"、速度 {speed}%")
|
||||
name, ok = QInputDialog.getText(self, "保存为新挡位", f"{desc} 的挡位名称:")
|
||||
name = str(name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
if any(item["name"] == name for item in builtin_grip_behavior_presets()):
|
||||
QMessageBox.warning(self, "名称冲突", f"“{name}”是默认挡位名称,请换一个。")
|
||||
return
|
||||
preset = {"name": name, "level": level}
|
||||
if speed is not None:
|
||||
preset["speed"] = speed
|
||||
existing = next((item for item in self._custom_presets if item["name"] == name), None)
|
||||
if existing is not None:
|
||||
answer = QMessageBox.question(
|
||||
self, "覆盖挡位", f"已存在自定义挡位“{name}”({existing['level']}),覆盖为{desc}?"
|
||||
)
|
||||
if answer != QMessageBox.Yes:
|
||||
return
|
||||
existing.clear()
|
||||
existing.update(preset)
|
||||
else:
|
||||
self._custom_presets.append(preset)
|
||||
self._custom_presets.sort(key=lambda item: (item["level"], item["name"]))
|
||||
try:
|
||||
self._persist_presets()
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "保存失败", f"无法写入自定义挡位:\n{exc}")
|
||||
return
|
||||
self._refresh_level_widgets(selected_name=name)
|
||||
|
||||
def _delete_level_preset(self):
|
||||
combo = self._preset_combo()
|
||||
name = combo.currentData() if combo is not None else None
|
||||
if not any(item["name"] == name for item in self._custom_presets):
|
||||
return
|
||||
answer = QMessageBox.question(self, "删除挡位", f"删除自定义挡位“{name}”?")
|
||||
if answer != QMessageBox.Yes:
|
||||
return
|
||||
self._custom_presets = [item for item in self._custom_presets if item["name"] != name]
|
||||
try:
|
||||
self._persist_presets()
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "删除失败", f"无法写入自定义挡位:\n{exc}")
|
||||
return
|
||||
self._refresh_level_widgets()
|
||||
|
||||
def _search_serial_ports(self):
|
||||
control = self.controls.get("port")
|
||||
@@ -3241,6 +3818,7 @@ class GripperDemo02Viewer(QMainWindow):
|
||||
QTimer.singleShot(0, lambda: self.search_main_serial_ports(silent=True))
|
||||
QTimer.singleShot(50, lambda: self.auto_assign_sensor_cameras(silent=True))
|
||||
|
||||
self._action_smoother = ActionTextSmoother()
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self.timer.start(33)
|
||||
@@ -3797,9 +4375,9 @@ class GripperDemo02Viewer(QMainWindow):
|
||||
status_layout = QVBoxLayout(self.status_panel)
|
||||
status_layout.setContentsMargins(10, 6, 10, 6)
|
||||
status_layout.setSpacing(1)
|
||||
self.status_chip = QLabel("state=--")
|
||||
self.status_chip = QLabel("--")
|
||||
self.status_chip.setObjectName("StatusMain")
|
||||
self.action_label = QLabel("action=--")
|
||||
self.action_label = QLabel("--")
|
||||
self.action_label.setObjectName("StatusSub")
|
||||
status_layout.addWidget(self.status_chip)
|
||||
status_layout.addWidget(self.action_label)
|
||||
@@ -3997,7 +4575,17 @@ class GripperDemo02Viewer(QMainWindow):
|
||||
self.right_sensor_reset_button.clicked.connect(
|
||||
lambda: self.send_manual_command("restart_right_sensor")
|
||||
)
|
||||
sensor_layout.addWidget(sensor_title, 0, 0, 1, 8, Qt.AlignLeft | Qt.AlignTop)
|
||||
self.sensor_reference_reset_button = AnimatedButton("重置底图")
|
||||
self.sensor_reference_reset_button.setObjectName("ManualButton")
|
||||
self.sensor_reference_reset_button.setToolTip(
|
||||
"用当前画面更换左右传感器的光流参考底图(不断开相机)"
|
||||
)
|
||||
self.sensor_reference_reset_button.setEnabled(False)
|
||||
self.sensor_reference_reset_button.clicked.connect(
|
||||
lambda: self.send_manual_command("reset_sensor_reference")
|
||||
)
|
||||
sensor_layout.addWidget(sensor_title, 0, 0, 1, 6, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
sensor_layout.addWidget(self.sensor_reference_reset_button, 0, 6, 1, 2)
|
||||
sensor_layout.addWidget(port_label, 1, 0)
|
||||
sensor_layout.addWidget(self.main_port_combo, 1, 1, 1, 2)
|
||||
sensor_layout.addWidget(self.main_port_search_button, 1, 3)
|
||||
@@ -4182,6 +4770,8 @@ class GripperDemo02Viewer(QMainWindow):
|
||||
self.left_sensor_reset_button.setEnabled(sensor_restart_enabled)
|
||||
if hasattr(self, "right_sensor_reset_button"):
|
||||
self.right_sensor_reset_button.setEnabled(sensor_restart_enabled)
|
||||
if hasattr(self, "sensor_reference_reset_button"):
|
||||
self.sensor_reference_reset_button.setEnabled(sensor_restart_enabled)
|
||||
|
||||
def set_control_mode(self, mode):
|
||||
if mode not in ("smart", "manual"):
|
||||
@@ -4492,12 +5082,13 @@ class GripperDemo02Viewer(QMainWindow):
|
||||
action = status.get("action", "--")
|
||||
unit = status.get("normal_unit", "N")
|
||||
shear_unit = status.get("shear_unit", "N")
|
||||
self.status_chip.setText(
|
||||
shorten_status_text(f"state={label_with_cn(state, STATE_TEXT)}", 28)
|
||||
)
|
||||
self.status_chip.setText(status_short_text(state, STATE_TEXT))
|
||||
self.status_chip.setToolTip(f"state={state}")
|
||||
held = normalize_grip_behavior_mode(status.get("grip_behavior_mode")) != "sensitive"
|
||||
self.action_label.setText(
|
||||
shorten_status_text(f"action={label_with_cn(action, ACTION_TEXT)}", 28)
|
||||
self._action_smoother.update(state, action, HELD_ACTION_TEXT if held else ACTION_TEXT)
|
||||
)
|
||||
self.action_label.setToolTip(f"action={action}")
|
||||
self._sync_mode_controls(status.get("grip_behavior_mode"))
|
||||
self.manual_hold_button.setText(
|
||||
"释放" if bool(status.get("manual_hold_active")) else "保持"
|
||||
|
||||
Reference in New Issue
Block a user