注释与提示信息全面中文化:源码 66 个 .py + 脚本/配置 45 个文件
背景:现场读代码的人只读中文。此前代码里的 docstring 是英文、行内注释是中文,
一半英文一半中文反而更难看懂 —— 新手打开一个 .py 不知道这个模块为什么存在。
范围(本仓库自己的代码,厂商上游不动)
src/arm_application/** 37 个文件:servo_node / perception_node / handeye_* / dh_pgea /
yolo_detector / kalman_filter / goto_ee_plane / dual_gripper …
src/arm_interfaces/** 11 个文件:paths / manifest / loader / config_guard / errors …
src/arm_control/** 5 个文件
src/arm_bringup/** 3 个 launch
scripts/** 6 个 .py + 29 个 .sh(scripts/ 是"唯一操作入口",用户屏幕上
看到的就是这些提示,所以提示信息也一并中文化)
config/ handeye/ devices/ src/arm_*/config/ 17 个 .yaml/.xml/.xacro/.cfg
口径(写进 docs/FRAMEWORK_RULES.md 新增的 R6.5,作为长期约束)
注释、docstring、print/日志/异常里给人看的句子 = 中文;
标识符、API 名、话题名、参数名、字段名、命令名 = 保留英文原文(用 `` `` 包起来);
单位、十六进制、协议报文、CRC 值、命令示例 = 原样保留;
src/flexiv_*/ = 厂商上游代码,不许改也不许翻译(R4.4)。
翻译保原意与原有详细程度:那些"为什么不能用另一种做法""踩过什么坑"的说明
全部完整保留 —— 它们才是这份代码里最贵的部分。
新增 scripts/check_text_only.py:只改文字时的自证工具
人工 review 靠不住(115 个文件、3000 多行 diff)。这个脚本把改动前后的源码解析成
语法树,删掉 docstring、抹平字符串常量、把 f-string 只留"插值了几次 + 插值是什么",
再比对:完全相同 = 改动范围内没有逻辑变化。
留了一个口子:f-string 的插值按内容排序后再比 —— 因为中文语序常常要和英文反着来
(f"on {port} read {idx} failed" → f"在 {port} 上读 {idx} 失败"),只换显示顺序不该判失败;
但多插一个、少插一个、换掉被插入的表达式,立刻会报出来。
本次 66 个 .py 全部通过该闸门。
验收
改动的 66 个 .py:scripts/check_text_only.py 全部通过(零逻辑变化)
45 个非 .py 文件:去注释后与 HEAD 逐字节相同(零配置变化)
docstring 总数:没有一个文件减少(不存在"翻译时顺手删了说明")
单测:arm_application 233 / arm_driver_flexiv 69 / arm_interfaces 46 /
arm_kinematics 24 / arm_sim_gazebo 236 = 608 passed,与改动前一致
全部 .sh:bash -n 通过;并额外实际执行了 --help 验证(见下一个提交:
bash -n 对 CRLF 脚本会漏报,只有真跑才暴露)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,6 +34,21 @@ alwaysApply: true
|
|||||||
- 把"降级跳过"写成"通过"。
|
- 把"降级跳过"写成"通过"。
|
||||||
- 在中文路径下 `colcon build`。
|
- 在中文路径下 `colcon build`。
|
||||||
- 提交信息只写"改了什么"而不写"为什么"(中文,附实测验收数据)。
|
- 提交信息只写"改了什么"而不写"为什么"(中文,附实测验收数据)。
|
||||||
|
- 在 `src/arm_*/**`、`scripts/**` 里写英文注释 / docstring / 提示信息(R6.5)。
|
||||||
|
注释和给人看的句子**一律中文**;标识符、话题名、字段名、命令名**保留英文原文**
|
||||||
|
(写在中文句子里时用 `` `` 包起来)。`src/flexiv_*/` 是厂商上游代码,不许翻译(R4.4)。
|
||||||
|
|
||||||
|
## 只改文字时,必须自证没碰逻辑
|
||||||
|
|
||||||
|
翻译注释、补文档这类"纯文本"改动最容易出事(漏掉 f-string 插值、顺手改个变量名),
|
||||||
|
而且人工 review 看不出来。提交前跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/check_text_only.py
|
||||||
|
```
|
||||||
|
|
||||||
|
它比对语法树(删 docstring + 抹平字符串常量 + f-string 只留插值),
|
||||||
|
不一致就报出来。基准是 `git HEAD`,所以要在提交前跑。
|
||||||
|
|
||||||
## 出问题先查症状表
|
## 出问题先查症状表
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<!--
|
<!--
|
||||||
FastDDS profile for visual-servo hard paths (Ubuntu 22.04 / Humble default RMW).
|
visual-servo 硬实时路径用的 FastDDS 配置(Ubuntu 22.04 / Humble 默认 RMW)。
|
||||||
|
|
||||||
Use:
|
用法:
|
||||||
export FASTRTPS_DEFAULT_PROFILES_FILE=~/ros2/config/fastdds_visual_servo.xml
|
export FASTRTPS_DEFAULT_PROFILES_FILE=~/ros2/config/fastdds_visual_servo.xml
|
||||||
export RMW_IMPLEMENTATION=rmw_fastrtps_cpp
|
export RMW_IMPLEMENTATION=rmw_fastrtps_cpp
|
||||||
|
|
||||||
Goals:
|
目标:
|
||||||
- Prefer UDP unicast for low-latency control topics
|
- 低延迟的控制话题优先使用 UDP 单播
|
||||||
- Shrink discovery / participant lease so late joiners do not stall 100 Hz
|
- 缩短发现 / 参与者租约,避免后加入的节点把 100 Hz 卡住
|
||||||
- Keep history shallow so subscribers never chase a backlog
|
- 保持较浅的历史队列,让订阅者永远不必去追积压的旧数据
|
||||||
-->
|
-->
|
||||||
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
|
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
|
||||||
<transport_descriptors>
|
<transport_descriptors>
|
||||||
<transport_descriptor>
|
<transport_descriptor>
|
||||||
<transport_id>udp_transport</transport_id>
|
<transport_id>udp_transport</transport_id>
|
||||||
<type>UDPv4</type>
|
<type>UDPv4</type>
|
||||||
<!-- Avoid shared-memory backlog stalls between heavy perception and servo -->
|
<!-- 避免繁重的感知与伺服之间因共享内存积压而卡顿 -->
|
||||||
<maxMessageSize>65500</maxMessageSize>
|
<maxMessageSize>65500</maxMessageSize>
|
||||||
</transport_descriptor>
|
</transport_descriptor>
|
||||||
</transport_descriptors>
|
</transport_descriptors>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<useBuiltinTransports>false</useBuiltinTransports>
|
<useBuiltinTransports>false</useBuiltinTransports>
|
||||||
<builtin>
|
<builtin>
|
||||||
<discovery_config>
|
<discovery_config>
|
||||||
<!-- Faster discovery; reduce lease so dead peers drop quickly -->
|
<!-- 加快发现;缩短租约,让已经死掉的节点尽快被剔除 -->
|
||||||
<leaseDuration>
|
<leaseDuration>
|
||||||
<sec>10</sec>
|
<sec>10</sec>
|
||||||
<nanosec>0</nanosec>
|
<nanosec>0</nanosec>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</rtps>
|
</rtps>
|
||||||
</participant>
|
</participant>
|
||||||
|
|
||||||
<!-- Perception image topics: BEST_EFFORT + KEEP_LAST (drop old frames) -->
|
<!-- 感知图像话题:BEST_EFFORT + KEEP_LAST(丢弃旧帧) -->
|
||||||
<data_reader profile_name="sensor_best_effort">
|
<data_reader profile_name="sensor_best_effort">
|
||||||
<qos>
|
<qos>
|
||||||
<reliability>
|
<reliability>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</qos>
|
</qos>
|
||||||
</data_reader>
|
</data_reader>
|
||||||
|
|
||||||
<!-- /visual_servo/cmd_vel: always only the newest Twist -->
|
<!-- /visual_servo/cmd_vel:永远只保留最新的 Twist -->
|
||||||
<data_writer profile_name="cmd_vel_latest">
|
<data_writer profile_name="cmd_vel_latest">
|
||||||
<qos>
|
<qos>
|
||||||
<reliability>
|
<reliability>
|
||||||
|
|||||||
@@ -351,6 +351,22 @@ ROS 构建对非 ASCII 路径会出各种问题。工作目录保持 ASCII(推
|
|||||||
格式:`<阶段号> <一句话结果>` + 正文说明"问题是什么、为什么这么改、怎么验证的"。
|
格式:`<阶段号> <一句话结果>` + 正文说明"问题是什么、为什么这么改、怎么验证的"。
|
||||||
修静默失效时,把"症状"写进去 —— 那是后来人搜索时唯一能匹配上的东西。
|
修静默失效时,把"症状"写进去 —— 那是后来人搜索时唯一能匹配上的东西。
|
||||||
|
|
||||||
|
### R6.5 注释、docstring、提示信息一律用中文
|
||||||
|
|
||||||
|
现场操作的人读中文。所以本仓库(`src/arm_*/**`、`scripts/**`)里:
|
||||||
|
|
||||||
|
- **注释、docstring、`print`/日志/异常里给人看的句子 = 中文**
|
||||||
|
- **标识符、API 名、话题名、参数名、字段名、文件名、命令名 = 保留英文原文**,
|
||||||
|
写在中文句子里时用双反引号包起来,例如 ``SITE_ROS2_ROOT``、``devices/<kind>/<brand>/``
|
||||||
|
- 单位、十六进制、协议报文、CRC 值、寄存器地址、命令示例**原样保留**
|
||||||
|
|
||||||
|
**例外:`src/flexiv_*/` 是厂商上游代码,不许改、也不许翻译**(见 R4.4)。
|
||||||
|
|
||||||
|
只改文本的提交,必须能自证"没碰到逻辑"。做法:把新旧两份源码都解析成 AST,
|
||||||
|
删掉 docstring、把字符串常量抹平、把 f-string 只留"插值了几次 + 插值是什么",
|
||||||
|
再比对 —— 必须完全相同。这条检查会抓出"顺手改名""顺手调整语句顺序""漏掉一个
|
||||||
|
f-string 插值"这类问题;而"为了中文语序把两个插值换个显示顺序"属于允许的改动。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 改动前的检查清单
|
## 7. 改动前的检查清单
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Hand-eye auto calibration config.
|
# 手眼自动标定配置。
|
||||||
#
|
#
|
||||||
# Workspace: ~/ros2
|
# 工作空间:~/ros2
|
||||||
#
|
#
|
||||||
# Teach poses, then solve (prefer the wrapper):
|
# 先示教位姿,再求解(推荐用封装脚本):
|
||||||
# bash ~/ros2/scripts/run_handeye.sh teach
|
# bash ~/ros2/scripts/run_handeye.sh teach
|
||||||
# bash ~/ros2/scripts/run_handeye.sh merge
|
# bash ~/ros2/scripts/run_handeye.sh merge
|
||||||
# bash ~/ros2/scripts/run_handeye.sh calibrate
|
# bash ~/ros2/scripts/run_handeye.sh calibrate
|
||||||
@@ -33,7 +33,7 @@ handeye_calibrate:
|
|||||||
start_index: 0
|
start_index: 0
|
||||||
min_poses: 5
|
min_poses: 5
|
||||||
|
|
||||||
# Camera on flange (eye-in-hand): D405
|
# 相机装在法兰上(eye-in-hand):D405
|
||||||
camera_source: "d405" # d405 | d435 | manual
|
camera_source: "d405" # d405 | d435 | manual
|
||||||
camera_serial: ""
|
camera_serial: ""
|
||||||
camera_namespace: ""
|
camera_namespace: ""
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
# Hand-eye taught joint poses (7 joints × N). Shared by calibrate + waypoints.
|
# 手眼标定示教的关节位姿(7 关节 × N)。由 calibrate + waypoints 共用。
|
||||||
#
|
#
|
||||||
# Edit here, or:
|
# 在这里编辑,或者:
|
||||||
# bash ~/ros2/scripts/run_handeye.sh teach
|
# bash ~/ros2/scripts/run_handeye.sh teach
|
||||||
# bash ~/ros2/scripts/run_handeye.sh merge
|
# bash ~/ros2/scripts/run_handeye.sh merge
|
||||||
#
|
#
|
||||||
# /** so any node name receives these params.
|
# /** 这样任意节点名都能收到这些参数。
|
||||||
|
|
||||||
/**:
|
/**:
|
||||||
ros__parameters:
|
ros__parameters:
|
||||||
num_poses: 12
|
num_poses: 12
|
||||||
# Taught 2026-09-02 via handeye_teach (RDK free-drive)
|
# 2026-09-02 通过 handeye_teach 示教(RDK 自由驱动)
|
||||||
waypoint_positions: [
|
waypoint_positions: [
|
||||||
# pose 0
|
# 位姿 0
|
||||||
-0.751865, -0.769544, 0.713051, 1.140021, -0.189690, 0.133294, -0.342184,
|
-0.751865, -0.769544, 0.713051, 1.140021, -0.189690, 0.133294, -0.342184,
|
||||||
# pose 1
|
# 位姿 1
|
||||||
-0.541318, -0.867448, 0.864868, 1.233428, -0.348623, 0.146046, -0.249719,
|
-0.541318, -0.867448, 0.864868, 1.233428, -0.348623, 0.146046, -0.249719,
|
||||||
# pose 2
|
# 位姿 2
|
||||||
-0.343594, -0.948720, 0.848287, 1.273279, -0.954339, 0.019624, 0.071314,
|
-0.343594, -0.948720, 0.848287, 1.273279, -0.954339, 0.019624, 0.071314,
|
||||||
# pose 3
|
# 位姿 3
|
||||||
-0.379884, -1.002193, 1.166394, 1.579194, -1.097644, -0.115142, -0.347640,
|
-0.379884, -1.002193, 1.166394, 1.579194, -1.097644, -0.115142, -0.347640,
|
||||||
# pose 4
|
# 位姿 4
|
||||||
-0.694826, -0.943610, 0.820178, 0.963907, -0.427763, 0.165888, -0.761562,
|
-0.694826, -0.943610, 0.820178, 0.963907, -0.427763, 0.165888, -0.761562,
|
||||||
# pose 5
|
# 位姿 5
|
||||||
-0.964023, -1.025531, 1.013898, 1.032490, -0.167151, 0.017429, -0.566073,
|
-0.964023, -1.025531, 1.013898, 1.032490, -0.167151, 0.017429, -0.566073,
|
||||||
# pose 6
|
# 位姿 6
|
||||||
-0.812664, -0.984951, 1.072695, 1.473414, -0.772970, 0.267382, 0.401278,
|
-0.812664, -0.984951, 1.072695, 1.473414, -0.772970, 0.267382, 0.401278,
|
||||||
# pose 7
|
# 位姿 7
|
||||||
-0.967527, -1.067191, 1.101680, 1.434183, -0.915388, 0.259486, 0.205330,
|
-0.967527, -1.067191, 1.101680, 1.434183, -0.915388, 0.259486, 0.205330,
|
||||||
# pose 8
|
# 位姿 8
|
||||||
-0.451483, -1.257408, 0.895481, 0.806594, -0.652401, -0.221453, 0.184234,
|
-0.451483, -1.257408, 0.895481, 0.806594, -0.652401, -0.221453, 0.184234,
|
||||||
# pose 9
|
# 位姿 9
|
||||||
-0.981877, -1.415295, 1.339004, 1.131348, -0.863194, 0.170952, 1.615403,
|
-0.981877, -1.415295, 1.339004, 1.131348, -0.863194, 0.170952, 1.615403,
|
||||||
# pose 10
|
# 位姿 10
|
||||||
-0.967163, -1.377051, 1.337671, 1.399175, -1.241886, 0.284547, 1.927756,
|
-0.967163, -1.377051, 1.337671, 1.399175, -1.241886, 0.284547, 1.927756,
|
||||||
# pose 11
|
# 位姿 11
|
||||||
-0.575271, -1.141574, 1.121935, 1.502172, -1.452029, 0.088544, 2.200019,
|
-0.575271, -1.141574, 1.121935, 1.502172, -1.452029, 0.088544, 2.200019,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
# Right arm 91 taught joints for hand-eye.
|
# 右臂 91 手眼标定示教的关节位姿。
|
||||||
# Fill with:
|
# 用以下命令填充:
|
||||||
# bash ~/ros2/scripts/run_handeye.sh --arm 91 teach
|
# bash ~/ros2/scripts/run_handeye.sh --arm 91 teach
|
||||||
# bash ~/ros2/scripts/run_handeye.sh --arm 91 merge
|
# bash ~/ros2/scripts/run_handeye.sh --arm 91 merge
|
||||||
#
|
#
|
||||||
# Do not reuse 89's waypoint_positions.
|
# 不要复用 89 的 waypoint_positions。
|
||||||
|
|
||||||
/**:
|
/**:
|
||||||
ros__parameters:
|
ros__parameters:
|
||||||
num_poses: 12
|
num_poses: 12
|
||||||
waypoint_positions: [
|
waypoint_positions: [
|
||||||
# pose 0
|
# 位姿 0
|
||||||
0.387144, -0.514770, -0.072695, 1.399918, 0.326501, 0.308501, -0.298212,
|
0.387144, -0.514770, -0.072695, 1.399918, 0.326501, 0.308501, -0.298212,
|
||||||
# pose 1
|
# 位姿 1
|
||||||
0.459205, -0.747691, 0.042380, 0.908430, 0.436933, -0.099413, -0.398336,
|
0.459205, -0.747691, 0.042380, 0.908430, 0.436933, -0.099413, -0.398336,
|
||||||
# pose 2
|
# 位姿 2
|
||||||
0.113162, -0.929267, -0.132969, 0.776759, 0.579499, -0.081271, -0.588715,
|
0.113162, -0.929267, -0.132969, 0.776759, 0.579499, -0.081271, -0.588715,
|
||||||
# pose 3
|
# 位姿 3
|
||||||
0.823056, -1.181803, -0.157161, 0.344860, 0.073450, -0.436449, -0.333484,
|
0.823056, -1.181803, -0.157161, 0.344860, 0.073450, -0.436449, -0.333484,
|
||||||
# pose 4
|
# 位姿 4
|
||||||
0.561660, 0.126479, -0.217063, 2.400061, 1.116363, 1.205544, -0.955972,
|
0.561660, 0.126479, -0.217063, 2.400061, 1.116363, 1.205544, -0.955972,
|
||||||
# pose 5
|
# 位姿 5
|
||||||
0.749011, 0.058548, -0.125615, 1.951037, 0.399065, 0.388437, -1.057397,
|
0.749011, 0.058548, -0.125615, 1.951037, 0.399065, 0.388437, -1.057397,
|
||||||
# pose 6
|
# 位姿 6
|
||||||
0.707611, -0.557010, -0.302672, 1.263980, 0.333093, 0.208229, -0.938372,
|
0.707611, -0.557010, -0.302672, 1.263980, 0.333093, 0.208229, -0.938372,
|
||||||
# pose 7
|
# 位姿 7
|
||||||
0.197846, -0.400530, 0.263064, 1.879643, 0.062586, 1.133715, -0.791285,
|
0.197846, -0.400530, 0.263064, 1.879643, 0.062586, 1.133715, -0.791285,
|
||||||
# pose 8
|
# 位姿 8
|
||||||
0.804027, -0.518162, 0.353947, 1.985246, -0.941195, 0.481238, -1.551113,
|
0.804027, -0.518162, 0.353947, 1.985246, -0.941195, 0.481238, -1.551113,
|
||||||
# pose 9
|
# 位姿 9
|
||||||
0.296161, -0.030159, 0.317117, 2.335652, 1.072158, 1.524893, -0.591372,
|
0.296161, -0.030159, 0.317117, 2.335652, 1.072158, 1.524893, -0.591372,
|
||||||
# pose 10
|
# 位姿 10
|
||||||
0.346492, -0.906418, -0.118014, 0.810448, 0.420788, -0.122104, -0.197859,
|
0.346492, -0.906418, -0.118014, 0.810448, 0.420788, -0.122104, -0.197859,
|
||||||
# pose 11
|
# 位姿 11
|
||||||
1.031802, -0.422436, -0.640939, 1.656629, 0.375163, 0.470365, -0.328041,
|
1.031802, -0.422436, -0.640939, 1.656629, 0.375163, 0.470365, -0.328041,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Left arm 89 eye-in-hand. Do not use for 91.
|
# 左臂 89 eye-in-hand. 不要用于 91。
|
||||||
# Hand-eye result
|
# 手眼标定结果
|
||||||
# mode: eye_in_hand
|
# mode: eye_in_hand
|
||||||
# camera_source: d405
|
# camera_source: d405
|
||||||
# views: 12
|
# views: 12
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Hand-eye interactive waypoint tour (preview + Free Drive confirm).
|
# 手眼标定交互式路点巡回(预览 + 自由驱动确认)。
|
||||||
# Poses: sibling handeye_poses.yaml
|
# 位姿来源:同目录的 handeye_poses.yaml
|
||||||
#
|
#
|
||||||
# Run:
|
# 运行:
|
||||||
# bash ~/ros2/scripts/run_handeye.sh waypoints
|
# bash ~/ros2/scripts/run_handeye.sh waypoints
|
||||||
|
|
||||||
handeye_waypoints:
|
handeye_waypoints:
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
- 注意 `run_sim.sh` 不是 Gazebo,它走 mock 驱动(假数据 + RViz),永远不会产生接触力
|
- 注意 `run_sim.sh` 不是 Gazebo,它走 mock 驱动(假数据 + RViz),永远不会产生接触力
|
||||||
**环境:** `env.sh` · `build_all.sh`
|
**环境:** `env.sh` · `build_all.sh`
|
||||||
**RDK:** `test_rdk_python.sh` · `rdk_switch_tool.sh` · `rdk_drag_bridge.py`
|
**RDK:** `test_rdk_python.sh` · `rdk_switch_tool.sh` · `rdk_drag_bridge.py`
|
||||||
|
**改注释/写文档后自检:** `python3 scripts/check_text_only.py`
|
||||||
|
- 用途:证明这次改动**只动了文字**(注释、docstring、提示信息),没碰逻辑。
|
||||||
|
它把改动前后的源码解析成语法树,删掉 docstring、抹平字符串常量后比对;
|
||||||
|
"翻译 f-string 时漏掉一个 `{}` 插值""顺手改了个变量名"这类问题会被抓出来。
|
||||||
|
- `python3 scripts/check_text_only.py` 查所有改过的 `.py`;也可以直接跟文件名。
|
||||||
|
基准是 `git HEAD`,所以要在**提交之前**跑。老手滑手滑的代价比跑一条命令高得多。
|
||||||
|
- 规矩写在 [docs/FRAMEWORK_RULES.md](../docs/FRAMEWORK_RULES.md) 的 R6.5。
|
||||||
|
一句话:**注释/docstring/提示信息用中文;标识符、话题名、字段名保留英文**
|
||||||
|
(但 `src/flexiv_*/` 是厂商上游代码,不许翻译,见 R4.4)。
|
||||||
**搁置未用:** `fetch_isaac_flexiv.sh`(拉 Flexiv 的 Isaac Sim / RDK 源码到 `thirdparty/`;Isaac 路线目前搁置,Gazebo 是选定平台)
|
**搁置未用:** `fetch_isaac_flexiv.sh`(拉 Flexiv 的 Isaac Sim / RDK 源码到 `thirdparty/`;Isaac 路线目前搁置,Gazebo 是选定平台)
|
||||||
|
|
||||||
`lib/parse_arm_args.sh` 解析 `--arm 89|91`。
|
`lib/parse_arm_args.sh` 解析 `--arm 89|91`。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build entire workspace: arm stack + flexiv real drivers
|
# 构建整个工作区:机械臂栈 + Flexiv 真机驱动
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build Flexiv RDK v1.x into ~/flexiv_install (required for real robot / flexiv_hardware).
|
# 把 Flexiv RDK v1.x 构建安装到 ~/flexiv_install(真机 / `flexiv_hardware` 必需)。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
RDK="${ROOT}/src/flexiv_rdk"
|
RDK="${ROOT}/src/flexiv_rdk"
|
||||||
@@ -11,7 +11,7 @@ if [[ ! -d "$RDK" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building RDK thirdparty deps into $PREFIX ..."
|
echo "Building RDK thirdparty deps into $PREFIX ..."
|
||||||
# Prefer system /opt/ros if present; else conda env already has compilers
|
# 有系统 /opt/ros 就优先用它;否则 conda 环境里已经有编译器
|
||||||
if [[ -f /opt/ros/humble/setup.bash ]]; then
|
if [[ -f /opt/ros/humble/setup.bash ]]; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source /opt/ros/humble/setup.bash
|
source /opt/ros/humble/setup.bash
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build Flexiv hardware packages for real robot (after RDK installed to ~/flexiv_install)
|
# 构建真机用的 Flexiv 硬件包(前提:RDK 已装到 ~/flexiv_install)
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Check PC ↔ Flexiv robot network (addresses from site.yaml)
|
# 检查 PC ↔ Flexiv 机器人网络(地址取自 site.yaml)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""纯文本改动的安全闸门:证明一次改动**只动了文字**,没碰逻辑。
|
||||||
|
|
||||||
|
=============================================================================
|
||||||
|
为什么需要这个工具
|
||||||
|
=============================================================================
|
||||||
|
"把英文注释翻成中文""补一批说明文档"这类改动,看起来零风险,其实经常出事:
|
||||||
|
改注释的人手滑动了变量名、顺手调了语句顺序、翻译 f-string 时漏掉一个 ``{}`` 插值。
|
||||||
|
这些都不会报错,只在某天变成一句"结果怎么不对了"。
|
||||||
|
|
||||||
|
人工 review 靠不住(几十个文件、几千行 diff,没人真的逐行看)。
|
||||||
|
所以这里用 AST 做机器判据:**把两份源码解析成语法树,抹掉所有文字,再比对。**
|
||||||
|
抹掉规则:
|
||||||
|
|
||||||
|
1. 删掉所有 docstring
|
||||||
|
2. 所有字符串常量换成同一个占位符
|
||||||
|
3. f-string 只保留"插值了几次、插值的是什么表达式",
|
||||||
|
字面量部分整段丢掉、插值按内容排序
|
||||||
|
|
||||||
|
第 3 条是为中文语序专门留的口子。英文 ``f"on {port} read {idx} failed"``
|
||||||
|
翻成 ``f"在 {port} 上读 {idx} 失败"`` 只是把两个插值的**显示顺序**换了,
|
||||||
|
语义没变,不该判失败。但只要有人**多插一个、少插一个、或换掉被插入的表达式**,
|
||||||
|
这里立刻报出来。
|
||||||
|
|
||||||
|
=============================================================================
|
||||||
|
用法
|
||||||
|
=============================================================================
|
||||||
|
python3 scripts/check_text_only.py # 检查所有改过的 .py
|
||||||
|
python3 scripts/check_text_only.py <文件> [...] # 只检查指定文件
|
||||||
|
python3 scripts/check_text_only.py --staged # 只检查已 git add 的
|
||||||
|
|
||||||
|
比较基准固定为 ``git HEAD``(也就是"上一次提交"),所以要用在**提交之前**。
|
||||||
|
|
||||||
|
退出码 0 = 改动范围内没有逻辑变化;非 0 = 有文件动了不该动的东西(会打印差异)。
|
||||||
|
=============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import difflib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
# 被抹平后的字符串统一长这样。用控制字符是为了保证它不会和真实内容撞车。
|
||||||
|
PLACEHOLDER = "\0STR\0"
|
||||||
|
|
||||||
|
|
||||||
|
class _Normalize(ast.NodeTransformer):
|
||||||
|
"""把语法树里的"文字"全部抹掉,只留下结构与标识符。"""
|
||||||
|
|
||||||
|
def _strip_docstring(self, node):
|
||||||
|
"""删掉模块 / 类 / 函数的第一个字符串表达式(docstring)。"""
|
||||||
|
self.generic_visit(node)
|
||||||
|
body = getattr(node, "body", None)
|
||||||
|
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
|
||||||
|
and isinstance(body[0].value.value, str):
|
||||||
|
node.body = body[1:]
|
||||||
|
return node
|
||||||
|
|
||||||
|
visit_Module = _strip_docstring
|
||||||
|
visit_FunctionDef = _strip_docstring
|
||||||
|
visit_AsyncFunctionDef = _strip_docstring
|
||||||
|
visit_ClassDef = _strip_docstring
|
||||||
|
|
||||||
|
def visit_Constant(self, node: ast.Constant):
|
||||||
|
if isinstance(node.value, str):
|
||||||
|
return ast.copy_location(ast.Constant(value=PLACEHOLDER), node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
def visit_JoinedStr(self, node: ast.JoinedStr):
|
||||||
|
"""f-string:只留插值,不留字面量;插值按内容排序(见模块 docstring)。"""
|
||||||
|
self.generic_visit(node)
|
||||||
|
parts = [ast.dump(v) for v in node.values if isinstance(v, ast.FormattedValue)]
|
||||||
|
return ast.copy_location(
|
||||||
|
ast.Constant(value="FSTR[" + "|".join(sorted(parts)) + "]"), node)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(source: str) -> str:
|
||||||
|
"""源码 -> 抹掉文字后的规范化结构字符串。"""
|
||||||
|
return ast.dump(_Normalize().visit(ast.parse(source)))
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args: str) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(["git", *args], capture_output=True, text=True, cwd=REPO_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def head_source(path: str) -> str | None:
|
||||||
|
"""取该文件在 HEAD 里的内容;返回 None 表示 HEAD 里没有它(新文件)。"""
|
||||||
|
r = _git("show", f"HEAD:{path}")
|
||||||
|
return r.stdout if r.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def changed_python_files() -> list[str]:
|
||||||
|
r = _git("diff", "--name-only", "HEAD")
|
||||||
|
return [p for p in r.stdout.split() if p.endswith(".py")]
|
||||||
|
|
||||||
|
|
||||||
|
def staged_python_files() -> list[str]:
|
||||||
|
r = _git("diff", "--cached", "--name-only")
|
||||||
|
return [p for p in r.stdout.split() if p.endswith(".py")]
|
||||||
|
|
||||||
|
|
||||||
|
def first_diff(old: str, new: str) -> str:
|
||||||
|
"""给出一段人能看懂的差异上下文。"""
|
||||||
|
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag != "equal":
|
||||||
|
return (f" 结构差异类型: {tag}\n"
|
||||||
|
f" 改前: ...{old[max(0, i1 - 70):i2 + 70]}...\n"
|
||||||
|
f" 改后: ...{new[max(0, j1 - 70):j2 + 70]}...")
|
||||||
|
return " (未找到差异片段——理论上不该走到这里)"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="证明一次改动只动了注释/字符串,没有改逻辑",
|
||||||
|
)
|
||||||
|
ap.add_argument("paths", nargs="*", help="要检查的文件;留空 = 所有改过的 .py")
|
||||||
|
ap.add_argument("--staged", action="store_true", help="只检查已 git add 的文件")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
paths = args.paths or (staged_python_files() if args.staged else changed_python_files())
|
||||||
|
if not paths:
|
||||||
|
print("没有需要检查的 .py 文件(工作区是干净的?)。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ok, problems, skipped = 0, [], []
|
||||||
|
for path in paths:
|
||||||
|
disk = REPO_ROOT / path
|
||||||
|
if not disk.is_file():
|
||||||
|
skipped.append((path, "工作区里不存在(被删除了?)"))
|
||||||
|
continue
|
||||||
|
old = head_source(path)
|
||||||
|
if old is None:
|
||||||
|
skipped.append((path, "HEAD 里没有它(新增文件),没有基准可比"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
same = normalize(old) == normalize(disk.read_text(encoding="utf-8"))
|
||||||
|
except SyntaxError as exc:
|
||||||
|
problems.append((path, f" 语法错误,先修好再看:{exc}"))
|
||||||
|
continue
|
||||||
|
if same:
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
problems.append((path, first_diff(normalize(old),
|
||||||
|
normalize(disk.read_text(encoding="utf-8")))))
|
||||||
|
|
||||||
|
print(f"通过(只改了文字): {ok}")
|
||||||
|
for path, why in skipped:
|
||||||
|
print(f"跳过 {path} —— {why}")
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
print(f"\n!! 有 {len(problems)} 个文件动了文字之外的东西:")
|
||||||
|
for path, why in problems:
|
||||||
|
print(f" {path}")
|
||||||
|
print(why)
|
||||||
|
print("\n注意:如果差异只出现在 f-string 的插值顺序上,那是允许的(见脚本说明)。")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n闸门通过:改动范围内没有逻辑变化。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Convert Flexiv Elements .traj (text) -> arm_application YAML waypoints."""
|
"""把 Flexiv Elements 的 .traj(文本)转换成 arm_application 的 YAML 路点。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
def parse_flexiv_traj(text: str) -> list[dict[str, Any]]:
|
def parse_flexiv_traj(text: str) -> list[dict[str, Any]]:
|
||||||
"""Parse Elements Studio scatter-point .traj file."""
|
"""解析 Elements Studio 导出的散点 .traj 文件。"""
|
||||||
blocks = re.split(r'pattern_params\s*\{', text)[1:]
|
blocks = re.split(r'pattern_params\s*\{', text)[1:]
|
||||||
waypoints: list[dict[str, Any]] = []
|
waypoints: list[dict[str, Any]] = []
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
@@ -35,7 +35,7 @@ def parse_flexiv_traj(text: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def assign_times(waypoints: list[dict[str, Any]], min_seg: float = 3.0) -> None:
|
def assign_times(waypoints: list[dict[str, Any]], min_seg: float = 3.0) -> None:
|
||||||
"""Cumulative time_from_start from joint-space segment length."""
|
"""按关节空间的段长累加出 time_from_start。"""
|
||||||
t = 0.0
|
t = 0.0
|
||||||
prev = None
|
prev = None
|
||||||
for wp in waypoints:
|
for wp in waypoints:
|
||||||
@@ -44,7 +44,7 @@ def assign_times(waypoints: list[dict[str, Any]], min_seg: float = 3.0) -> None:
|
|||||||
t += min_seg
|
t += min_seg
|
||||||
else:
|
else:
|
||||||
delta = max(abs(a - b) for a, b in zip(pos, prev))
|
delta = max(abs(a - b) for a, b in zip(pos, prev))
|
||||||
# ~4 s per radian max joint move, floored at min_seg
|
# 关节最大运动量每弧度约 4 秒,且不低于 min_seg
|
||||||
t += max(min_seg, delta * 4.0)
|
t += max(min_seg, delta * 4.0)
|
||||||
wp['time'] = round(t, 2)
|
wp['time'] = round(t, 2)
|
||||||
prev = pos
|
prev = pos
|
||||||
@@ -65,17 +65,17 @@ def to_yaml(
|
|||||||
pos_line = ', '.join(f'{v:.6f}' for v in flat)
|
pos_line = ', '.join(f'{v:.6f}' for v in flat)
|
||||||
time_line = ', '.join(str(t) for t in times)
|
time_line = ', '.join(str(t) for t in times)
|
||||||
names = ', '.join(f' # {wp.get("name", "")}' if i == 0 else '' for i, wp in enumerate(waypoints))
|
names = ', '.join(f' # {wp.get("name", "")}' if i == 0 else '' for i, wp in enumerate(waypoints))
|
||||||
_ = names # names kept in comments below
|
_ = names # 名称保留在下面的注释里
|
||||||
lines = [
|
lines = [
|
||||||
'# Auto-converted from Flexiv Elements .traj (joints: deg -> rad)',
|
'# 由 Flexiv Elements .traj 自动转换而来(关节角:度 -> 弧度)',
|
||||||
'# ROS params: flat waypoint_positions + waypoint_times (no nested dicts)',
|
'# ROS 参数:扁平的 waypoint_positions + waypoint_times(不使用嵌套字典)',
|
||||||
'# Edit velocity_scale / loop_count as needed (no rebuild).',
|
'# 按需修改 velocity_scale / loop_count(无需重新编译)。',
|
||||||
'run_joint_trajectory:',
|
'run_joint_trajectory:',
|
||||||
' ros__parameters:',
|
' ros__parameters:',
|
||||||
f' joint_prefix: "{joint_prefix}"',
|
f' joint_prefix: "{joint_prefix}"',
|
||||||
' joint_names: [joint1, joint2, joint3, joint4, joint5, joint6, joint7]',
|
' joint_names: [joint1, joint2, joint3, joint4, joint5, joint6, joint7]',
|
||||||
f' velocity_scale: {velocity_scale}',
|
f' velocity_scale: {velocity_scale}',
|
||||||
' loop_count: 1 # 1=once, 0=forever, N=repeat N',
|
' loop_count: 1 # 1=执行一次,0=无限循环,N=重复 N 次',
|
||||||
' pause_between_loops: 0.5',
|
' pause_between_loops: 0.5',
|
||||||
f' waypoint_times: [{time_line}]',
|
f' waypoint_times: [{time_line}]',
|
||||||
' waypoint_positions: [',
|
' waypoint_positions: [',
|
||||||
@@ -89,16 +89,16 @@ def to_yaml(
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument('input', type=Path, help='Flexiv Elements .traj file')
|
ap.add_argument('input', type=Path, help='Flexiv Elements 的 .traj 文件')
|
||||||
ap.add_argument('-o', '--output', type=Path, help='Output YAML path')
|
ap.add_argument('-o', '--output', type=Path, help='输出的 YAML 路径')
|
||||||
ap.add_argument('--prefix', default='Rizon4-063989_', help='Joint name prefix (site SN + _)')
|
ap.add_argument('--prefix', default='Rizon4-063989_', help='关节名前缀(现场 SN + _)')
|
||||||
ap.add_argument('--min-seg', type=float, default=3.0, help='Min seconds per segment')
|
ap.add_argument('--min-seg', type=float, default=3.0, help='每段的最小秒数')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
text = args.input.read_text(encoding='utf-8')
|
text = args.input.read_text(encoding='utf-8')
|
||||||
wps = parse_flexiv_traj(text)
|
wps = parse_flexiv_traj(text)
|
||||||
if not wps:
|
if not wps:
|
||||||
print('ERROR: no waypoints parsed', file=sys.stderr)
|
print('错误:没有解析到任何路点', file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
assign_times(wps, min_seg=args.min_seg)
|
assign_times(wps, min_seg=args.min_seg)
|
||||||
yaml_text = to_yaml(wps, joint_prefix=args.prefix)
|
yaml_text = to_yaml(wps, joint_prefix=args.prefix)
|
||||||
@@ -107,7 +107,7 @@ def main() -> int:
|
|||||||
if out:
|
if out:
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
out.write_text(yaml_text, encoding='utf-8')
|
out.write_text(yaml_text, encoding='utf-8')
|
||||||
print(f'Wrote {len(wps)} waypoints -> {out}')
|
print(f'已写入 {len(wps)} 个路点 -> {out}')
|
||||||
else:
|
else:
|
||||||
print(yaml_text)
|
print(yaml_text)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Convert Flexiv Elements .traj → arm_application YAML (standard entry for all trajectories).
|
# 把 Flexiv Elements 的 .traj 转成 `arm_application` 的 YAML(所有轨迹的统一入口)。
|
||||||
# Usage:
|
# 用法:
|
||||||
# ./scripts/convert_traj.sh ~/文档/my_task.traj
|
# ./scripts/convert_traj.sh ~/文档/my_task.traj
|
||||||
# ./scripts/convert_traj.sh ~/文档/my_task.traj -o /path/to/out.yaml
|
# ./scripts/convert_traj.sh ~/文档/my_task.traj -o /path/to/out.yaml
|
||||||
# ./scripts/convert_traj.sh ~/文档/my_task.traj --min-seg 2.5 --no-copy
|
# ./scripts/convert_traj.sh ~/文档/my_task.traj --min-seg 2.5 --no-copy
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Source this workspace environment (conda + ROS + flexiv libs).
|
# 加载本工作区环境(conda + ROS + flexiv 库)。
|
||||||
# Usage: source scripts/env.sh
|
# 用法:source scripts/env.sh
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
if [[ -f /opt/ros/humble/setup.bash ]]; then
|
if [[ -f /opt/ros/humble/setup.bash ]]; then
|
||||||
@@ -33,7 +33,7 @@ fi
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "${ROOT}/install/setup.bash"
|
source "${ROOT}/install/setup.bash"
|
||||||
export LD_LIBRARY_PATH="${HOME}/flexiv_install/lib:${LD_LIBRARY_PATH:-}"
|
export LD_LIBRARY_PATH="${HOME}/flexiv_install/lib:${LD_LIBRARY_PATH:-}"
|
||||||
# Pip TensorRT (tensorrt-cu12) — needed to load YOLO .engine
|
# pip 版 TensorRT(tensorrt-cu12)—— 加载 YOLO `.engine` 需要它
|
||||||
_TRT_SITE="${CONDA_PREFIX:-${HOME}/anaconda3/envs/ros_flexiv}/lib/python3.11/site-packages"
|
_TRT_SITE="${CONDA_PREFIX:-${HOME}/anaconda3/envs/ros_flexiv}/lib/python3.11/site-packages"
|
||||||
for _d in "${_TRT_SITE}/tensorrt" "${_TRT_SITE}/tensorrt_libs" "${_TRT_SITE}/nvidia/tensorrt/lib"; do
|
for _d in "${_TRT_SITE}/tensorrt" "${_TRT_SITE}/tensorrt_libs" "${_TRT_SITE}/nvidia/tensorrt/lib"; do
|
||||||
if [[ -d "${_d}" ]]; then
|
if [[ -d "${_d}" ]]; then
|
||||||
@@ -44,6 +44,6 @@ unset _d _TRT_SITE
|
|||||||
export YOLO_AUTOINSTALL=false
|
export YOLO_AUTOINSTALL=false
|
||||||
export ROS2_WS="${ROOT}"
|
export ROS2_WS="${ROOT}"
|
||||||
|
|
||||||
# Master site config → SITE_* (YOLO weights, camera, robot SN, …)
|
# 主站点配置 → `SITE_*`(YOLO 权重、相机、机器人 SN……)
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "${ROOT}/scripts/lib/load_site_config.sh" 2>/dev/null || true
|
source "${ROOT}/scripts/lib/load_site_config.sh" 2>/dev/null || true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Clone official Flexiv ROS 2 stack for Rizon (RDK v1.x → humble-v1 branch).
|
# 克隆 Flexiv 官方 ROS 2 栈(Rizon 用,RDK v1.x → humble-v1 分支)。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@@ -9,7 +9,7 @@ BRANCH="${FLEXIV_ROS2_BRANCH:-humble-v1}"
|
|||||||
mkdir -p "${SRC}"
|
mkdir -p "${SRC}"
|
||||||
cd "${SRC}"
|
cd "${SRC}"
|
||||||
|
|
||||||
# Always ensure official URDF package is present (meshes + xacro)
|
# 无论如何都要保证官方 URDF 包存在(mesh + xacro)
|
||||||
if [[ ! -d flexiv_description ]]; then
|
if [[ ! -d flexiv_description ]]; then
|
||||||
echo "Fetching flexiv_description (${BRANCH})..."
|
echo "Fetching flexiv_description (${BRANCH})..."
|
||||||
if ! git clone --depth 1 -b "${BRANCH}" https://github.com/flexivrobotics/flexiv_description.git; then
|
if ! git clone --depth 1 -b "${BRANCH}" https://github.com/flexivrobotics/flexiv_description.git; then
|
||||||
@@ -58,7 +58,7 @@ if ! command -v vcs >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Prefer local Flexiv_Rizon4 RDK (avoids GitHub clone when present)
|
# 优先用本地 Flexiv_Rizon4 里的 RDK(有本地副本时就不去 GitHub 克隆)
|
||||||
LOCAL_FLEXIV="${LOCAL_FLEXIV:-$HOME/文档/Flexiv_Rizon4}"
|
LOCAL_FLEXIV="${LOCAL_FLEXIV:-$HOME/文档/Flexiv_Rizon4}"
|
||||||
if [[ -d "${LOCAL_FLEXIV}/flexiv_rdk" ]]; then
|
if [[ -d "${LOCAL_FLEXIV}/flexiv_rdk" ]]; then
|
||||||
echo "Using local RDK: ${LOCAL_FLEXIV}/flexiv_rdk"
|
echo "Using local RDK: ${LOCAL_FLEXIV}/flexiv_rdk"
|
||||||
@@ -73,7 +73,7 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Always import remaining deps from repos (description already handled; skip-existing)
|
# 其余依赖一律从 .repos 导入(description 已单独处理;skip-existing 跳过已有)
|
||||||
vcs import "${SRC}" < "${REPOS_FILE}" --recursive --skip-existing || true
|
vcs import "${SRC}" < "${REPOS_FILE}" --recursive --skip-existing || true
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Clone Flexiv Isaac Sim integration repos into thirdparty/
|
# 把 Flexiv Isaac Sim 集成仓库克隆到 thirdparty/
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@@ -23,7 +23,7 @@ clone_or_update https://github.com/flexivrobotics/flexiv_sim_plugin.git flexiv_s
|
|||||||
clone_or_update https://github.com/flexivrobotics/isaac_sim_ws.git isaac_sim_ws
|
clone_or_update https://github.com/flexivrobotics/isaac_sim_ws.git isaac_sim_ws
|
||||||
clone_or_update https://github.com/flexivrobotics/flexiv_description.git flexiv_description
|
clone_or_update https://github.com/flexivrobotics/flexiv_description.git flexiv_description
|
||||||
|
|
||||||
# Optional: RDK sources next to sim (if not already under src/)
|
# 可选:在仿真目录旁放一份 RDK 源码(如果 src/ 下还没有)
|
||||||
if [[ ! -d "${ROOT}/src/flexiv_rdk" ]]; then
|
if [[ ! -d "${ROOT}/src/flexiv_rdk" ]]; then
|
||||||
clone_or_update https://github.com/flexivrobotics/flexiv_rdk.git flexiv_rdk
|
clone_or_update https://github.com/flexivrobotics/flexiv_rdk.git flexiv_rdk
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ source "${ROOT}/scripts/lib/load_site_config.sh" 2>/dev/null || true
|
|||||||
PC_IP_PRIMARY="${SITE_PC_IP:-192.168.2.10}"
|
PC_IP_PRIMARY="${SITE_PC_IP:-192.168.2.10}"
|
||||||
PC_ADDRS=("192.168.2.10/24" "192.168.100.10/24")
|
PC_ADDRS=("192.168.2.10/24" "192.168.100.10/24")
|
||||||
ROBOT_IPS=("${SITE_ROBOT_IP:-192.168.2.100}")
|
ROBOT_IPS=("${SITE_ROBOT_IP:-192.168.2.100}")
|
||||||
# Catalog extras (second arm)
|
# catalog 里的额外项(第二条臂)
|
||||||
if [[ -n "${SITE_ROBOT_CATALOG:-}" ]]; then
|
if [[ -n "${SITE_ROBOT_CATALOG:-}" ]]; then
|
||||||
:
|
:
|
||||||
fi
|
fi
|
||||||
@@ -132,7 +132,7 @@ for ip in 192.168.2.100 192.168.101.1 192.168.2.101 "${SITE_ROBOT_IP:-}"; do
|
|||||||
[[ -z "$ip" ]] && continue
|
[[ -z "$ip" ]] && continue
|
||||||
ping_one "$ip" && ok=1 || true
|
ping_one "$ip" && ok=1 || true
|
||||||
done
|
done
|
||||||
# unique
|
# 去重
|
||||||
echo
|
echo
|
||||||
if ping -c 1 -W 1 "${SITE_ROBOT_IP:-192.168.2.100}" >/dev/null 2>&1; then
|
if ping -c 1 -W 1 "${SITE_ROBOT_IP:-192.168.2.100}" >/dev/null 2>&1; then
|
||||||
echo "OK — 当前臂 ${SITE_ROBOT_SN:-} @ ${SITE_ROBOT_IP} 可达。RDK 用 SN 连接。"
|
echo "OK — 当前臂 ${SITE_ROBOT_SN:-} @ ${SITE_ROBOT_IP} 可达。RDK 用 SN 连接。"
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ REGEX_REPLACEMENTS: list[tuple[str, str]] = [
|
|||||||
|
|
||||||
|
|
||||||
def render(site_text: str) -> str:
|
def render(site_text: str) -> str:
|
||||||
"""Turn the live config text into the anonymised template text."""
|
"""把现场配置文本转换成匿名化后的模板文本。"""
|
||||||
out = site_text
|
out = site_text
|
||||||
for old, new in LITERAL_REPLACEMENTS:
|
for old, new in LITERAL_REPLACEMENTS:
|
||||||
out = out.replace(old, new)
|
out = out.replace(old, new)
|
||||||
|
|||||||
+15
-15
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Move Rizon4 to a named pose (default: home) and FORCE stop.
|
# 把 Rizon4 移动到指定命名的位姿(默认:home)并强制停止。
|
||||||
#
|
#
|
||||||
# Critical: visual_servo.launch / servo_node must be killed first, otherwise
|
# 关键:必须先杀掉 visual_servo.launch / servo_node,否则
|
||||||
# they keep streaming JointTrajectory and the arm never settles.
|
# 它们会一直发 JointTrajectory,机械臂永远稳定不下来。
|
||||||
#
|
#
|
||||||
# Usage:
|
# 用法:
|
||||||
# bash ~/ros2/scripts/go_home.sh
|
# bash ~/ros2/scripts/go_home.sh
|
||||||
# bash ~/ros2/scripts/go_home.sh --pose ready --speed 0.3
|
# bash ~/ros2/scripts/go_home.sh --pose ready --speed 0.3
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--pose) POSE="${2:?}"; shift 2 ;;
|
--pose) POSE="${2:?}"; shift 2 ;;
|
||||||
--speed) SPEED="${2:?}"; shift 2 ;;
|
--speed) SPEED="${2:?}"; shift 2 ;;
|
||||||
--keep-servo) KEEP_SERVO=1; shift ;;
|
--keep-servo) KEEP_SERVO=1; shift ;;
|
||||||
--stop-track) shift ;; # legacy
|
--stop-track) shift ;; # 历史遗留
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) echo "Unknown: $1" >&2; usage >&2; exit 1 ;;
|
*) echo "Unknown: $1" >&2; usage >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
@@ -56,10 +56,10 @@ SPEED="${SPEED:-${SITE_GO_HOME_SPEED:-0.25}}"
|
|||||||
|
|
||||||
kill_streamers() {
|
kill_streamers() {
|
||||||
echo "=== killing visual-servo streamers ==="
|
echo "=== killing visual-servo streamers ==="
|
||||||
# Prefer disable control before kill (if node still up)
|
# 能先禁用控制就禁用(如果节点还活着),再动手 kill
|
||||||
ros2 param set /servo_node enable_control false >/dev/null 2>&1 || true
|
ros2 param set /servo_node enable_control false >/dev/null 2>&1 || true
|
||||||
|
|
||||||
# Kill launch parent first, then executables (SIGTERM then SIGKILL)
|
# 先杀 launch 父进程,再杀可执行文件(先 SIGTERM 再 SIGKILL)
|
||||||
local patterns=(
|
local patterns=(
|
||||||
'visual_servo.launch.py'
|
'visual_servo.launch.py'
|
||||||
'/lib/arm_application/servo_node'
|
'/lib/arm_application/servo_node'
|
||||||
@@ -115,7 +115,7 @@ n.destroy_node(); rclpy.shutdown()
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
# Strong settle via controller ACTION (not topic — matches JTC reliably)
|
# 用控制器的 ACTION 做强力稳定(不走话题 —— 这样能可靠地匹配 JTC)
|
||||||
settle_hold_action() {
|
settle_hold_action() {
|
||||||
local pose="$1"
|
local pose="$1"
|
||||||
python3 - "$pose" <<'PY'
|
python3 - "$pose" <<'PY'
|
||||||
@@ -160,7 +160,7 @@ def spin_until(pred, timeout=3.0):
|
|||||||
|
|
||||||
spin_until(lambda: js_box[0] is not None, 3.0)
|
spin_until(lambda: js_box[0] is not None, 3.0)
|
||||||
|
|
||||||
# Two-point: current -> target (or target hold), BOTH with zero velocity
|
# 两点轨迹:当前位姿 -> 目标位姿(或保持在目标位姿),两点速度都为零
|
||||||
q_now = list(target)
|
q_now = list(target)
|
||||||
if js_box[0] is not None:
|
if js_box[0] is not None:
|
||||||
m = dict(zip(js_box[0].name, js_box[0].position))
|
m = dict(zip(js_box[0].name, js_box[0].position))
|
||||||
@@ -172,14 +172,14 @@ if js_box[0] is not None:
|
|||||||
def send_hold(q, T=0.40):
|
def send_hold(q, T=0.40):
|
||||||
traj = JointTrajectory()
|
traj = JointTrajectory()
|
||||||
traj.joint_names = list(JOINTS)
|
traj.joint_names = list(JOINTS)
|
||||||
# start
|
# 起点
|
||||||
p0 = JointTrajectoryPoint()
|
p0 = JointTrajectoryPoint()
|
||||||
p0.positions = [float(v) for v in q]
|
p0.positions = [float(v) for v in q]
|
||||||
p0.velocities = [0.0] * 7
|
p0.velocities = [0.0] * 7
|
||||||
p0.accelerations = [0.0] * 7
|
p0.accelerations = [0.0] * 7
|
||||||
p0.time_from_start.sec = 0
|
p0.time_from_start.sec = 0
|
||||||
p0.time_from_start.nanosec = 50_000_000
|
p0.time_from_start.nanosec = 50_000_000
|
||||||
# end same pose, still zero vel (pin)
|
# 终点是同一姿势,速度仍为零(钉住)
|
||||||
p1 = JointTrajectoryPoint()
|
p1 = JointTrajectoryPoint()
|
||||||
p1.positions = [float(v) for v in q]
|
p1.positions = [float(v) for v in q]
|
||||||
p1.velocities = [0.0] * 7
|
p1.velocities = [0.0] * 7
|
||||||
@@ -203,11 +203,11 @@ def send_hold(q, T=0.40):
|
|||||||
spin_until(lambda: rfut.done(), T + 3.0)
|
spin_until(lambda: rfut.done(), T + 3.0)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 1) snap toward named pose with zero end vel
|
# 1) 先朝命名位姿快速靠拢,末端速度为零
|
||||||
print(f" action hold → {name} (zero vel)")
|
print(f" action hold → {name} (zero vel)")
|
||||||
send_hold(target, T=0.60)
|
send_hold(target, T=0.60)
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
# 2–4) re-pin measured pose several times (align JTC open-loop cmd to encoder)
|
# 2–4) 反复把实测位姿重新钉住(让 JTC 的开环指令与编码器读数对齐)
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
spin_until(lambda: js_box[0] is not None, 1.0)
|
spin_until(lambda: js_box[0] is not None, 1.0)
|
||||||
if js_box[0] is None:
|
if js_box[0] is None:
|
||||||
@@ -218,7 +218,7 @@ for i in range(3):
|
|||||||
send_hold(q_pin, T=0.45)
|
send_hold(q_pin, T=0.45)
|
||||||
time.sleep(0.25)
|
time.sleep(0.25)
|
||||||
|
|
||||||
# Wait until |qd| small
|
# 等到 |qd| 足够小
|
||||||
print(" waiting for joints to stop ...")
|
print(" waiting for joints to stop ...")
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
while time.monotonic() - t0 < 5.0:
|
while time.monotonic() - t0 < 5.0:
|
||||||
@@ -270,7 +270,7 @@ sleep "${WAIT_SEC}"
|
|||||||
echo "=== settle (action, vel=0) ==="
|
echo "=== settle (action, vel=0) ==="
|
||||||
settle_hold_action "${POSE}"
|
settle_hold_action "${POSE}"
|
||||||
|
|
||||||
# Final check: servo must still be dead
|
# 最后确认:servo 必须仍然是死的
|
||||||
if pgrep -f '/lib/arm_application/servo_node' >/dev/null 2>&1; then
|
if pgrep -f '/lib/arm_application/servo_node' >/dev/null 2>&1; then
|
||||||
echo "WARNING: servo_node came back — killing again"
|
echo "WARNING: servo_node came back — killing again"
|
||||||
kill_streamers
|
kill_streamers
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Offline-friendly Flexiv RDK v1.9.1 install for ROS (humble-v1 / flexiv_hardware).
|
# 面向离线场景的 Flexiv RDK v1.9.1 安装脚本(供 ROS 的 humble-v1 / `flexiv_hardware` 使用)。
|
||||||
# CMakeLists already supports FLEXIV_RDK_OFFLINE_LIB / vendor/rdk_libs/.
|
# CMakeLists 已经支持 `FLEXIV_RDK_OFFLINE_LIB` / `vendor/rdk_libs/`。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@@ -11,7 +11,7 @@ EXPECTED_HASH="$(tr -d '[:space:]' < "${RDK_SRC}/lib/${LIB_NAME}.sha256")"
|
|||||||
VENDOR_LIB="${ROOT}/vendor/rdk_libs/${LIB_NAME}"
|
VENDOR_LIB="${ROOT}/vendor/rdk_libs/${LIB_NAME}"
|
||||||
JOBS="${JOBS:-$(nproc)}"
|
JOBS="${JOBS:-$(nproc)}"
|
||||||
|
|
||||||
# Prefer conda ros_flexiv toolchain (cmake/g++ often only there)
|
# 优先用 conda 的 ros_flexiv 工具链(cmake/g++ 往往只在那里有)
|
||||||
if ! command -v cmake >/dev/null 2>&1; then
|
if ! command -v cmake >/dev/null 2>&1; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "${HOME}/anaconda3/etc/profile.d/conda.sh" 2>/dev/null \
|
source "${HOME}/anaconda3/etc/profile.d/conda.sh" 2>/dev/null \
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Load ~/ros2/config/site.yaml and print bash export lines or JSON.
|
"""读取 ``~/ros2/config/site.yaml``,打印 bash 的 export 行或 JSON。
|
||||||
|
|
||||||
Usage:
|
用法:
|
||||||
eval "$(python3 ~/ros2/scripts/lib/export_site_env.py)"
|
eval "$(python3 ~/ros2/scripts/lib/export_site_env.py)"
|
||||||
python3 ~/ros2/scripts/lib/export_site_env.py --json
|
python3 ~/ros2/scripts/lib/export_site_env.py --json
|
||||||
python3 ~/ros2/scripts/lib/export_site_env.py --print
|
python3 ~/ros2/scripts/lib/export_site_env.py --print
|
||||||
@@ -35,18 +35,18 @@ def site_path() -> Path:
|
|||||||
def load_site(path: Path | None = None) -> dict:
|
def load_site(path: Path | None = None) -> dict:
|
||||||
path = path or site_path()
|
path = path or site_path()
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise FileNotFoundError(f"site config not found: {path}")
|
raise FileNotFoundError(f"找不到 site 配置: {path}")
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
if yaml is None:
|
if yaml is None:
|
||||||
raise RuntimeError("PyYAML required: pip/conda install pyyaml")
|
raise RuntimeError("需要 PyYAML:pip/conda install pyyaml")
|
||||||
data = yaml.safe_load(text) or {}
|
data = yaml.safe_load(text) or {}
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError(f"invalid site.yaml: {path}")
|
raise ValueError(f"无效的 site.yaml: {path}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def list_robots(data: dict) -> list[dict]:
|
def list_robots(data: dict) -> list[dict]:
|
||||||
"""Catalog entries from robot.robots (list or SN-keyed map)."""
|
"""从 robot.robots 取清单条目(列表或按 SN 索引的映射)。"""
|
||||||
raw = (data.get("robot") or {}).get("robots") or []
|
raw = (data.get("robot") or {}).get("robots") or []
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
@@ -90,7 +90,7 @@ def _deep_merge(base: dict, overlay: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_robot_sn(data: dict, want: str) -> str:
|
def normalize_robot_sn(data: dict, want: str) -> str:
|
||||||
"""Map 89/91/left/right/label → full Flexiv SN."""
|
"""把 89/91/left/right/label 映射成完整的 Flexiv SN。"""
|
||||||
w = (want or "").strip()
|
w = (want or "").strip()
|
||||||
if not w:
|
if not w:
|
||||||
return w
|
return w
|
||||||
@@ -109,7 +109,7 @@ def normalize_robot_sn(data: dict, want: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_robot(data: dict, sn: str | None = None) -> dict:
|
def resolve_robot(data: dict, sn: str | None = None) -> dict:
|
||||||
"""Active arm: ROBOT_SN env overrides robot.sn; catalog fills ip/iface/frames."""
|
"""当前生效的机械臂:环境变量 ``ROBOT_SN`` 覆盖 robot.sn;清单补齐 ip/iface/frames。"""
|
||||||
robot = dict(data.get("robot") or {})
|
robot = dict(data.get("robot") or {})
|
||||||
catalog = list_robots(data)
|
catalog = list_robots(data)
|
||||||
want = normalize_robot_sn(
|
want = normalize_robot_sn(
|
||||||
@@ -146,7 +146,7 @@ def resolve_robot(data: dict, sn: str | None = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def apply_active_arm(data: dict, sn: str | None = None) -> tuple[dict, dict]:
|
def apply_active_arm(data: dict, sn: str | None = None) -> tuple[dict, dict]:
|
||||||
"""Merge robots[] camera/gripper/paths/servo onto site defaults for the active SN."""
|
"""把 robots[] 里的 camera/gripper/paths/servo 合并到当前 SN 对应的 site 默认值上。"""
|
||||||
robot = resolve_robot(data, sn)
|
robot = resolve_robot(data, sn)
|
||||||
merged = dict(data)
|
merged = dict(data)
|
||||||
for key in _ARM_SECTION_KEYS:
|
for key in _ARM_SECTION_KEYS:
|
||||||
@@ -157,14 +157,14 @@ def apply_active_arm(data: dict, sn: str | None = None) -> tuple[dict, dict]:
|
|||||||
|
|
||||||
|
|
||||||
def camera_ns(data: dict) -> str:
|
def camera_ns(data: dict) -> str:
|
||||||
"""Topic namespace for the active wrist camera (d405_left / d405_right)."""
|
"""当前腕部相机(d405_left / d405_right)的话题命名空间。"""
|
||||||
cam = data.get("camera") or {}
|
cam = data.get("camera") or {}
|
||||||
name = str(cam.get("name") or "d405").strip().strip("/")
|
name = str(cam.get("name") or "d405").strip().strip("/")
|
||||||
return name or "d405"
|
return name or "d405"
|
||||||
|
|
||||||
|
|
||||||
def camera_frames(data: dict) -> tuple[str, str, str]:
|
def camera_frames(data: dict) -> tuple[str, str, str]:
|
||||||
"""(namespace, optical_frame, depth_topic) for the active arm."""
|
"""当前机械臂的(命名空间, 光学坐标系, 深度话题)。"""
|
||||||
ns = camera_ns(data)
|
ns = camera_ns(data)
|
||||||
yolo = data.get("yolo") or {}
|
yolo = data.get("yolo") or {}
|
||||||
frame = str(yolo.get("camera_frame") or "").strip()
|
frame = str(yolo.get("camera_frame") or "").strip()
|
||||||
@@ -177,7 +177,7 @@ def camera_frames(data: dict) -> tuple[str, str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def flatten(data: dict) -> dict:
|
def flatten(data: dict) -> dict:
|
||||||
"""Flat SITE_* env map for bash scripts."""
|
"""供 bash 脚本使用的扁平 ``SITE_*`` 环境变量映射。"""
|
||||||
data, robot = apply_active_arm(data)
|
data, robot = apply_active_arm(data)
|
||||||
paths = data.get("paths") or {}
|
paths = data.get("paths") or {}
|
||||||
cam = data.get("camera") or {}
|
cam = data.get("camera") or {}
|
||||||
@@ -341,7 +341,7 @@ def flatten(data: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def gripper_overrides(data: dict) -> dict:
|
def gripper_overrides(data: dict) -> dict:
|
||||||
"""ROS parameter dict for dh_pgea_node (DH Modbus, not Flexiv gripper)."""
|
"""供 dh_pgea_node 使用的 ROS 参数字典(DH Modbus,不是 Flexiv 夹爪)。"""
|
||||||
data, _robot = apply_active_arm(data)
|
data, _robot = apply_active_arm(data)
|
||||||
g = data.get("gripper") or {}
|
g = data.get("gripper") or {}
|
||||||
return {
|
return {
|
||||||
@@ -362,7 +362,7 @@ def gripper_overrides(data: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def perception_overrides(data: dict) -> dict:
|
def perception_overrides(data: dict) -> dict:
|
||||||
"""ROS parameter dict for perception_node."""
|
"""供 perception_node 使用的 ROS 参数字典。"""
|
||||||
data, _robot = apply_active_arm(data)
|
data, _robot = apply_active_arm(data)
|
||||||
paths = data.get("paths") or {}
|
paths = data.get("paths") or {}
|
||||||
yolo = data.get("yolo") or {}
|
yolo = data.get("yolo") or {}
|
||||||
@@ -415,7 +415,7 @@ def perception_overrides(data: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def servo_overrides(data: dict) -> dict:
|
def servo_overrides(data: dict) -> dict:
|
||||||
"""ROS parameter dict for servo_node."""
|
"""供 servo_node 使用的 ROS 参数字典。"""
|
||||||
data, robot = apply_active_arm(data)
|
data, robot = apply_active_arm(data)
|
||||||
paths = data.get("paths") or {}
|
paths = data.get("paths") or {}
|
||||||
yolo = data.get("yolo") or {}
|
yolo = data.get("yolo") or {}
|
||||||
@@ -431,7 +431,7 @@ def servo_overrides(data: dict) -> dict:
|
|||||||
"arm_label": str(robot.get("label") or robot.get("arm") or ""),
|
"arm_label": str(robot.get("label") or robot.get("arm") or ""),
|
||||||
"z_ref_m": float(servo.get("z_ref_m") or yolo.get("z_ref_m") or 0.40),
|
"z_ref_m": float(servo.get("z_ref_m") or yolo.get("z_ref_m") or 0.40),
|
||||||
}
|
}
|
||||||
# Pass through remaining servo keys that match node params
|
# 透传其余与节点参数同名的 servo 键
|
||||||
keys = [
|
keys = [
|
||||||
"control_hz", "watchdog_timeout_sec", "lost_timeout_sec", "stale_hold_sec",
|
"control_hz", "watchdog_timeout_sec", "lost_timeout_sec", "stale_hold_sec",
|
||||||
"max_extrapolate_sec", "predict_enable", "predict_sec",
|
"max_extrapolate_sec", "predict_enable", "predict_sec",
|
||||||
@@ -460,7 +460,7 @@ def main() -> int:
|
|||||||
ap.add_argument("--json", action="store_true")
|
ap.add_argument("--json", action="store_true")
|
||||||
ap.add_argument("--print", action="store_true", dest="do_print")
|
ap.add_argument("--print", action="store_true", dest="do_print")
|
||||||
ap.add_argument("--config", default="")
|
ap.add_argument("--config", default="")
|
||||||
ap.add_argument("--sn", "--arm", dest="sn", default="", help="89|91|left|right|full SN")
|
ap.add_argument("--sn", "--arm", dest="sn", default="", help="89|91|left|right|完整 SN")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
if args.sn:
|
if args.sn:
|
||||||
os.environ["ROBOT_SN"] = str(args.sn)
|
os.environ["ROBOT_SN"] = str(args.sn)
|
||||||
@@ -471,35 +471,35 @@ def main() -> int:
|
|||||||
print(json.dumps({"site": data, "env": flat}, ensure_ascii=False, indent=2))
|
print(json.dumps({"site": data, "env": flat}, ensure_ascii=False, indent=2))
|
||||||
return 0
|
return 0
|
||||||
if args.do_print:
|
if args.do_print:
|
||||||
print(f"site config: {path}")
|
print(f"site 配置: {path}")
|
||||||
print(
|
print(
|
||||||
f" robot SN : {flat['SITE_ROBOT_SN']}"
|
f" 机械臂 SN : {flat['SITE_ROBOT_SN']}"
|
||||||
f"{(' (' + flat['SITE_ROBOT_LABEL'] + ')') if flat.get('SITE_ROBOT_LABEL') else ''}"
|
f"{(' (' + flat['SITE_ROBOT_LABEL'] + ')') if flat.get('SITE_ROBOT_LABEL') else ''}"
|
||||||
f" arm={flat.get('SITE_ROBOT_ARM') or '?'}"
|
f" arm={flat.get('SITE_ROBOT_ARM') or '?'}"
|
||||||
)
|
)
|
||||||
print(f" catalog : {flat.get('SITE_ROBOT_CATALOG') or '(none)'}")
|
print(f" 清单 : {flat.get('SITE_ROBOT_CATALOG') or '(无)'}")
|
||||||
print(f" robot IP : {flat['SITE_ROBOT_IP']} pc={flat['SITE_PC_IP']} iface={flat.get('SITE_NET_IFACE') or '?'}")
|
print(f" 机械臂 IP : {flat['SITE_ROBOT_IP']} pc={flat['SITE_PC_IP']} iface={flat.get('SITE_NET_IFACE') or '?'}")
|
||||||
print(f" frames : prefix={flat['SITE_JOINT_PREFIX']} ee={flat['SITE_EE_FRAME']}")
|
print(f" 坐标系 : prefix={flat['SITE_JOINT_PREFIX']} ee={flat['SITE_EE_FRAME']}")
|
||||||
print(f" tool name : {flat.get('SITE_ROBOT_TOOL_NAME') or '(keep current)'}")
|
print(f" 工具名 : {flat.get('SITE_ROBOT_TOOL_NAME') or '(保持当前)'}")
|
||||||
catalog_sns = [str(r.get("sn") or "") for r in list_robots(data)]
|
catalog_sns = [str(r.get("sn") or "") for r in list_robots(data)]
|
||||||
if catalog_sns and flat["SITE_ROBOT_SN"] not in catalog_sns:
|
if catalog_sns and flat["SITE_ROBOT_SN"] not in catalog_sns:
|
||||||
print(
|
print(
|
||||||
f" WARNING: robot.sn {flat['SITE_ROBOT_SN']!r} is not in robots[]",
|
f" 警告:robot.sn {flat['SITE_ROBOT_SN']!r} 不在 robots[] 里",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
print(f" yolo weights : {flat['SITE_YOLO_WEIGHTS']}")
|
print(f" YOLO 权重 : {flat['SITE_YOLO_WEIGHTS']}")
|
||||||
print(f" resolved : {flat['SITE_YOLO_WEIGHTS_RESOLVED']}")
|
print(f" 解析后 : {flat['SITE_YOLO_WEIGHTS_RESOLVED']}")
|
||||||
print(f" handeye : {flat['SITE_HANDEYE_FILE']}")
|
print(f" 手眼 : {flat['SITE_HANDEYE_FILE']}")
|
||||||
print(f" handeye wp : {flat['SITE_HANDEYE_WAYPOINTS']}")
|
print(f" 手眼路点 : {flat['SITE_HANDEYE_WAYPOINTS']}")
|
||||||
print(f" handeye dir : {flat['SITE_HANDEYE_WORKDIR']}")
|
print(f" 手眼目录 : {flat['SITE_HANDEYE_WORKDIR']}")
|
||||||
print(f" named poses : {flat.get('SITE_NAMED_POSES_FILE')}")
|
print(f" 命名位姿 : {flat.get('SITE_NAMED_POSES_FILE')}")
|
||||||
print(
|
print(
|
||||||
" board : "
|
" 标定板 : "
|
||||||
f"{flat['SITE_HANDEYE_BOARD_COLS']}x{flat['SITE_HANDEYE_BOARD_ROWS']} "
|
f"{flat['SITE_HANDEYE_BOARD_COLS']}x{flat['SITE_HANDEYE_BOARD_ROWS']} "
|
||||||
f"inner, square={flat['SITE_HANDEYE_SQUARE_M']} m"
|
f"内角点, square={flat['SITE_HANDEYE_SQUARE_M']} m"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f" camera : serial={flat['SITE_CAM_SERIAL'] or '(empty)'} "
|
f" 相机 : serial={flat['SITE_CAM_SERIAL'] or '(空)'} "
|
||||||
f"{flat['SITE_CAM_WIDTH']}x{flat['SITE_CAM_HEIGHT']}@{flat['SITE_CAM_FPS']} "
|
f"{flat['SITE_CAM_WIDTH']}x{flat['SITE_CAM_HEIGHT']}@{flat['SITE_CAM_FPS']} "
|
||||||
f"ae={flat.get('SITE_CAM_AUTO_EXPOSURE')} "
|
f"ae={flat.get('SITE_CAM_AUTO_EXPOSURE')} "
|
||||||
f"exp={flat.get('SITE_CAM_EXPOSURE_US')}us "
|
f"exp={flat.get('SITE_CAM_EXPOSURE_US')}us "
|
||||||
@@ -507,26 +507,26 @@ def main() -> int:
|
|||||||
f"ae_limit={flat.get('SITE_CAM_AE_LIMIT_US')}us emitter={flat.get('SITE_CAM_EMITTER')}"
|
f"ae_limit={flat.get('SITE_CAM_AE_LIMIT_US')}us emitter={flat.get('SITE_CAM_EMITTER')}"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f" catalog cam : leftD405={flat.get('SITE_D405_LEFT_SERIAL')} "
|
f" 清单相机 : leftD405={flat.get('SITE_D405_LEFT_SERIAL')} "
|
||||||
f"rightD405={flat.get('SITE_D405_RIGHT_SERIAL')} "
|
f"rightD405={flat.get('SITE_D405_RIGHT_SERIAL')} "
|
||||||
f"D435={flat.get('SITE_D435_SERIAL')}"
|
f"D435={flat.get('SITE_D435_SERIAL')}"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f" gripper : {flat.get('SITE_GRIP_MODEL')} port={flat.get('SITE_GRIP_PORT')}"
|
f" 夹爪 : {flat.get('SITE_GRIP_MODEL')} port={flat.get('SITE_GRIP_PORT')}"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f" hole_detect : backend={flat.get('SITE_HOLE_BACKEND')} "
|
f" hole_detect : backend={flat.get('SITE_HOLE_BACKEND')} "
|
||||||
f"weights={flat.get('SITE_HOLE_WEIGHTS')}"
|
f"weights={flat.get('SITE_HOLE_WEIGHTS')}"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f" tactile : backend={flat.get('SITE_TACTILE_BACKEND')} "
|
f" 触觉 : backend={flat.get('SITE_TACTILE_BACKEND')} "
|
||||||
f"L={flat.get('SITE_TACTILE_LEFT')} R={flat.get('SITE_TACTILE_RIGHT')} "
|
f"L={flat.get('SITE_TACTILE_LEFT')} R={flat.get('SITE_TACTILE_RIGHT')} "
|
||||||
f"cfg={flat.get('SITE_TACTILE_CONFIG')}"
|
f"cfg={flat.get('SITE_TACTILE_CONFIG')}"
|
||||||
)
|
)
|
||||||
print(f" go_home : pose={flat['SITE_GO_HOME_POSE']} speed={flat['SITE_GO_HOME_SPEED']}")
|
print(f" go_home : pose={flat['SITE_GO_HOME_POSE']} speed={flat['SITE_GO_HOME_SPEED']}")
|
||||||
he_path = Path(flat["SITE_HANDEYE_FILE"]).expanduser()
|
he_path = Path(flat["SITE_HANDEYE_FILE"]).expanduser()
|
||||||
if not he_path.is_file():
|
if not he_path.is_file():
|
||||||
print(f" WARNING: hand-eye file missing: {he_path}", file=sys.stderr)
|
print(f" 警告:手眼文件缺失: {he_path}", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
he_data = yaml.safe_load(he_path.read_text(encoding="utf-8")) or {}
|
he_data = yaml.safe_load(he_path.read_text(encoding="utf-8")) or {}
|
||||||
@@ -536,28 +536,28 @@ def main() -> int:
|
|||||||
he_data.get("uncalibrated") or "T_4x4" not in he_data
|
he_data.get("uncalibrated") or "T_4x4" not in he_data
|
||||||
):
|
):
|
||||||
print(
|
print(
|
||||||
f" WARNING: {he_path.name} is not calibrated for this arm "
|
f" 警告:{he_path.name} 没有针对该机械臂标定 "
|
||||||
f"(run_handeye.sh teach/calibrate --arm {flat.get('SITE_ROBOT_ARM') or '91'})",
|
f"(run_handeye.sh teach/calibrate --arm {flat.get('SITE_ROBOT_ARM') or '91'})",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
if "063991" in flat["SITE_ROBOT_SN"] and flat.get("SITE_CAM_SERIAL") == flat.get("SITE_D405_LEFT_SERIAL"):
|
if "063991" in flat["SITE_ROBOT_SN"] and flat.get("SITE_CAM_SERIAL") == flat.get("SITE_D405_LEFT_SERIAL"):
|
||||||
print(
|
print(
|
||||||
" WARNING: 91 is using the LEFT wrist D405 serial — check robots[].camera.serial",
|
" 警告:91 用的是左腕 D405 的序列号 — 请检查 robots[].camera.serial",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
if "RGI" in str(flat.get("SITE_GRIP_MODEL") or ""):
|
if "RGI" in str(flat.get("SITE_GRIP_MODEL") or ""):
|
||||||
print(
|
print(
|
||||||
" NOTE: 本臂是 RGI。CLI: bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli open|rotate",
|
" 提示:本臂是 RGI。CLI: bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli open|rotate",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
w = Path(flat["SITE_YOLO_WEIGHTS"]).expanduser()
|
w = Path(flat["SITE_YOLO_WEIGHTS"]).expanduser()
|
||||||
if not w.is_file():
|
if not w.is_file():
|
||||||
print(f" WARNING: weights file missing: {w}", file=sys.stderr)
|
print(f" 警告:权重文件缺失: {w}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
return 0
|
return 0
|
||||||
# bash exports
|
# bash 导出
|
||||||
for k, v in flat.items():
|
for k, v in flat.items():
|
||||||
# escape for eval
|
# 为 eval 转义
|
||||||
vv = str(v).replace("'", "'\"'\"'")
|
vv = str(v).replace("'", "'\"'\"'")
|
||||||
print(f"export {k}='{vv}'")
|
print(f"export {k}='{vv}'")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Load ~/ros2/config/site.yaml into SITE_* environment variables.
|
# 把 ~/ros2/config/site.yaml 加载成 `SITE_*` 环境变量。
|
||||||
# Usage (from other scripts):
|
# 用法(在其它脚本里):
|
||||||
# source "${ROOT}/scripts/lib/load_site_config.sh"
|
# source "${ROOT}/scripts/lib/load_site_config.sh"
|
||||||
|
|
||||||
_SITE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
_SITE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
@@ -13,7 +13,7 @@ else
|
|||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
eval "$(python3 "${_SITE_EXPORTER}" --config "${_SITE_FILE}")"
|
eval "$(python3 "${_SITE_EXPORTER}" --config "${_SITE_FILE}")"
|
||||||
export SITE_CONFIG="${_SITE_FILE}"
|
export SITE_CONFIG="${_SITE_FILE}"
|
||||||
# Resolve 89/91/left/right aliases to the full Flexiv SN for RDK.
|
# 把 89/91/left/right 别名解析成完整的 Flexiv SN,供 RDK 使用。
|
||||||
if [[ -n "${SITE_ROBOT_SN:-}" ]]; then
|
if [[ -n "${SITE_ROBOT_SN:-}" ]]; then
|
||||||
export ROBOT_SN="${SITE_ROBOT_SN}"
|
export ROBOT_SN="${SITE_ROBOT_SN}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Parse --sn / --arm from argv. Must be sourced BEFORE load_site_config.sh
|
# 从 argv 里解析 --sn / --arm。必须在 `load_site_config.sh` 之前 source,
|
||||||
# so ROBOT_SN is visible to export_site_env.py.
|
# 这样 `ROBOT_SN` 才能被 export_site_env.py 看到。
|
||||||
#
|
#
|
||||||
# source "${ROOT}/scripts/lib/parse_arm_args.sh"
|
# source "${ROOT}/scripts/lib/parse_arm_args.sh"
|
||||||
# parse_arm_args "$@"
|
# parse_arm_args "$@"
|
||||||
# set -- "${PARSE_ARM_REST[@]}"
|
# set -- "${PARSE_ARM_REST[@]}"
|
||||||
#
|
#
|
||||||
# Aliases (89|91|left|right) are resolved in export_site_env.py.
|
# 别名(89|91|left|right)在 export_site_env.py 里解析。
|
||||||
|
|
||||||
parse_arm_args() {
|
parse_arm_args() {
|
||||||
PARSE_ARM_REST=()
|
PARSE_ARM_REST=()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# List plugged RealSense cameras (serials used in site.yaml).
|
# 列出已插上的 RealSense 相机(序列号在 site.yaml 里使用)。
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import pyrealsense2 as rs
|
import pyrealsense2 as rs
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def format_positions_block(poses: list[list[float]], indent: str = " ") -> st
|
|||||||
|
|
||||||
def merge(yaml_path: Path, poses: list[list[float]]) -> None:
|
def merge(yaml_path: Path, poses: list[list[float]]) -> None:
|
||||||
text = yaml_path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n")
|
text = yaml_path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n")
|
||||||
# Detect indent of waypoint_positions (2 or 4 spaces typical)
|
# 探测 waypoint_positions 的缩进(通常 2 空格或 4 空格)
|
||||||
m_ind = re.search(r"^([ \t]*)waypoint_positions:\s*\[", text, re.MULTILINE)
|
m_ind = re.search(r"^([ \t]*)waypoint_positions:\s*\[", text, re.MULTILINE)
|
||||||
indent = m_ind.group(1) if m_ind else " "
|
indent = m_ind.group(1) if m_ind else " "
|
||||||
block = format_positions_block(poses, indent=indent)
|
block = format_positions_block(poses, indent=indent)
|
||||||
@@ -82,7 +82,7 @@ def merge(yaml_path: Path, poses: list[list[float]]) -> None:
|
|||||||
rf"{re.escape(indent)}waypoint_positions:\s*\[[\s\S]*?{re.escape(indent)}\]",
|
rf"{re.escape(indent)}waypoint_positions:\s*\[[\s\S]*?{re.escape(indent)}\]",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
# Also match a one-line empty list: waypoint_positions: []
|
# 也要匹配"单行空列表"的写法:waypoint_positions: []
|
||||||
one_line = re.compile(
|
one_line = re.compile(
|
||||||
rf"{re.escape(indent)}waypoint_positions:\s*\[\s*\]",
|
rf"{re.escape(indent)}waypoint_positions:\s*\[\s*\]",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
@@ -94,7 +94,7 @@ def merge(yaml_path: Path, poses: list[list[float]]) -> None:
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError("waypoint_positions block not found in YAML")
|
raise RuntimeError("waypoint_positions block not found in YAML")
|
||||||
|
|
||||||
# Keep num_poses in sync when present
|
# 顺手把 num_poses 同步成实际点数(如果原本就有这个键)
|
||||||
n = len(poses)
|
n = len(poses)
|
||||||
if re.search(r"^\s*num_poses:\s*\d+", new_text, re.MULTILINE):
|
if re.search(r"^\s*num_poses:\s*\d+", new_text, re.MULTILINE):
|
||||||
new_text = re.sub(
|
new_text = re.sub(
|
||||||
@@ -105,7 +105,7 @@ def merge(yaml_path: Path, poses: list[list[float]]) -> None:
|
|||||||
flags=re.MULTILINE,
|
flags=re.MULTILINE,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Insert num_poses just before waypoint_positions
|
# 原本没有 num_poses,就插在 waypoint_positions 前面
|
||||||
new_text = new_text.replace(
|
new_text = new_text.replace(
|
||||||
block,
|
block,
|
||||||
f"{indent}num_poses: {n}\n{block}",
|
f"{indent}num_poses: {n}\n{block}",
|
||||||
|
|||||||
+24
-24
@@ -105,13 +105,13 @@ class SoftDrag:
|
|||||||
robot.SetJointImpedance(g, soft)
|
robot.SetJointImpedance(g, soft)
|
||||||
log(f'SetJointImpedance {g} K={soft}')
|
log(f'SetJointImpedance {g} K={soft}')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f'SetJointImpedance group {g}: {exc}')
|
log(f'SetJointImpedance 组 {g}: {exc}')
|
||||||
else:
|
else:
|
||||||
n = max(7, len(self.latest_q))
|
n = max(7, len(self.latest_q))
|
||||||
try:
|
try:
|
||||||
robot.SetJointImpedance([35.0] * n, [0.7] * n)
|
robot.SetJointImpedance([35.0] * n, [0.7] * n)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f'SetJointImpedance flat: {exc}')
|
log(f'SetJointImpedance flat(不分组): {exc}')
|
||||||
|
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
self._thread = threading.Thread(target=self._loop, name='soft-drag', daemon=True)
|
self._thread = threading.Thread(target=self._loop, name='soft-drag', daemon=True)
|
||||||
@@ -144,9 +144,9 @@ class SoftDrag:
|
|||||||
n = len(q)
|
n = len(q)
|
||||||
robot.SendJointPosition(q, [0.0] * n, [1.5] * n, [2.0] * n)
|
robot.SendJointPosition(q, [0.0] * n, [1.5] * n, [2.0] * n)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f'soft loop: {exc}')
|
log(f'soft 循环: {exc}')
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
# ~20 Hz — keep lock released most of the time
|
# 约 20 Hz —— 大部分时间让锁保持释放状态
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
return
|
return
|
||||||
@@ -158,7 +158,7 @@ class SoftDrag:
|
|||||||
if th is not None:
|
if th is not None:
|
||||||
th.join(timeout=4.0)
|
th.join(timeout=4.0)
|
||||||
if th.is_alive():
|
if th.is_alive():
|
||||||
log('soft loop still alive after join (will idle anyway)')
|
log('join 之后 soft 循环仍然存活(无论如何都会切到 IDLE)')
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ def _ensure_operational(robot) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _idle_async(robot, lock: threading.Lock, flexivrdk, timeout: float = 4.0) -> bool:
|
def _idle_async(robot, lock: threading.Lock, flexivrdk, timeout: float = 4.0) -> bool:
|
||||||
"""Switch to IDLE without blocking the stdin loop forever."""
|
"""切到 IDLE,同时避免把 stdin 循环永久阻塞住。"""
|
||||||
done = threading.Event()
|
done = threading.Event()
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
@@ -193,7 +193,7 @@ def _idle_async(robot, lock: threading.Lock, flexivrdk, timeout: float = 4.0) ->
|
|||||||
threading.Thread(target=run, name='idle', daemon=True).start()
|
threading.Thread(target=run, name='idle', daemon=True).start()
|
||||||
ok = done.wait(timeout)
|
ok = done.wait(timeout)
|
||||||
if not ok:
|
if not ok:
|
||||||
log('IDLE/Stop timed out — stdin loop continues')
|
log('IDLE/Stop 超时 —— stdin 循环继续运行')
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
msg = json.loads(line)
|
msg = json.loads(line)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
reply({'ok': False, 'error': f'bad json: {exc}'})
|
reply({'ok': False, 'error': f'JSON 解析失败: {exc}'})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cmd = str(msg.get('cmd', '')).strip()
|
cmd = str(msg.get('cmd', '')).strip()
|
||||||
@@ -233,17 +233,17 @@ def main() -> int:
|
|||||||
for args in attempts:
|
for args in attempts:
|
||||||
try:
|
try:
|
||||||
robot = flexivrdk.Robot(*args)
|
robot = flexivrdk.Robot(*args)
|
||||||
log(f'connected {sn} args={args!r}')
|
log(f'已连接 {sn} args={args!r}')
|
||||||
last_err = None
|
last_err = None
|
||||||
break
|
break
|
||||||
except TypeError:
|
except TypeError:
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = exc
|
last_err = exc
|
||||||
log(f'connect {args!r}: {exc}')
|
log(f'连接 {args!r}: {exc}')
|
||||||
robot = None
|
robot = None
|
||||||
if robot is None:
|
if robot is None:
|
||||||
reply({'ok': False, 'error': str(last_err or 'connect failed')})
|
reply({'ok': False, 'error': str(last_err or '连接失败')})
|
||||||
continue
|
continue
|
||||||
err = _ensure_operational(robot)
|
err = _ensure_operational(robot)
|
||||||
if err:
|
if err:
|
||||||
@@ -256,7 +256,7 @@ def main() -> int:
|
|||||||
|
|
||||||
elif cmd == 'drag_on':
|
elif cmd == 'drag_on':
|
||||||
if robot is None:
|
if robot is None:
|
||||||
reply({'ok': False, 'error': 'not connected'})
|
reply({'ok': False, 'error': '尚未连接'})
|
||||||
continue
|
continue
|
||||||
err = _ensure_operational(robot)
|
err = _ensure_operational(robot)
|
||||||
if err:
|
if err:
|
||||||
@@ -274,7 +274,7 @@ def main() -> int:
|
|||||||
reply({'ok': True, 'drag': True, 'mode': 'floating', 'primitive': name})
|
reply({'ok': True, 'drag': True, 'mode': 'floating', 'primitive': name})
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f'Floating failed, fallback soft: {exc}')
|
log(f'Floating 失败,退回 soft 模式: {exc}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if soft is not None:
|
if soft is not None:
|
||||||
@@ -284,18 +284,18 @@ def main() -> int:
|
|||||||
if soft.latest_q:
|
if soft.latest_q:
|
||||||
cached_q = list(soft.latest_q)
|
cached_q = list(soft.latest_q)
|
||||||
drag_on = True
|
drag_on = True
|
||||||
log('soft drag ON')
|
log('软拖拽已开启')
|
||||||
reply({
|
reply({
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'drag': True,
|
'drag': True,
|
||||||
'mode': 'soft',
|
'mode': 'soft',
|
||||||
'hint': 'Push the arm gently; low joint impedance. Enter=photo (s=hold).',
|
'hint': '轻推机械臂即可,关节阻抗已调低。Enter=拍照(s=保持)。',
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
reply({'ok': False, 'error': f'soft drag failed: {exc}'})
|
reply({'ok': False, 'error': f'软拖拽失败: {exc}'})
|
||||||
|
|
||||||
elif cmd == 'drag_off':
|
elif cmd == 'drag_off':
|
||||||
log('drag_off requested')
|
log('收到 drag_off 请求')
|
||||||
if soft is not None:
|
if soft is not None:
|
||||||
if soft.latest_q:
|
if soft.latest_q:
|
||||||
cached_q = list(soft.latest_q)
|
cached_q = list(soft.latest_q)
|
||||||
@@ -304,7 +304,7 @@ def main() -> int:
|
|||||||
if robot is not None:
|
if robot is not None:
|
||||||
_idle_async(robot, rlock, flexivrdk, timeout=4.0)
|
_idle_async(robot, rlock, flexivrdk, timeout=4.0)
|
||||||
drag_on = False
|
drag_on = False
|
||||||
log('drag_off done')
|
log('drag_off 已完成')
|
||||||
reply({'ok': True, 'drag': False, 'q': cached_q})
|
reply({'ok': True, 'drag': False, 'q': cached_q})
|
||||||
|
|
||||||
elif cmd == 'joints':
|
elif cmd == 'joints':
|
||||||
@@ -314,14 +314,14 @@ def main() -> int:
|
|||||||
elif len(cached_q) >= 7:
|
elif len(cached_q) >= 7:
|
||||||
q = list(cached_q)
|
q = list(cached_q)
|
||||||
if q is None or len(q) < 7:
|
if q is None or len(q) < 7:
|
||||||
reply({'ok': False, 'error': 'no joint cache'})
|
reply({'ok': False, 'error': '没有关节缓存'})
|
||||||
continue
|
continue
|
||||||
cached_q = q
|
cached_q = q
|
||||||
reply({'ok': True, 'q': q, 'drag': drag_on})
|
reply({'ok': True, 'q': q, 'drag': drag_on})
|
||||||
|
|
||||||
elif cmd == 'enable':
|
elif cmd == 'enable':
|
||||||
if robot is None:
|
if robot is None:
|
||||||
reply({'ok': False, 'error': '尚未 connect'})
|
reply({'ok': False, 'error': '尚未连接'})
|
||||||
continue
|
continue
|
||||||
err = ensure_operational(robot)
|
err = ensure_operational(robot)
|
||||||
reply({
|
reply({
|
||||||
@@ -332,7 +332,7 @@ def main() -> int:
|
|||||||
|
|
||||||
elif cmd == 'clear_fault':
|
elif cmd == 'clear_fault':
|
||||||
if robot is None:
|
if robot is None:
|
||||||
reply({'ok': False, 'error': '尚未 connect'})
|
reply({'ok': False, 'error': '尚未连接'})
|
||||||
continue
|
continue
|
||||||
err = rdk_clear_fault(robot)
|
err = rdk_clear_fault(robot)
|
||||||
if err:
|
if err:
|
||||||
@@ -349,7 +349,7 @@ def main() -> int:
|
|||||||
|
|
||||||
elif cmd == 'switch_tool':
|
elif cmd == 'switch_tool':
|
||||||
if robot is None:
|
if robot is None:
|
||||||
reply({'ok': False, 'error': '尚未 connect'})
|
reply({'ok': False, 'error': '尚未连接'})
|
||||||
continue
|
continue
|
||||||
want = str(msg.get('name', '')).strip()
|
want = str(msg.get('name', '')).strip()
|
||||||
|
|
||||||
@@ -428,9 +428,9 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
else:
|
else:
|
||||||
reply({'ok': False, 'error': f'unknown cmd {cmd}'})
|
reply({'ok': False, 'error': f'未知命令 {cmd}'})
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log(f'cmd={cmd} error: {exc}')
|
log(f'cmd={cmd} 出错: {exc}')
|
||||||
reply({'ok': False, 'error': str(exc)})
|
reply({'ok': False, 'error': str(exc)})
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+27
-27
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Control Studio device GripperDahuan / GripperDahuanModbus via Flexiv RDK.
|
"""通过 Flexiv RDK 控制 Studio 设备 GripperDahuan / GripperDahuanModbus。
|
||||||
|
|
||||||
Must run under RDK Python (rdk_py310). Uses a *lite* RDK instance so it can
|
必须在 RDK 的 Python(rdk_py310)下运行。使用 *lite*(轻量)RDK 实例,
|
||||||
share the robot with run_real.sh (ros2_control holds the normal RDK).
|
以便和 run_real.sh 共用机器人(常规 RDK 被 ros2_control 占用)。
|
||||||
|
|
||||||
The controller talks RS485 itself (its own /dev/ttyUSB0), not the IPC serial.
|
控制器自己走 RS485(用自带的 /dev/ttyUSB0),不走工控机的串口。
|
||||||
|
|
||||||
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py --list
|
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py --list
|
||||||
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py status
|
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_gripper.py status
|
||||||
@@ -65,28 +65,28 @@ def _move(grip, params, width: float, vel: float, force: float) -> None:
|
|||||||
vmax = float(params.max_vel)
|
vmax = float(params.max_vel)
|
||||||
if vmax <= 0:
|
if vmax <= 0:
|
||||||
vmax = 0.05
|
vmax = 0.05
|
||||||
# Move velocity cannot be 0 (RDK).
|
# 移动速度不能为 0(RDK 限制)。
|
||||||
vel = _clamp(vel, max(vmin, 1e-4), vmax)
|
vel = _clamp(vel, max(vmin, 1e-4), vmax)
|
||||||
force = _clamp(force, float(params.min_force), float(params.max_force))
|
force = _clamp(force, float(params.min_force), float(params.max_force))
|
||||||
width = _clamp(width, float(params.min_width), float(params.max_width))
|
width = _clamp(width, float(params.min_width), float(params.max_width))
|
||||||
print(f"Move width={width:.4f} m vel={vel:.4f} m/s force={force:.1f} N")
|
print(f"移动 width={width:.4f} m vel={vel:.4f} m/s force={force:.1f} N")
|
||||||
grip.Move(width, vel, force)
|
grip.Move(width, vel, force)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description="Flexiv RDK gripper (GripperDahuanModbus)")
|
ap = argparse.ArgumentParser(description="Flexiv RDK 夹爪(GripperDahuanModbus)")
|
||||||
ap.add_argument("--sn", default="")
|
ap.add_argument("--sn", default="")
|
||||||
ap.add_argument("--iface", default="")
|
ap.add_argument("--iface", default="")
|
||||||
ap.add_argument("--device", default="", help="Studio Device name")
|
ap.add_argument("--device", default="", help="Studio 设备名")
|
||||||
ap.add_argument("--force-n", type=float, default=0.0, help="force limit [N]; 0=mid")
|
ap.add_argument("--force-n", type=float, default=0.0, help="力上限 [N];0=取中值")
|
||||||
ap.add_argument("--vel", type=float, default=0.0, help="speed [m/s]; 0=half max")
|
ap.add_argument("--vel", type=float, default=0.0, help="速度 [m/s];0=最大速度的一半")
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"cmd",
|
"cmd",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default="status",
|
default="status",
|
||||||
choices=["list", "status", "init", "open", "close", "pos", "enable"],
|
choices=["list", "status", "init", "open", "close", "pos", "enable"],
|
||||||
)
|
)
|
||||||
ap.add_argument("value", nargs="?", default="", help="pos: meters or 0–1000")
|
ap.add_argument("value", nargs="?", default="", help="pos: 米,或 0–1000")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
sys.path.insert(0, str(ROOT / "scripts" / "lib"))
|
sys.path.insert(0, str(ROOT / "scripts" / "lib"))
|
||||||
@@ -99,18 +99,18 @@ def main() -> int:
|
|||||||
iface = (args.iface or robot_cfg.get("iface") or "").strip()
|
iface = (args.iface or robot_cfg.get("iface") or "").strip()
|
||||||
device = (args.device or g.get("device_name") or "GripperDahuanModbus").strip()
|
device = (args.device or g.get("device_name") or "GripperDahuanModbus").strip()
|
||||||
if not sn:
|
if not sn:
|
||||||
print("ERROR: robot SN missing", file=sys.stderr)
|
print("错误:没拿到机械臂序列号(SN)", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
import flexivrdk
|
import flexivrdk
|
||||||
|
|
||||||
print(f"=== RDK lite gripper SN={sn} device={device} iface={iface or 'auto'} ===")
|
print(f"=== RDK lite 夹爪 SN={sn} device={device} iface={iface or 'auto'} ===")
|
||||||
robot = connect_lite(sn, iface)
|
robot = connect_lite(sn, iface)
|
||||||
dev = flexivrdk.Device(robot)
|
dev = flexivrdk.Device(robot)
|
||||||
names = list(dev.list() or [])
|
names = list(dev.list() or [])
|
||||||
|
|
||||||
if args.cmd == "list":
|
if args.cmd == "list":
|
||||||
print("Studio devices:")
|
print("Studio 设备列表:")
|
||||||
for n in names:
|
for n in names:
|
||||||
try:
|
try:
|
||||||
en = dev.enabled(n)
|
en = dev.enabled(n)
|
||||||
@@ -123,7 +123,7 @@ def main() -> int:
|
|||||||
|
|
||||||
if device not in names:
|
if device not in names:
|
||||||
print(
|
print(
|
||||||
f"ERROR: device {device!r} not in Studio pool.\n available: {', '.join(names)}",
|
f"错误:设备 {device!r} 不在 Studio 设备池里。\n 可用: {', '.join(names)}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
@@ -133,7 +133,7 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
if not dev.connected(device):
|
if not dev.connected(device):
|
||||||
print(
|
print(
|
||||||
"WARN: connected=False — 控制器自己的 /dev/ttyUSB0 没接到爪。\n"
|
"警告:connected=False —— 控制器自己的 /dev/ttyUSB0 没接到夹爪。\n"
|
||||||
" USB–RS485 要插 Flexiv 机箱 USB(不是工控机),A/B + 24V 到 PGEA。\n"
|
" USB–RS485 要插 Flexiv 机箱 USB(不是工控机),A/B + 24V 到 PGEA。\n"
|
||||||
" 或用法兰工具口 485(腕部白绿+/绿-)。"
|
" 或用法兰工具口 485(腕部白绿+/绿-)。"
|
||||||
)
|
)
|
||||||
@@ -142,14 +142,14 @@ def main() -> int:
|
|||||||
if args.cmd in ("enable", "status", "init", "open", "close", "pos"):
|
if args.cmd in ("enable", "status", "init", "open", "close", "pos"):
|
||||||
try:
|
try:
|
||||||
grip.Enable(device)
|
grip.Enable(device)
|
||||||
print(f"Enable [{device}] OK")
|
print(f"使能 [{device}] 成功")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"ERROR: Enable failed: {exc}", file=sys.stderr)
|
print(f"错误:使能失败: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
params = grip.params()
|
params = grip.params()
|
||||||
print(" params:", _fmt_params(params))
|
print(" 参数:", _fmt_params(params))
|
||||||
print(" states:", _fmt_states(grip.states()))
|
print(" 状态:", _fmt_states(grip.states()))
|
||||||
|
|
||||||
force = float(args.force_n or g.get("force_n") or 0.0)
|
force = float(args.force_n or g.get("force_n") or 0.0)
|
||||||
if force <= 0:
|
if force <= 0:
|
||||||
@@ -164,27 +164,27 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.cmd == "init":
|
if args.cmd == "init":
|
||||||
print("Init (fingers home, ~10 s) …")
|
print("初始化(手指回零,约 10 秒)…")
|
||||||
grip.Init()
|
grip.Init()
|
||||||
time.sleep(2.0)
|
time.sleep(2.0)
|
||||||
print(" states:", _fmt_states(grip.states()))
|
print(" 状态:", _fmt_states(grip.states()))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.cmd == "open":
|
if args.cmd == "open":
|
||||||
_move(grip, params, float(params.max_width), vel, force)
|
_move(grip, params, float(params.max_width), vel, force)
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
print(" states:", _fmt_states(grip.states()))
|
print(" 状态:", _fmt_states(grip.states()))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.cmd == "close":
|
if args.cmd == "close":
|
||||||
_move(grip, params, float(params.min_width), vel, force)
|
_move(grip, params, float(params.min_width), vel, force)
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
print(" states:", _fmt_states(grip.states()))
|
print(" 状态:", _fmt_states(grip.states()))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.cmd == "pos":
|
if args.cmd == "pos":
|
||||||
if args.value == "":
|
if args.value == "":
|
||||||
print("usage: pos <meters|0-1000>", file=sys.stderr)
|
print("用法: pos <米|0-1000>", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
raw = float(args.value)
|
raw = float(args.value)
|
||||||
if raw > 1.5:
|
if raw > 1.5:
|
||||||
@@ -194,7 +194,7 @@ def main() -> int:
|
|||||||
width = raw
|
width = raw
|
||||||
_move(grip, params, width, vel, force)
|
_move(grip, params, width, vel, force)
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
print(" states:", _fmt_states(grip.states()))
|
print(" 状态:", _fmt_states(grip.states()))
|
||||||
return 0
|
return 0
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -203,5 +203,5 @@ if __name__ == "__main__":
|
|||||||
try:
|
try:
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|||||||
+38
-38
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Switch the Flexiv active tool (TCP + payload) to a Studio-configured name.
|
"""把 Flexiv 当前工具(TCP + 负载)切换成 Studio 里配置好的名字。
|
||||||
|
|
||||||
Must run under RDK Python (rdk_py310). Robot must be IDLE; stop run_real.sh first
|
必须在 RDK 的 Python(rdk_py310)下运行。机器人必须处于 IDLE;如果
|
||||||
if ros2_control already owns the RDK connection.
|
ros2_control 已经占住了 RDK 连接,请先停掉 run_real.sh。
|
||||||
|
|
||||||
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_switch_tool.py --list
|
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_switch_tool.py --list
|
||||||
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_switch_tool.py --name Flange
|
/home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_switch_tool.py --name Flange
|
||||||
@@ -17,7 +17,7 @@ import time
|
|||||||
|
|
||||||
|
|
||||||
def _single_arm_groups(robot):
|
def _single_arm_groups(robot):
|
||||||
"""RDK 1.9 Tool API is single-arm (no JointGroup). Older builds use groups."""
|
"""RDK 1.9 的 Tool API 是单臂的(没有 JointGroup)。老版本用 groups。"""
|
||||||
info = robot.info()
|
info = robot.info()
|
||||||
groups = getattr(info, "single_arm_groups", None) or {}
|
groups = getattr(info, "single_arm_groups", None) or {}
|
||||||
if isinstance(groups, dict):
|
if isinstance(groups, dict):
|
||||||
@@ -33,7 +33,7 @@ def _single_arm_groups(robot):
|
|||||||
return [flexivrdk.JointGroup.ARM_1]
|
return [flexivrdk.JointGroup.ARM_1]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return [None] # sentinel: call Tool.name()/Switch() without a group
|
return [None] # 哨兵:调用 Tool.name()/Switch() 时不带 group
|
||||||
|
|
||||||
|
|
||||||
def _tool_name(tool, group):
|
def _tool_name(tool, group):
|
||||||
@@ -57,7 +57,7 @@ def _tool_switch(tool, group, target: str) -> None:
|
|||||||
|
|
||||||
def _group_name(group) -> str:
|
def _group_name(group) -> str:
|
||||||
if group is None:
|
if group is None:
|
||||||
return "arm"
|
return "单臂"
|
||||||
try:
|
try:
|
||||||
import flexivrdk
|
import flexivrdk
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ def _ensure_idle(robot, mode) -> None:
|
|||||||
time.sleep(0.25)
|
time.sleep(0.25)
|
||||||
if not robot.operational():
|
if not robot.operational():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"robot not operational — AUTO Remote + Enable, release E-stop"
|
"机器人未处于 operational 状态 —— 请在示教器切 AUTO Remote 并使能,松开急停"
|
||||||
)
|
)
|
||||||
robot.SwitchMode(mode.IDLE)
|
robot.SwitchMode(mode.IDLE)
|
||||||
|
|
||||||
@@ -108,13 +108,13 @@ def _resolve_name(tool, want: str) -> str:
|
|||||||
if hit:
|
if hit:
|
||||||
return hit
|
return hit
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"tool %r not found in Studio pool.\n available: %s"
|
"工具 %r 不在 Studio 工具池里。\n 可选: %s"
|
||||||
% (want, ", ".join(names) or "(empty)")
|
% (want, ", ".join(names) or "(空)")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _iface_ipv4s(name: str) -> list:
|
def _iface_ipv4s(name: str) -> list:
|
||||||
"""FastDDS whitelist wants IPv4 addresses, not NIC names like enp4s0."""
|
"""FastDDS 白名单要的是 IPv4 地址,而不是 enp4s0 这类网卡名。"""
|
||||||
import socket
|
import socket
|
||||||
import fcntl
|
import fcntl
|
||||||
import struct
|
import struct
|
||||||
@@ -147,7 +147,7 @@ def _iface_ipv4s(name: str) -> list:
|
|||||||
addrs.append(cidr.split("/")[0])
|
addrs.append(cidr.split("/")[0])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# unique, keep order
|
# 去重,保持原有顺序
|
||||||
seen = set()
|
seen = set()
|
||||||
uniq = []
|
uniq = []
|
||||||
for a in addrs:
|
for a in addrs:
|
||||||
@@ -170,7 +170,7 @@ def _rdk_whitelist(iface: str) -> list[list[str]]:
|
|||||||
robot_lan = [a for a in _iface_ipv4s(iface) if a.startswith("192.168.2.")]
|
robot_lan = [a for a in _iface_ipv4s(iface) if a.startswith("192.168.2.")]
|
||||||
if robot_lan:
|
if robot_lan:
|
||||||
lists.append(robot_lan)
|
lists.append(robot_lan)
|
||||||
# unique
|
# 去重
|
||||||
seen: set[tuple[str, ...]] = set()
|
seen: set[tuple[str, ...]] = set()
|
||||||
out: list[list[str]] = []
|
out: list[list[str]] = []
|
||||||
for item in lists:
|
for item in lists:
|
||||||
@@ -185,7 +185,7 @@ def connect_rdk(sn: str, iface: str = ""):
|
|||||||
import flexivrdk
|
import flexivrdk
|
||||||
|
|
||||||
for listen in _rdk_whitelist(iface):
|
for listen in _rdk_whitelist(iface):
|
||||||
print("RDK whitelist:", ", ".join(listen))
|
print("RDK 白名单:", ", ".join(listen))
|
||||||
for args in ((sn, listen), (sn, listen, True), (sn, listen, True, False)):
|
for args in ((sn, listen), (sn, listen, True), (sn, listen, True, False)):
|
||||||
try:
|
try:
|
||||||
return flexivrdk.Robot(*args)
|
return flexivrdk.Robot(*args)
|
||||||
@@ -194,51 +194,51 @@ def connect_rdk(sn: str, iface: str = ""):
|
|||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
msg = str(exc)
|
msg = str(exc)
|
||||||
if "whitelist" in msg.lower() or "filtered out" in msg.lower():
|
if "whitelist" in msg.lower() or "filtered out" in msg.lower():
|
||||||
print(" whitelist rejected, try next")
|
print(" 白名单被拒,试下一个")
|
||||||
break
|
break
|
||||||
if "Failed to connect" in msg or "failed to connect" in msg.lower():
|
if "Failed to connect" in msg or "failed to connect" in msg.lower():
|
||||||
print(" search timeout with this whitelist")
|
print(" 用这个白名单搜索超时")
|
||||||
break
|
break
|
||||||
raise
|
raise
|
||||||
print("retry without iface filter")
|
print("不带 iface 过滤再试一次")
|
||||||
return flexivrdk.Robot(sn)
|
return flexivrdk.Robot(sn)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description="Switch Flexiv Studio tool / TCP")
|
ap = argparse.ArgumentParser(description="切换 Flexiv Studio 工具 / TCP")
|
||||||
ap.add_argument("--sn", default="", help="robot serial (default: site.yaml)")
|
ap.add_argument("--sn", default="", help="机械臂序列号(默认取 site.yaml)")
|
||||||
ap.add_argument("--iface", default="", help="NIC whitelist for RDK (e.g. enp4s0)")
|
ap.add_argument("--iface", default="", help="RDK 用的网卡白名单(如 enp4s0)")
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--name",
|
"--name",
|
||||||
default="",
|
default="",
|
||||||
help="Studio tool name (Flange = no tool). Empty = site.yaml robot.tool_name",
|
help="Studio 工具名(Flange = 不装工具)。留空 = 用 site.yaml 的 robot.tool_name",
|
||||||
)
|
)
|
||||||
ap.add_argument("--list", action="store_true", help="list tools and exit")
|
ap.add_argument("--list", action="store_true", help="列出工具后退出")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
sn = (args.sn or "").strip()
|
sn = (args.sn or "").strip()
|
||||||
want = (args.name or "").strip()
|
want = (args.name or "").strip()
|
||||||
if not sn:
|
if not sn:
|
||||||
print("ERROR: --sn is required (run via run_real.sh / rdk_switch_tool.sh)", file=sys.stderr)
|
print("错误:必须提供 --sn(请通过 run_real.sh / rdk_switch_tool.sh 运行)", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
import flexivrdk
|
import flexivrdk
|
||||||
|
|
||||||
mode = flexivrdk.Mode
|
mode = flexivrdk.Mode
|
||||||
iface = (args.iface or "").strip()
|
iface = (args.iface or "").strip()
|
||||||
print(f"=== RDK tool SN={sn} iface={iface or 'auto'} ===")
|
print(f"=== RDK 工具 SN={sn} iface={iface or 'auto'} ===")
|
||||||
robot = None
|
robot = None
|
||||||
try:
|
try:
|
||||||
robot = connect_rdk(sn, iface)
|
robot = connect_rdk(sn, iface)
|
||||||
if robot.fault():
|
if robot.fault():
|
||||||
print("fault present, ClearFault ...")
|
print("检测到故障,正在 ClearFault ...")
|
||||||
if not robot.ClearFault():
|
if not robot.ClearFault():
|
||||||
print("ERROR: fault cannot be cleared", file=sys.stderr)
|
print("错误:故障无法清除", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
tool = flexivrdk.Tool(robot)
|
tool = flexivrdk.Tool(robot)
|
||||||
names = list(tool.list() or [])
|
names = list(tool.list() or [])
|
||||||
groups = _single_arm_groups(robot)
|
groups = _single_arm_groups(robot)
|
||||||
print("Studio tools:", ", ".join(names) or "(none)")
|
print("Studio 工具:", ", ".join(names) or "(无)")
|
||||||
for g in groups:
|
for g in groups:
|
||||||
cur = _tool_name(tool, g)
|
cur = _tool_name(tool, g)
|
||||||
extra = ""
|
extra = ""
|
||||||
@@ -246,23 +246,23 @@ def main() -> int:
|
|||||||
extra = " " + _fmt_params(_tool_params(tool, g))
|
extra = " " + _fmt_params(_tool_params(tool, g))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
print(f" active [{_group_name(g)}]: {cur}{extra}")
|
print(f" 当前 [{_group_name(g)}]: {cur}{extra}")
|
||||||
|
|
||||||
if args.list:
|
if args.list:
|
||||||
return 0
|
return 0
|
||||||
if not want:
|
if not want:
|
||||||
print("tool_name empty — keep current (set robot.tool_name in site.yaml to switch)")
|
print("tool_name 为空 —— 保持当前工具(要切换请在 site.yaml 里设置 robot.tool_name)")
|
||||||
print("RDK OK")
|
print("RDK 正常")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
target = _resolve_name(tool, want)
|
target = _resolve_name(tool, want)
|
||||||
already = all(_tool_name(tool, g) == target for g in groups)
|
already = all(_tool_name(tool, g) == target for g in groups)
|
||||||
if already:
|
if already:
|
||||||
print(f"already on [{target}]")
|
print(f"已经处于 [{target}]")
|
||||||
print("RDK OK")
|
print("RDK 正常")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"switching → [{target}] (requires IDLE)")
|
print(f"正在切换到 → [{target}](需要 IDLE)")
|
||||||
_ensure_idle(robot, mode)
|
_ensure_idle(robot, mode)
|
||||||
for g in groups:
|
for g in groups:
|
||||||
_tool_switch(tool, g, target)
|
_tool_switch(tool, g, target)
|
||||||
@@ -273,16 +273,16 @@ def main() -> int:
|
|||||||
extra = " " + _fmt_params(_tool_params(tool, g))
|
extra = " " + _fmt_params(_tool_params(tool, g))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
print(f" now [{_group_name(g)}]: {cur}{extra}")
|
print(f" 现在 [{_group_name(g)}]: {cur}{extra}")
|
||||||
if cur != target:
|
if cur != target:
|
||||||
print(f"ERROR: switch failed, still {cur!r}", file=sys.stderr)
|
print(f"错误:切换失败,当前仍是 {cur!r}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
print("RDK OK")
|
print("RDK 正常")
|
||||||
return 0
|
return 0
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
print(" Stop run_real.sh first (RDK is exclusive).", file=sys.stderr)
|
print(" 请先停掉 run_real.sh(RDK 是独占的)。", file=sys.stderr)
|
||||||
print(" Pendant: AUTO Remote + Enable Ethernet/RDK.", file=sys.stderr)
|
print(" 示教器:切 AUTO Remote,并使能 Ethernet/RDK。", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
robot = None
|
robot = None
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Switch / list Flexiv Studio tools via RDK (reads name from site.yaml).
|
# 通过 RDK 切换 / 列出 Flexiv Studio 工具(工具名从 site.yaml 读取)。
|
||||||
#
|
#
|
||||||
# bash ~/ros2/scripts/rdk_switch_tool.sh # switch to robot.tool_name
|
# bash ~/ros2/scripts/rdk_switch_tool.sh # 切换到 robot.tool_name
|
||||||
# bash ~/ros2/scripts/rdk_switch_tool.sh --list # print Studio tool pool
|
# bash ~/ros2/scripts/rdk_switch_tool.sh --list # 打印 Studio 工具池
|
||||||
# bash ~/ros2/scripts/rdk_switch_tool.sh --name Flange
|
# bash ~/ros2/scripts/rdk_switch_tool.sh --name Flange
|
||||||
#
|
#
|
||||||
# Stop run_real.sh first — RDK allows only one client.
|
# 先停掉 `run_real.sh` —— RDK 只允许一个客户端。
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Terminal: D405 via official pyrealsense2 SDK — color + depth + stereo IR topics.
|
# 终端方式运行 D405:走官方 pyrealsense2 SDK —— 发布彩色 + 深度 + 双目 IR 话题。
|
||||||
# With --preview the Qt window runs in a SEPARATE viewer process (realsense_view)
|
# 带 --preview 时,Qt 窗口跑在**独立的**查看器进程(`realsense_view`)里,
|
||||||
# so painting never slows the camera stream. Closing the window ≠ stop camera.
|
# 这样绘制界面永远不会拖慢相机数据流。关掉窗口 ≠ 停止相机。
|
||||||
# Do NOT start view_realsense_depth.sh at the same time (USB is exclusive).
|
# 不要同时启动 `view_realsense_depth.sh`(USB 是独占的)。
|
||||||
#
|
#
|
||||||
# Usage:
|
# 用法:
|
||||||
# bash ~/ros2/scripts/run_camera_d405.sh
|
# bash ~/ros2/scripts/run_camera_d405.sh
|
||||||
# bash ~/ros2/scripts/run_camera_d405.sh --preview
|
# bash ~/ros2/scripts/run_camera_d405.sh --preview
|
||||||
# bash ~/ros2/scripts/run_camera_d405.sh --arm 91 --preview
|
# bash ~/ros2/scripts/run_camera_d405.sh --arm 91 --preview
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PREVIEW=-1 # -1 = use site; 0/1 = force
|
PREVIEW=-1 # -1 = 沿用 site;0/1 = 强制
|
||||||
HIGH_FPS=0
|
HIGH_FPS=0
|
||||||
|
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
@@ -143,8 +143,8 @@ fi
|
|||||||
echo "=== next: bash ~/ros2/scripts/run_visual_servo_rt.sh --arm ${SITE_ROBOT_ARM:-89} [--control] ==="
|
echo "=== next: bash ~/ros2/scripts/run_visual_servo_rt.sh --arm ${SITE_ROBOT_ARM:-89} [--control] ==="
|
||||||
|
|
||||||
if [[ "${DO_PREVIEW}" == "1" ]]; then
|
if [[ "${DO_PREVIEW}" == "1" ]]; then
|
||||||
# Camera publishes only; the Qt viewer runs in its own process so painting
|
# 相机进程只负责发布;Qt 查看器跑在自己的进程里,所以界面绘制
|
||||||
# can never starve the capture loop (shared GIL cost ~70 ms/frame before).
|
# 永远不会把采集循环饿死(以前共用 GIL 时每帧要花约 70 ms)。
|
||||||
pkill -f 'lib/arm_application/realsense_view' 2>/dev/null || true
|
pkill -f 'lib/arm_application/realsense_view' 2>/dev/null || true
|
||||||
ros2 run arm_application realsense_color_pub "${CAM_ARGS[@]}" -p show_preview:=false &
|
ros2 run arm_application realsense_color_pub "${CAM_ARGS[@]}" -p show_preview:=false &
|
||||||
CAM_PID=$!
|
CAM_PID=$!
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Overhead D435 between the two arms (eye-to-hand). Does not open either D405.
|
# 两臂之间的顶置 D435(`eye-to-hand`,相机固定在外)。不会打开任何一个 D405。
|
||||||
#
|
#
|
||||||
# bash ~/ros2/scripts/run_camera_d435.sh --preview
|
# bash ~/ros2/scripts/run_camera_d435.sh --preview
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# View URDF only (joint_state_publisher_gui + RViz)
|
# 只看 URDF(joint_state_publisher_gui + RViz)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Both DH grippers (left PGEA + right RGI) as one ROS 2 node.
|
# 把两个 DH 夹爪(左 PGEA + 右 RGI)作为同一个 ROS 2 节点启动。
|
||||||
# bash ~/ros2/scripts/run_dual_gripper.sh
|
# bash ~/ros2/scripts/run_dual_gripper.sh
|
||||||
# Topics: /dual_gripper_driver/left|right/{command,status}
|
# 话题:/dual_gripper_driver/left|right/{command,status}
|
||||||
# Services: /dual_gripper_driver/left|right/execute (arm_msgs/srv/GripperExecute)
|
# 服务:/dual_gripper_driver/left|right/execute (arm_msgs/srv/GripperExecute)
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# DH grippers on the IPC (Modbus RTU). Not Flexiv Studio / RDK.
|
# 工控机上的 DH 夹爪(Modbus RTU)。不是 Flexiv Studio / RDK 那一套。
|
||||||
#
|
#
|
||||||
# Left 89 PGEA parallel /dev/ttyUSB0
|
# 左臂 89 PGEA 平行夹爪 /dev/ttyUSB0
|
||||||
# Right 91 RGI rotary /dev/ttyUSB1
|
# 右臂 91 RGI 旋转夹爪 /dev/ttyUSB1
|
||||||
#
|
#
|
||||||
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli probe
|
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli probe
|
||||||
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli init
|
# bash ~/ros2/scripts/run_gripper.sh --arm 89 --cli init
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli close
|
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli close
|
||||||
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli rotate 90
|
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli rotate 90
|
||||||
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli revolutions 2
|
# bash ~/ros2/scripts/run_gripper.sh --arm 91 --cli revolutions 2
|
||||||
# bash ~/ros2/scripts/run_gripper.sh --arm 89 # ROS node PGEA
|
# bash ~/ros2/scripts/run_gripper.sh --arm 89 # ROS 节点 PGEA
|
||||||
# bash ~/ros2/scripts/run_gripper.sh --arm 91 # ROS node RGI
|
# bash ~/ros2/scripts/run_gripper.sh --arm 91 # ROS 节点 RGI
|
||||||
# bash ~/ros2/scripts/run_dual_gripper.sh # both ROS nodes
|
# bash ~/ros2/scripts/run_dual_gripper.sh # 两个 ROS 节点都起
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Hand-eye calibration helpers.
|
# 手眼标定辅助脚本。
|
||||||
# Scripts: ~/ros2/scripts/run_handeye.sh
|
# 脚本:~/ros2/scripts/run_handeye.sh
|
||||||
# Config & results: ~/ros2/handeye/ ← 见 handeye/README.md
|
# 配置与结果:~/ros2/handeye/ ← 见 handeye/README.md
|
||||||
# poses/<臂号>_<模式>.yaml 采点(输入)
|
# poses/<臂号>_<模式>.yaml 采点(输入)
|
||||||
# results/<臂号>_<模式>.yaml 标定结果(输出)
|
# results/<臂号>_<模式>.yaml 标定结果(输出)
|
||||||
# calibrate.yaml 板尺寸 / 运动 / 稳定性参数
|
# calibrate.yaml 板尺寸 / 运动 / 稳定性参数
|
||||||
# tmp/ 临时产物
|
# tmp/ 临时产物
|
||||||
# Site overrides: ~/ros2/config/site.yaml → handeye:
|
# 站点覆盖项:~/ros2/config/site.yaml → handeye:
|
||||||
#
|
#
|
||||||
# Usage:
|
# 用法:
|
||||||
# bash ~/ros2/scripts/run_handeye.sh calibrate
|
# bash ~/ros2/scripts/run_handeye.sh calibrate
|
||||||
# bash ~/ros2/scripts/run_handeye.sh waypoints
|
# bash ~/ros2/scripts/run_handeye.sh waypoints
|
||||||
# bash ~/ros2/scripts/run_handeye.sh teach # RDK 软拖拽(推荐)
|
# bash ~/ros2/scripts/run_handeye.sh teach # RDK 软拖拽(推荐)
|
||||||
@@ -61,7 +61,7 @@ source "${ROOT}/scripts/env.sh"
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "${ROOT}/scripts/lib/load_site_config.sh"
|
source "${ROOT}/scripts/lib/load_site_config.sh"
|
||||||
|
|
||||||
# Prefer package source tree (editable); fall back to install share
|
# 优先用包的源码树(editable 模式);退而用 install 里的 share
|
||||||
APP_CFG="${ROOT}/src/arm_application/config"
|
APP_CFG="${ROOT}/src/arm_application/config"
|
||||||
if [[ ! -d "${APP_CFG}" ]]; then
|
if [[ ! -d "${APP_CFG}" ]]; then
|
||||||
APP_CFG="$(ros2 pkg prefix arm_application 2>/dev/null)/share/arm_application/config" || true
|
APP_CFG="$(ros2 pkg prefix arm_application 2>/dev/null)/share/arm_application/config" || true
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Hole detection on the current-arm wrist D405. Detect only — no insert motion.
|
# 用当前臂腕部 D405 做孔检测。只检测 —— 不做插入动作。
|
||||||
#
|
#
|
||||||
# bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
|
# bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview
|
||||||
# bash ~/ros2/scripts/run_hole_detect.sh --arm 89 --preview
|
# bash ~/ros2/scripts/run_hole_detect.sh --arm 89 --preview
|
||||||
#
|
#
|
||||||
# backend from site.yaml hole_detect.backend:
|
# 后端取自 site.yaml 的 hole_detect.backend:
|
||||||
# two_stage YOLO workpiece bbox + HoughCircles (camera_test hole_detect.py)
|
# two_stage YOLO 工件 bbox + HoughCircles(camera_test 的 hole_detect.py)
|
||||||
# yolo YOLO boxes treated as holes
|
# yolo 把 YOLO 框直接当作孔
|
||||||
# hough full-image HoughCircles
|
# hough 整图 HoughCircles
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Real Rizon4 bringup — sets RDK runtime libs + launches stack backend:=real
|
# 真机 Rizon4 启动入口 —— 设置 RDK 运行时库,然后以 backend:=real 启动整栈
|
||||||
# Note: no `set -u` — conda/RoboStack hooks reference unset vars (CONDA_BUILD, COLCON_TRACE)
|
# 注意:这里不加 `set -u` —— conda/RoboStack 的钩子会引用未绑定的变量(CONDA_BUILD、COLCON_TRACE)
|
||||||
#
|
#
|
||||||
# bash ~/ros2/scripts/run_real.sh
|
# bash ~/ros2/scripts/run_real.sh
|
||||||
# bash ~/ros2/scripts/run_real.sh --arm 91
|
# bash ~/ros2/scripts/run_real.sh --arm 91
|
||||||
@@ -31,10 +31,10 @@ source "${ROOT}/install/setup.bash"
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "${ROOT}/scripts/lib/load_site_config.sh"
|
source "${ROOT}/scripts/lib/load_site_config.sh"
|
||||||
|
|
||||||
# flexiv_hardware must load spdlog/RBDyn from flexiv_install (not conda fmt v11)
|
# `flexiv_hardware` 必须从 flexiv_install 加载 spdlog/RBDyn(不能用 conda 的 fmt v11)
|
||||||
export LD_LIBRARY_PATH="${FLEXIV_PREFIX}/lib:${LD_LIBRARY_PATH:-}"
|
export LD_LIBRARY_PATH="${FLEXIV_PREFIX}/lib:${LD_LIBRARY_PATH:-}"
|
||||||
|
|
||||||
# SITE_ROBOT_SN is the resolved Flexiv serial (89/91 aliases already mapped).
|
# `SITE_ROBOT_SN` 是解析后的 Flexiv 序列号(89/91 别名已经映射好了)。
|
||||||
ROBOT_SN="${SITE_ROBOT_SN:-${ROBOT_SN:-Rizon4-063989}}"
|
ROBOT_SN="${SITE_ROBOT_SN:-${ROBOT_SN:-Rizon4-063989}}"
|
||||||
ROBOT_IP="${ROBOT_IP:-${SITE_ROBOT_IP:-192.168.100.1}}"
|
ROBOT_IP="${ROBOT_IP:-${SITE_ROBOT_IP:-192.168.100.1}}"
|
||||||
NET_IFACE="${SITE_NET_IFACE:-}"
|
NET_IFACE="${SITE_NET_IFACE:-}"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Quick-start: mock arm + RViz
|
# 快速启动:mock 机械臂 + RViz
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Orisys visuo-tactile (finger cameras, not D405). Config: site.yaml tactile:
|
# Orisys 视触觉(指端相机,不是 D405)。配置:site.yaml 的 tactile:
|
||||||
#
|
#
|
||||||
# bash ~/ros2/scripts/run_tactile.sh --cli
|
# bash ~/ros2/scripts/run_tactile.sh --cli
|
||||||
# bash ~/ros2/scripts/run_tactile.sh # ROS node /tactile/left|right/status
|
# bash ~/ros2/scripts/run_tactile.sh # ROS 节点 /tactile/left|right/status
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Real-time visual servo (reads ALL tunables from ~/ros2/config/site.yaml).
|
# 实时视觉伺服(所有可调参数全部从 ~/ros2/config/site.yaml 读取)。
|
||||||
#
|
#
|
||||||
# bash ~/ros2/scripts/run_visual_servo_rt.sh
|
# bash ~/ros2/scripts/run_visual_servo_rt.sh
|
||||||
# bash ~/ros2/scripts/run_visual_servo_rt.sh --control
|
# bash ~/ros2/scripts/run_visual_servo_rt.sh --control
|
||||||
# bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 91 --control
|
# bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 91 --control
|
||||||
#
|
#
|
||||||
# --control starts tracking from the current pose (no lift, no return-to-home).
|
# --control 从当前位姿直接开始跟踪(不抬升,也不回 home)。
|
||||||
# 89/91 手眼与相机各自独立:必须与 run_real.sh 同一 --arm。
|
# 89/91 手眼与相机各自独立:必须与 run_real.sh 同一 --arm。
|
||||||
#
|
#
|
||||||
# Switch YOLO weights: edit paths.yolo_weights in site.yaml
|
# 切换 YOLO 权重:改 site.yaml 里的 paths.yolo_weights
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@@ -42,9 +42,9 @@ fi
|
|||||||
|
|
||||||
if [[ "${CONTROL}" == true ]]; then
|
if [[ "${CONTROL}" == true ]]; then
|
||||||
echo "=== waiting for rizon_arm_controller ACTIVE (T1: run_real.sh --arm ${SITE_ROBOT_ARM:-?}) ==="
|
echo "=== waiting for rizon_arm_controller ACTIVE (T1: run_real.sh --arm ${SITE_ROBOT_ARM:-?}) ==="
|
||||||
# NOTE: action-list can show zombie entries from a crashed ros2_control,
|
# 注意:action-list 可能显示出崩溃过的 ros2_control 留下的僵尸条目,
|
||||||
# and `ros2 control` CLI is broken in this env (xmlrpc Fault). A raw
|
# 而且这个环境里的 `ros2 control` CLI 是坏的(xmlrpc Fault)。直接发
|
||||||
# service call only answers when the controller manager is actually alive.
|
# 服务请求只有控制器管理器真的活着时才会应答。
|
||||||
ready=false
|
ready=false
|
||||||
for _i in $(seq 1 40); do
|
for _i in $(seq 1 40); do
|
||||||
if timeout 5 ros2 service call /controller_manager/list_controllers \
|
if timeout 5 ros2 service call /controller_manager/list_controllers \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Show / validate the master site config.
|
# 显示 / 校验主站点配置。
|
||||||
# bash ~/ros2/scripts/show_site_config.sh
|
# bash ~/ros2/scripts/show_site_config.sh
|
||||||
# SITE_CONFIG=/path/to/other.yaml bash ~/ros2/scripts/show_site_config.sh
|
# SITE_CONFIG=/path/to/other.yaml bash ~/ros2/scripts/show_site_config.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Smoke-test Flexiv RDK Python against site robot (no ROS / no GitHub).
|
# 针对站点机器人对 Flexiv RDK Python 做冒烟测试(不需要 ROS / 不访问 GitHub)。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Wire local Flexiv trees under ~/文档/Flexiv_Rizon4 into this workspace.
|
# 把 ~/文档/Flexiv_Rizon4 下的本地 Flexiv 代码树接入本工作区。
|
||||||
# Avoids re-cloning RDK / sim plugin from GitHub.
|
# 免得再从 GitHub 重新克隆 RDK / 仿真插件。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@@ -30,7 +30,7 @@ if [[ -d "${LOCAL_FLEXIV}/flexiv_sim_plugin" ]]; then
|
|||||||
echo "Linked thirdparty/flexiv_sim_plugin -> ${LOCAL_FLEXIV}/flexiv_sim_plugin"
|
echo "Linked thirdparty/flexiv_sim_plugin -> ${LOCAL_FLEXIV}/flexiv_sim_plugin"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# humble-v1 ROS hardware needs RDK 1.9.x (kept separately if present)
|
# humble-v1 的 ROS 硬件需要 RDK 1.9.x(如果存在,会单独保留一份)
|
||||||
if [[ -d "${SRC}/flexiv_rdk.bak.1787646725" ]]; then
|
if [[ -d "${SRC}/flexiv_rdk.bak.1787646725" ]]; then
|
||||||
ln -sfn "${SRC}/flexiv_rdk.bak.1787646725" "${SRC}/flexiv_rdk_v1"
|
ln -sfn "${SRC}/flexiv_rdk.bak.1787646725" "${SRC}/flexiv_rdk_v1"
|
||||||
touch "${SRC}/flexiv_rdk.bak.1787646725/COLCON_IGNORE"
|
touch "${SRC}/flexiv_rdk.bak.1787646725/COLCON_IGNORE"
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
"""arm_application — field nodes for Flexiv Rizon + D405 + DH grippers.
|
"""arm_application —— Flexiv Rizon + D405 + DH 夹爪的现场节点包。
|
||||||
|
|
||||||
Public ROS executables (see setup.py console_scripts)
|
对外可直接运行的 ROS 程序(见 ``setup.py`` 的 ``console_scripts``)
|
||||||
----------------------------------------------------
|
------------------------------------------------------------------
|
||||||
Camera: realsense_color_pub
|
相机: realsense_color_pub
|
||||||
Perception: perception_node
|
感知: perception_node
|
||||||
Servo: servo_node
|
伺服: servo_node
|
||||||
Hand-eye: handeye_teach, handeye_waypoints, handeye_calibrate
|
手眼: handeye_teach、handeye_waypoints、handeye_calibrate
|
||||||
Gripper: dh_pgea, dh_pgea_cli, dual_gripper, dual_gripper_cli
|
夹爪: dh_pgea、dh_pgea_cli、dual_gripper、dual_gripper_cli
|
||||||
Hole detect: hole_detect
|
入孔检测: hole_detect
|
||||||
Tactile: tactile, tactile_cli
|
触觉: tactile、tactile_cli
|
||||||
Helpers: goto_ee_plane, demo_named_poses, run_joint_trajectory
|
辅助: goto_ee_plane、demo_named_poses、run_joint_trajectory
|
||||||
|
|
||||||
Reusable Python modules (import from your own nodes)
|
可复用的 Python 模块(可以在你自己的节点里 import)
|
||||||
----------------------------------------------------
|
------------------------------------------------------------------
|
||||||
yolo_detector YOLO / TensorRT inference + pick_best
|
``yolo_detector`` YOLO / TensorRT 推理 + ``pick_best`` 挑最优框
|
||||||
box_tracker pixel-space constant-velocity box coast
|
``box_tracker`` 像素平面内的匀速模型框预测(丢帧时"滑行"用)
|
||||||
plane_feature stereo plane centroid inside a YOLO box
|
``plane_feature`` 在 YOLO 框内取立体平面的质心
|
||||||
dh_pgea official Modbus RTU client for DH PGEA
|
``dh_pgea`` 大寰 PGEA 的官方 Modbus RTU 客户端
|
||||||
dh_official DH Modbus client used by dual_gripper
|
``dh_official`` ``dual_gripper`` 用的 DH Modbus 客户端
|
||||||
dual_gripper PGEA left + RGI right
|
``dual_gripper`` 左 PGEA + 右 RGI
|
||||||
hole_detector yolo / hough / two_stage hole centers
|
``hole_detector`` yolo / hough / two_stage 三种孔心检测
|
||||||
orisys_tactile Orisys visuo-tactile adapter
|
``orisys_tactile`` Orisys 视触觉适配器
|
||||||
geom_ik Rizon geometric Jacobian / DLS / hand-eye load
|
``geom_ik`` Rizon 几何雅可比 / DLS / 手眼加载
|
||||||
handeye_lib chessboard + OpenCV calibrateHandEye
|
``handeye_lib`` 棋盘格 + OpenCV ``calibrateHandEye``
|
||||||
handeye_realsense topic / serial helpers for D405
|
``handeye_realsense`` D405 的话题 / 序列号辅助
|
||||||
pid_controller PID with integral separation
|
``pid_controller`` 带积分分离的 PID
|
||||||
kalman_filter 3D constant-velocity KF
|
``kalman_filter`` 三维匀速模型卡尔曼滤波
|
||||||
|
|
||||||
Operators should use ``scripts/*.sh`` and ``config/site.yaml``, not these
|
现场操作请走 ``scripts/*.sh`` 和 ``config/site.yaml``,不要直接 import 这些模块。
|
||||||
imports. See ``docs/INTERFACES.md``.
|
接口契约见 ``docs/INTERFACES.md``。
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Constant-velocity tracker for a YOLO xyxy box (pixel space).
|
"""针对 YOLO ``xyxy`` 框(像素坐标系)的匀速跟踪器。
|
||||||
|
|
||||||
Used so fast workpiece motion does not drop the lock: the next-frame gate
|
用于避免工件快速运动时丢失锁定:下一帧的闸门使用*预测*框,
|
||||||
is the *predicted* box, and brief YOLO misses coast the overlay.
|
短暂的 YOLO 漏检则由惯性滑行(coast)维持叠加显示。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -38,7 +38,7 @@ class BoxTracker:
|
|||||||
self.v_lpf = float(np.clip(v_lpf, 0.05, 1.0))
|
self.v_lpf = float(np.clip(v_lpf, 0.05, 1.0))
|
||||||
self.max_dt = float(max_dt)
|
self.max_dt = float(max_dt)
|
||||||
self.xyxy: Optional[np.ndarray] = None
|
self.xyxy: Optional[np.ndarray] = None
|
||||||
self.v = np.zeros(2, dtype=float) # center px/s
|
self.v = np.zeros(2, dtype=float) # 中心点 px/s
|
||||||
self.t: Optional[float] = None
|
self.t: Optional[float] = None
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Demo: cycle named poses via /set_named_pose (works for any backend)."""
|
"""演示:通过 ``/set_named_pose`` 循环切换命名位姿(对任何后端都适用)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,10 +31,10 @@ class DemoNamedPoses(Node):
|
|||||||
|
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
if not self._cli.wait_for_service(timeout_sec=15.0):
|
if not self._cli.wait_for_service(timeout_sec=15.0):
|
||||||
self.get_logger().error('/set_named_pose not available')
|
self.get_logger().error('``/set_named_pose`` 服务不可用')
|
||||||
return 1
|
return 1
|
||||||
for loop in range(max(self.loops, 1)):
|
for loop in range(max(self.loops, 1)):
|
||||||
self.get_logger().info(f'loop {loop + 1}/{self.loops}')
|
self.get_logger().info(f'第 {loop + 1}/{self.loops} 轮')
|
||||||
for pose in self.poses:
|
for pose in self.poses:
|
||||||
req = SetNamedPose.Request()
|
req = SetNamedPose.Request()
|
||||||
req.pose_name = pose
|
req.pose_name = pose
|
||||||
@@ -45,17 +45,17 @@ class DemoNamedPoses(Node):
|
|||||||
while rclpy.ok() and not fut.done():
|
while rclpy.ok() and not fut.done():
|
||||||
rclpy.spin_once(self, timeout_sec=0.05)
|
rclpy.spin_once(self, timeout_sec=0.05)
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
self.get_logger().error(f'timeout on {pose}')
|
self.get_logger().error(f'{pose} 调用超时')
|
||||||
return 2
|
return 2
|
||||||
res = fut.result()
|
res = fut.result()
|
||||||
if res is None or not res.success:
|
if res is None or not res.success:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'{pose} failed: {res.message if res else "no response"}'
|
f'{pose} 执行失败:{res.message if res else "无响应"}'
|
||||||
)
|
)
|
||||||
return 3
|
return 3
|
||||||
self.get_logger().info(f'{pose}: {res.message}')
|
self.get_logger().info(f'{pose}:{res.message}')
|
||||||
time.sleep(self.dwell_sec)
|
time.sleep(self.dwell_sec)
|
||||||
self.get_logger().info('demo done')
|
self.get_logger().info('演示结束')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,34 +20,34 @@ from typing import Iterable
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DH-Robotics Modbus register map
|
# DH-Robotics Modbus 寄存器映射
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Basic control registers (holding registers, function code 03/06/10)
|
# 基础控制寄存器(保持寄存器,功能码 03/06/10)
|
||||||
REG_INIT = 0x0100 # 0x01 initialize, 0xA5 fully initialize
|
REG_INIT = 0x0100 # 0x01 初始化,0xA5 完全初始化
|
||||||
REG_FORCE = 0x0101 # gripper closing force, 20~100 %
|
REG_FORCE = 0x0101 # 夹爪闭合力,20~100 %
|
||||||
REG_POSITION = 0x0103 # finger reference position, 0~1000 per-mille
|
REG_POSITION = 0x0103 # 手指参考位置,0~1000 千分比
|
||||||
REG_SPEED = 0x0104 # finger open/close speed, 1~100 %
|
REG_SPEED = 0x0104 # 手指开合速度,1~100 %
|
||||||
|
|
||||||
# RGI rotation control registers
|
# RGI 旋转控制寄存器
|
||||||
REG_ROTATION_ANGLE = 0x0105 # target rotation angle, signed degree value
|
REG_ROTATION_ANGLE = 0x0105 # 目标旋转角度,有符号角度值
|
||||||
REG_ROTATION_SPEED = 0x0107 # rotation speed, 1~100 %
|
REG_ROTATION_SPEED = 0x0107 # 旋转速度,1~100 %
|
||||||
REG_ROTATION_FORCE = 0x0108 # rotation force/torque, 20~100 %
|
REG_ROTATION_FORCE = 0x0108 # 旋转力/力矩,20~100 %
|
||||||
|
|
||||||
# Feedback registers
|
# 反馈寄存器
|
||||||
REG_INIT_STATE = 0x0200 # 0 uninitialized, 1 initialized, 2 initializing
|
REG_INIT_STATE = 0x0200 # 0 未初始化,1 已初始化,2 初始化中
|
||||||
REG_GRIPPER_STATE = 0x0201 # 0 moving, 1 reached, 2 object caught, 3 object dropped
|
REG_GRIPPER_STATE = 0x0201 # 0 运动中,1 已到位,2 已夹到物体,3 物体掉落
|
||||||
REG_CURRENT_POSITION = 0x0202 # current finger position, per-mille
|
REG_CURRENT_POSITION = 0x0202 # 当前手指位置,千分比
|
||||||
REG_ROTATION_ANGLE_FB = 0x0208 # current rotation angle, signed degree value
|
REG_ROTATION_ANGLE_FB = 0x0208 # 当前旋转角度,有符号角度值
|
||||||
REG_ROTATION_INIT_STATE = 0x020A # 0 uninitialized, 1 initialized, 2 initializing
|
REG_ROTATION_INIT_STATE = 0x020A # 0 未初始化,1 已初始化,2 初始化中
|
||||||
REG_ROTATION_STATE = 0x020B # 0 moving, 1 reached, 2 blocking, 3 had been blocked
|
REG_ROTATION_STATE = 0x020B # 0 运动中,1 已到位,2 受阻,3 曾经受阻
|
||||||
|
|
||||||
# Configuration registers
|
# 配置寄存器
|
||||||
REG_SAVE_PARAMS = 0x0300 # write 1 to save all parameters to flash
|
REG_SAVE_PARAMS = 0x0300 # 写入 1 可把所有参数保存到 flash
|
||||||
|
|
||||||
|
|
||||||
class DHModbusError(RuntimeError):
|
class DHModbusError(RuntimeError):
|
||||||
"""DH Modbus communication or protocol error."""
|
"""DH Modbus 通信或协议错误。"""
|
||||||
|
|
||||||
|
|
||||||
def _clamp_int(value: float | int, lo: int, hi: int) -> int:
|
def _clamp_int(value: float | int, lo: int, hi: int) -> int:
|
||||||
@@ -55,9 +55,9 @@ def _clamp_int(value: float | int, lo: int, hi: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _encode_signed(value: float | int) -> int:
|
def _encode_signed(value: float | int) -> int:
|
||||||
"""Encode signed degree value as DH one's-complement register value.
|
"""把有符号角度值编码为 DH 的反码(one's complement)寄存器值。
|
||||||
|
|
||||||
RGI manual example: +360 -> 0x0168, -360 -> 0xFE97.
|
RGI 手册示例:+360 -> 0x0168,-360 -> 0xFE97。
|
||||||
"""
|
"""
|
||||||
value = _clamp_int(value, -32767, 32767)
|
value = _clamp_int(value, -32767, 32767)
|
||||||
if value >= 0:
|
if value >= 0:
|
||||||
@@ -66,7 +66,7 @@ def _encode_signed(value: float | int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _decode_signed(raw: int) -> int:
|
def _decode_signed(raw: int) -> int:
|
||||||
"""Decode DH one's-complement register value to signed integer."""
|
"""把 DH 的反码(one's complement)寄存器值解码为有符号整数。"""
|
||||||
raw = int(raw) & 0xFFFF
|
raw = int(raw) & 0xFFFF
|
||||||
if raw & 0x8000:
|
if raw & 0x8000:
|
||||||
mag = (~raw) & 0x7FFF
|
mag = (~raw) & 0x7FFF
|
||||||
@@ -75,7 +75,7 @@ def _decode_signed(raw: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class DHModbusClient:
|
class DHModbusClient:
|
||||||
"""Minimal thread-safe Modbus RTU client for DH-Robotics grippers."""
|
"""面向 DH-Robotics 夹爪的最小线程安全 Modbus RTU 客户端。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -91,7 +91,7 @@ class DHModbusClient:
|
|||||||
try:
|
try:
|
||||||
import minimalmodbus
|
import minimalmodbus
|
||||||
import serial
|
import serial
|
||||||
except ImportError as exc: # pragma: no cover - depends on runtime env
|
except ImportError as exc: # pragma: no cover - 取决于运行时环境
|
||||||
raise DHModbusError(
|
raise DHModbusError(
|
||||||
"缺少 minimalmodbus/pyserial。请先安装:pip install minimalmodbus pyserial"
|
"缺少 minimalmodbus/pyserial。请先安装:pip install minimalmodbus pyserial"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -124,7 +124,7 @@ class DHModbusClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Low-level register access
|
# 底层寄存器读写
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def write_register(self, address: int, value: int) -> None:
|
def write_register(self, address: int, value: int) -> None:
|
||||||
value = _clamp_int(value, 0, 0xFFFF)
|
value = _clamp_int(value, 0, 0xFFFF)
|
||||||
@@ -170,10 +170,10 @@ class DHModbusClient:
|
|||||||
return _decode_signed(self.read_register(address))
|
return _decode_signed(self.read_register(address))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Common PGE/PGEA/RGI control
|
# PGE/PGEA/RGI 通用控制
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def initialize(self, *, full: bool = False, rotation: bool = False, timeout: float = 10.0) -> None:
|
def initialize(self, *, full: bool = False, rotation: bool = False, timeout: float = 10.0) -> None:
|
||||||
"""Initialize/homing the gripper and wait until ready."""
|
"""初始化/回零夹爪,并等待其就绪。"""
|
||||||
self.write_register(REG_INIT, 0xA5 if full else 0x01)
|
self.write_register(REG_INIT, 0xA5 if full else 0x01)
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
while time.monotonic() - start < timeout:
|
while time.monotonic() - start < timeout:
|
||||||
@@ -229,11 +229,11 @@ class DHModbusClient:
|
|||||||
return self.read_register(REG_INIT_STATE)
|
return self.read_register(REG_INIT_STATE)
|
||||||
|
|
||||||
def hold_current_position(self) -> None:
|
def hold_current_position(self) -> None:
|
||||||
"""Best-effort stop/hold for DH grippers without a dedicated stop register."""
|
"""在没有专用停止寄存器的 DH 夹爪上尽力停止/保持。"""
|
||||||
self.set_position_per_mille(self.get_position_per_mille())
|
self.set_position_per_mille(self.get_position_per_mille())
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# RGI rotation control
|
# RGI 旋转控制
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def set_rotation_speed_pct(self, speed_pct: float | int) -> int:
|
def set_rotation_speed_pct(self, speed_pct: float | int) -> int:
|
||||||
value = _clamp_int(speed_pct, 1, 100)
|
value = _clamp_int(speed_pct, 1, 100)
|
||||||
@@ -263,7 +263,7 @@ class DHModbusClient:
|
|||||||
return self.read_register(REG_ROTATION_STATE)
|
return self.read_register(REG_ROTATION_STATE)
|
||||||
|
|
||||||
def hold_current_rotation(self) -> None:
|
def hold_current_rotation(self) -> None:
|
||||||
"""Stop rotation by re-commanding the current feedback angle."""
|
"""重新下发当前反馈角度来停止旋转。"""
|
||||||
self.write_register(REG_ROTATION_ANGLE, self.get_rotation_angle_raw())
|
self.write_register(REG_ROTATION_ANGLE, self.get_rotation_angle_raw())
|
||||||
|
|
||||||
def hold_current_all(self) -> None:
|
def hold_current_all(self) -> None:
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""DH Robotics PGE / PGEA Modbus RTU client.
|
"""DH Robotics PGE / PGEA Modbus RTU 客户端。
|
||||||
|
|
||||||
Port of the official C++ driver:
|
移植自官方 C++ 驱动:
|
||||||
DH-Robotics/dh_gripper_ros → DH_Modbus_Gripper
|
DH-Robotics/dh_gripper_ros → DH_Modbus_Gripper
|
||||||
(factory maps model names containing ``PGE`` / ``PGEA`` onto this class)
|
(工厂会按型号名里是否包含 ``PGE`` / ``PGEA`` 映射到本类)
|
||||||
|
|
||||||
Flexiv RDK ``Gripper`` cannot drive this claw: it is not a Studio Device.
|
Flexiv RDK 的 ``Gripper`` 驱动不了这个夹爪:它不是 Studio Device。
|
||||||
Talk RS485 (USB–UART) with the same registers the DH ROS driver uses.
|
改用 RS485(USB–UART)通信,寄存器与 DH 的 ROS 驱动完全一致。
|
||||||
|
|
||||||
Registers (holding, FC03 read / FC06 write, CRC-16/Modbus LE):
|
寄存器(保持寄存器,FC03 读 / FC06 写,CRC-16/Modbus 小端):
|
||||||
0x0100 init write 0xA5
|
0x0100 初始化,写 0xA5
|
||||||
0x0101 force 20–100 %
|
0x0101 力 20–100 %
|
||||||
0x0103 position 0–1000 ‰
|
0x0103 位置 0–1000 ‰
|
||||||
0x0104 speed 1–100 %
|
0x0104 速度 1–100 %
|
||||||
0x0200 init state 0=need 1=done 2=doing
|
0x0200 初始化状态 0=需要 1=完成 2=进行中
|
||||||
0x0201 grip state 0=moving 1=arrived 2=caught 3=dropped
|
0x0201 夹持状态 0=运动中 1=已到位 2=已夹住 3=已掉落
|
||||||
0x0202 current position
|
0x0202 当前位置
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -31,11 +31,11 @@ try:
|
|||||||
import serial
|
import serial
|
||||||
except ImportError as exc: # pragma: no cover
|
except ImportError as exc: # pragma: no cover
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"pyserial missing. In conda ros_flexiv: pip install pyserial"
|
"缺少 pyserial。在 conda 环境 ros_flexiv 里执行:pip install pyserial"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
# Official CRC-16/Modbus (same table as dh_modbus_gripper.cpp).
|
# 官方 CRC-16/Modbus(与 dh_modbus_gripper.cpp 使用同一张表)。
|
||||||
_CRC_TABLE = (
|
_CRC_TABLE = (
|
||||||
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
|
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
|
||||||
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
|
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
|
||||||
@@ -105,7 +105,7 @@ INIT_STATE_NAME = {
|
|||||||
|
|
||||||
|
|
||||||
def crc16_modbus(data: bytes) -> int:
|
def crc16_modbus(data: bytes) -> int:
|
||||||
"""Official DH CRC16 (Modbus, little-endian on the wire)."""
|
"""官方 DH CRC16(Modbus,线路上按小端发送)。"""
|
||||||
crc = 0xFFFF
|
crc = 0xFFFF
|
||||||
for byte in data:
|
for byte in data:
|
||||||
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
|
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
|
||||||
@@ -176,7 +176,7 @@ WIRING_HINT = (
|
|||||||
|
|
||||||
|
|
||||||
class DhPgeaGripper:
|
class DhPgeaGripper:
|
||||||
"""Thread-safe Modbus RTU client for PGE / PGEA (official register map)."""
|
"""面向 PGE / PGEA 的线程安全 Modbus RTU 客户端(官方寄存器映射)。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -196,7 +196,7 @@ class DhPgeaGripper:
|
|||||||
if mode in ("", "off", "none", "false", "0"):
|
if mode in ("", "off", "none", "false", "0"):
|
||||||
mode = ""
|
mode = ""
|
||||||
if mode not in ("", "tx", "tx_inv"):
|
if mode not in ("", "tx", "tx_inv"):
|
||||||
raise DhPgeaError(f"rs485_rts must be '', tx, or tx_inv, got {rs485_rts!r}")
|
raise DhPgeaError(f"rs485_rts 只能是 ''、tx 或 tx_inv,实际为 {rs485_rts!r}")
|
||||||
self.rs485_rts = mode
|
self.rs485_rts = mode
|
||||||
self._ser: Optional[serial.Serial] = None
|
self._ser: Optional[serial.Serial] = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@@ -220,8 +220,8 @@ class DhPgeaGripper:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DhPgeaError(
|
raise DhPgeaError(
|
||||||
f"cannot open {self.port}: {exc}. "
|
f"无法打开 {self.port}:{exc}。"
|
||||||
"Need 24 V DC + RS485, and user in group dialout."
|
"需要 24 V DC 供电 + RS485 接线,且当前用户在 dialout 组内。"
|
||||||
) from exc
|
) from exc
|
||||||
try:
|
try:
|
||||||
self._ser.dtr = True
|
self._ser.dtr = True
|
||||||
@@ -248,7 +248,7 @@ class DhPgeaGripper:
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def write_register(self, index: int, value: int, retries: int = 3) -> bool:
|
def write_register(self, index: int, value: int, retries: int = 3) -> bool:
|
||||||
"""FC06 write, wait for echo (official WriteRegisterFunc)."""
|
"""FC06 写,等待回显(对应官方 WriteRegisterFunc)。"""
|
||||||
frame = _frame(self.slave_id, 0x06, index, int(value) & 0xFFFF)
|
frame = _frame(self.slave_id, 0x06, index, int(value) & 0xFFFF)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for _ in range(max(1, retries)):
|
for _ in range(max(1, retries)):
|
||||||
@@ -258,7 +258,7 @@ class DhPgeaGripper:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def read_register(self, index: int, retries: int = 8) -> int:
|
def read_register(self, index: int, retries: int = 8) -> int:
|
||||||
"""FC03 read one holding register (official ReadRegisterFunc)."""
|
"""FC03 读一个保持寄存器(对应官方 ReadRegisterFunc)。"""
|
||||||
req = _frame(self.slave_id, 0x03, index, 1)
|
req = _frame(self.slave_id, 0x03, index, 1)
|
||||||
last: Optional[bytes] = None
|
last: Optional[bytes] = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -275,13 +275,13 @@ class DhPgeaGripper:
|
|||||||
return (rsp[3] << 8) | (rsp[4] & 0xFF)
|
return (rsp[3] << 8) | (rsp[4] & 0xFF)
|
||||||
hint = f" last={last.hex() if last else '(empty)'}"
|
hint = f" last={last.hex() if last else '(empty)'}"
|
||||||
raise DhPgeaError(
|
raise DhPgeaError(
|
||||||
f"Modbus read 0x{index:04X} failed on {self.port} "
|
f"在 {self.port} 上 Modbus 读 0x{index:04X} 失败 "
|
||||||
f"(no valid reply{hint}). baud={self.baudrate} slave={self.slave_id} "
|
f"(无有效应答{hint})。baud={self.baudrate} slave={self.slave_id} "
|
||||||
f"rts={self.rs485_rts or 'off'}"
|
f"rts={self.rs485_rts or 'off'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def probe(self) -> int:
|
def probe(self) -> int:
|
||||||
"""One short read of init-state. Raises if the claw is silent."""
|
"""短读一次初始化状态。夹爪无应答时会抛异常。"""
|
||||||
return self.read_register(REG_INIT_STATE, retries=2)
|
return self.read_register(REG_INIT_STATE, retries=2)
|
||||||
|
|
||||||
def _set_tx(self, tx: bool) -> None:
|
def _set_tx(self, tx: bool) -> None:
|
||||||
@@ -297,7 +297,7 @@ class DhPgeaGripper:
|
|||||||
|
|
||||||
def _transact(self, frame: bytes, expect: int) -> Optional[bytes]:
|
def _transact(self, frame: bytes, expect: int) -> Optional[bytes]:
|
||||||
if self._ser is None or not self._ser.is_open:
|
if self._ser is None or not self._ser.is_open:
|
||||||
raise DhPgeaError("serial not open")
|
raise DhPgeaError("串口未打开")
|
||||||
bit_s = 10.0 / max(1, self.baudrate)
|
bit_s = 10.0 / max(1, self.baudrate)
|
||||||
self._set_tx(True)
|
self._set_tx(True)
|
||||||
if self.rs485_rts:
|
if self.rs485_rts:
|
||||||
@@ -320,12 +320,12 @@ class DhPgeaGripper:
|
|||||||
return self.write_register(REG_INIT, INIT_CMD)
|
return self.write_register(REG_INIT, INIT_CMD)
|
||||||
|
|
||||||
def initialize(self, timeout_s: float = 20.0, poll_s: float = 0.2) -> bool:
|
def initialize(self, timeout_s: float = 20.0, poll_s: float = 0.2) -> bool:
|
||||||
"""Write 0xA5 then wait until init state == finished (official bringup)."""
|
"""写 0xA5,然后等到初始化状态 == 完成(对应官方上电流程)。"""
|
||||||
state = self.get_init_state()
|
state = self.get_init_state()
|
||||||
if state == S_INIT_FINISHED:
|
if state == S_INIT_FINISHED:
|
||||||
return True
|
return True
|
||||||
if not self.initialization():
|
if not self.initialization():
|
||||||
raise DhPgeaError("init write 0x0100=0xA5 failed (no Modbus echo)")
|
raise DhPgeaError("初始化写 0x0100=0xA5 失败(无 Modbus 回显)")
|
||||||
deadline = time.monotonic() + float(timeout_s)
|
deadline = time.monotonic() + float(timeout_s)
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
state = self.get_init_state()
|
state = self.get_init_state()
|
||||||
@@ -333,8 +333,8 @@ class DhPgeaGripper:
|
|||||||
return True
|
return True
|
||||||
time.sleep(poll_s)
|
time.sleep(poll_s)
|
||||||
raise DhPgeaError(
|
raise DhPgeaError(
|
||||||
f"init timeout after {timeout_s:.0f}s (state={state}). "
|
f"初始化在 {timeout_s:.0f}s 后超时(state={state})。"
|
||||||
"Fingers should home once on first power-up."
|
"首次上电时手指本应先回零一次。"
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_target_force(self, force_pct: int) -> bool:
|
def set_target_force(self, force_pct: int) -> bool:
|
||||||
@@ -427,11 +427,11 @@ class DhPgeaGripper:
|
|||||||
|
|
||||||
|
|
||||||
def _selfcheck_crc() -> None:
|
def _selfcheck_crc() -> None:
|
||||||
"""Official short-manual example: set position 500."""
|
"""官方简短手册示例:设置位置 500。"""
|
||||||
frame = _frame(1, 0x06, 0x0103, 500)
|
frame = _frame(1, 0x06, 0x0103, 500)
|
||||||
expect = bytes([0x01, 0x06, 0x01, 0x03, 0x01, 0xF4, 0x78, 0x21])
|
expect = bytes([0x01, 0x06, 0x01, 0x03, 0x01, 0xF4, 0x78, 0x21])
|
||||||
if frame != expect:
|
if frame != expect:
|
||||||
raise AssertionError(f"CRC mismatch: {frame.hex()} != {expect.hex()}")
|
raise AssertionError(f"CRC 不匹配:{frame.hex()} != {expect.hex()}")
|
||||||
|
|
||||||
|
|
||||||
def _site_defaults() -> dict:
|
def _site_defaults() -> dict:
|
||||||
@@ -465,11 +465,11 @@ def _site_defaults() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _scan_bus(port: str) -> int:
|
def _scan_bus(port: str) -> int:
|
||||||
"""Try baud × slave × RTS until one combination answers."""
|
"""遍历波特率 × 从站号 × RTS,直到某个组合有应答。"""
|
||||||
bauds = (115200, 57600, 38400, 19200, 9600)
|
bauds = (115200, 57600, 38400, 19200, 9600)
|
||||||
slaves = (1, 2, 3)
|
slaves = (1, 2, 3)
|
||||||
rts_modes = ("", "tx", "tx_inv")
|
rts_modes = ("", "tx", "tx_inv")
|
||||||
print(f"scan {port} (stop the ROS node first — port is exclusive)")
|
print(f"扫描 {port}(请先停掉 ROS 节点 —— 该串口是独占的)")
|
||||||
for baud in bauds:
|
for baud in bauds:
|
||||||
for slave in slaves:
|
for slave in slaves:
|
||||||
for rts in rts_modes:
|
for rts in rts_modes:
|
||||||
@@ -492,7 +492,7 @@ def _scan_bus(port: str) -> int:
|
|||||||
print(f" miss {label}")
|
print(f" miss {label}")
|
||||||
finally:
|
finally:
|
||||||
g.close()
|
g.close()
|
||||||
print("no reply on any combination.", file=sys.stderr)
|
print("所有组合都无应答。", file=sys.stderr)
|
||||||
print(WIRING_HINT, file=sys.stderr)
|
print(WIRING_HINT, file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -500,24 +500,24 @@ def _scan_bus(port: str) -> int:
|
|||||||
def _cli(argv: Optional[list[str]] = None) -> int:
|
def _cli(argv: Optional[list[str]] = None) -> int:
|
||||||
site = _site_defaults()
|
site = _site_defaults()
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description="DH PGEA Modbus CLI (official register map, not Flexiv RDK)"
|
description="DH PGEA Modbus 命令行工具(官方寄存器映射,不用 Flexiv RDK)"
|
||||||
)
|
)
|
||||||
ap.add_argument("--port", default=site["port"])
|
ap.add_argument("--port", default=site["port"])
|
||||||
ap.add_argument("--baud", type=int, default=site["baudrate"])
|
ap.add_argument("--baud", type=int, default=site["baudrate"])
|
||||||
ap.add_argument("--slave", type=int, default=site["slave_id"])
|
ap.add_argument("--slave", type=int, default=site["slave_id"])
|
||||||
ap.add_argument("--force", type=int, default=site["force"])
|
ap.add_argument("--force", type=int, default=site["force"])
|
||||||
ap.add_argument("--speed", type=int, default=site["speed"])
|
ap.add_argument("--speed", type=int, default=site["speed"])
|
||||||
ap.add_argument("--rts", default=site.get("rs485_rts") or "", help="RS485 DE: empty | tx | tx_inv")
|
ap.add_argument("--rts", default=site.get("rs485_rts") or "", help="RS485 DE:空 | tx | tx_inv")
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"cmd",
|
"cmd",
|
||||||
choices=["probe", "init", "status", "open", "close", "pos", "selfcheck", "scan"],
|
choices=["probe", "init", "status", "open", "close", "pos", "selfcheck", "scan"],
|
||||||
)
|
)
|
||||||
ap.add_argument("value", nargs="?", default="", help="position 0–1000 for 'pos'")
|
ap.add_argument("value", nargs="?", default="", help="'pos' 命令的位置,0–1000")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
if args.cmd == "selfcheck":
|
if args.cmd == "selfcheck":
|
||||||
_selfcheck_crc()
|
_selfcheck_crc()
|
||||||
print("CRC self-check OK (01 06 01 03 01 F4 78 21)")
|
print("CRC 自检通过(01 06 01 03 01 F4 78 21)")
|
||||||
return 0
|
return 0
|
||||||
if args.cmd == "scan":
|
if args.cmd == "scan":
|
||||||
return _scan_bus(args.port)
|
return _scan_bus(args.port)
|
||||||
@@ -532,7 +532,7 @@ def _cli(argv: Optional[list[str]] = None) -> int:
|
|||||||
try:
|
try:
|
||||||
grip.open()
|
grip.open()
|
||||||
except DhPgeaError as exc:
|
except DhPgeaError as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -541,7 +541,7 @@ def _cli(argv: Optional[list[str]] = None) -> int:
|
|||||||
print(st.summary())
|
print(st.summary())
|
||||||
return 0
|
return 0
|
||||||
if args.cmd == "init":
|
if args.cmd == "init":
|
||||||
print("initializing (fingers home)…")
|
print("正在初始化(手指回零)…")
|
||||||
grip.initialize()
|
grip.initialize()
|
||||||
print(grip.get_run_states().summary())
|
print(grip.get_run_states().summary())
|
||||||
return 0
|
return 0
|
||||||
@@ -550,25 +550,25 @@ def _cli(argv: Optional[list[str]] = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
if args.cmd == "open":
|
if args.cmd == "open":
|
||||||
if not grip.open_jaw(args.force, args.speed):
|
if not grip.open_jaw(args.force, args.speed):
|
||||||
raise DhPgeaError("open write failed")
|
raise DhPgeaError("张开写入失败")
|
||||||
print(grip.wait_settle(1000).summary())
|
print(grip.wait_settle(1000).summary())
|
||||||
return 0
|
return 0
|
||||||
if args.cmd == "close":
|
if args.cmd == "close":
|
||||||
if not grip.close_jaw(args.force, args.speed):
|
if not grip.close_jaw(args.force, args.speed):
|
||||||
raise DhPgeaError("close write failed")
|
raise DhPgeaError("闭合写入失败")
|
||||||
print(grip.wait_settle(0).summary())
|
print(grip.wait_settle(0).summary())
|
||||||
return 0
|
return 0
|
||||||
if args.cmd == "pos":
|
if args.cmd == "pos":
|
||||||
if args.value == "":
|
if args.value == "":
|
||||||
print("usage: pos <0-1000>", file=sys.stderr)
|
print("用法:pos <0-1000>", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
pos = int(float(args.value))
|
pos = int(float(args.value))
|
||||||
if not grip.move(pos, args.force, args.speed):
|
if not grip.move(pos, args.force, args.speed):
|
||||||
raise DhPgeaError("position write failed")
|
raise DhPgeaError("位置写入失败")
|
||||||
print(grip.wait_settle(pos).summary())
|
print(grip.wait_settle(pos).summary())
|
||||||
return 0
|
return 0
|
||||||
except DhPgeaError as exc:
|
except DhPgeaError as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
print(WIRING_HINT, file=sys.stderr)
|
print(WIRING_HINT, file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""ROS 2 node for DH PGEA (official Modbus RTU, not Flexiv gripper).
|
"""DH PGEA 的 ROS 2 节点(官方 Modbus RTU,不用 Flexiv 夹爪)。
|
||||||
|
|
||||||
Topics (layout matches DH-Robotics/dh_gripper_ros):
|
话题(布局与 DH-Robotics/dh_gripper_ros 一致):
|
||||||
/gripper/ctrl std_msgs/Float32MultiArray
|
/gripper/ctrl std_msgs/Float32MultiArray
|
||||||
data: [initialize, position, force, speed]
|
data: [initialize, position, force, speed]
|
||||||
initialize>0.5 → write 0xA5; else set force/speed/position
|
initialize>0.5 → 写 0xA5;否则设置力/速度/位置
|
||||||
/gripper/states std_msgs/Float32MultiArray
|
/gripper/states std_msgs/Float32MultiArray
|
||||||
data: [initialized, grip_state, position, target_pos, target_force]
|
data: [initialized, grip_state, position, target_pos, target_force]
|
||||||
/gripper/joint_states sensor_msgs/JointState
|
/gripper/joint_states sensor_msgs/JointState
|
||||||
|
|
||||||
Services (std_srvs/Trigger):
|
服务(std_srvs/Trigger):
|
||||||
/gripper/initialize /gripper/open /gripper/close
|
/gripper/initialize /gripper/open /gripper/close
|
||||||
|
|
||||||
Do not set Flexiv bringup load_gripper:=true — that is Flexiv-GN01 only.
|
不要设置 Flexiv bringup 的 load_gripper:=true —— 那只是 Flexiv-GN01 用的。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -36,7 +36,7 @@ from arm_application.dh_pgea import (
|
|||||||
|
|
||||||
|
|
||||||
def _param_as_str(node: Node, name: str, default: str = "") -> str:
|
def _param_as_str(node: Node, name: str, default: str = "") -> str:
|
||||||
"""String param that also accepts numeric CLI overrides (e.g. SN 26610799)."""
|
"""字符串参数,同时允许命令行用数字覆盖(例如 SN 26610799)。"""
|
||||||
node.declare_parameter(name, default, ParameterDescriptor(dynamic_typing=True))
|
node.declare_parameter(name, default, ParameterDescriptor(dynamic_typing=True))
|
||||||
val = node.get_parameter(name).value
|
val = node.get_parameter(name).value
|
||||||
if val is None or val == "":
|
if val is None or val == "":
|
||||||
@@ -105,7 +105,7 @@ class DhPgeaNode(Node):
|
|||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"DH {self._model} SN={self._sn or '?'} "
|
f"DH {self._model} SN={self._sn or '?'} "
|
||||||
f"{self._port} @{self._baud} slave={self._slave} rts={self._rts or 'off'} "
|
f"{self._port} @{self._baud} slave={self._slave} rts={self._rts or 'off'} "
|
||||||
f"(official Modbus RTU; Flexiv RDK gripper API unused)"
|
f"(官方 Modbus RTU;未使用 Flexiv RDK 夹爪 API)"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self._grip.open()
|
self._grip.open()
|
||||||
@@ -121,14 +121,14 @@ class DhPgeaNode(Node):
|
|||||||
self.get_logger().error(str(exc))
|
self.get_logger().error(str(exc))
|
||||||
for line in WIRING_HINT.splitlines():
|
for line in WIRING_HINT.splitlines():
|
||||||
self.get_logger().error(line)
|
self.get_logger().error(line)
|
||||||
self.get_logger().error("will keep retrying; open/close will fail until the bus answers")
|
self.get_logger().error("会持续重试;在总线恢复应答前 open/close 都会失败")
|
||||||
|
|
||||||
if self._auto_init and self._online:
|
if self._auto_init and self._online:
|
||||||
try:
|
try:
|
||||||
self._grip.initialize()
|
self._grip.initialize()
|
||||||
self.get_logger().info("gripper initialized")
|
self.get_logger().info("夹爪已初始化")
|
||||||
except DhPgeaError as exc:
|
except DhPgeaError as exc:
|
||||||
self.get_logger().error(f"auto_init failed: {exc}")
|
self.get_logger().error(f"auto_init 失败:{exc}")
|
||||||
self._online = False
|
self._online = False
|
||||||
|
|
||||||
period = 1.0 / max(1.0, hz)
|
period = 1.0 / max(1.0, hz)
|
||||||
@@ -150,7 +150,7 @@ class DhPgeaNode(Node):
|
|||||||
if speed is not None:
|
if speed is not None:
|
||||||
self._speed = s
|
self._speed = s
|
||||||
if not self._grip.move(int(position), f, s):
|
if not self._grip.move(int(position), f, s):
|
||||||
raise DhPgeaError("move write failed")
|
raise DhPgeaError("移动写入失败")
|
||||||
return f"pos={int(position)} force={f}% speed={s}%"
|
return f"pos={int(position)} force={f}% speed={s}%"
|
||||||
|
|
||||||
def _on_ctrl(self, msg: Float32MultiArray) -> None:
|
def _on_ctrl(self, msg: Float32MultiArray) -> None:
|
||||||
@@ -158,7 +158,7 @@ class DhPgeaNode(Node):
|
|||||||
try:
|
try:
|
||||||
if initialize > 0.5:
|
if initialize > 0.5:
|
||||||
self._grip.initialize()
|
self._grip.initialize()
|
||||||
self.get_logger().info("initialize via /gripper/ctrl")
|
self.get_logger().info("通过 /gripper/ctrl 触发初始化")
|
||||||
return
|
return
|
||||||
f = int(force) if force > 0 else self._force
|
f = int(force) if force > 0 else self._force
|
||||||
s = int(speed) if speed > 0 else self._speed
|
s = int(speed) if speed > 0 else self._speed
|
||||||
@@ -210,7 +210,7 @@ class DhPgeaNode(Node):
|
|||||||
self._state = st
|
self._state = st
|
||||||
if not self._online:
|
if not self._online:
|
||||||
self._online = True
|
self._online = True
|
||||||
self.get_logger().info("gripper bus recovered")
|
self.get_logger().info("夹爪总线已恢复")
|
||||||
except DhPgeaError as exc:
|
except DhPgeaError as exc:
|
||||||
self._online = False
|
self._online = False
|
||||||
self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
|
self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
|
||||||
@@ -227,7 +227,7 @@ class DhPgeaNode(Node):
|
|||||||
js = JointState()
|
js = JointState()
|
||||||
js.header.stamp = self.get_clock().now().to_msg()
|
js.header.stamp = self.get_clock().now().to_msg()
|
||||||
js.name = ["gripper_finger1_joint"]
|
js.name = ["gripper_finger1_joint"]
|
||||||
# Official ROS driver maps 0–1000 → ~0–0.637 rad; we publish mm as well.
|
# 官方 ROS 驱动把 0–1000 映射到约 0–0.637 rad;我们这里也发布 mm。
|
||||||
js.position = [float(st.width_mm) * 0.001]
|
js.position = [float(st.width_mm) * 0.001]
|
||||||
js.effort = [float(st.target_force)]
|
js.effort = [float(st.target_force)]
|
||||||
self._pub_js.publish(js)
|
self._pub_js.publish(js)
|
||||||
|
|||||||
@@ -750,7 +750,7 @@ def load_dual_arm_gripper_config(path: str | Path) -> DualArmGripperConfig:
|
|||||||
|
|
||||||
|
|
||||||
def load_from_site(data: dict) -> DualArmGripperConfig:
|
def load_from_site(data: dict) -> DualArmGripperConfig:
|
||||||
"""Build DualArmGripperConfig from ~/ros2/config/site.yaml."""
|
"""从 ``~/ros2/config/site.yaml`` 构建 ``DualArmGripperConfig``。"""
|
||||||
robots = list((data.get("robot") or {}).get("robots") or [])
|
robots = list((data.get("robot") or {}).get("robots") or [])
|
||||||
left = next((r for r in robots if str(r.get("arm") or "").lower() == "left"), {})
|
left = next((r for r in robots if str(r.get("arm") or "").lower() == "left"), {})
|
||||||
right = next((r for r in robots if str(r.get("arm") or "").lower() == "right"), {})
|
right = next((r for r in robots if str(r.get("arm") or "").lower() == "right"), {})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""CLI for DH PGEA (left) and RGI (right). Loads ports from site.yaml."""
|
"""DH PGEA(左臂)与 RGI(右臂)的命令行工具。从 site.yaml 读取串口。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -39,67 +39,67 @@ def _normalize_arm(raw: str) -> str:
|
|||||||
return "right"
|
return "right"
|
||||||
if t in ("both", "dual", "all"):
|
if t in ("both", "dual", "all"):
|
||||||
return "both"
|
return "both"
|
||||||
raise SystemExit(f"unknown --arm {raw!r} (use 89|91|left|right|both)")
|
raise SystemExit(f"未知的 --arm {raw!r}(可用 89|91|left|right|both)")
|
||||||
|
|
||||||
|
|
||||||
def test_parallel(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
|
def test_parallel(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
|
||||||
gripper = manager.left
|
gripper = manager.left
|
||||||
print(f"=== PGEA parallel ({gripper.cfg.port}) ===")
|
print(f"=== PGEA 平行夹爪({gripper.cfg.port})===")
|
||||||
gripper.connect()
|
gripper.connect()
|
||||||
_print_status("connected", gripper.get_status())
|
_print_status("已连接", gripper.get_status())
|
||||||
if args.test in ("init", "probe", "all"):
|
if args.test in ("init", "probe", "all"):
|
||||||
if args.test != "probe":
|
if args.test != "probe":
|
||||||
gripper.initialize(full=args.init_full)
|
gripper.initialize(full=args.init_full)
|
||||||
_print_status("init", gripper.get_status())
|
_print_status("初始化", gripper.get_status())
|
||||||
if args.test in ("open", "all"):
|
if args.test in ("open", "all"):
|
||||||
_print_status("open", gripper.open(force=args.force, speed=args.speed))
|
_print_status("张开", gripper.open(force=args.force, speed=args.speed))
|
||||||
if args.test in ("close", "all"):
|
if args.test in ("close", "all"):
|
||||||
_print_status("close", gripper.close(force=args.force, speed=args.speed))
|
_print_status("闭合", gripper.close(force=args.force, speed=args.speed))
|
||||||
if args.test in ("move", "pos") and args.pos is not None:
|
if args.test in ("move", "pos") and args.pos is not None:
|
||||||
_print_status("move", gripper.move_to(args.pos, force=args.force, speed=args.speed))
|
_print_status("移动", gripper.move_to(args.pos, force=args.force, speed=args.speed))
|
||||||
if args.test in ("status", "probe"):
|
if args.test in ("status", "probe"):
|
||||||
_print_status("status", gripper.get_status())
|
_print_status("状态", gripper.get_status())
|
||||||
|
|
||||||
|
|
||||||
def test_rotary(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
|
def test_rotary(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
|
||||||
gripper = manager.right
|
gripper = manager.right
|
||||||
print(f"=== RGI rotary ({gripper.cfg.port}) ===")
|
print(f"=== RGI 旋转夹爪({gripper.cfg.port})===")
|
||||||
gripper.connect()
|
gripper.connect()
|
||||||
_print_status("connected", gripper.get_status())
|
_print_status("已连接", gripper.get_status())
|
||||||
if args.test in ("init", "all"):
|
if args.test in ("init", "all"):
|
||||||
gripper.initialize(full=args.init_full)
|
gripper.initialize(full=args.init_full)
|
||||||
_print_status("init", gripper.get_status())
|
_print_status("初始化", gripper.get_status())
|
||||||
if args.test in ("open", "all"):
|
if args.test in ("open", "all"):
|
||||||
_print_status("open", gripper.open_fingers(force=args.force, speed=args.speed))
|
_print_status("张开", gripper.open_fingers(force=args.force, speed=args.speed))
|
||||||
if args.test in ("close", "all"):
|
if args.test in ("close", "all"):
|
||||||
_print_status("close", gripper.close_fingers(force=args.force, speed=args.speed))
|
_print_status("闭合", gripper.close_fingers(force=args.force, speed=args.speed))
|
||||||
if args.test in ("move", "pos") and args.pos is not None:
|
if args.test in ("move", "pos") and args.pos is not None:
|
||||||
_print_status("fingers", gripper.move_fingers(int(args.pos), force=args.force, speed=args.speed))
|
_print_status("手指", gripper.move_fingers(int(args.pos), force=args.force, speed=args.speed))
|
||||||
if args.test in ("rotate", "rotate_to", "all"):
|
if args.test in ("rotate", "rotate_to", "all"):
|
||||||
if args.revolutions is not None:
|
if args.revolutions is not None:
|
||||||
_print_status(
|
_print_status(
|
||||||
"revolutions",
|
"圈数",
|
||||||
gripper.rotate_revolutions(args.revolutions, speed=args.speed, torque=args.force),
|
gripper.rotate_revolutions(args.revolutions, speed=args.speed, torque=args.force),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
angle = args.angle if args.angle is not None else 90.0
|
angle = args.angle if args.angle is not None else 90.0
|
||||||
_print_status("rotate", gripper.rotate_to(angle, speed=args.speed, torque=args.force))
|
_print_status("旋转", gripper.rotate_to(angle, speed=args.speed, torque=args.force))
|
||||||
if args.test == "rotate_by":
|
if args.test == "rotate_by":
|
||||||
delta = args.angle if args.angle is not None else 45.0
|
delta = args.angle if args.angle is not None else 45.0
|
||||||
_print_status("rotate_by", gripper.rotate_by(delta, speed=args.speed, torque=args.force))
|
_print_status("相对旋转", gripper.rotate_by(delta, speed=args.speed, torque=args.force))
|
||||||
if args.test in ("status", "probe"):
|
if args.test in ("status", "probe"):
|
||||||
_print_status("status", gripper.get_status())
|
_print_status("状态", gripper.get_status())
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="DH PGEA / RGI CLI (site.yaml ports)")
|
parser = argparse.ArgumentParser(description="DH PGEA / RGI 命令行工具(串口来自 site.yaml)")
|
||||||
parser.add_argument("--arm", default=os.environ.get("ROBOT_ARM") or os.environ.get("ROBOT_SN") or "89")
|
parser.add_argument("--arm", default=os.environ.get("ROBOT_ARM") or os.environ.get("ROBOT_SN") or "89")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--test",
|
"--test",
|
||||||
default="status",
|
default="status",
|
||||||
help="probe|init|open|close|move|pos|rotate|rotate_by|status",
|
help="probe|init|open|close|move|pos|rotate|rotate_by|status",
|
||||||
)
|
)
|
||||||
parser.add_argument("--pos", type=float, default=None, help="PGEA mm or RGI finger 0-1000")
|
parser.add_argument("--pos", type=float, default=None, help="PGEA 为 mm;RGI 为手指 0-1000")
|
||||||
parser.add_argument("--angle", type=float, default=None)
|
parser.add_argument("--angle", type=float, default=None)
|
||||||
parser.add_argument("--revolutions", type=float, default=None)
|
parser.add_argument("--revolutions", type=float, default=None)
|
||||||
parser.add_argument("--speed", type=int, default=25)
|
parser.add_argument("--speed", type=int, default=25)
|
||||||
@@ -110,7 +110,7 @@ def main() -> int:
|
|||||||
|
|
||||||
site = Path(args.site).expanduser() if args.site else _site_path()
|
site = Path(args.site).expanduser() if args.site else _site_path()
|
||||||
if not site.is_file():
|
if not site.is_file():
|
||||||
print(f"ERROR: site.yaml missing: {site}", file=sys.stderr)
|
print(f"错误:site.yaml 缺失:{site}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
|
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
|
||||||
manager = DualArmGripperManager(load_from_site(data))
|
manager = DualArmGripperManager(load_from_site(data))
|
||||||
@@ -126,11 +126,11 @@ def main() -> int:
|
|||||||
test_parallel(manager, args)
|
test_parallel(manager, args)
|
||||||
test_rotary(manager, args)
|
test_rotary(manager, args)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("interrupt — stop")
|
print("收到中断 —— 停止")
|
||||||
manager.emergency_stop()
|
manager.emergency_stop()
|
||||||
return 130
|
return 130
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
manager.disconnect_all()
|
manager.disconnect_all()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""ROS 2 dual DH gripper node (PGEA left + RGI right). Uses arm_msgs, not Flexiv RDK."""
|
"""ROS 2 双臂 DH 夹爪节点(左臂 PGEA + 右臂 RGI)。使用 arm_msgs,而非 Flexiv RDK。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class DualGripperNode(Node):
|
|||||||
site = _site_path()
|
site = _site_path()
|
||||||
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
|
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
|
||||||
self._manager = DualArmGripperManager(load_from_site(data))
|
self._manager = DualArmGripperManager(load_from_site(data))
|
||||||
self.get_logger().info(f"gripper config from site.yaml: {site}")
|
self.get_logger().info(f"夹爪配置来自 site.yaml:{site}")
|
||||||
|
|
||||||
self._connect_errors: dict[str, str] = {}
|
self._connect_errors: dict[str, str] = {}
|
||||||
if _as_bool(self.get_parameter("auto_connect").value, True):
|
if _as_bool(self.get_parameter("auto_connect").value, True):
|
||||||
@@ -112,12 +112,12 @@ class DualGripperNode(Node):
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
gripper.connect()
|
gripper.connect()
|
||||||
self.get_logger().info(f"{side} gripper connected: {gripper.cfg.port}")
|
self.get_logger().info(f"{side} 夹爪已连接:{gripper.cfg.port}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._connect_errors[side] = str(exc)
|
self._connect_errors[side] = str(exc)
|
||||||
self.get_logger().error(f"{side} gripper connect failed: {exc}")
|
self.get_logger().error(f"{side} 夹爪连接失败:{exc}")
|
||||||
if self._connect_errors and not _as_bool(self.get_parameter("allow_offline").value, True):
|
if self._connect_errors and not _as_bool(self.get_parameter("allow_offline").value, True):
|
||||||
raise RuntimeError(f"gripper connect failed: {self._connect_errors}")
|
raise RuntimeError(f"夹爪连接失败:{self._connect_errors}")
|
||||||
|
|
||||||
def _gripper(self, side: str):
|
def _gripper(self, side: str):
|
||||||
return self._manager.left if side == "left" else self._manager.right
|
return self._manager.left if side == "left" else self._manager.right
|
||||||
@@ -245,14 +245,14 @@ class DualGripperNode(Node):
|
|||||||
|
|
||||||
status = gripper.get_status()
|
status = gripper.get_status()
|
||||||
status.state = GripperState.ERROR
|
status.state = GripperState.ERROR
|
||||||
status.error_msg = f"unsupported command {command!r} (side={side})"
|
status.error_msg = f"不支持的命令 {command!r}(side={side})"
|
||||||
return status
|
return status
|
||||||
|
|
||||||
def _on_command(self, side: str, msg: GripperCommandMsg) -> None:
|
def _on_command(self, side: str, msg: GripperCommandMsg) -> None:
|
||||||
with self._locks[side]:
|
with self._locks[side]:
|
||||||
status = self._execute(side, msg)
|
status = self._execute(side, msg)
|
||||||
if status.state == GripperState.ERROR:
|
if status.state == GripperState.ERROR:
|
||||||
self.get_logger().warn(f"{side} command {msg.command!r} failed: {status.error_msg}")
|
self.get_logger().warn(f"{side} 命令 {msg.command!r} 失败:{status.error_msg}")
|
||||||
|
|
||||||
def _on_execute(self, side: str, request: GripperExecute.Request, response: GripperExecute.Response):
|
def _on_execute(self, side: str, request: GripperExecute.Request, response: GripperExecute.Response):
|
||||||
with self._locks[side]:
|
with self._locks[side]:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Startup return-to-initial before perception/servo starts.
|
"""感知/伺服启动前的开机回初始位流程。
|
||||||
|
|
||||||
Sequence:
|
流程:
|
||||||
[A] if the flange is below servo.safe_z_m, lift it STRAIGHT UP first
|
[A] 若法兰低于 ``servo.safe_z_m``,先让它竖直上升(闭环 Z 小步跳),
|
||||||
(closed-loop Z hops) so the home move can't sweep walls/fixtures;
|
以免回 home 时扫到墙面/工装;
|
||||||
[B] call /set_named_pose (Flexiv named pose, default "home") — the arm
|
[B] 调用 ``/set_named_pose``(Flexiv 命名位姿,默认 "home")—— 由 Flexiv
|
||||||
returns to the Flexiv initial pose with Flexiv's own planner;
|
自己的规划器把机械臂送回 Flexiv 初始位姿;
|
||||||
[C] hold (pin) so the servo takes over from a clean, stationary pose.
|
[C] 保持(pin)住,让伺服从一个干净、静止的位姿接手。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -49,13 +49,13 @@ def _load_ee_cfg() -> dict:
|
|||||||
"base": str(robot.get("base_frame") or "Rizon4-063989_base_link"),
|
"base": str(robot.get("base_frame") or "Rizon4-063989_base_link"),
|
||||||
"ee": str(robot.get("ee_frame") or "Rizon4-063989_flange"),
|
"ee": str(robot.get("ee_frame") or "Rizon4-063989_flange"),
|
||||||
"z": ee_z,
|
"z": ee_z,
|
||||||
# Lift straight up to this Z before the home move (avoid hitting
|
# 回 home 前先竖直升到这个 Z(避免撞到墙面/工装);
|
||||||
# walls/fixtures); then return to the Flexiv named initial pose.
|
# 然后再回到 Flexiv 的命名初始位姿。
|
||||||
"safe_z": float(servo.get("safe_z_m") or max(0.40, ee_z + 0.10)),
|
"safe_z": float(servo.get("safe_z_m") or max(0.40, ee_z + 0.10)),
|
||||||
"home_pose": str(servo.get("home_pose_name") or "home"),
|
"home_pose": str(servo.get("home_pose_name") or "home"),
|
||||||
"home_speed": float(servo.get("home_pose_speed") or 0.30),
|
"home_speed": float(servo.get("home_pose_speed") or 0.30),
|
||||||
"v_z": float(servo.get("v_max_z") or 0.04),
|
"v_z": float(servo.get("v_max_z") or 0.04),
|
||||||
# only used by the (currently unused) verticalize planner path
|
# 仅用于(目前未使用的)verticalize 规划路径
|
||||||
"kp": float(servo.get("ee_hold_kp") or 2.0),
|
"kp": float(servo.get("ee_hold_kp") or 2.0),
|
||||||
"w_max": float(servo.get("ee_w_max") or 0.50),
|
"w_max": float(servo.get("ee_w_max") or 0.50),
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ class GotoEePlane(Node):
|
|||||||
self._spin(0.05)
|
self._spin(0.05)
|
||||||
handle = fut.result() if fut.done() else None
|
handle = fut.result() if fut.done() else None
|
||||||
if handle is None or not handle.accepted:
|
if handle is None or not handle.accepted:
|
||||||
print(" ERROR: JTC rejected the height trajectory", flush=True)
|
print(" 错误:JTC 拒绝了抬升轨迹", flush=True)
|
||||||
return False
|
return False
|
||||||
res = handle.get_result_async()
|
res = handle.get_result_async()
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
@@ -183,13 +183,13 @@ class GotoEePlane(Node):
|
|||||||
if Tee is not None:
|
if Tee is not None:
|
||||||
p = Tee[:3, 3]
|
p = Tee[:3, 3]
|
||||||
print(
|
print(
|
||||||
f" moving ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f}) "
|
f" 运动中 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f}) "
|
||||||
f"z_err={(self.cfg['z'] - p[2]) * 1000:+.0f}mm",
|
f"z_err={(self.cfg['z'] - p[2]) * 1000:+.0f}mm",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
last = now
|
last = now
|
||||||
if not res.done():
|
if not res.done():
|
||||||
print(" WARN: JTC result timeout — checking TF anyway", flush=True)
|
print(" 警告:JTC 结果超时 —— 仍然继续检查 TF", flush=True)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -203,25 +203,24 @@ class GotoEePlane(Node):
|
|||||||
max_seg: float = 0.08,
|
max_seg: float = 0.08,
|
||||||
v: float = 0.05,
|
v: float = 0.05,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Plan and execute ONE short cartesian segment (≤ max_seg) toward
|
"""规划并执行一段朝向 target 的短笛卡尔位移(≤ max_seg)。
|
||||||
target. Long moves are handled by the caller in closed-loop hops,
|
长距离移动由调用方以闭环小步跳的方式完成,因为雅可比是在当前位姿
|
||||||
because the Jacobian is frozen at the current pose — over >10 cm the
|
处冻结的 —— 超过 10 cm 后线性化误差会让一次性规划严重偏离目标。"""
|
||||||
linearization error makes single-shot plans land far off target."""
|
|
||||||
p = Tee[:3, 3].copy()
|
p = Tee[:3, 3].copy()
|
||||||
R = Tee[:3, :3]
|
R = Tee[:3, :3]
|
||||||
dp, ang, _ = self._target_err(p, R, target, verticalize)
|
dp, ang, _ = self._target_err(p, R, target, verticalize)
|
||||||
dist = float(np.linalg.norm(dp))
|
dist = float(np.linalg.norm(dp))
|
||||||
if dist > max_seg: # clamp this hop; caller loops with fresh TF
|
if dist > max_seg: # 限制本次小步跳的长度;调用方会用最新的 TF 循环执行
|
||||||
dp = dp * (max_seg / dist)
|
dp = dp * (max_seg / dist)
|
||||||
dist = max_seg
|
dist = max_seg
|
||||||
print(
|
print(
|
||||||
f" plan {tag} Δxyz=({dp[0]*1000:+.0f},{dp[1]*1000:+.0f},{dp[2]*1000:+.0f})mm "
|
f" 规划 {tag} Δxyz=({dp[0]*1000:+.0f},{dp[1]*1000:+.0f},{dp[2]*1000:+.0f})mm "
|
||||||
f"tilt={ang * 180 / np.pi:.1f}deg",
|
f"倾角={ang * 180 / np.pi:.1f}deg",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
J = self._jacobian(p)
|
J = self._jacobian(p)
|
||||||
if J is None:
|
if J is None:
|
||||||
print("ERROR: missing link TF for Jacobian", flush=True)
|
print("错误:缺少用于雅可比的 link TF", flush=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
q = q0.copy()
|
q = q0.copy()
|
||||||
@@ -253,14 +252,14 @@ class GotoEePlane(Node):
|
|||||||
planned = dist - float(np.linalg.norm(remain))
|
planned = dist - float(np.linalg.norm(remain))
|
||||||
if len(waypoints) < 2 or planned < 0.4 * dist:
|
if len(waypoints) < 2 or planned < 0.4 * dist:
|
||||||
print(
|
print(
|
||||||
f" WARN {tag}: planner stalled "
|
f" 警告 {tag}:规划器停滞 "
|
||||||
f"(planned {planned*1000:.0f}/{dist*1000:.0f}mm) — skip hop",
|
f"(已规划 {planned*1000:.0f}/{dist*1000:.0f}mm)—— 跳过本次小步跳",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
# joint-excursion sanity gate: refuse wild plans
|
# 关节偏移量的合理性闸门:拒绝过于夸张的规划
|
||||||
if float(np.max(np.abs(waypoints[-1] - q0))) > 1.0:
|
if float(np.max(np.abs(waypoints[-1] - q0))) > 1.0:
|
||||||
print(f" ERROR {tag}: joint excursion >1 rad — abort hop", flush=True)
|
print(f" 错误 {tag}:关节偏移 >1 rad —— 放弃本次小步跳", flush=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
duration = max(1.6, dist / max(min(v, 0.06), 1e-3))
|
duration = max(1.6, dist / max(min(v, 0.06), 1e-3))
|
||||||
@@ -278,10 +277,10 @@ class GotoEePlane(Node):
|
|||||||
)
|
)
|
||||||
qdot = np.clip(qdot, -0.40, 0.40)
|
qdot = np.clip(qdot, -0.40, 0.40)
|
||||||
traj.points.append(_pt(qi, qdot, t))
|
traj.points.append(_pt(qi, qdot, t))
|
||||||
# force zero velocity at the last point
|
# 强制最后一个点的速度为零
|
||||||
traj.points[-1].velocities = [0.0] * 7
|
traj.points[-1].velocities = [0.0] * 7
|
||||||
print(
|
print(
|
||||||
f" send JTC action {tag} {n} steps T={duration:.1f}s",
|
f" 发送 JTC action {tag},{n} 步 T={duration:.1f}s",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
return self._send_action(traj, wait_s=duration + 6.0)
|
return self._send_action(traj, wait_s=duration + 6.0)
|
||||||
@@ -298,9 +297,8 @@ class GotoEePlane(Node):
|
|||||||
v: float,
|
v: float,
|
||||||
max_hops: int = 8,
|
max_hops: int = 8,
|
||||||
) -> Tuple[bool, np.ndarray, np.ndarray]:
|
) -> Tuple[bool, np.ndarray, np.ndarray]:
|
||||||
"""Closed-loop point-to-point: plan ≤8 cm hop → execute → re-measure
|
"""闭环点到点:规划 ≤8 cm 的小步跳 → 执行 → 重新测量 TF → 重复,
|
||||||
TF → repeat, until within tolerance. Robust to Jacobian linearization
|
直到落在容差内。这样对长距离下的雅可比线性化误差更鲁棒。"""
|
||||||
error over long distances."""
|
|
||||||
for hop in range(max_hops):
|
for hop in range(max_hops):
|
||||||
p = Tee[:3, 3]
|
p = Tee[:3, 3]
|
||||||
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
|
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
|
||||||
@@ -308,8 +306,8 @@ class GotoEePlane(Node):
|
|||||||
if dist < tol_m and ((not verticalize) or ang < tol_tilt):
|
if dist < tol_m and ((not verticalize) or ang < tol_tilt):
|
||||||
return True, q, Tee
|
return True, q, Tee
|
||||||
print(
|
print(
|
||||||
f" {tag} hop{hop + 1} remain={dist*1000:.0f}mm "
|
f" {tag} 第 {hop + 1} 跳 remain={dist*1000:.0f}mm "
|
||||||
f"tilt={ang * 180 / np.pi:.1f}deg",
|
f"倾角={ang * 180 / np.pi:.1f}deg",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
if not self._plan_and_send(q, Tee, target, verticalize, tag, v=v):
|
if not self._plan_and_send(q, Tee, target, verticalize, tag, v=v):
|
||||||
@@ -319,7 +317,7 @@ class GotoEePlane(Node):
|
|||||||
self._spin(0.05)
|
self._spin(0.05)
|
||||||
q2, Tee2 = self._wait_state(2.0)
|
q2, Tee2 = self._wait_state(2.0)
|
||||||
if q2 is None or Tee2 is None:
|
if q2 is None or Tee2 is None:
|
||||||
print("ERROR: lost state during move", flush=True)
|
print("错误:移动过程中丢失状态", flush=True)
|
||||||
return False, q, Tee
|
return False, q, Tee
|
||||||
q, Tee = q2, Tee2
|
q, Tee = q2, Tee2
|
||||||
p = Tee[:3, 3]
|
p = Tee[:3, 3]
|
||||||
@@ -340,10 +338,10 @@ class GotoEePlane(Node):
|
|||||||
self._send_action(traj, wait_s=3.0)
|
self._send_action(traj, wait_s=3.0)
|
||||||
|
|
||||||
def _named_pose(self, name: str, speed: float) -> bool:
|
def _named_pose(self, name: str, speed: float) -> bool:
|
||||||
"""Call Flexiv /set_named_pose and wait until the arm is stationary."""
|
"""调用 Flexiv 的 ``/set_named_pose``,并等待机械臂静止。"""
|
||||||
cli = self.create_client(SetNamedPose, "/set_named_pose")
|
cli = self.create_client(SetNamedPose, "/set_named_pose")
|
||||||
if not cli.wait_for_server(timeout_sec=8.0):
|
if not cli.wait_for_server(timeout_sec=8.0):
|
||||||
print("ERROR: /set_named_pose unavailable — start run_real.sh", flush=True)
|
print("错误:``/set_named_pose`` 不可用 —— 请启动 run_real.sh", flush=True)
|
||||||
return False
|
return False
|
||||||
req = SetNamedPose.Request()
|
req = SetNamedPose.Request()
|
||||||
req.pose_name = name
|
req.pose_name = name
|
||||||
@@ -355,16 +353,16 @@ class GotoEePlane(Node):
|
|||||||
while not fut.done() and time.monotonic() - t0 < 90.0:
|
while not fut.done() and time.monotonic() - t0 < 90.0:
|
||||||
self._spin(0.1)
|
self._spin(0.1)
|
||||||
if not fut.done():
|
if not fut.done():
|
||||||
print("ERROR: set_named_pose timeout", flush=True)
|
print("错误:set_named_pose 超时", flush=True)
|
||||||
return False
|
return False
|
||||||
resp = fut.result()
|
resp = fut.result()
|
||||||
print(
|
print(
|
||||||
f" [B] resp success={resp.success} msg={resp.message}",
|
f" [B] 响应 success={resp.success} msg={resp.message}",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
if not resp.success:
|
if not resp.success:
|
||||||
return False
|
return False
|
||||||
# wait until the flange stops moving (Δp < 1 mm over ~0.8 s)
|
# 等待法兰停止运动(约 0.8 s 内 Δp < 1 mm)
|
||||||
last = None
|
last = None
|
||||||
still = 0.0
|
still = 0.0
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
@@ -380,57 +378,57 @@ class GotoEePlane(Node):
|
|||||||
else:
|
else:
|
||||||
still = 0.0
|
still = 0.0
|
||||||
last = p.copy()
|
last = p.copy()
|
||||||
print(" WARN: still-moving wait timeout — continue anyway", flush=True)
|
print(" 警告:等待静止超时 —— 仍然继续", flush=True)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def run(self, timeout_s: float = 25.0) -> int:
|
def run(self, timeout_s: float = 25.0) -> int:
|
||||||
cfg = self.cfg
|
cfg = self.cfg
|
||||||
q, Tee = self._wait_state(4.0)
|
q, Tee = self._wait_state(4.0)
|
||||||
if q is None or Tee is None:
|
if q is None or Tee is None:
|
||||||
print("ERROR: no joint_states / TF — start run_real.sh", flush=True)
|
print("错误:没有 joint_states / TF —— 请启动 run_real.sh", flush=True)
|
||||||
return 2
|
return 2
|
||||||
if not self._cli.wait_for_server(timeout_sec=8.0):
|
if not self._cli.wait_for_server(timeout_sec=8.0):
|
||||||
print("ERROR: JTC action unavailable — start run_real.sh", flush=True)
|
print("错误:JTC action 不可用 —— 请启动 run_real.sh", flush=True)
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
p = Tee[:3, 3]
|
p = Tee[:3, 3]
|
||||||
safe_z = float(cfg["safe_z"])
|
safe_z = float(cfg["safe_z"])
|
||||||
print(
|
print(
|
||||||
f"=== return to Flexiv initial pose '{cfg['home_pose']}' "
|
f"=== 回到 Flexiv 初始位姿 '{cfg['home_pose']}' "
|
||||||
f"safe_z={safe_z*1000:.0f}mm ===",
|
f"safe_z={safe_z*1000:.0f}mm ===",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f" now ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
|
f" 当前 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Phase A: pure-Z lift to the safe height before the home move ---
|
# --- 阶段 A:回 home 前先沿纯 Z 方向升到安全高度 ---
|
||||||
if p[2] < safe_z - 0.01:
|
if p[2] < safe_z - 0.01:
|
||||||
lift_tgt = np.array([p[0], p[1], safe_z], dtype=float)
|
lift_tgt = np.array([p[0], p[1], safe_z], dtype=float)
|
||||||
print(f" [A] lift Z {p[2]*1000:.0f} → {safe_z*1000:.0f}mm", flush=True)
|
print(f" [A] 抬升 Z {p[2]*1000:.0f} → {safe_z*1000:.0f}mm", flush=True)
|
||||||
ok, q, Tee = self._goto(
|
ok, q, Tee = self._goto(
|
||||||
q, Tee, lift_tgt, False, 0.010, 0.0, "[A]lift", v=0.05
|
q, Tee, lift_tgt, False, 0.010, 0.0, "[A]lift", v=0.05
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
print("ERROR: lift phase failed — pin here", flush=True)
|
print("错误:抬升阶段失败 —— 就地 pin 住", flush=True)
|
||||||
self._pin(q)
|
self._pin(q)
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
# --- Phase B: Flexiv named pose → real initial pose, good posture ---
|
# --- 阶段 B:Flexiv 命名位姿 → 真正的初始位姿,姿态良好 ---
|
||||||
if not self._named_pose(cfg["home_pose"], cfg["home_speed"]):
|
if not self._named_pose(cfg["home_pose"], cfg["home_speed"]):
|
||||||
print("ERROR: named-pose return failed — pin here", flush=True)
|
print("错误:回命名位姿失败 —— 就地 pin 住", flush=True)
|
||||||
self._pin(q)
|
self._pin(q)
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
# --- Phase C: hold so the servo takes over from a stationary pose ---
|
# --- 阶段 C:保持住,让伺服从一个静止位姿接手 ---
|
||||||
q, Tee = self._wait_state(2.0)
|
q, Tee = self._wait_state(2.0)
|
||||||
if q is not None:
|
if q is not None:
|
||||||
self._pin(q)
|
self._pin(q)
|
||||||
if Tee is not None:
|
if Tee is not None:
|
||||||
p = Tee[:3, 3]
|
p = Tee[:3, 3]
|
||||||
print(
|
print(
|
||||||
f" at initial pose ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
|
f" 已到初始位姿 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Full hand-eye pipeline: move -> stabilize chessboard -> capture -> solve -> cleanup.
|
"""完整手眼标定流水线:移动 → 棋盘稳定判定 → 采集 → 求解 → 清理。
|
||||||
|
|
||||||
Supports eye_in_hand / eye_to_hand.
|
支持 eye_in_hand / eye_to_hand。
|
||||||
|
|
||||||
Camera intrinsics priority:
|
相机内参优先级:
|
||||||
1) ROS CameraInfo topic (if publishing)
|
1) ROS 的 CameraInfo 话题(若正在发布)
|
||||||
2) RealSense D405/D435 EEPROM via pyrealsense2 (camera_source)
|
2) 通过 pyrealsense2 读取 RealSense D405/D435 的 EEPROM(camera_source)
|
||||||
3) Manual camera_fx/fy/cx/cy in YAML
|
3) YAML 中手写的 camera_fx/fy/cx/cy
|
||||||
|
|
||||||
Chessboard size (board_cols / board_rows / square_size_m) is user-configurable.
|
棋盘尺寸(board_cols / board_rows / square_size_m)可由用户配置。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -68,7 +68,7 @@ def _image_to_bgr(msg: Image) -> np.ndarray:
|
|||||||
img = data.reshape(h, w, 4)
|
img = data.reshape(h, w, 4)
|
||||||
bgr = img[:, :, :3]
|
bgr = img[:, :, :3]
|
||||||
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
|
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
|
||||||
raise RuntimeError(f'Unsupported image encoding: {msg.encoding!r}')
|
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
|
||||||
|
|
||||||
|
|
||||||
def _camera_info_to_intrinsics(msg: CameraInfo) -> CameraIntrinsics:
|
def _camera_info_to_intrinsics(msg: CameraInfo) -> CameraIntrinsics:
|
||||||
@@ -93,7 +93,7 @@ class HandeyeCalibrate(Node):
|
|||||||
self.declare_parameter('waypoint_positions', [0.0] * (12 * 7))
|
self.declare_parameter('waypoint_positions', [0.0] * (12 * 7))
|
||||||
self.declare_parameter('min_poses', 5)
|
self.declare_parameter('min_poses', 5)
|
||||||
|
|
||||||
# Camera: manual | d405 | d435
|
# 相机来源:manual | d405 | d435
|
||||||
self.declare_parameter('camera_source', 'manual')
|
self.declare_parameter('camera_source', 'manual')
|
||||||
self.declare_parameter('camera_serial', '')
|
self.declare_parameter('camera_serial', '')
|
||||||
self.declare_parameter('camera_namespace', '')
|
self.declare_parameter('camera_namespace', '')
|
||||||
@@ -107,12 +107,12 @@ class HandeyeCalibrate(Node):
|
|||||||
self.declare_parameter('camera_cy', 0.0)
|
self.declare_parameter('camera_cy', 0.0)
|
||||||
self.declare_parameter('camera_dist', [0.0, 0.0, 0.0, 0.0, 0.0])
|
self.declare_parameter('camera_dist', [0.0, 0.0, 0.0, 0.0, 0.0])
|
||||||
|
|
||||||
# Chessboard (customizable)
|
# 棋盘(可自定义)
|
||||||
# 默认值必须与 site.yaml 的 handeye.board_* 及结果文件头里的实际板子一致。
|
# 默认值必须与 site.yaml 的 handeye.board_* 及结果文件头里的实际板子一致。
|
||||||
# 真实使用的板:内角点 8x11,方格 15mm(见 handeye/results/*.yaml 的头部注释)。
|
# 真实使用的板:内角点 8x11,方格 15mm(见 handeye/results/*.yaml 的头部注释)。
|
||||||
# 这里原先写的是 9x6 / 0.025(另一张早期的板),一旦 site.yaml 漏配就会被
|
# 这里原先写的是 9x6 / 0.025(另一张早期的板),一旦 site.yaml 漏配就会被
|
||||||
# 静默启用 —— 症状是"标定能顺利跑完,但结果明显不对",极难排查。
|
# 静默启用 —— 症状是"标定能顺利跑完,但结果明显不对",极难排查。
|
||||||
self.declare_parameter('board_cols', 8) # inner corners
|
self.declare_parameter('board_cols', 8) # 内角点
|
||||||
self.declare_parameter('board_rows', 11)
|
self.declare_parameter('board_rows', 11)
|
||||||
self.declare_parameter('square_size_m', 0.015)
|
self.declare_parameter('square_size_m', 0.015)
|
||||||
|
|
||||||
@@ -205,18 +205,18 @@ class HandeyeCalibrate(Node):
|
|||||||
|
|
||||||
if not img_topic:
|
if not img_topic:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
'image_topic empty. Set camera_source:=d405|d435 or fill image_topic.'
|
'image_topic 为空。请设置 camera_source:=d405|d435,或填写 image_topic。'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.create_subscription(
|
self.create_subscription(
|
||||||
Image, img_topic, self._on_image, qos_profile_sensor_data
|
Image, img_topic, self._on_image, qos_profile_sensor_data
|
||||||
)
|
)
|
||||||
self.get_logger().info(f'Subscribed image: {img_topic} (BEST_EFFORT)')
|
self.get_logger().info(f'已订阅图像:{img_topic}(BEST_EFFORT)')
|
||||||
if info_topic:
|
if info_topic:
|
||||||
self.create_subscription(
|
self.create_subscription(
|
||||||
CameraInfo, info_topic, self._on_info, qos_profile_sensor_data
|
CameraInfo, info_topic, self._on_info, qos_profile_sensor_data
|
||||||
)
|
)
|
||||||
self.get_logger().info(f'Subscribed camera_info: {info_topic} (BEST_EFFORT)')
|
self.get_logger().info(f'已订阅 camera_info:{info_topic}(BEST_EFFORT)')
|
||||||
|
|
||||||
def _on_js(self, msg: JointState) -> None:
|
def _on_js(self, msg: JointState) -> None:
|
||||||
self._latest_js = msg
|
self._latest_js = msg
|
||||||
@@ -230,20 +230,20 @@ class HandeyeCalibrate(Node):
|
|||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
if self.mode not in ('eye_in_hand', 'eye_to_hand'):
|
if self.mode not in ('eye_in_hand', 'eye_to_hand'):
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'Unknown mode={self.mode!r}; use eye_in_hand or eye_to_hand'
|
f'未知的 mode={self.mode!r};请使用 eye_in_hand 或 eye_to_hand'
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
poses = self._load_poses()
|
poses = self._load_poses()
|
||||||
if len(poses) < self.min_poses:
|
if len(poses) < self.min_poses:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'Only {len(poses)} filled poses (need >= {self.min_poses}). '
|
f'只有 {len(poses)} 个已填写位姿(需要 >= {self.min_poses})。'
|
||||||
'Teach with: bash ~/ros2/scripts/run_handeye.sh teach && merge.'
|
'示教命令:bash ~/ros2/scripts/run_handeye.sh teach && merge。'
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
if self.start_index >= len(poses):
|
if self.start_index >= len(poses):
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'start_index {self.start_index} >= filled poses {len(poses)}'
|
f'start_index {self.start_index} >= 已填写位姿数 {len(poses)}'
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -252,8 +252,8 @@ class HandeyeCalibrate(Node):
|
|||||||
|
|
||||||
if not self._client.wait_for_server(timeout_sec=15.0):
|
if not self._client.wait_for_server(timeout_sec=15.0):
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'Action [{self.action_name}] unavailable. '
|
f'Action [{self.action_name}] 不可用。'
|
||||||
'Start: bash ~/ros2/scripts/run_real.sh'
|
'请启动:bash ~/ros2/scripts/run_real.sh'
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
@@ -262,11 +262,11 @@ class HandeyeCalibrate(Node):
|
|||||||
|
|
||||||
eye_to_hand = self.mode == 'eye_to_hand'
|
eye_to_hand = self.mode == 'eye_to_hand'
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Hand-eye [{self.mode}] camera_source={self.camera_source}: '
|
f'手眼标定 [{self.mode}] camera_source={self.camera_source}:'
|
||||||
f'{len(poses)} poses, board={self.board.cols}x{self.board.rows} '
|
f'{len(poses)} 个位姿,棋盘={self.board.cols}x{self.board.rows} '
|
||||||
f'square={self.board.square_size}m, v={self.velocity_scale}'
|
f'方格={self.board.square_size}m,v={self.velocity_scale}'
|
||||||
)
|
)
|
||||||
self.get_logger().warn('Clear workspace; E-Stop ready. Ctrl+C aborts.')
|
self.get_logger().warn('清理工作空间;确认急停就绪。Ctrl+C 可中止。')
|
||||||
|
|
||||||
Rg: List[np.ndarray] = []
|
Rg: List[np.ndarray] = []
|
||||||
tg: List[np.ndarray] = []
|
tg: List[np.ndarray] = []
|
||||||
@@ -277,17 +277,17 @@ class HandeyeCalibrate(Node):
|
|||||||
try:
|
try:
|
||||||
for i in range(self.start_index, len(poses)):
|
for i in range(self.start_index, len(poses)):
|
||||||
q = poses[i]
|
q = poses[i]
|
||||||
self._log_progress(i, len(poses), 'moving...')
|
self._log_progress(i, len(poses), '移动中...')
|
||||||
code = self._move_one(q, self.move_time_sec)
|
code = self._move_one(q, self.move_time_sec)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
return code
|
return code
|
||||||
|
|
||||||
if not self._verify_joints(q, tol=0.08):
|
if not self._verify_joints(q, tol=0.08):
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'[{i + 1}] arm did not reach target joints '
|
f'[{i + 1}] 机械臂未到达目标关节位置 '
|
||||||
'(controller reported OK but joints unchanged). '
|
'(控制器报告成功,但关节没有变化)。'
|
||||||
'Restart run_real.sh; pendant AUTO Remote; clear fault. '
|
'请重启 run_real.sh;示教器切 AUTO Remote;清除故障。'
|
||||||
'Do NOT trust this calibration.'
|
'不要相信这次标定结果。'
|
||||||
)
|
)
|
||||||
return 10
|
return 10
|
||||||
|
|
||||||
@@ -301,16 +301,16 @@ class HandeyeCalibrate(Node):
|
|||||||
|
|
||||||
ok, std, corners, pattern, bgr = self._wait_stable()
|
ok, std, corners, pattern, bgr = self._wait_stable()
|
||||||
if not ok or corners is None or bgr is None:
|
if not ok or corners is None or bgr is None:
|
||||||
msg = f'[{i + 1}] chessboard not stable (std={std:.3f})'
|
msg = f'[{i + 1}] 棋盘不稳定(std={std:.3f})'
|
||||||
if self.skip_unstable:
|
if self.skip_unstable:
|
||||||
self.get_logger().warn(f'{msg} — skip')
|
self.get_logger().warn(f'{msg} —— 跳过')
|
||||||
continue
|
continue
|
||||||
self.get_logger().error(f'{msg}. Abort.')
|
self.get_logger().error(f'{msg}。中止。')
|
||||||
return 7
|
return 7
|
||||||
|
|
||||||
ee = self._read_ee_pose()
|
ee = self._read_ee_pose()
|
||||||
if ee is None:
|
if ee is None:
|
||||||
self.get_logger().error('Failed to read ee_pose')
|
self.get_logger().error('读取 ee_pose 失败')
|
||||||
return 8
|
return 8
|
||||||
|
|
||||||
assert self._intrinsics is not None
|
assert self._intrinsics is not None
|
||||||
@@ -349,20 +349,20 @@ class HandeyeCalibrate(Node):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'[{i + 1}/{len(poses)}] CAPTURED '
|
f'[{i + 1}/{len(poses)}] 已采集 '
|
||||||
f'(corner_std={std:.3f}px) -> {img_path.name}'
|
f'(corner_std={std:.3f}px) -> {img_path.name}'
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
self.get_logger().info('Interrupted')
|
self.get_logger().info('已中断')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if len(Rg) < self.min_poses:
|
if len(Rg) < self.min_poses:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'Only {len(Rg)} captures; need >= {self.min_poses} to solve'
|
f'只采集到 {len(Rg)} 次;求解至少需要 {self.min_poses} 次'
|
||||||
)
|
)
|
||||||
return 9
|
return 9
|
||||||
|
|
||||||
self.get_logger().info(f'Solving hand-eye with {len(Rg)} views...')
|
self.get_logger().info(f'用 {len(Rg)} 个视角求解手眼标定...')
|
||||||
R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=eye_to_hand)
|
R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=eye_to_hand)
|
||||||
name = (
|
name = (
|
||||||
'T_cam_to_base (eye_to_hand)'
|
'T_cam_to_base (eye_to_hand)'
|
||||||
@@ -371,7 +371,7 @@ class HandeyeCalibrate(Node):
|
|||||||
)
|
)
|
||||||
yaml_txt = format_transform_yaml(R, t, name)
|
yaml_txt = format_transform_yaml(R, t, name)
|
||||||
header = (
|
header = (
|
||||||
f'# Hand-eye result\n'
|
f'# 手眼标定结果\n'
|
||||||
f'# mode: {self.mode}\n'
|
f'# mode: {self.mode}\n'
|
||||||
f'# camera_source: {self.camera_source}\n'
|
f'# camera_source: {self.camera_source}\n'
|
||||||
f'# views: {len(Rg)}\n'
|
f'# views: {len(Rg)}\n'
|
||||||
@@ -385,17 +385,17 @@ class HandeyeCalibrate(Node):
|
|||||||
json.dumps(meta, indent=2), encoding='utf-8'
|
json.dumps(meta, indent=2), encoding='utf-8'
|
||||||
)
|
)
|
||||||
|
|
||||||
self.get_logger().info('======== Hand-eye result ========')
|
self.get_logger().info('======== 手眼标定结果 ========')
|
||||||
for line in (header + yaml_txt).strip().splitlines():
|
for line in (header + yaml_txt).strip().splitlines():
|
||||||
self.get_logger().info(line)
|
self.get_logger().info(line)
|
||||||
self.get_logger().info(f'Saved -> {self.result_file}')
|
self.get_logger().info(f'已保存 -> {self.result_file}')
|
||||||
|
|
||||||
if self.cleanup_images:
|
if self.cleanup_images:
|
||||||
if self.images_dir.exists():
|
if self.images_dir.exists():
|
||||||
shutil.rmtree(self.images_dir, ignore_errors=True)
|
shutil.rmtree(self.images_dir, ignore_errors=True)
|
||||||
self.get_logger().info('Calibration images deleted')
|
self.get_logger().info('标定图像已删除')
|
||||||
else:
|
else:
|
||||||
self.get_logger().info(f'Images kept in {self.images_dir}')
|
self.get_logger().info(f'图像保留在 {self.images_dir}')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def _load_poses(self) -> List[List[float]]:
|
def _load_poses(self) -> List[List[float]]:
|
||||||
@@ -407,8 +407,8 @@ class HandeyeCalibrate(Node):
|
|||||||
need = n_cfg * n_j
|
need = n_cfg * n_j
|
||||||
if len(flat) < need:
|
if len(flat) < need:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'waypoint_positions length {len(flat)} < num_poses*{n_j}={need}; '
|
f'waypoint_positions 长度 {len(flat)} < num_poses*{n_j}={need};'
|
||||||
'padding with zeros'
|
'用零补齐'
|
||||||
)
|
)
|
||||||
flat = flat + [0.0] * (need - len(flat))
|
flat = flat + [0.0] * (need - len(flat))
|
||||||
poses: List[List[float]] = []
|
poses: List[List[float]] = []
|
||||||
@@ -417,7 +417,7 @@ class HandeyeCalibrate(Node):
|
|||||||
if is_pose_filled(q):
|
if is_pose_filled(q):
|
||||||
poses.append(q)
|
poses.append(q)
|
||||||
else:
|
else:
|
||||||
self.get_logger().warn(f'Pose slot {i} empty (all-zero) — skipped')
|
self.get_logger().warn(f'位姿槽 {i} 为空(全零)—— 已跳过')
|
||||||
return poses
|
return poses
|
||||||
|
|
||||||
def _resolve_intrinsics(self) -> bool:
|
def _resolve_intrinsics(self) -> bool:
|
||||||
@@ -430,7 +430,7 @@ class HandeyeCalibrate(Node):
|
|||||||
self._intrinsics = _camera_info_to_intrinsics(self._latest_info)
|
self._intrinsics = _camera_info_to_intrinsics(self._latest_info)
|
||||||
K = self._intrinsics.K
|
K = self._intrinsics.K
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Intrinsics from CameraInfo ({self._info_topic}): '
|
f'内参来自 CameraInfo({self._info_topic}):'
|
||||||
f'fx={K[0, 0]:.2f} fy={K[1, 1]:.2f} cx={K[0, 2]:.2f} cy={K[1, 2]:.2f}'
|
f'fx={K[0, 0]:.2f} fy={K[1, 1]:.2f} cx={K[0, 2]:.2f} cy={K[1, 2]:.2f}'
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
@@ -445,19 +445,19 @@ class HandeyeCalibrate(Node):
|
|||||||
height=self.rs_height,
|
height=self.rs_height,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().error(f'RealSense intrinsics query failed: {exc}')
|
self.get_logger().error(f'RealSense 内参查询失败:{exc}')
|
||||||
else:
|
else:
|
||||||
self._intrinsics = CameraIntrinsics(K=intr.as_K(), dist=intr.as_dist())
|
self._intrinsics = CameraIntrinsics(K=intr.as_K(), dist=intr.as_dist())
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Intrinsics from RealSense {intr.model.upper()} '
|
f'内参来自 RealSense {intr.model.upper()} '
|
||||||
f'serial={intr.serial} {intr.width}x{intr.height}: '
|
f'serial={intr.serial} {intr.width}x{intr.height}:'
|
||||||
f'fx={intr.fx:.2f} fy={intr.fy:.2f} '
|
f'fx={intr.fx:.2f} fy={intr.fy:.2f} '
|
||||||
f'cx={intr.cx:.2f} cy={intr.cy:.2f}'
|
f'cx={intr.cx:.2f} cy={intr.cy:.2f}'
|
||||||
)
|
)
|
||||||
if (intr.width, intr.height) != (self.rs_width, self.rs_height):
|
if (intr.width, intr.height) != (self.rs_width, self.rs_height):
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'Requested {self.rs_width}x{self.rs_height}, '
|
f'请求 {self.rs_width}x{self.rs_height},'
|
||||||
f'got {intr.width}x{intr.height} (closest profile)'
|
f'实际得到 {intr.width}x{intr.height}(最接近的 profile)'
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -468,8 +468,8 @@ class HandeyeCalibrate(Node):
|
|||||||
cy = float(self.get_parameter('camera_cy').value)
|
cy = float(self.get_parameter('camera_cy').value)
|
||||||
if fx <= 0 or fy <= 0:
|
if fx <= 0 or fy <= 0:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
'No intrinsics. Set camera_source:=d405|d435, '
|
'没有内参。请设置 camera_source:=d405|d435,'
|
||||||
'or provide camera_info_topic, or fill camera_fx/fy/cx/cy.'
|
'或提供 camera_info_topic,或填写 camera_fx/fy/cx/cy。'
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
dist = np.array(
|
dist = np.array(
|
||||||
@@ -478,7 +478,7 @@ class HandeyeCalibrate(Node):
|
|||||||
K = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]], dtype=float)
|
K = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]], dtype=float)
|
||||||
self._intrinsics = CameraIntrinsics(K=K, dist=dist)
|
self._intrinsics = CameraIntrinsics(K=K, dist=dist)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Intrinsics from YAML: fx={fx:.2f} fy={fy:.2f} cx={cx:.2f} cy={cy:.2f}'
|
f'内参来自 YAML:fx={fx:.2f} fy={fy:.2f} cx={cx:.2f} cy={cy:.2f}'
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -561,7 +561,7 @@ class HandeyeCalibrate(Node):
|
|||||||
try:
|
try:
|
||||||
bgr = _image_to_bgr(self._latest_image)
|
bgr = _image_to_bgr(self._latest_image)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().error(f'Image convert failed: {exc}')
|
self.get_logger().error(f'图像转换失败:{exc}')
|
||||||
return False, last_std, None, None, None
|
return False, last_std, None, None, None
|
||||||
detected = detect_chessboard(bgr, self.board)
|
detected = detect_chessboard(bgr, self.board)
|
||||||
corners = detected[0] if detected is not None else None
|
corners = detected[0] if detected is not None else None
|
||||||
@@ -574,9 +574,9 @@ class HandeyeCalibrate(Node):
|
|||||||
last_bgr = bgr
|
last_bgr = bgr
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_log > 1.0:
|
if now - last_log > 1.0:
|
||||||
seen = 'yes' if corners is not None else 'NO'
|
seen = '有' if corners is not None else '无'
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f' board={seen} corner_std='
|
f' 棋盘={seen} 角点标准差='
|
||||||
f'{std if std < 1e8 else float("nan"):.3f}px'
|
f'{std if std < 1e8 else float("nan"):.3f}px'
|
||||||
)
|
)
|
||||||
last_log = now
|
last_log = now
|
||||||
@@ -586,7 +586,12 @@ class HandeyeCalibrate(Node):
|
|||||||
|
|
||||||
|
|
||||||
def _verify_joints(self, target: List[float], tol: float = 0.08) -> bool:
|
def _verify_joints(self, target: List[float], tol: float = 0.08) -> bool:
|
||||||
"""Confirm joint_states reached target (detect fake trajectory success)."""
|
"""确认 ``joint_states`` 真的走到了目标位置。
|
||||||
|
|
||||||
|
用来识破"假的成功":有些执行接口会立刻返回成功,但关节其实没动
|
||||||
|
(或只动了一半)。这里在 2 秒内轮询关节角,逐轴比对容差 ``tol``(弧度),
|
||||||
|
只有全部落在容差内才算真到位。
|
||||||
|
"""
|
||||||
deadline = time.monotonic() + 2.0
|
deadline = time.monotonic() + 2.0
|
||||||
while rclpy.ok() and time.monotonic() < deadline:
|
while rclpy.ok() and time.monotonic() < deadline:
|
||||||
rclpy.spin_once(self, timeout_sec=0.05)
|
rclpy.spin_once(self, timeout_sec=0.05)
|
||||||
@@ -602,10 +607,10 @@ class HandeyeCalibrate(Node):
|
|||||||
continue
|
continue
|
||||||
err = float(np.sqrt(np.mean([(a - b) ** 2 for a, b in zip(actual, target)])))
|
err = float(np.sqrt(np.mean([(a - b) ** 2 for a, b in zip(actual, target)])))
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f' joint RMSE vs target: {err:.4f} rad (tol={tol})'
|
f' 关节相对目标的 RMSE:{err:.4f} rad(tol={tol})'
|
||||||
)
|
)
|
||||||
return err <= tol
|
return err <= tol
|
||||||
self.get_logger().error('No joint_states while verifying motion')
|
self.get_logger().error('校验运动时没有 joint_states')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _move_one(self, positions: List[float], duration: float) -> int:
|
def _move_one(self, positions: List[float], duration: float) -> int:
|
||||||
@@ -617,18 +622,18 @@ class HandeyeCalibrate(Node):
|
|||||||
goal.time_from_start = [float(duration)]
|
goal.time_from_start = [float(duration)]
|
||||||
|
|
||||||
send = self._client.send_goal_async(goal)
|
send = self._client.send_goal_async(goal)
|
||||||
if not self._wait_future(send, 10.0, 'send goal'):
|
if not self._wait_future(send, 10.0, '发送目标'):
|
||||||
return 3
|
return 3
|
||||||
handle = send.result()
|
handle = send.result()
|
||||||
if handle is None or not handle.accepted:
|
if handle is None or not handle.accepted:
|
||||||
self.get_logger().error('Goal rejected')
|
self.get_logger().error('目标被拒绝')
|
||||||
return 4
|
return 4
|
||||||
res_fut = handle.get_result_async()
|
res_fut = handle.get_result_async()
|
||||||
if not self._wait_future(res_fut, max(60.0, duration * 3.0), 'result'):
|
if not self._wait_future(res_fut, max(60.0, duration * 3.0), '结果'):
|
||||||
return 5
|
return 5
|
||||||
result = res_fut.result().result
|
result = res_fut.result().result
|
||||||
if not result.success:
|
if not result.success:
|
||||||
self.get_logger().error(f'Move failed: {result.message}')
|
self.get_logger().error(f'移动失败:{result.message}')
|
||||||
return 6
|
return 6
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -637,7 +642,7 @@ class HandeyeCalibrate(Node):
|
|||||||
while rclpy.ok() and not fut.done():
|
while rclpy.ok() and not fut.done():
|
||||||
rclpy.spin_once(self, timeout_sec=0.05)
|
rclpy.spin_once(self, timeout_sec=0.05)
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
self.get_logger().error(f'Timeout waiting for {label}')
|
self.get_logger().error(f'等待 {label} 超时')
|
||||||
return False
|
return False
|
||||||
return fut.done()
|
return fut.done()
|
||||||
|
|
||||||
@@ -656,7 +661,7 @@ class HandeyeCalibrate(Node):
|
|||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
if not cv2.imwrite(str(path), bgr):
|
if not cv2.imwrite(str(path), bgr):
|
||||||
raise RuntimeError(f'cv2.imwrite failed: {path}')
|
raise RuntimeError(f'cv2.imwrite 失败:{path}')
|
||||||
|
|
||||||
|
|
||||||
def main(args=None) -> None:
|
def main(args=None) -> None:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Hand-eye helpers: chessboard stabilize + AX=XB solve.
|
"""手眼标定辅助工具:棋盘稳定判定 + AX=XB 求解。
|
||||||
|
|
||||||
Uses ``cv2.calibrateHandEye`` when present; otherwise Tsai-Lenz (NumPy).
|
有 ``cv2.calibrateHandEye`` 时用它,否则用 Tsai-Lenz(NumPy 实现)。
|
||||||
Chessboard detection always needs OpenCV.
|
棋盘检测始终依赖 OpenCV。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,9 +21,9 @@ except Exception: # noqa: BLE001
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BoardConfig:
|
class BoardConfig:
|
||||||
cols: int # inner corners (width)
|
cols: int # 内角点数(宽)
|
||||||
rows: int # inner corners (height)
|
rows: int # 内角点数(高)
|
||||||
square_size: float # meters
|
square_size: float # 米
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -78,9 +78,9 @@ def board_object_points(cfg: BoardConfig) -> np.ndarray:
|
|||||||
def detect_chessboard(
|
def detect_chessboard(
|
||||||
image_bgr: np.ndarray, cfg: BoardConfig
|
image_bgr: np.ndarray, cfg: BoardConfig
|
||||||
) -> Optional[Tuple[np.ndarray, Tuple[int, int]]]:
|
) -> Optional[Tuple[np.ndarray, Tuple[int, int]]]:
|
||||||
"""Return (corners, pattern_size) or None. pattern is (cols, rows) used."""
|
"""返回 (角点, pattern_size) 或 None。pattern 为实际使用的 (cols, rows)。"""
|
||||||
if cv2 is None:
|
if cv2 is None:
|
||||||
raise RuntimeError('OpenCV (cv2) is required for chessboard detection')
|
raise RuntimeError('棋盘检测需要 OpenCV(cv2)')
|
||||||
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
||||||
pattern = (int(cfg.cols), int(cfg.rows))
|
pattern = (int(cfg.cols), int(cfg.rows))
|
||||||
flags = cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE
|
flags = cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE
|
||||||
@@ -105,7 +105,7 @@ def solve_board_pose(
|
|||||||
pattern: Optional[Tuple[int, int]] = None,
|
pattern: Optional[Tuple[int, int]] = None,
|
||||||
) -> Tuple[np.ndarray, np.ndarray]:
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
if cv2 is None:
|
if cv2 is None:
|
||||||
raise RuntimeError('OpenCV (cv2) is required for solvePnP')
|
raise RuntimeError('solvePnP 需要 OpenCV(cv2)')
|
||||||
if pattern is None:
|
if pattern is None:
|
||||||
pattern = (cfg.cols, cfg.rows)
|
pattern = (cfg.cols, cfg.rows)
|
||||||
board = BoardConfig(pattern[0], pattern[1], cfg.square_size)
|
board = BoardConfig(pattern[0], pattern[1], cfg.square_size)
|
||||||
@@ -114,7 +114,7 @@ def solve_board_pose(
|
|||||||
obj, corners, cam.K, cam.dist, flags=cv2.SOLVEPNP_ITERATIVE
|
obj, corners, cam.K, cam.dist, flags=cv2.SOLVEPNP_ITERATIVE
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise RuntimeError('solvePnP failed')
|
raise RuntimeError('solvePnP 失败')
|
||||||
R, _ = cv2.Rodrigues(rvec)
|
R, _ = cv2.Rodrigues(rvec)
|
||||||
return R.astype(float), np.asarray(tvec, dtype=float).reshape(3, 1)
|
return R.astype(float), np.asarray(tvec, dtype=float).reshape(3, 1)
|
||||||
|
|
||||||
@@ -172,10 +172,10 @@ def calibrate_hand_eye_tsai(
|
|||||||
R_target2cam: List[np.ndarray],
|
R_target2cam: List[np.ndarray],
|
||||||
t_target2cam: List[np.ndarray],
|
t_target2cam: List[np.ndarray],
|
||||||
) -> Tuple[np.ndarray, np.ndarray]:
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
"""Tsai-Lenz. Returns R_cam2gripper, t_cam2gripper."""
|
"""Tsai-Lenz 方法。返回 R_cam2gripper、t_cam2gripper。"""
|
||||||
n = len(R_gripper2base)
|
n = len(R_gripper2base)
|
||||||
if n < 3:
|
if n < 3:
|
||||||
raise ValueError('Need >= 3 poses')
|
raise ValueError('至少需要 3 个位姿')
|
||||||
|
|
||||||
Hg = [Rt_to_T(R, t) for R, t in zip(R_gripper2base, t_gripper2base)]
|
Hg = [Rt_to_T(R, t) for R, t in zip(R_gripper2base, t_gripper2base)]
|
||||||
Hc = [Rt_to_T(R, t) for R, t in zip(R_target2cam, t_target2cam)]
|
Hc = [Rt_to_T(R, t) for R, t in zip(R_target2cam, t_target2cam)]
|
||||||
@@ -220,8 +220,8 @@ def calibrate_hand_eye(
|
|||||||
eye_to_hand: bool = False,
|
eye_to_hand: bool = False,
|
||||||
) -> Tuple[np.ndarray, np.ndarray]:
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
"""
|
"""
|
||||||
eye_in_hand -> X = cam -> gripper (mounted on flange)
|
eye_in_hand -> X = cam -> gripper(相机装在法兰上)
|
||||||
eye_to_hand -> X = cam -> base (fixed camera)
|
eye_to_hand -> X = cam -> base(相机固定安装)
|
||||||
"""
|
"""
|
||||||
Rg = list(R_gripper2base)
|
Rg = list(R_gripper2base)
|
||||||
tg = list(t_gripper2base)
|
tg = list(t_gripper2base)
|
||||||
@@ -240,7 +240,7 @@ def calibrate_hand_eye(
|
|||||||
)
|
)
|
||||||
return np.asarray(R, float), np.asarray(t, float).reshape(3, 1)
|
return np.asarray(R, float), np.asarray(t, float).reshape(3, 1)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # fall back to NumPy Tsai
|
pass # 退回 NumPy 版 Tsai
|
||||||
return calibrate_hand_eye_tsai(Rg, tg, R_target2cam, t_target2cam)
|
return calibrate_hand_eye_tsai(Rg, tg, R_target2cam, t_target2cam)
|
||||||
|
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ def format_transform_yaml(R: np.ndarray, t: np.ndarray, name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def is_pose_filled(joints: Sequence[float]) -> bool:
|
def is_pose_filled(joints: Sequence[float]) -> bool:
|
||||||
"""False if all zeros / NaN placeholder."""
|
"""若全为零 / NaN 占位则返回 False。"""
|
||||||
arr = np.asarray(joints, dtype=float)
|
arr = np.asarray(joints, dtype=float)
|
||||||
if arr.size != 7:
|
if arr.size != 7:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Query RealSense (D405 / D435) factory color intrinsics via pyrealsense2."""
|
"""通过 pyrealsense2 查询 RealSense(D405 / D435)的出厂彩色内参。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ class RealsenseIntrinsics:
|
|||||||
fy: float
|
fy: float
|
||||||
cx: float
|
cx: float
|
||||||
cy: float
|
cy: float
|
||||||
dist: List[float] # OpenCV order, length 5
|
dist: List[float] # OpenCV 顺序,长度 5
|
||||||
|
|
||||||
def as_K(self) -> np.ndarray:
|
def as_K(self) -> np.ndarray:
|
||||||
return np.array(
|
return np.array(
|
||||||
@@ -32,7 +32,7 @@ class RealsenseIntrinsics:
|
|||||||
return np.array(d[:5], dtype=float).reshape(-1, 1)
|
return np.array(d[:5], dtype=float).reshape(-1, 1)
|
||||||
|
|
||||||
|
|
||||||
# Default ROS topic namespaces when camera_source is d405/d435.
|
# camera_source 为 d405/d435 时的默认 ROS 话题命名空间。
|
||||||
REALSENSE_TOPIC_DEFAULTS = {
|
REALSENSE_TOPIC_DEFAULTS = {
|
||||||
'd405': {
|
'd405': {
|
||||||
'image_topic': '/d405/color/image_raw',
|
'image_topic': '/d405/color/image_raw',
|
||||||
@@ -55,13 +55,13 @@ def _norm_model(model: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def list_realsense_devices() -> List[Tuple[str, str]]:
|
def list_realsense_devices() -> List[Tuple[str, str]]:
|
||||||
"""Return [(name, serial), ...]."""
|
"""返回 [(name, serial), ...]。"""
|
||||||
try:
|
try:
|
||||||
import pyrealsense2 as rs
|
import pyrealsense2 as rs
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
'pyrealsense2 not installed. '
|
'未安装 pyrealsense2。'
|
||||||
'Install in ros_flexiv: pip/conda install pyrealsense2'
|
'请在 ros_flexiv 环境中安装:pip/conda install pyrealsense2'
|
||||||
) from exc
|
) from exc
|
||||||
out: List[Tuple[str, str]] = []
|
out: List[Tuple[str, str]] = []
|
||||||
for d in rs.context().devices:
|
for d in rs.context().devices:
|
||||||
@@ -79,27 +79,27 @@ def query_realsense_color_intrinsics(
|
|||||||
height: int = 720,
|
height: int = 720,
|
||||||
) -> RealsenseIntrinsics:
|
) -> RealsenseIntrinsics:
|
||||||
"""
|
"""
|
||||||
Read on-device color intrinsics for D405/D435 at the given resolution.
|
在给定分辨率下读取 D405/D435 设备上的彩色内参。
|
||||||
|
|
||||||
Does not start a streaming pipeline (uses profile metadata).
|
不会启动流管道(只读取 profile 元数据)。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import pyrealsense2 as rs
|
import pyrealsense2 as rs
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
'pyrealsense2 not installed. '
|
'未安装 pyrealsense2。'
|
||||||
'Install in ros_flexiv: pip/conda install pyrealsense2'
|
'请在 ros_flexiv 环境中安装:pip/conda install pyrealsense2'
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
want = _norm_model(model)
|
want = _norm_model(model)
|
||||||
if want not in ('d405', 'd435'):
|
if want not in ('d405', 'd435'):
|
||||||
raise ValueError(f'Unsupported model {model!r}; use d405 or d435')
|
raise ValueError(f'不支持的型号 {model!r};请使用 d405 或 d435')
|
||||||
|
|
||||||
hint = want.upper() # D405 / D435
|
hint = want.upper() # D405 / D435
|
||||||
serial = (serial or '').strip()
|
serial = (serial or '').strip()
|
||||||
devices = list(rs.context().devices)
|
devices = list(rs.context().devices)
|
||||||
if not devices:
|
if not devices:
|
||||||
raise RuntimeError('No RealSense device connected')
|
raise RuntimeError('未连接任何 RealSense 设备')
|
||||||
|
|
||||||
chosen = None
|
chosen = None
|
||||||
for d in devices:
|
for d in devices:
|
||||||
@@ -119,14 +119,14 @@ def query_realsense_color_intrinsics(
|
|||||||
for d in devices
|
for d in devices
|
||||||
)
|
)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'No RealSense matching model={want!r} serial={serial!r}. '
|
f'没有匹配 model={want!r} serial={serial!r} 的 RealSense。'
|
||||||
f'Connected: {avail or "none"}'
|
f'已连接的设备:{avail or "无"}'
|
||||||
)
|
)
|
||||||
|
|
||||||
name = chosen.get_info(rs.camera_info.name)
|
name = chosen.get_info(rs.camera_info.name)
|
||||||
sn = chosen.get_info(rs.camera_info.serial_number)
|
sn = chosen.get_info(rs.camera_info.serial_number)
|
||||||
|
|
||||||
best = None # (score, width, height, fx, fy, cx, cy, coeffs)
|
best = None # (评分, width, height, fx, fy, cx, cy, 畸变系数)
|
||||||
for s in chosen.sensors:
|
for s in chosen.sensors:
|
||||||
for p in s.profiles:
|
for p in s.profiles:
|
||||||
if p.stream_type() != rs.stream.color:
|
if p.stream_type() != rs.stream.color:
|
||||||
@@ -155,7 +155,7 @@ def query_realsense_color_intrinsics(
|
|||||||
best = cand
|
best = cand
|
||||||
|
|
||||||
if best is None:
|
if best is None:
|
||||||
raise RuntimeError(f'No color stream profile on {name} ({sn})')
|
raise RuntimeError(f'{name} ({sn}) 上没有彩色流 profile')
|
||||||
|
|
||||||
_, w, h, fx, fy, cx, cy, coeffs = best
|
_, w, h, fx, fy, cx, cy, coeffs = best
|
||||||
while len(coeffs) < 5:
|
while len(coeffs) < 5:
|
||||||
@@ -174,7 +174,7 @@ def query_realsense_color_intrinsics(
|
|||||||
|
|
||||||
|
|
||||||
def is_realsense_source(name: str) -> bool:
|
def is_realsense_source(name: str) -> bool:
|
||||||
"""True for d405 / d435 and namespaced aliases (d405_left, d405_right)."""
|
"""对 d405 / d435 及带命名空间的别名(d405_left、d405_right)返回 True。"""
|
||||||
return _norm_model(name or "") in ("d405", "d435")
|
return _norm_model(name or "") in ("d405", "d435")
|
||||||
|
|
||||||
|
|
||||||
@@ -185,9 +185,9 @@ def resolve_realsense_topics(
|
|||||||
image_topic: str = '',
|
image_topic: str = '',
|
||||||
camera_info_topic: str = '',
|
camera_info_topic: str = '',
|
||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
"""Fill default image/camera_info topics for d405/d435 if empty.
|
"""为空时,为 d405/d435 填充默认的 image/camera_info 话题。
|
||||||
|
|
||||||
``model`` may be a topic namespace such as ``d405_left`` / ``d405_right``.
|
``model`` 可以是 ``d405_left`` / ``d405_right`` 这类话题命名空间。
|
||||||
"""
|
"""
|
||||||
ns = (camera_namespace or model or 'd405').strip().strip('/')
|
ns = (camera_namespace or model or 'd405').strip().strip('/')
|
||||||
img = (image_topic or '').strip()
|
img = (image_topic or '').strip()
|
||||||
@@ -210,7 +210,7 @@ def apply_realsense_exposure(
|
|||||||
gain: float | None = None,
|
gain: float | None = None,
|
||||||
log=None,
|
log=None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Cap AE / kill projector. Wrist D405 overexposure is usually long AE + emitter."""
|
"""限制自动曝光 / 关闭投影器。腕部 D405 过曝通常是自动曝光过长 + 发射器导致。"""
|
||||||
import pyrealsense2 as rs
|
import pyrealsense2 as rs
|
||||||
|
|
||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
@@ -230,7 +230,7 @@ def apply_realsense_exposure(
|
|||||||
laser_opt = getattr(rs.option, "laser_power", None)
|
laser_opt = getattr(rs.option, "laser_power", None)
|
||||||
if laser_opt is not None and not emitter_enabled:
|
if laser_opt is not None and not emitter_enabled:
|
||||||
_set(sensor, laser_opt, float(laser_power))
|
_set(sensor, laser_opt, float(laser_power))
|
||||||
# Must disable AE *before* writing exposure; D405 firmware ignores exposure while AE is on.
|
# 必须在写曝光*之前*关闭自动曝光;D405 固件在自动曝光开启时会忽略曝光设置。
|
||||||
if _set(sensor, rs.option.enable_auto_exposure, 1.0 if auto_exposure else 0.0):
|
if _set(sensor, rs.option.enable_auto_exposure, 1.0 if auto_exposure else 0.0):
|
||||||
notes.append(f"ae={int(bool(auto_exposure))}")
|
notes.append(f"ae={int(bool(auto_exposure))}")
|
||||||
pri = getattr(rs.option, "auto_exposure_priority", None)
|
pri = getattr(rs.option, "auto_exposure_priority", None)
|
||||||
@@ -247,5 +247,5 @@ def apply_realsense_exposure(
|
|||||||
if gain is not None and _set(sensor, rs.option.gain, float(gain)):
|
if gain is not None and _set(sensor, rs.option.gain, float(gain)):
|
||||||
notes.append(f"gain={gain}")
|
notes.append(f"gain={gain}")
|
||||||
if log and notes:
|
if log and notes:
|
||||||
log("RealSense exposure: " + ", ".join(dict.fromkeys(notes)))
|
log("RealSense 曝光设置:" + ", ".join(dict.fromkeys(notes)))
|
||||||
return notes
|
return notes
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Hand-eye teach: live preview + Free Drive + Enter to photo/record joints.
|
"""手眼示教:实时预览 + Free Drive + 按 Enter 拍照/记录关节。
|
||||||
|
|
||||||
Two backends
|
两种后端
|
||||||
------------
|
------------
|
||||||
ros (default, stack running):
|
ros(默认,整套栈正在运行):
|
||||||
- Joints from /get_arm_state
|
- 关节来自 ``/get_arm_state``
|
||||||
- Drag: teach pendant Free Drive + hold enable
|
- 拖拽:示教器 Free Drive + 按住使能键
|
||||||
Keep: bash ~/ros2/scripts/run_real.sh
|
需保持:bash ~/ros2/scripts/run_real.sh
|
||||||
|
|
||||||
rdk (software Free Drive):
|
rdk(软件 Free Drive):
|
||||||
- Stop run_real.sh first (RDK exclusive)
|
- 先停掉 run_real.sh(RDK 独占)
|
||||||
- Press 'd' to enable FloatingJoint drag via RDK
|
- 按 'd' 通过 RDK 启用 FloatingJoint 拖拽
|
||||||
- Joints from RDK robot.states
|
- 关节来自 RDK 的 robot.states
|
||||||
|
|
||||||
source ~/ros2/scripts/env.sh
|
source ~/ros2/scripts/env.sh
|
||||||
ros2 run arm_application handeye_teach --ros-args -p backend:=rdk
|
ros2 run arm_application handeye_teach --ros-args -p backend:=rdk
|
||||||
|
|
||||||
UI keys (preview window)
|
界面按键(预览窗口)
|
||||||
Enter / Space photo + record joints
|
Enter / Space 拍照 + 记录关节
|
||||||
d enable Free Drive (rdk) / show pendant tip (ros)
|
d 启用 Free Drive(rdk)/ 显示示教器提示(ros)
|
||||||
s stop Free Drive (rdk) / hold
|
s 停止 Free Drive(rdk)/ 保持
|
||||||
q / Esc finish
|
q / Esc 结束
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -40,21 +40,21 @@ from rclpy.node import Node
|
|||||||
|
|
||||||
HELP = """
|
HELP = """
|
||||||
================================================
|
================================================
|
||||||
Hand-eye TEACH: preview -> drag -> photo+pose
|
手眼示教 TEACH:预览 -> 拖拽 -> 拍照+位姿
|
||||||
================================================
|
================================================
|
||||||
Window keys:
|
窗口按键:
|
||||||
Enter / Space = take photo + save joints
|
Enter / Space = 拍照 + 保存关节
|
||||||
d = Free Drive ON
|
d = 开启 Free Drive
|
||||||
s = Free Drive OFF / hold
|
s = 关闭 Free Drive / 保持
|
||||||
q / Esc = finish
|
q / Esc = 结束
|
||||||
Tip: make board clear & fill ~30-70% of image
|
提示:让棋盘清晰,并占到画面的约 30-70%
|
||||||
Prefer 8-12 poses with varied angles
|
建议采 8-12 个角度有变化的位姿
|
||||||
================================================
|
================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _setup_matplotlib_font():
|
def _setup_matplotlib_font():
|
||||||
"""Avoid Chinese mojibake: prefer CJK font if present."""
|
"""避免中文乱码:优先选用存在的中日韩(CJK)字体。"""
|
||||||
import matplotlib
|
import matplotlib
|
||||||
|
|
||||||
matplotlib.use('QtAgg')
|
matplotlib.use('QtAgg')
|
||||||
@@ -77,7 +77,7 @@ def _setup_matplotlib_font():
|
|||||||
chosen = name
|
chosen = name
|
||||||
break
|
break
|
||||||
if chosen is None:
|
if chosen is None:
|
||||||
# try by file path
|
# 按文件路径再试一次
|
||||||
for path in (
|
for path in (
|
||||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc',
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc',
|
||||||
@@ -104,7 +104,7 @@ def _param_as_str(node: Node, name: str, default: str = '') -> str:
|
|||||||
|
|
||||||
|
|
||||||
class RdkBridge:
|
class RdkBridge:
|
||||||
"""Talk to Flexiv RDK via rdk_py310 subprocess (Python 3.10 ABI)."""
|
"""通过 rdk_py310 子进程(Python 3.10 ABI)与 Flexiv RDK 通信。"""
|
||||||
|
|
||||||
def __init__(self, robot_sn: str, logger=None) -> None:
|
def __init__(self, robot_sn: str, logger=None) -> None:
|
||||||
import json
|
import json
|
||||||
@@ -120,9 +120,9 @@ class RdkBridge:
|
|||||||
if not bridge.is_file():
|
if not bridge.is_file():
|
||||||
bridge = Path('/home/cc/ros2/scripts/rdk_drag_bridge.py')
|
bridge = Path('/home/cc/ros2/scripts/rdk_drag_bridge.py')
|
||||||
if not bridge.is_file():
|
if not bridge.is_file():
|
||||||
raise FileNotFoundError(f'rdk_drag_bridge.py not found: {bridge}')
|
raise FileNotFoundError(f'找不到 rdk_drag_bridge.py:{bridge}')
|
||||||
|
|
||||||
# Only Flexiv libs — do not inherit conda LD_LIBRARY_PATH (can hang RDK).
|
# 只保留 Flexiv 的库 —— 不要继承 conda 的 LD_LIBRARY_PATH(会让 RDK 卡死)。
|
||||||
flexiv_lib = os.path.expanduser('~/flexiv_install/lib')
|
flexiv_lib = os.path.expanduser('~/flexiv_install/lib')
|
||||||
env = {
|
env = {
|
||||||
'HOME': os.environ.get('HOME', ''),
|
'HOME': os.environ.get('HOME', ''),
|
||||||
@@ -130,9 +130,9 @@ class RdkBridge:
|
|||||||
'LD_LIBRARY_PATH': flexiv_lib,
|
'LD_LIBRARY_PATH': flexiv_lib,
|
||||||
'LANG': 'C.UTF-8',
|
'LANG': 'C.UTF-8',
|
||||||
}
|
}
|
||||||
# Do NOT inherit conda PYTHONPATH.
|
# 不要继承 conda 的 PYTHONPATH。
|
||||||
# IMPORTANT: do not use PIPE for stderr — RDK logs fill the pipe and
|
# 重要:stderr 不能用 PIPE —— RDK 日志会填满管道并让桥接进程死锁
|
||||||
# deadlock the bridge (stdout never replies). Log to a file instead.
|
# (stdout 永远不返回)。改成写入日志文件。
|
||||||
log_path = Path('/tmp/rdk_drag_bridge.log')
|
log_path = Path('/tmp/rdk_drag_bridge.log')
|
||||||
self._err_file = open(log_path, 'w', encoding='utf-8')
|
self._err_file = open(log_path, 'w', encoding='utf-8')
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
@@ -145,14 +145,14 @@ class RdkBridge:
|
|||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f'RDK bridge started; log → {log_path}')
|
logger.info(f'RDK 桥接已启动;日志 → {log_path}')
|
||||||
pong = self._ask({'cmd': 'ping'}, timeout=8.0)
|
pong = self._ask({'cmd': 'ping'}, timeout=8.0)
|
||||||
if not pong.get('ok'):
|
if not pong.get('ok'):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'RDK bridge ping failed: {pong}. See {log_path}'
|
f'RDK 桥接 ping 失败:{pong}。参见 {log_path}'
|
||||||
)
|
)
|
||||||
if logger:
|
if logger:
|
||||||
logger.info('RDK bridge ping OK, connecting to robot...')
|
logger.info('RDK 桥接 ping 正常,正在连接机器人...')
|
||||||
conn = self._ask(
|
conn = self._ask(
|
||||||
{
|
{
|
||||||
'cmd': 'connect',
|
'cmd': 'connect',
|
||||||
@@ -163,11 +163,11 @@ class RdkBridge:
|
|||||||
)
|
)
|
||||||
if not conn.get('ok'):
|
if not conn.get('ok'):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'RDK connect failed: {conn.get("error")}. '
|
f'RDK 连接失败:{conn.get("error")}。'
|
||||||
f'Stop run_real.sh first; AUTO Remote + clear fault. See {log_path}'
|
f'请先停掉 run_real.sh;切 AUTO Remote 并清除故障。参见 {log_path}'
|
||||||
)
|
)
|
||||||
if logger:
|
if logger:
|
||||||
logger.info(f'RDK bridge OK (py={pong.get("py")}) sn={robot_sn}')
|
logger.info(f'RDK 桥接就绪(py={pong.get("py")})sn={robot_sn}')
|
||||||
q0 = conn.get('q') or []
|
q0 = conn.get('q') or []
|
||||||
if len(q0) >= 7:
|
if len(q0) >= 7:
|
||||||
self._last_q = [float(v) for v in q0[:7]]
|
self._last_q = [float(v) for v in q0[:7]]
|
||||||
@@ -175,9 +175,9 @@ class RdkBridge:
|
|||||||
def _ask(self, msg: dict, timeout: float = 10.0) -> dict:
|
def _ask(self, msg: dict, timeout: float = 10.0) -> dict:
|
||||||
import select
|
import select
|
||||||
|
|
||||||
# One in-flight command: camera/GUI must never share this pipe.
|
# 同一时刻只允许一条在途命令:相机/GUI 绝不能共用这个管道。
|
||||||
if not self._io.acquire(timeout=max(timeout, 0.2)):
|
if not self._io.acquire(timeout=max(timeout, 0.2)):
|
||||||
return {'ok': False, 'error': 'bridge busy'}
|
return {'ok': False, 'error': '桥接忙'}
|
||||||
try:
|
try:
|
||||||
assert self._proc.stdin and self._proc.stdout
|
assert self._proc.stdin and self._proc.stdout
|
||||||
self._proc.stdin.write(self._json.dumps(msg) + '\n')
|
self._proc.stdin.write(self._json.dumps(msg) + '\n')
|
||||||
@@ -188,7 +188,7 @@ class RdkBridge:
|
|||||||
tip = '/tmp/rdk_drag_bridge.log'
|
tip = '/tmp/rdk_drag_bridge.log'
|
||||||
return {
|
return {
|
||||||
'ok': False,
|
'ok': False,
|
||||||
'error': f'bridge exited rc={self._proc.returncode}; see {tip}',
|
'error': f'桥接已退出 rc={self._proc.returncode};参见 {tip}',
|
||||||
}
|
}
|
||||||
ready, _, _ = select.select([self._proc.stdout], [], [], 0.05)
|
ready, _, _ = select.select([self._proc.stdout], [], [], 0.05)
|
||||||
if not ready:
|
if not ready:
|
||||||
@@ -202,8 +202,8 @@ class RdkBridge:
|
|||||||
try:
|
try:
|
||||||
return self._json.loads(line)
|
return self._json.loads(line)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return {'ok': False, 'error': f'bad reply: {exc} {line!r}'}
|
return {'ok': False, 'error': f'应答异常:{exc} {line!r}'}
|
||||||
return {'ok': False, 'error': 'bridge timeout'}
|
return {'ok': False, 'error': '桥接超时'}
|
||||||
finally:
|
finally:
|
||||||
self._io.release()
|
self._io.release()
|
||||||
|
|
||||||
@@ -296,14 +296,14 @@ class HandeyeTeach(Node):
|
|||||||
self._start_gui()
|
self._start_gui()
|
||||||
print(HELP)
|
print(HELP)
|
||||||
if self.backend == 'rdk':
|
if self.backend == 'rdk':
|
||||||
print('[backend=rdk] Press d = soft-drag (works in AUTO Remote, no Floating license)')
|
print('[backend=rdk] 按 d = 软拖拽(在 AUTO Remote 下可用,无需 Floating 许可)')
|
||||||
print(' run_real.sh must be STOPPED.')
|
print(' run_real.sh 必须已停止。')
|
||||||
print(' 拖着就能按 Enter 拍照,不必先按 s')
|
print(' 拖着就能按 Enter 拍照,不必先按 s')
|
||||||
else:
|
else:
|
||||||
print('[backend=ros] Drag with pendant Free Drive + hold enable.')
|
print('[backend=ros] 用示教器 Free Drive + 按住使能键来拖拽。')
|
||||||
print(' Or stop run_real.sh and use: -p backend:=rdk')
|
print(' 或停掉 run_real.sh 后改用:-p backend:=rdk')
|
||||||
if font:
|
if font:
|
||||||
self.get_logger().info(f'UI font: {font}')
|
self.get_logger().info(f'界面字体:{font}')
|
||||||
self.create_timer(1.0 / max(1, self._fps), self._tick)
|
self.create_timer(1.0 / max(1, self._fps), self._tick)
|
||||||
|
|
||||||
def _init_ros_state(self) -> None:
|
def _init_ros_state(self) -> None:
|
||||||
@@ -312,15 +312,15 @@ class HandeyeTeach(Node):
|
|||||||
self._get_state = self.create_client(GetArmState, 'get_arm_state')
|
self._get_state = self.create_client(GetArmState, 'get_arm_state')
|
||||||
if not self._get_state.wait_for_service(timeout_sec=10.0):
|
if not self._get_state.wait_for_service(timeout_sec=10.0):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
'get_arm_state unavailable. Start run_real.sh, '
|
'get_arm_state 不可用。请启动 run_real.sh,'
|
||||||
'or use: -p backend:=rdk (after stopping the stack)'
|
'或改用:-p backend:=rdk(需先停掉整套栈)'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _init_rdk(self) -> None:
|
def _init_rdk(self) -> None:
|
||||||
self.get_logger().info(f'Connecting RDK bridge to {self.robot_sn} ...')
|
self.get_logger().info(f'正在把 RDK 桥接连到 {self.robot_sn} ...')
|
||||||
self._rdk = RdkBridge(self.robot_sn, logger=self.get_logger())
|
self._rdk = RdkBridge(self.robot_sn, logger=self.get_logger())
|
||||||
self._robot = True # marker: rdk backend ready
|
self._robot = True # 标记:RDK 后端已就绪
|
||||||
self.get_logger().info('RDK connected. Press d for Free Drive.')
|
self.get_logger().info('RDK 已连接。按 d 启用 Free Drive。')
|
||||||
|
|
||||||
def _start_camera(self) -> None:
|
def _start_camera(self) -> None:
|
||||||
import pyrealsense2 as rs
|
import pyrealsense2 as rs
|
||||||
@@ -342,7 +342,7 @@ class HandeyeTeach(Node):
|
|||||||
cfg.enable_stream(rs.stream.color, ww, hh, rs.format.bgr8, ff)
|
cfg.enable_stream(rs.stream.color, ww, hh, rs.format.bgr8, ff)
|
||||||
profile = pipe.start(cfg)
|
profile = pipe.start(cfg)
|
||||||
self._fps = ff
|
self._fps = ff
|
||||||
self.get_logger().info(f'Camera {ww}x{hh}@{ff}Hz')
|
self.get_logger().info(f'相机 {ww}x{hh}@{ff}Hz')
|
||||||
break
|
break
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
last_err = exc
|
last_err = exc
|
||||||
@@ -353,7 +353,7 @@ class HandeyeTeach(Node):
|
|||||||
pipe = rs.pipeline()
|
pipe = rs.pipeline()
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Cannot open D405 — stop realsense_color_pub first. ({last_err})'
|
f'无法打开 D405 —— 请先停掉 realsense_color_pub。({last_err})'
|
||||||
)
|
)
|
||||||
self._pipe = pipe
|
self._pipe = pipe
|
||||||
try:
|
try:
|
||||||
@@ -371,7 +371,7 @@ class HandeyeTeach(Node):
|
|||||||
log=self.get_logger().info,
|
log=self.get_logger().info,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f'exposure apply: {exc}')
|
self.get_logger().warn(f'曝光设置应用失败:{exc}')
|
||||||
self._cam_thread = threading.Thread(target=self._cam_loop, name='d405', daemon=True)
|
self._cam_thread = threading.Thread(target=self._cam_loop, name='d405', daemon=True)
|
||||||
self._cam_thread.start()
|
self._cam_thread.start()
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@ class HandeyeTeach(Node):
|
|||||||
self._plt = plt
|
self._plt = plt
|
||||||
self._fig, self._ax = plt.subplots(1, 1, figsize=(11, 7))
|
self._fig, self._ax = plt.subplots(1, 1, figsize=(11, 7))
|
||||||
self._fig.canvas.manager.set_window_title(
|
self._fig.canvas.manager.set_window_title(
|
||||||
'handeye_teach | Enter=photo d=drag s=stop q=quit'
|
'handeye_teach | Enter=拍照 d=拖拽 s=停止 q=退出'
|
||||||
)
|
)
|
||||||
self._im = None
|
self._im = None
|
||||||
self._status = self._ax.text(
|
self._status = self._ax.text(
|
||||||
@@ -415,21 +415,21 @@ class HandeyeTeach(Node):
|
|||||||
plt.show(block=False)
|
plt.show(block=False)
|
||||||
|
|
||||||
def _status_text(self) -> str:
|
def _status_text(self) -> str:
|
||||||
drag = 'DRAG ON' if self._drag_on else 'drag off'
|
drag = '拖拽开启' if self._drag_on else '拖拽关闭'
|
||||||
if self.backend == 'rdk':
|
if self.backend == 'rdk':
|
||||||
tip = 'd=soft-drag s=hold'
|
tip = 'd=软拖拽 s=保持'
|
||||||
else:
|
else:
|
||||||
tip = 'Pendant FreeDrive+Enable'
|
tip = '示教器 FreeDrive+使能'
|
||||||
return (
|
return (
|
||||||
f'captured:{len(self._captures)} [{drag}] '
|
f'已采集:{len(self._captures)} [{drag}] '
|
||||||
f'{tip} | Enter=photo q=quit'
|
f'{tip} | Enter=拍照 q=退出'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_key(self, event) -> None:
|
def _on_key(self, event) -> None:
|
||||||
key = (event.key or '').lower()
|
key = (event.key or '').lower()
|
||||||
if key in ('q', 'escape'):
|
if key in ('q', 'escape'):
|
||||||
self._quit = True
|
self._quit = True
|
||||||
self.get_logger().info('Quit requested')
|
self.get_logger().info('收到退出请求')
|
||||||
return
|
return
|
||||||
if self._cmd_pending:
|
if self._cmd_pending:
|
||||||
return
|
return
|
||||||
@@ -446,7 +446,7 @@ class HandeyeTeach(Node):
|
|||||||
elif key == 's':
|
elif key == 's':
|
||||||
self._disable_drag()
|
self._disable_drag()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().error(f'key {key}: {exc}')
|
self.get_logger().error(f'按键 {key}:{exc}')
|
||||||
finally:
|
finally:
|
||||||
self._cmd_pending = False
|
self._cmd_pending = False
|
||||||
|
|
||||||
@@ -454,34 +454,34 @@ class HandeyeTeach(Node):
|
|||||||
if self.backend != 'rdk':
|
if self.backend != 'rdk':
|
||||||
self._drag_on = True
|
self._drag_on = True
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
'ROS backend: use teach pendant Free Drive + hold enable button. '
|
'ROS 后端:请用示教器的 Free Drive + 按住使能键。'
|
||||||
'For software drag: stop run_real.sh, then '
|
'想用软件拖拽:先停掉 run_real.sh,然后执行 '
|
||||||
'ros2 run arm_application handeye_teach --ros-args -p backend:=rdk'
|
'ros2 run arm_application handeye_teach --ros-args -p backend:=rdk'
|
||||||
)
|
)
|
||||||
self._status.set_text(self._status_text())
|
self._status.set_text(self._status_text())
|
||||||
return
|
return
|
||||||
self.get_logger().info('starting soft-drag (preview stays live) ...')
|
self.get_logger().info('正在启动软拖拽(预览保持实时刷新)...')
|
||||||
# soft = joint impedance drag under AUTO Remote (no Floating license)
|
# soft = AUTO Remote 模式下的关节阻抗拖拽(不需要 Floating 许可)
|
||||||
res = self._rdk.drag_on(self.drag_primitive, mode='soft')
|
res = self._rdk.drag_on(self.drag_primitive, mode='soft')
|
||||||
if res.get('ok'):
|
if res.get('ok'):
|
||||||
self._drag_on = True
|
self._drag_on = True
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Drag ON mode={res.get("mode")} — push the arm, Enter=photo (s=hold optional)'
|
f'拖拽已开启 mode={res.get("mode")} —— 推着机械臂走,Enter=拍照(s=保持,可选)'
|
||||||
)
|
)
|
||||||
if res.get('hint'):
|
if res.get('hint'):
|
||||||
self.get_logger().info(str(res.get('hint')))
|
self.get_logger().info(str(res.get('hint')))
|
||||||
else:
|
else:
|
||||||
self._drag_on = False
|
self._drag_on = False
|
||||||
self.get_logger().error(f'Drag failed: {res.get("error")}')
|
self.get_logger().error(f'拖拽失败:{res.get("error")}')
|
||||||
# status text is refreshed by _tick on the GUI thread
|
# 状态文字由 GUI 线程里的 _tick 刷新
|
||||||
|
|
||||||
def _disable_drag(self) -> None:
|
def _disable_drag(self) -> None:
|
||||||
if self.backend == 'rdk' and getattr(self, '_rdk', None) is not None:
|
if self.backend == 'rdk' and getattr(self, '_rdk', None) is not None:
|
||||||
res = self._rdk.drag_off()
|
res = self._rdk.drag_off()
|
||||||
if not res.get('ok'):
|
if not res.get('ok'):
|
||||||
self.get_logger().warn(f'Stop drag: {res.get("error")}')
|
self.get_logger().warn(f'停止拖拽:{res.get("error")}')
|
||||||
self._drag_on = False
|
self._drag_on = False
|
||||||
self.get_logger().info('Free Drive OFF / hold')
|
self.get_logger().info('Free Drive 已关闭 / 保持')
|
||||||
|
|
||||||
def _tick(self) -> None:
|
def _tick(self) -> None:
|
||||||
if self._quit:
|
if self._quit:
|
||||||
@@ -510,7 +510,7 @@ class HandeyeTeach(Node):
|
|||||||
if self.backend == 'rdk' and getattr(self, '_rdk', None) is not None:
|
if self.backend == 'rdk' and getattr(self, '_rdk', None) is not None:
|
||||||
q = self._rdk.joints()
|
q = self._rdk.joints()
|
||||||
if q is None:
|
if q is None:
|
||||||
self.get_logger().error('RDK joints failed')
|
self.get_logger().error('读取 RDK 关节失败')
|
||||||
return q
|
return q
|
||||||
from arm_msgs.srv import GetArmState
|
from arm_msgs.srv import GetArmState
|
||||||
|
|
||||||
@@ -532,11 +532,11 @@ class HandeyeTeach(Node):
|
|||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
bgr = None if self._latest_bgr is None else self._latest_bgr.copy()
|
bgr = None if self._latest_bgr is None else self._latest_bgr.copy()
|
||||||
if bgr is None:
|
if bgr is None:
|
||||||
self.get_logger().warn('No camera frame yet')
|
self.get_logger().warn('还没有相机帧')
|
||||||
return
|
return
|
||||||
q = self._read_joints()
|
q = self._read_joints()
|
||||||
if q is None:
|
if q is None:
|
||||||
self.get_logger().error('Failed to read joints')
|
self.get_logger().error('读取关节失败')
|
||||||
return
|
return
|
||||||
|
|
||||||
idx = len(self._captures)
|
idx = len(self._captures)
|
||||||
@@ -569,7 +569,7 @@ class HandeyeTeach(Node):
|
|||||||
if target > 0:
|
if target > 0:
|
||||||
if n < target:
|
if n < target:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'[{n}/{target}] saved photo+joints —— 还差 {target - n} 个点'
|
f'[{n}/{target}] 已保存照片+关节 —— 还差 {target - n} 个点'
|
||||||
)
|
)
|
||||||
elif n == target:
|
elif n == target:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
@@ -579,18 +579,18 @@ class HandeyeTeach(Node):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'[{n}/{target}] saved photo+joints —— 已超出目标 '
|
f'[{n}/{target}] 已保存照片+关节 —— 已超出目标 '
|
||||||
f'{n - target} 个点(会用全部点求解)'
|
f'{n - target} 个点(会用全部点求解)'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.get_logger().info(f'[{n}] saved photo+joints')
|
self.get_logger().info(f'[{n}] 已保存照片+关节')
|
||||||
self._write_yaml_snippet()
|
self._write_yaml_snippet()
|
||||||
finally:
|
finally:
|
||||||
self._busy = False
|
self._busy = False
|
||||||
|
|
||||||
def _write_yaml_snippet(self) -> None:
|
def _write_yaml_snippet(self) -> None:
|
||||||
lines = [
|
lines = [
|
||||||
'# Paste into handeye_calibrate.yaml -> waypoint_positions',
|
'# 粘贴到 handeye_calibrate.yaml -> waypoint_positions',
|
||||||
f'# num_poses: {len(self._captures)}',
|
f'# num_poses: {len(self._captures)}',
|
||||||
'waypoint_positions: [',
|
'waypoint_positions: [',
|
||||||
]
|
]
|
||||||
@@ -612,17 +612,17 @@ class HandeyeTeach(Node):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
n = len(self._captures)
|
n = len(self._captures)
|
||||||
print('\n========== TEACH DONE ==========')
|
print('\n========== 示教完成 ==========')
|
||||||
print(f'poses: {n}')
|
print(f'位姿: {n}')
|
||||||
print(f'joints: {self.poses_file}')
|
print(f'关节: {self.poses_file}')
|
||||||
print(f'yaml: {self.yaml_file}')
|
print(f'yaml: {self.yaml_file}')
|
||||||
print(f'images: {self.images_dir}')
|
print(f'图像: {self.images_dir}')
|
||||||
if n >= 1:
|
if n >= 1:
|
||||||
print('\n--- paste into handeye_calibrate.yaml ---\n')
|
print('\n--- 粘贴到 handeye_calibrate.yaml ---\n')
|
||||||
print(self.yaml_file.read_text(encoding='utf-8'))
|
print(self.yaml_file.read_text(encoding='utf-8'))
|
||||||
print('Also set num_poses:', n)
|
print('同时设置 num_poses:', n)
|
||||||
if n < 5:
|
if n < 5:
|
||||||
self.get_logger().warn(f'Only {n} poses; need >=5 (prefer 8-12).')
|
self.get_logger().warn(f'只有 {n} 个位姿;至少需要 5 个(建议 8-12 个)。')
|
||||||
|
|
||||||
|
|
||||||
def main(args=None) -> None:
|
def main(args=None) -> None:
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Hand-eye calibration waypoint runner.
|
"""手眼标定路点执行器。
|
||||||
|
|
||||||
Moves to each joint waypoint one-by-one, waits for arrival, then dwells so a
|
逐个移动到每个关节路点,等到位后停留一段时间,
|
||||||
camera (or operator) can capture a calibration image.
|
让相机(或操作者)采集一张标定图像。
|
||||||
|
|
||||||
capture_mode:
|
capture_mode:
|
||||||
auto — dwell_sec then next pose
|
auto — 停留 dwell_sec 后进入下一个位姿
|
||||||
keypress — terminal Enter to continue
|
keypress — 在终端按 Enter 继续
|
||||||
preview — OpenCV live camera + Enter/Space confirm (recommended for teaching)
|
preview — OpenCV 实时相机 + Enter/Space 确认(示教时推荐)
|
||||||
|
|
||||||
During ``preview`` mode, use the teach pendant **Free Drive** to drag-adjust
|
处于 ``preview`` 模式时,可在预览窗口打开期间用示教器的 **Free Drive**
|
||||||
while the preview window is open (ros2_control keeps AUTO Remote; drag is
|
拖拽微调(ros2_control 保持 AUTO Remote;拖拽经由示教器,而非 RDK)。
|
||||||
via pendant, not RDK).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -106,7 +105,7 @@ def _image_to_bgr(msg: Image) -> np.ndarray:
|
|||||||
img = data.reshape(h, w, 4)
|
img = data.reshape(h, w, 4)
|
||||||
bgr = img[:, :, :3]
|
bgr = img[:, :, :3]
|
||||||
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
|
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
|
||||||
raise RuntimeError(f'Unsupported image encoding: {msg.encoding!r}')
|
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
|
||||||
|
|
||||||
|
|
||||||
class HandeyeWaypoints(Node):
|
class HandeyeWaypoints(Node):
|
||||||
@@ -165,14 +164,14 @@ class HandeyeWaypoints(Node):
|
|||||||
self._cv_gui = _cv_gui_available() and self.preview_window
|
self._cv_gui = _cv_gui_available() and self.preview_window
|
||||||
if self.capture_mode == 'preview' and not self._cv_gui:
|
if self.capture_mode == 'preview' and not self._cv_gui:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
'OpenCV has no GUI (headless). Preview on /handeye/preview_image; '
|
'OpenCV 没有 GUI(无头环境)。请在 /handeye/preview_image 上预览,'
|
||||||
'confirm with Enter in this terminal.'
|
'在本终端按 Enter 确认。'
|
||||||
)
|
)
|
||||||
if self.image_topic and self.capture_mode == 'preview':
|
if self.image_topic and self.capture_mode == 'preview':
|
||||||
self.create_subscription(
|
self.create_subscription(
|
||||||
Image, self.image_topic, self._on_image, qos_profile_sensor_data
|
Image, self.image_topic, self._on_image, qos_profile_sensor_data
|
||||||
)
|
)
|
||||||
self.get_logger().info(f'Preview image: {self.image_topic}')
|
self.get_logger().info(f'预览图像:{self.image_topic}')
|
||||||
|
|
||||||
def _on_image(self, msg: Image) -> None:
|
def _on_image(self, msg: Image) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -183,10 +182,10 @@ class HandeyeWaypoints(Node):
|
|||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
if self.capture_mode == 'preview':
|
if self.capture_mode == 'preview':
|
||||||
if cv2 is None:
|
if cv2 is None:
|
||||||
self.get_logger().error('preview mode needs OpenCV (cv2)')
|
self.get_logger().error('preview 模式需要 OpenCV(cv2)')
|
||||||
return 1
|
return 1
|
||||||
if not self.image_topic:
|
if not self.image_topic:
|
||||||
self.get_logger().error('preview mode needs image_topic or camera_source')
|
self.get_logger().error('preview 模式需要 image_topic 或 camera_source')
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
times = list(self.get_parameter('waypoint_times').value)
|
times = list(self.get_parameter('waypoint_times').value)
|
||||||
@@ -194,23 +193,23 @@ class HandeyeWaypoints(Node):
|
|||||||
n_j = len(self.joint_names)
|
n_j = len(self.joint_names)
|
||||||
n_pts = len(times)
|
n_pts = len(times)
|
||||||
if n_pts < 1:
|
if n_pts < 1:
|
||||||
self.get_logger().error('waypoint_times is empty')
|
self.get_logger().error('waypoint_times 为空')
|
||||||
return 1
|
return 1
|
||||||
if len(positions) != n_pts * n_j:
|
if len(positions) != n_pts * n_j:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'waypoint_positions length {len(positions)} != {n_pts}*{n_j}'
|
f'waypoint_positions 长度 {len(positions)} != {n_pts}*{n_j}'
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
if self.start_index >= n_pts:
|
if self.start_index >= n_pts:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'start_index {self.start_index} >= num waypoints {n_pts}'
|
f'start_index {self.start_index} >= 路点总数 {n_pts}'
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if not self._client.wait_for_server(timeout_sec=15.0):
|
if not self._client.wait_for_server(timeout_sec=15.0):
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'Action [{self.action_name}] not available. '
|
f'Action [{self.action_name}] 不可用。'
|
||||||
'Start stack first: bash scripts/run_real.sh'
|
'请先启动整套栈:bash scripts/run_real.sh'
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
@@ -226,18 +225,18 @@ class HandeyeWaypoints(Node):
|
|||||||
+ [f'target_{n}' for n in self.joint_names]
|
+ [f'target_{n}' for n in self.joint_names]
|
||||||
+ [f'measured_{n}' for n in self.joint_names]
|
+ [f'measured_{n}' for n in self.joint_names]
|
||||||
)
|
)
|
||||||
self.get_logger().info(f'Logging captures → {path}')
|
self.get_logger().info(f'正在记录采集数据 → {path}')
|
||||||
|
|
||||||
if self.capture_mode == 'preview':
|
if self.capture_mode == 'preview':
|
||||||
print(PREVIEW_HELP_GUI if self._cv_gui else PREVIEW_HELP_HEADLESS)
|
print(PREVIEW_HELP_GUI if self._cv_gui else PREVIEW_HELP_HEADLESS)
|
||||||
|
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Hand-eye: {n_pts} poses, start={self.start_index}, '
|
f'手眼标定:{n_pts} 个位姿,start={self.start_index},'
|
||||||
f'v_scale={self.velocity_scale}, dwell={self.dwell_sec}s, '
|
f'v_scale={self.velocity_scale},dwell={self.dwell_sec}s,'
|
||||||
f'mode={self.capture_mode}'
|
f'mode={self.capture_mode}'
|
||||||
)
|
)
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
'Clear workspace; keep board in FOV. Ctrl+C aborts.'
|
'清理工作空间;让棋盘保持在视野内。Ctrl+C 可中止。'
|
||||||
)
|
)
|
||||||
|
|
||||||
win = 'handeye_preview'
|
win = 'handeye_preview'
|
||||||
@@ -246,7 +245,7 @@ class HandeyeWaypoints(Node):
|
|||||||
q = positions[i * n_j : (i + 1) * n_j]
|
q = positions[i * n_j : (i + 1) * n_j]
|
||||||
move_t = max(1.0, float(times[i]))
|
move_t = max(1.0, float(times[i]))
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'[{i + 1}/{n_pts}] moving (~{move_t:.1f}s)...'
|
f'[{i + 1}/{n_pts}] 移动中(约 {move_t:.1f}s)...'
|
||||||
)
|
)
|
||||||
code = self._move_one(q, move_t)
|
code = self._move_one(q, move_t)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
@@ -264,10 +263,10 @@ class HandeyeWaypoints(Node):
|
|||||||
csv_file.flush()
|
csv_file.flush()
|
||||||
|
|
||||||
if not self._wait_capture(i, n_pts):
|
if not self._wait_capture(i, n_pts):
|
||||||
self.get_logger().info('Aborted by user')
|
self.get_logger().info('用户已中止')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# After confirm in preview mode, save taught joints
|
# 在 preview 模式下确认后,保存示教关节
|
||||||
if self.capture_mode == 'preview':
|
if self.capture_mode == 'preview':
|
||||||
taught = self._read_joints()
|
taught = self._read_joints()
|
||||||
if taught:
|
if taught:
|
||||||
@@ -281,10 +280,10 @@ class HandeyeWaypoints(Node):
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.get_logger().info('Hand-eye waypoint sequence done')
|
self.get_logger().info('手眼标定路点序列执行完毕')
|
||||||
if self.append_poses_file and self.capture_mode == 'preview':
|
if self.append_poses_file and self.capture_mode == 'preview':
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Taught poses saved → {self.append_poses_file}'
|
f'示教位姿已保存 → {self.append_poses_file}'
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -299,7 +298,7 @@ class HandeyeWaypoints(Node):
|
|||||||
with path.open('a', encoding='utf-8') as f:
|
with path.open('a', encoding='utf-8') as f:
|
||||||
f.write(f'# pose {index}\n')
|
f.write(f'# pose {index}\n')
|
||||||
f.write(line + '\n')
|
f.write(line + '\n')
|
||||||
self.get_logger().info(f'Saved pose {index} → {path}')
|
self.get_logger().info(f'已保存位姿 {index} → {path}')
|
||||||
|
|
||||||
def _move_one(self, positions: List[float], duration: float) -> int:
|
def _move_one(self, positions: List[float], duration: float) -> int:
|
||||||
goal = FollowJointTrajectory.Goal()
|
goal = FollowJointTrajectory.Goal()
|
||||||
@@ -310,19 +309,19 @@ class HandeyeWaypoints(Node):
|
|||||||
goal.time_from_start = [float(duration)]
|
goal.time_from_start = [float(duration)]
|
||||||
|
|
||||||
send = self._client.send_goal_async(goal)
|
send = self._client.send_goal_async(goal)
|
||||||
if not self._wait_future(send, 10.0, 'send goal'):
|
if not self._wait_future(send, 10.0, '发送目标'):
|
||||||
return 3
|
return 3
|
||||||
handle = send.result()
|
handle = send.result()
|
||||||
if handle is None or not handle.accepted:
|
if handle is None or not handle.accepted:
|
||||||
self.get_logger().error('Goal rejected')
|
self.get_logger().error('目标被拒绝')
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
res_fut = handle.get_result_async()
|
res_fut = handle.get_result_async()
|
||||||
if not self._wait_future(res_fut, max(60.0, duration * 3.0), 'result'):
|
if not self._wait_future(res_fut, max(60.0, duration * 3.0), '结果'):
|
||||||
return 5
|
return 5
|
||||||
result = res_fut.result().result
|
result = res_fut.result().result
|
||||||
if not result.success:
|
if not result.success:
|
||||||
self.get_logger().error(f'Move failed: {result.message}')
|
self.get_logger().error(f'移动失败:{result.message}')
|
||||||
return 6
|
return 6
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -331,7 +330,7 @@ class HandeyeWaypoints(Node):
|
|||||||
while rclpy.ok() and not fut.done():
|
while rclpy.ok() and not fut.done():
|
||||||
rclpy.spin_once(self, timeout_sec=0.05)
|
rclpy.spin_once(self, timeout_sec=0.05)
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
self.get_logger().error(f'Timeout waiting for {label}')
|
self.get_logger().error(f'等待 {label} 超时')
|
||||||
return False
|
return False
|
||||||
return fut.done()
|
return fut.done()
|
||||||
|
|
||||||
@@ -359,11 +358,11 @@ class HandeyeWaypoints(Node):
|
|||||||
q_str = ', '.join(f'{v:.4f}' for v in (measured or target))
|
q_str = ', '.join(f'{v:.4f}' for v in (measured or target))
|
||||||
if self.capture_mode == 'preview':
|
if self.capture_mode == 'preview':
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'[{index + 1}/{n_pts}] ARRIVED — Free Drive adjust, then Enter/Space'
|
f'[{index + 1}/{n_pts}] 已到位 —— 用 Free Drive 调整,然后按 Enter/Space'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'[{index + 1}/{n_pts}] ARRIVED — CAPTURE NOW q=[{q_str}]'
|
f'[{index + 1}/{n_pts}] 已到位 —— 现在采集 q=[{q_str}]'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _wait_capture(self, index: int, n_pts: int) -> bool:
|
def _wait_capture(self, index: int, n_pts: int) -> bool:
|
||||||
@@ -372,7 +371,7 @@ class HandeyeWaypoints(Node):
|
|||||||
|
|
||||||
if self.capture_mode in ('key', 'keypress', 'manual'):
|
if self.capture_mode in ('key', 'keypress', 'manual'):
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
'Press Enter after capturing (q+Enter to quit)...'
|
'采集完成后按 Enter(q+Enter 退出)...'
|
||||||
)
|
)
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
rclpy.spin_once(self, timeout_sec=0.05)
|
rclpy.spin_once(self, timeout_sec=0.05)
|
||||||
@@ -394,7 +393,7 @@ class HandeyeWaypoints(Node):
|
|||||||
return vis
|
return vis
|
||||||
cv2.putText(
|
cv2.putText(
|
||||||
vis,
|
vis,
|
||||||
f'Pose {index + 1}/{n_pts}',
|
f'位姿 {index + 1}/{n_pts}',
|
||||||
(12, 28),
|
(12, 28),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
0.7,
|
0.7,
|
||||||
@@ -403,7 +402,7 @@ class HandeyeWaypoints(Node):
|
|||||||
)
|
)
|
||||||
cv2.putText(
|
cv2.putText(
|
||||||
vis,
|
vis,
|
||||||
'Free Drive drag | Enter=next q=quit',
|
'Free Drive 拖拽 | Enter=下一个 q=退出',
|
||||||
(12, 56),
|
(12, 56),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
0.6,
|
0.6,
|
||||||
@@ -420,7 +419,7 @@ class HandeyeWaypoints(Node):
|
|||||||
msg.encoding = 'bgr8'
|
msg.encoding = 'bgr8'
|
||||||
msg.is_bigendian = 0
|
msg.is_bigendian = 0
|
||||||
msg.step = msg.width * 3
|
msg.step = msg.width * 3
|
||||||
msg.data = array.array('B', bgr.tobytes()) # fast path; bytes costs ~40 ms
|
msg.data = array.array('B', bgr.tobytes()) # 快速路径;用 bytes 会多花约 40 ms
|
||||||
self._preview_pub.publish(msg)
|
self._preview_pub.publish(msg)
|
||||||
|
|
||||||
def _wait_preview(self, index: int, n_pts: int) -> bool:
|
def _wait_preview(self, index: int, n_pts: int) -> bool:
|
||||||
@@ -444,8 +443,8 @@ class HandeyeWaypoints(Node):
|
|||||||
confirmed.set()
|
confirmed.set()
|
||||||
|
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'[{index + 1}/{n_pts}] Adjust with Free Drive, then press Enter '
|
f'[{index + 1}/{n_pts}] 用 Free Drive 调整,然后按 Enter '
|
||||||
'(q+Enter to quit)'
|
'(q+Enter 退出)'
|
||||||
)
|
)
|
||||||
reader = threading.Thread(target=_stdin_reader, daemon=True)
|
reader = threading.Thread(target=_stdin_reader, daemon=True)
|
||||||
reader.start()
|
reader.start()
|
||||||
@@ -464,7 +463,7 @@ class HandeyeWaypoints(Node):
|
|||||||
if bgr is None:
|
if bgr is None:
|
||||||
if not no_frame_warn:
|
if not no_frame_warn:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'Waiting for {self.image_topic} ...',
|
f'正在等待 {self.image_topic} ...',
|
||||||
throttle_duration_sec=3.0,
|
throttle_duration_sec=3.0,
|
||||||
)
|
)
|
||||||
no_frame_warn = True
|
no_frame_warn = True
|
||||||
@@ -485,7 +484,7 @@ class HandeyeWaypoints(Node):
|
|||||||
if bgr is None:
|
if bgr is None:
|
||||||
if not no_frame_warn:
|
if not no_frame_warn:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'Waiting for {self.image_topic} ...',
|
f'正在等待 {self.image_topic} ...',
|
||||||
throttle_duration_sec=3.0,
|
throttle_duration_sec=3.0,
|
||||||
)
|
)
|
||||||
no_frame_warn = True
|
no_frame_warn = True
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Hole detection on the current-arm D405 color topic. Detect-only (no insert motion)."""
|
"""在当前机械臂的 D405 彩色话题上做入孔检测。只检测,不做插入动作。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ _HOLE_SITE_SKIP = {"weights", "workpiece_weights", "config"}
|
|||||||
|
|
||||||
|
|
||||||
def config_from_site(data: dict[str, Any] | None = None) -> dict[str, Any]:
|
def config_from_site(data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
"""Package yaml defaults, then overlay site.yaml hole_detect: (operator source of truth)."""
|
"""先取包内 yaml 默认值,再用 ``site.yaml`` 的 ``hole_detect:`` 覆盖。
|
||||||
|
|
||||||
|
以 ``site.yaml`` 为准(operator 的唯一数据源);``data`` 参数用于测试时直接喂字典。
|
||||||
|
"""
|
||||||
from arm_application.site_io import deep_merge, load_site_dict, ros2_root
|
from arm_application.site_io import deep_merge, load_site_dict, ros2_root
|
||||||
|
|
||||||
site = data if data is not None else load_site_dict()
|
site = data if data is not None else load_site_dict()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Constant-velocity Kalman filter for a 3D point (camera frame)."""
|
"""三维点的匀速模型卡尔曼滤波(坐标系为相机系)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import numpy as np
|
|||||||
|
|
||||||
|
|
||||||
class KalmanFilter3D:
|
class KalmanFilter3D:
|
||||||
"""State ``[x, y, z, vx, vy, vz]``. Measurement is position only."""
|
"""状态量为 ``[x, y, z, vx, vy, vz]``;观测只有位置,没有速度。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -43,7 +43,11 @@ class KalmanFilter3D:
|
|||||||
return self.x[3:].copy()
|
return self.x[3:].copy()
|
||||||
|
|
||||||
def advance(self, t: float) -> None:
|
def advance(self, t: float) -> None:
|
||||||
"""Predict the state forward to time ``t`` (seconds, monotonic)."""
|
"""把状态预测到时刻 ``t``(秒,必须单调递增)。
|
||||||
|
|
||||||
|
超过 0.5 秒的空档按"目标可能已经跑掉"处理:位置不动、速度清零,
|
||||||
|
否则旧速度会把位置外推得很离谱。
|
||||||
|
"""
|
||||||
if not self.initialized or self._t is None:
|
if not self.initialized or self._t is None:
|
||||||
self._t = t
|
self._t = t
|
||||||
return
|
return
|
||||||
@@ -51,7 +55,7 @@ class KalmanFilter3D:
|
|||||||
if dt <= 1e-6:
|
if dt <= 1e-6:
|
||||||
return
|
return
|
||||||
if dt > 0.5:
|
if dt > 0.5:
|
||||||
# Long gap — keep position, zero velocity.
|
# 空档太长——位置保持不动,速度清零。
|
||||||
self.x[3:] = 0.0
|
self.x[3:] = 0.0
|
||||||
self._t = t
|
self._t = t
|
||||||
return
|
return
|
||||||
@@ -59,7 +63,7 @@ class KalmanFilter3D:
|
|||||||
self._t = t
|
self._t = t
|
||||||
|
|
||||||
def update(self, z: np.ndarray) -> None:
|
def update(self, z: np.ndarray) -> None:
|
||||||
"""Measurement update with a 3D position."""
|
"""用一次三维位置观测做量测更新。"""
|
||||||
z = np.asarray(z, dtype=float).reshape(3)
|
z = np.asarray(z, dtype=float).reshape(3)
|
||||||
if not self.initialized:
|
if not self.initialized:
|
||||||
self.reset(z)
|
self.reset(z)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Example client for arm_msgs/action/MoveToPose (Cartesian goal).
|
"""``arm_msgs/action/MoveToPose``(笛卡尔目标)的示例客户端。
|
||||||
|
|
||||||
Wire this to MoveIt or your IK solver action server when available.
|
等你有了 MoveIt 或自己的 IK 求解 action server 后,把它接上去即可。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,8 +24,8 @@ class MoveToPoseClient(Node):
|
|||||||
def send(self, x: float, y: float, z: float) -> None:
|
def send(self, x: float, y: float, z: float) -> None:
|
||||||
if not self._client.wait_for_server(timeout_sec=5.0):
|
if not self._client.wait_for_server(timeout_sec=5.0):
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
'move_to_pose action not available. '
|
'move_to_pose action 不可用。'
|
||||||
'Start a Cartesian planner (e.g. MoveIt) that implements it.'
|
'请启动一个实现了它的笛卡尔规划器(例如 MoveIt)。'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -36,18 +36,18 @@ class MoveToPoseClient(Node):
|
|||||||
goal.target_pose.pose.position.x = x
|
goal.target_pose.pose.position.x = x
|
||||||
goal.target_pose.pose.position.y = y
|
goal.target_pose.pose.position.y = y
|
||||||
goal.target_pose.pose.position.z = z
|
goal.target_pose.pose.position.z = z
|
||||||
# Identity orientation
|
# 单位姿态(不旋转)
|
||||||
goal.target_pose.pose.orientation.w = 1.0
|
goal.target_pose.pose.orientation.w = 1.0
|
||||||
goal.velocity_scale = 0.3
|
goal.velocity_scale = 0.3
|
||||||
goal.acceleration_scale = 0.3
|
goal.acceleration_scale = 0.3
|
||||||
goal.avoid_collisions = True
|
goal.avoid_collisions = True
|
||||||
|
|
||||||
self.get_logger().info(f'Sending Cartesian goal: ({x:.3f}, {y:.3f}, {z:.3f})')
|
self.get_logger().info(f'发送笛卡尔目标:({x:.3f}, {y:.3f}, {z:.3f})')
|
||||||
future = self._client.send_goal_async(goal)
|
future = self._client.send_goal_async(goal)
|
||||||
rclpy.spin_until_future_complete(self, future)
|
rclpy.spin_until_future_complete(self, future)
|
||||||
handle = future.result()
|
handle = future.result()
|
||||||
if handle is None or not handle.accepted:
|
if handle is None or not handle.accepted:
|
||||||
self.get_logger().error('Goal rejected')
|
self.get_logger().error('目标被拒绝')
|
||||||
return
|
return
|
||||||
|
|
||||||
result_future = handle.get_result_async()
|
result_future = handle.get_result_async()
|
||||||
@@ -59,7 +59,7 @@ class MoveToPoseClient(Node):
|
|||||||
def main(args=None) -> None:
|
def main(args=None) -> None:
|
||||||
rclpy.init(args=args)
|
rclpy.init(args=args)
|
||||||
node = MoveToPoseClient()
|
node = MoveToPoseClient()
|
||||||
# Default demo pose; override via CLI: x y z
|
# 默认演示位姿;可通过命令行覆盖:x y z
|
||||||
vals = [0.35, 0.0, 0.35]
|
vals = [0.35, 0.0, 0.35]
|
||||||
if len(sys.argv) >= 4:
|
if len(sys.argv) >= 4:
|
||||||
vals = [float(sys.argv[1]), float(sys.argv[2]), float(sys.argv[3])]
|
vals = [float(sys.argv[1]), float(sys.argv[2]), float(sys.argv[3])]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Orisys visuo-tactile adapter. SDK is pip-installed; site.yaml holds device ids."""
|
"""Orisys 视触觉适配器。SDK 通过 pip 安装;设备 id 存放在 ``site.yaml`` 里。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -185,8 +185,8 @@ class OrisysTactile(TactileInterface):
|
|||||||
import orisys
|
import orisys
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"orisys SDK missing. pip install -e /path/to/orisys-sdk "
|
"缺少 orisys SDK。请执行 pip install -e /path/to/orisys-sdk "
|
||||||
"(git.orisys-tech.cn/orisys/test). Config still comes from site.yaml tactile."
|
"(git.orisys-tech.cn/orisys/test)。配置依旧从 ``site.yaml`` 的 ``tactile`` 段读取。"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
self._sensors = []
|
self._sensors = []
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""perception_node — YOLO + depth + KF (own process).
|
"""perception_node —— YOLO + 深度 + 卡尔曼滤波(独立进程)。
|
||||||
|
|
||||||
Hard rules:
|
硬性规则:
|
||||||
- Image callbacks ONLY store latest frames (never run YOLO in the callback)
|
- 图像回调**只**存最新帧(绝不在回调里跑 YOLO)
|
||||||
- YOLO runs on a dedicated worker thread so MutuallyExclusive I/O is never blocked
|
- YOLO 跑在专门的工作线程上,保证 ``MutuallyExclusive`` 的 I/O 回调永远不会被阻塞
|
||||||
- SensorDataQoS BEST_EFFORT keep_last(5)
|
- 用 ``SensorDataQoS`` ``BEST_EFFORT`` ``keep_last(5)``
|
||||||
- Publish filtered 3D as geometry_msgs/PointStamped (no Services)
|
- 滤波后的三维点用 ``geometry_msgs/PointStamped`` 发布(不用 Service)
|
||||||
- Dynamic object TF at vision rate only
|
- 动态物体 TF 只在视觉帧率下发布
|
||||||
- High-confidence detections always reacquire (no freeze on fast motion)
|
- 高置信度检测始终重新捕获(快速运动时不会冻结)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -50,7 +50,7 @@ from arm_application.yolo_detector import (
|
|||||||
|
|
||||||
|
|
||||||
def _bgr_copy(msg: Image) -> np.ndarray:
|
def _bgr_copy(msg: Image) -> np.ndarray:
|
||||||
"""BGR uint8 owned by caller (safe for async YOLO; ROS may drop the msg)."""
|
"""返回调用方持有的 BGR uint8 数组(对异步 YOLO 安全;ROS 可能把原消息丢掉)。"""
|
||||||
h, w = int(msg.height), int(msg.width)
|
h, w = int(msg.height), int(msg.width)
|
||||||
enc = (msg.encoding or "").lower()
|
enc = (msg.encoding or "").lower()
|
||||||
buf = np.frombuffer(msg.data, dtype=np.uint8)
|
buf = np.frombuffer(msg.data, dtype=np.uint8)
|
||||||
@@ -58,7 +58,7 @@ def _bgr_copy(msg: Image) -> np.ndarray:
|
|||||||
return buf.reshape(h, w, 3).copy()
|
return buf.reshape(h, w, 3).copy()
|
||||||
if enc == "rgb8":
|
if enc == "rgb8":
|
||||||
return buf.reshape(h, w, 3)[:, :, ::-1].copy()
|
return buf.reshape(h, w, 3)[:, :, ::-1].copy()
|
||||||
raise RuntimeError(f"Unsupported encoding {msg.encoding!r}")
|
raise RuntimeError(f"不支持的编码 {msg.encoding!r}")
|
||||||
|
|
||||||
|
|
||||||
class PerceptionNode(Node):
|
class PerceptionNode(Node):
|
||||||
@@ -66,7 +66,7 @@ class PerceptionNode(Node):
|
|||||||
super().__init__("perception_node")
|
super().__init__("perception_node")
|
||||||
tid = threading.get_ident()
|
tid = threading.get_ident()
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"perception_node started pid={os.getpid()} thread_id={tid}"
|
f"perception_node 已启动 pid={os.getpid()} thread_id={tid}"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.declare_parameter("camera_source", "d405")
|
self.declare_parameter("camera_source", "d405")
|
||||||
@@ -86,30 +86,30 @@ class PerceptionNode(Node):
|
|||||||
self.declare_parameter("jump_z_m", 0.20)
|
self.declare_parameter("jump_z_m", 0.20)
|
||||||
self.declare_parameter("min_depth_m", 0.04)
|
self.declare_parameter("min_depth_m", 0.04)
|
||||||
self.declare_parameter("max_depth_m", 0.55)
|
self.declare_parameter("max_depth_m", 0.55)
|
||||||
self.declare_parameter("work_z_max_m", 0.50) # above this = hole/background
|
self.declare_parameter("work_z_max_m", 0.50) # 高于此值 = 孔 / 背景
|
||||||
self.declare_parameter("object_frame", "tracked_object")
|
self.declare_parameter("object_frame", "tracked_object")
|
||||||
self.declare_parameter("camera_frame", "d405_color_optical_frame")
|
self.declare_parameter("camera_frame", "d405_color_optical_frame")
|
||||||
self.declare_parameter("kf_q_pos", 2.0e-3)
|
self.declare_parameter("kf_q_pos", 2.0e-3)
|
||||||
self.declare_parameter("kf_q_vel", 5.0e-2)
|
self.declare_parameter("kf_q_vel", 5.0e-2)
|
||||||
self.declare_parameter("kf_r_pos", 8.0e-4)
|
self.declare_parameter("kf_r_pos", 8.0e-4)
|
||||||
self.declare_parameter("meas_blend", 0.65) # 1=raw meas, 0=pure KF
|
self.declare_parameter("meas_blend", 0.65) # 1 = 原始测量值,0 = 纯卡尔曼滤波
|
||||||
self.declare_parameter("half_precision", True)
|
self.declare_parameter("half_precision", True)
|
||||||
self.declare_parameter("backend", "auto") # auto|torch|tensorrt
|
self.declare_parameter("backend", "auto") # auto|torch|tensorrt
|
||||||
self.declare_parameter("mp_infer", True) # YOLO in a spawn() process
|
self.declare_parameter("mp_infer", True) # YOLO 跑在 spawn() 子进程里
|
||||||
# Adaptive Z LPF: alpha_z = clamp(z_alpha_min + |vel_z|*z_alpha_gain, min, max)
|
# 自适应 Z 低通滤波:alpha_z = clamp(z_alpha_min + |vel_z|*z_alpha_gain, min, max)
|
||||||
self.declare_parameter("z_alpha_min", 0.10)
|
self.declare_parameter("z_alpha_min", 0.10)
|
||||||
self.declare_parameter("z_alpha_max", 0.60)
|
self.declare_parameter("z_alpha_max", 0.60)
|
||||||
self.declare_parameter("z_alpha_gain", 2.0)
|
self.declare_parameter("z_alpha_gain", 2.0)
|
||||||
# Faster YOLO: downscale before infer; boxes scaled back to full res
|
# 加速 YOLO:推理前先降采样;检测框再缩放回全分辨率
|
||||||
self.declare_parameter("infer_max_width", 0)
|
self.declare_parameter("infer_max_width", 0)
|
||||||
# Box EMA (1=raw YOLO every frame — tight follow; lower = smoother/lag)
|
# 检测框 EMA(1 = 每帧都用原始 YOLO —— 紧跟目标;更低 = 更平滑但滞后)
|
||||||
self.declare_parameter("box_ema_alpha", 0.85)
|
self.declare_parameter("box_ema_alpha", 0.85)
|
||||||
# Brief YOLO misses: keep last box on preview; clear only after N frames
|
# YOLO 短暂漏检:预览里保留上一个框;只有连续 N 帧都漏检才清除
|
||||||
self.declare_parameter("miss_clear_frames", 12)
|
self.declare_parameter("miss_clear_frames", 12)
|
||||||
self.declare_parameter("overlay_hold_on_miss", True)
|
self.declare_parameter("overlay_hold_on_miss", True)
|
||||||
self.declare_parameter("hold_point_on_miss", True)
|
self.declare_parameter("hold_point_on_miss", True)
|
||||||
self.declare_parameter("miss_hold_point_frames", 2)
|
self.declare_parameter("miss_hold_point_frames", 2)
|
||||||
# plane = stereo surface centroid (recommended); box = YOLO box center
|
# plane = 立体表面质心(推荐);box = YOLO 检测框中心
|
||||||
self.declare_parameter("feature_mode", "plane")
|
self.declare_parameter("feature_mode", "plane")
|
||||||
self.declare_parameter("plane_inlier_m", 0.008)
|
self.declare_parameter("plane_inlier_m", 0.008)
|
||||||
self.declare_parameter("plane_band_m", 0.06)
|
self.declare_parameter("plane_band_m", 0.06)
|
||||||
@@ -170,7 +170,7 @@ class PerceptionNode(Node):
|
|||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._wake = threading.Event()
|
self._wake = threading.Event()
|
||||||
|
|
||||||
# I/O group: only lightweight store-latest (never YOLO)
|
# I/O 回调组:只做轻量的“存最新帧”(绝不跑 YOLO)
|
||||||
self._io_cbg = MutuallyExclusiveCallbackGroup()
|
self._io_cbg = MutuallyExclusiveCallbackGroup()
|
||||||
self._wake_cbg = MutuallyExclusiveCallbackGroup()
|
self._wake_cbg = MutuallyExclusiveCallbackGroup()
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ class PerceptionNode(Node):
|
|||||||
self._yolo_proc: Optional[YoloInferProcess] = None
|
self._yolo_proc: Optional[YoloInferProcess] = None
|
||||||
if self.mp_infer:
|
if self.mp_infer:
|
||||||
self._yolo_proc = YoloInferProcess(yolo_cfg)
|
self._yolo_proc = YoloInferProcess(yolo_cfg)
|
||||||
self.detector = self._yolo_proc # duck-typed infer()
|
self.detector = self._yolo_proc # 鸭子类型,只要求有 infer()
|
||||||
imgsz = self._yolo_proc.imgsz
|
imgsz = self._yolo_proc.imgsz
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"YOLO worker pid={self._yolo_proc._p.pid} weights={self._yolo_proc.weights_path}"
|
f"YOLO worker pid={self._yolo_proc._p.pid} weights={self._yolo_proc.weights_path}"
|
||||||
@@ -221,7 +221,7 @@ class PerceptionNode(Node):
|
|||||||
try:
|
try:
|
||||||
self.detector.warmup(half=self._yolo_half)
|
self.detector.warmup(half=self._yolo_half)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f"YOLO warmup failed: {exc}")
|
self.get_logger().warn(f"YOLO 预热失败:{exc}")
|
||||||
imgsz = self.detector.imgsz
|
imgsz = self.detector.imgsz
|
||||||
|
|
||||||
image_qos = QoSProfile(
|
image_qos = QoSProfile(
|
||||||
@@ -256,8 +256,8 @@ class PerceptionNode(Node):
|
|||||||
depth_topic = f"/{cam_ns}/aligned_depth_to_color/image_raw"
|
depth_topic = f"/{cam_ns}/aligned_depth_to_color/image_raw"
|
||||||
if not img_topic:
|
if not img_topic:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"image_topic empty (camera_source={cam_src!r} ns={ns!r}); "
|
f"image_topic 为空(camera_source={cam_src!r} ns={ns!r});"
|
||||||
"use d405 / d405_left / d405_right"
|
"请使用 d405 / d405_left / d405_right"
|
||||||
)
|
)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"perception image={img_topic} depth={depth_topic} info={info_topic or '-'}"
|
f"perception image={img_topic} depth={depth_topic} info={info_topic or '-'}"
|
||||||
@@ -283,7 +283,7 @@ class PerceptionNode(Node):
|
|||||||
self._point_pub = self.create_publisher(
|
self._point_pub = self.create_publisher(
|
||||||
PointStamped, "visual_servo/filtered_point", point_qos
|
PointStamped, "visual_servo/filtered_point", point_qos
|
||||||
)
|
)
|
||||||
# Reliable + keep_last(5): camera preview tolerates brief DDS gaps
|
# RELIABLE + keep_last(5):相机预览能容忍短暂的 DDS 丢包间隙
|
||||||
det_qos = QoSProfile(
|
det_qos = QoSProfile(
|
||||||
history=HistoryPolicy.KEEP_LAST,
|
history=HistoryPolicy.KEEP_LAST,
|
||||||
depth=5,
|
depth=5,
|
||||||
@@ -298,7 +298,7 @@ class PerceptionNode(Node):
|
|||||||
self._worker.start()
|
self._worker.start()
|
||||||
|
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"perception ready vision≤{vision_hz:.0f}Hz imgsz={imgsz} "
|
f"perception 就绪 vision≤{vision_hz:.0f}Hz imgsz={imgsz} "
|
||||||
f"infer_max_w={self.infer_max_width} mp={int(self.mp_infer)} "
|
f"infer_max_w={self.infer_max_width} mp={int(self.mp_infer)} "
|
||||||
f"backend={self.backend_name} half={self._yolo_half} "
|
f"backend={self.backend_name} half={self._yolo_half} "
|
||||||
f"feature={self.feature_mode} image={img_topic}"
|
f"feature={self.feature_mode} image={img_topic}"
|
||||||
@@ -328,8 +328,8 @@ class PerceptionNode(Node):
|
|||||||
w = float(msg.width)
|
w = float(msg.width)
|
||||||
self._fx = 654.8 if w >= 1200 else 393.9 * (w / 640.0)
|
self._fx = 654.8 if w >= 1200 else 393.9 * (w / 640.0)
|
||||||
self._fy = 654.0 if w >= 1200 else 393.4 * (float(msg.height) / 480.0)
|
self._fy = 654.0 if w >= 1200 else 393.4 * (float(msg.height) / 480.0)
|
||||||
# Do NOT pickle the image into the YOLO queue here — that stalls the
|
# 千万不要在这里把图像 pickle 进 YOLO 队列 —— 那会卡住相机回调,
|
||||||
# camera callback and looks like inference freeze. Infer thread submits.
|
# 看起来就像是推理冻结。由推理线程负责提交。
|
||||||
self._wake.set()
|
self._wake.set()
|
||||||
|
|
||||||
def _on_info(self, msg: CameraInfo) -> None:
|
def _on_info(self, msg: CameraInfo) -> None:
|
||||||
@@ -349,7 +349,7 @@ class PerceptionNode(Node):
|
|||||||
self._wake.set()
|
self._wake.set()
|
||||||
|
|
||||||
def _submit_latest(self) -> None:
|
def _submit_latest(self) -> None:
|
||||||
"""Copy the newest camera frame and hand it to the GPU process."""
|
"""复制最新的相机帧,交给 GPU 进程。"""
|
||||||
msg = self._latest_image
|
msg = self._latest_image
|
||||||
seq = int(self._frame_seq)
|
seq = int(self._frame_seq)
|
||||||
if msg is None or seq <= self._submitted_seq:
|
if msg is None or seq <= self._submitted_seq:
|
||||||
@@ -361,38 +361,37 @@ class PerceptionNode(Node):
|
|||||||
self.detector.submit(small, seq)
|
self.detector.submit(small, seq)
|
||||||
self._submitted_seq = seq
|
self._submitted_seq = seq
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f"yolo submit: {exc}", throttle_duration_sec=2.0)
|
self.get_logger().warn(f"yolo 提交失败:{exc}", throttle_duration_sec=2.0)
|
||||||
|
|
||||||
def _restart_yolo(self, why: str) -> None:
|
def _restart_yolo(self, why: str) -> None:
|
||||||
"""Self-heal a hung/dead GPU worker (engine reload takes a few sec)."""
|
"""自愈:重启卡死 / 挂掉的 GPU 工作进程(重新加载 engine 需要几秒)。"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - self._last_restart_t < 5.0:
|
if now - self._last_restart_t < 5.0:
|
||||||
return
|
return
|
||||||
if not hasattr(self.detector, "restart"):
|
if not hasattr(self.detector, "restart"):
|
||||||
return
|
return
|
||||||
self._last_restart_t = now
|
self._last_restart_t = now
|
||||||
self.get_logger().error(f"YOLO worker {why} — restarting (box coasts)")
|
self.get_logger().error(f"YOLO 工作进程 {why} —— 正在重启(检测框靠惯性滑行)")
|
||||||
try:
|
try:
|
||||||
self.detector.restart()
|
self.detector.restart()
|
||||||
self._submitted_seq = -1
|
self._submitted_seq = -1
|
||||||
self._last_gpu_t = time.monotonic()
|
self._last_gpu_t = time.monotonic()
|
||||||
self.get_logger().info("YOLO worker restarted")
|
self.get_logger().info("YOLO 工作进程已重启")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().error(f"YOLO restart failed: {exc}")
|
self.get_logger().error(f"YOLO 重启失败:{exc}")
|
||||||
|
|
||||||
def _infer_loop(self) -> None:
|
def _infer_loop(self) -> None:
|
||||||
"""Submit latest frame, collect GPU result, then stereo.
|
"""提交最新帧,收集 GPU 结果,再做立体匹配。
|
||||||
|
|
||||||
GPU must never wait on stereo: the worker drops old results if we are
|
GPU 绝不能等立体匹配:如果我们短暂正忙,工作进程会直接丢掉旧结果。
|
||||||
briefly busy. We also submit *before* waiting so the next infer is
|
我们还会**先**提交再去等,这样下一次推理已经排进队列了。
|
||||||
already queued.
|
|
||||||
"""
|
"""
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
self._submit_latest()
|
self._submit_latest()
|
||||||
try:
|
try:
|
||||||
got = self.detector.wait_result(timeout=0.030)
|
got = self.detector.wait_result(timeout=0.030)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().error(f"infer: {exc}", throttle_duration_sec=2.0)
|
self.get_logger().error(f"推理:{exc}", throttle_duration_sec=2.0)
|
||||||
if not getattr(self.detector, "alive", True):
|
if not getattr(self.detector, "alive", True):
|
||||||
self._restart_yolo("died")
|
self._restart_yolo("died")
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
@@ -402,13 +401,13 @@ class PerceptionNode(Node):
|
|||||||
silent = (now - self._last_gpu_t) if self._last_gpu_t else 0.0
|
silent = (now - self._last_gpu_t) if self._last_gpu_t else 0.0
|
||||||
if self._last_gpu_t is not None and silent > 0.25:
|
if self._last_gpu_t is not None and silent > 0.25:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"YOLO silent {silent*1000:.0f}ms "
|
f"YOLO 静默 {silent*1000:.0f}ms "
|
||||||
f"(worker={'up' if getattr(self.detector, 'alive', True) else 'DEAD'})",
|
f"(worker={'存活' if getattr(self.detector, 'alive', True) else '已死'})",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
outstanding = self._submitted_seq > self._done_seq
|
outstanding = self._submitted_seq > self._done_seq
|
||||||
if outstanding and self._last_gpu_t is not None and silent > 2.0:
|
if outstanding and self._last_gpu_t is not None and silent > 2.0:
|
||||||
self._restart_yolo(f"hung {silent:.1f}s")
|
self._restart_yolo(f"卡死 {silent:.1f}s")
|
||||||
continue
|
continue
|
||||||
if (
|
if (
|
||||||
self._last_yolo_t is not None
|
self._last_yolo_t is not None
|
||||||
@@ -429,7 +428,7 @@ class PerceptionNode(Node):
|
|||||||
try:
|
try:
|
||||||
self._apply_detections(dets, scale, t_img)
|
self._apply_detections(dets, scale, t_img)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().error(f"apply: {exc}", throttle_duration_sec=2.0)
|
self.get_logger().error(f"应用检测结果出错:{exc}", throttle_duration_sec=2.0)
|
||||||
self._done_seq = int(seq)
|
self._done_seq = int(seq)
|
||||||
self._submit_latest()
|
self._submit_latest()
|
||||||
dt_ms = (time.monotonic() - t0) * 1000.0
|
dt_ms = (time.monotonic() - t0) * 1000.0
|
||||||
@@ -457,13 +456,13 @@ class PerceptionNode(Node):
|
|||||||
def _depth_in_box(self, xyxy) -> tuple:
|
def _depth_in_box(self, xyxy) -> tuple:
|
||||||
msg = self._latest_depth
|
msg = self._latest_depth
|
||||||
if msg is None:
|
if msg is None:
|
||||||
return None, "no depth"
|
return None, "无深度"
|
||||||
if not self._depth_fresh(msg):
|
if not self._depth_fresh(msg):
|
||||||
return None, "stale depth"
|
return None, "深度过期"
|
||||||
enc = (msg.encoding or "").lower()
|
enc = (msg.encoding or "").lower()
|
||||||
buf = self._decode_depth_m(msg)
|
buf = self._decode_depth_m(msg)
|
||||||
if buf is None:
|
if buf is None:
|
||||||
return None, "bad depth"
|
return None, "深度无效"
|
||||||
h, w = buf.shape
|
h, w = buf.shape
|
||||||
x1, y1, x2, y2 = [float(v) for v in xyxy]
|
x1, y1, x2, y2 = [float(v) for v in xyxy]
|
||||||
img = self._latest_image
|
img = self._latest_image
|
||||||
@@ -471,7 +470,7 @@ class PerceptionNode(Node):
|
|||||||
sx, sy = w / float(img.width), h / float(img.height)
|
sx, sy = w / float(img.width), h / float(img.height)
|
||||||
x1, y1, x2, y2 = x1 * sx, y1 * sy, x2 * sx, y2 * sy
|
x1, y1, x2, y2 = x1 * sx, y1 * sy, x2 * sx, y2 * sy
|
||||||
bw, bh = max(8.0, x2 - x1), max(8.0, y2 - y1)
|
bw, bh = max(8.0, x2 - x1), max(8.0, y2 - y1)
|
||||||
# Inner ROI — avoid box edges; still keep enough pixels on a holey workpiece
|
# 内部 ROI —— 避开检测框边缘;但对有孔的工件仍要保留足够像素
|
||||||
ix0 = int(np.clip(x1 + 0.20 * bw, 0, w - 1))
|
ix0 = int(np.clip(x1 + 0.20 * bw, 0, w - 1))
|
||||||
iy0 = int(np.clip(y1 + 0.20 * bh, 0, h - 1))
|
iy0 = int(np.clip(y1 + 0.20 * bh, 0, h - 1))
|
||||||
ix1 = int(np.clip(x2 - 0.20 * bw, ix0 + 2, w))
|
ix1 = int(np.clip(x2 - 0.20 * bw, ix0 + 2, w))
|
||||||
@@ -491,7 +490,7 @@ class PerceptionNode(Node):
|
|||||||
scale = 1.0
|
scale = 1.0
|
||||||
vals = meters[np.isfinite(meters) & (meters > self.min_depth) & (meters < self.max_depth)]
|
vals = meters[np.isfinite(meters) & (meters > self.min_depth) & (meters < self.max_depth)]
|
||||||
if vals.size < 8:
|
if vals.size < 8:
|
||||||
# Inner ROI is often holes; retry the full box.
|
# 内部 ROI 经常落在孔上;改用整个检测框重试。
|
||||||
y0 = int(np.clip(y1, 0, h - 1))
|
y0 = int(np.clip(y1, 0, h - 1))
|
||||||
x0 = int(np.clip(x1, 0, w - 1))
|
x0 = int(np.clip(x1, 0, w - 1))
|
||||||
y1b = int(np.clip(y2, y0 + 2, h))
|
y1b = int(np.clip(y2, y0 + 2, h))
|
||||||
@@ -509,8 +508,8 @@ class PerceptionNode(Node):
|
|||||||
if vals.size < 8:
|
if vals.size < 8:
|
||||||
med_m = float(np.median(meters)) if meters.size else 0.0
|
med_m = float(np.median(meters)) if meters.size else 0.0
|
||||||
return None, f"ok={vals.size} scale={scale} med={med_m:.3f}"
|
return None, f"ok={vals.size} scale={scale} med={med_m:.3f}"
|
||||||
# Near-surface: holes / table / far background must NOT pull Z out.
|
# 只取近表面:孔 / 桌面 / 远处背景绝不能把 Z 拉偏。
|
||||||
# Take closest 40%, then keep a 6 cm band around the 10th percentile.
|
# 先取最近的 40%,再围绕第 10 百分位保留一个 6 cm 的带宽。
|
||||||
vals = np.sort(vals)
|
vals = np.sort(vals)
|
||||||
n_near = max(8, int(0.40 * vals.size))
|
n_near = max(8, int(0.40 * vals.size))
|
||||||
near = vals[:n_near]
|
near = vals[:n_near]
|
||||||
@@ -524,7 +523,7 @@ class PerceptionNode(Node):
|
|||||||
return z, f"ok={vals.size} near={band.size}"
|
return z, f"ok={vals.size} near={band.size}"
|
||||||
|
|
||||||
def _adaptive_filter_z(self, z_raw: float, now: float) -> float:
|
def _adaptive_filter_z(self, z_raw: float, now: float) -> float:
|
||||||
"""Item 4: adaptive LPF on depth — quiet when still, responsive when moving."""
|
"""第 4 项:对深度做自适应低通滤波 —— 静止时更稳,运动时更灵敏。"""
|
||||||
if self._z_filt is None or self._z_filt_t is None:
|
if self._z_filt is None or self._z_filt_t is None:
|
||||||
self._z_filt = float(z_raw)
|
self._z_filt = float(z_raw)
|
||||||
self._z_filt_t = now
|
self._z_filt_t = now
|
||||||
@@ -563,7 +562,7 @@ class PerceptionNode(Node):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _publish_detection_clear(self) -> None:
|
def _publish_detection_clear(self) -> None:
|
||||||
"""Empty detection → preview clears green box (must NOT reuse last xyxy)."""
|
"""空检测 → 预览清掉绿框(绝不能复用上一次的 xyxy)。"""
|
||||||
self._last_det_payload = None
|
self._last_det_payload = None
|
||||||
msg = Float32MultiArray()
|
msg = Float32MultiArray()
|
||||||
msg.data = []
|
msg.data = []
|
||||||
@@ -589,7 +588,7 @@ class PerceptionNode(Node):
|
|||||||
self._det_pub.publish(msg)
|
self._det_pub.publish(msg)
|
||||||
|
|
||||||
def _prepare_infer(self, bgr: np.ndarray) -> tuple:
|
def _prepare_infer(self, bgr: np.ndarray) -> tuple:
|
||||||
"""Optional downscale for YOLO; return (infer_bgr, scale) full = infer * scale."""
|
"""可选的 YOLO 降采样;返回 (infer_bgr, scale),全分辨率 = 推理图 * scale。"""
|
||||||
max_w = self.infer_max_width
|
max_w = self.infer_max_width
|
||||||
h, w = bgr.shape[:2]
|
h, w = bgr.shape[:2]
|
||||||
if max_w <= 0 or w <= max_w:
|
if max_w <= 0 or w <= max_w:
|
||||||
@@ -612,7 +611,7 @@ class PerceptionNode(Node):
|
|||||||
return (int(msg.width), int(msg.height))
|
return (int(msg.width), int(msg.height))
|
||||||
|
|
||||||
def _coast_box(self, note: str) -> bool:
|
def _coast_box(self, note: str) -> bool:
|
||||||
"""Keep the green box moving with last velocity; do not drive 3D."""
|
"""让绿框按上一次的速度继续移动;不要用它驱动三维。"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
pred = self._box_trk.predict(now, self._img_wh())
|
pred = self._box_trk.predict(now, self._img_wh())
|
||||||
if pred is None and self._last_raw_xyxy is not None:
|
if pred is None and self._last_raw_xyxy is not None:
|
||||||
@@ -626,7 +625,7 @@ class PerceptionNode(Node):
|
|||||||
self._publish_detection_box(
|
self._publish_detection_box(
|
||||||
pred, float(self._last_box_conf), float(self._last_box_cls), uv, z
|
pred, float(self._last_box_conf), float(self._last_box_cls), uv, z
|
||||||
)
|
)
|
||||||
self.get_logger().info(f"box coast {note}", throttle_duration_sec=0.5)
|
self.get_logger().info(f"检测框惯滑 {note}", throttle_duration_sec=0.5)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _clear_track(self) -> None:
|
def _clear_track(self) -> None:
|
||||||
@@ -640,7 +639,7 @@ class PerceptionNode(Node):
|
|||||||
self._publish_detection_clear()
|
self._publish_detection_clear()
|
||||||
|
|
||||||
def _apply_detections(self, dets, scale: float, t_img: float) -> None:
|
def _apply_detections(self, dets, scale: float, t_img: float) -> None:
|
||||||
"""Match YOLO, publish a *now*-predicted box, then stereo on that box."""
|
"""匹配 YOLO 结果,发布按当前时刻预测的检测框,再在该框上做立体匹配。"""
|
||||||
if scale != 1.0:
|
if scale != 1.0:
|
||||||
for d in dets:
|
for d in dets:
|
||||||
d.xyxy = d.xyxy * scale
|
d.xyxy = d.xyxy * scale
|
||||||
@@ -665,7 +664,7 @@ class PerceptionNode(Node):
|
|||||||
self._clear_track()
|
self._clear_track()
|
||||||
self._last_yolo_t = None
|
self._last_yolo_t = None
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
"detection miss — cleared overlay",
|
"检测漏检 —— 已清除叠加显示",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -691,7 +690,7 @@ class PerceptionNode(Node):
|
|||||||
self._last_box_conf = float(best.conf)
|
self._last_box_conf = float(best.conf)
|
||||||
self._last_box_cls = float(best.cls_id)
|
self._last_box_cls = float(best.cls_id)
|
||||||
self._box_trk.update(xyxy_raw, t_img)
|
self._box_trk.update(xyxy_raw, t_img)
|
||||||
# Compensate infer delay: draw where the part is *now*, not where YOLO saw it
|
# 补偿推理延迟:画工件**当前**所在的位置,而不是 YOLO 看到它时的位置
|
||||||
xyxy = self._box_trk.predict(now, self._img_wh())
|
xyxy = self._box_trk.predict(now, self._img_wh())
|
||||||
if xyxy is None:
|
if xyxy is None:
|
||||||
xyxy = xyxy_raw
|
xyxy = xyxy_raw
|
||||||
@@ -705,8 +704,8 @@ class PerceptionNode(Node):
|
|||||||
xyxy, float(best.conf), float(best.cls_id), box_uv, z_preview
|
xyxy, float(best.conf), float(best.cls_id), box_uv, z_preview
|
||||||
)
|
)
|
||||||
|
|
||||||
# Hover target = stereo 3D on the workpiece inside the box (not a ray
|
# 悬停目标 = 检测框内工件表面的立体三维点(不是穿过孔 / 桌面的射线,
|
||||||
# through a hole / table, and not z_ref). Red dot = that 3D point.
|
# 也不是 z_ref)。红点 = 该三维点。
|
||||||
feat = None
|
feat = None
|
||||||
p_cam: Optional[np.ndarray] = None
|
p_cam: Optional[np.ndarray] = None
|
||||||
note = "box"
|
note = "box"
|
||||||
@@ -727,13 +726,13 @@ class PerceptionNode(Node):
|
|||||||
z_ok = z_meas is not None
|
z_ok = z_meas is not None
|
||||||
z, note = self._metric_z(z_meas, note)
|
z, note = self._metric_z(z_meas, note)
|
||||||
if z is None:
|
if z is None:
|
||||||
# Never had usable depth (target beyond D405 range / not in the work
|
# 从未拿到过可用深度(目标超出 D405 量程 / 不在工作区内 / 相机安装或
|
||||||
# area / camera mount or handeye off). Do NOT publish a z_ref point —
|
# 手眼标定有问题)。不要发布 z_ref 点 —— 那会在幻影位置上假装锁定夹爪。
|
||||||
# that would fake-lock the gripper over a phantom. Hold instead.
|
# 此时应保持不动。
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"target has NO usable depth ({note}) — NOT tracking. "
|
f"目标没有可用深度({note})—— 不跟踪。"
|
||||||
f"Put the workpiece in view at {self.min_depth:.2f}-{self.work_z_max:.2f} m "
|
f"请把工件放到相机视野内 {self.min_depth:.2f}-{self.work_z_max:.2f} m 处,"
|
||||||
"from the camera, or check camera mount / handeye calibration.",
|
"或检查相机安装 / 手眼标定。",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -754,7 +753,7 @@ class PerceptionNode(Node):
|
|||||||
if dpx > self.jump_px or (z_ok and dz > self.jump_z_m):
|
if dpx > self.jump_px or (z_ok and dz > self.jump_z_m):
|
||||||
self.kf.reset(p_cam, now)
|
self.kf.reset(p_cam, now)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"reacquire conf={best.conf:.2f} dpx={dpx:.0f}",
|
f"重新捕获 conf={best.conf:.2f} dpx={dpx:.0f}",
|
||||||
throttle_duration_sec=0.5,
|
throttle_duration_sec=0.5,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -765,7 +764,7 @@ class PerceptionNode(Node):
|
|||||||
kf_p = self.kf.position
|
kf_p = self.kf.position
|
||||||
blend = self.meas_blend
|
blend = self.meas_blend
|
||||||
filt = blend * p_cam + (1.0 - blend) * kf_p
|
filt = blend * p_cam + (1.0 - blend) * kf_p
|
||||||
# Keep XY scale honest even if KF still has an old far-Z
|
# 即使卡尔曼里还残留着旧的远距离 Z,也要保证 XY 的尺度真实
|
||||||
z_use = float(np.clip(filt[2], self.min_depth, self.work_z_max))
|
z_use = float(np.clip(filt[2], self.min_depth, self.work_z_max))
|
||||||
if abs(z_use - float(p_cam[2])) > 0.04:
|
if abs(z_use - float(p_cam[2])) > 0.04:
|
||||||
filt = p_cam.copy()
|
filt = p_cam.copy()
|
||||||
@@ -775,14 +774,14 @@ class PerceptionNode(Node):
|
|||||||
|
|
||||||
self._publish_point(filt, stamp)
|
self._publish_point(filt, stamp)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"track {self.feature_mode} conf={best.conf:.2f} uv=({uv[0]:.0f},{uv[1]:.0f}) "
|
f"跟踪 {self.feature_mode} conf={best.conf:.2f} uv=({uv[0]:.0f},{uv[1]:.0f}) "
|
||||||
f"z={filt[2]:.3f} z_ok={int(z_ok)} {note}",
|
f"z={filt[2]:.3f} z_ok={int(z_ok)} {note}",
|
||||||
throttle_duration_sec=0.4,
|
throttle_duration_sec=0.4,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _depth_fresh(self, msg: Image) -> bool:
|
def _depth_fresh(self, msg: Image) -> bool:
|
||||||
"""Reject stale depth — on a fast-moving part, old depth at the current
|
"""拒绝过期的深度 —— 对快速运动的工件,当前检测框处的旧深度会读到背景
|
||||||
box reads the background (table at ~2.4 m) and poisons the 3D point."""
|
(约 2.4 m 处的桌面),从而污染三维点。"""
|
||||||
try:
|
try:
|
||||||
t = rclpy.time.Time.from_msg(msg.header.stamp)
|
t = rclpy.time.Time.from_msg(msg.header.stamp)
|
||||||
age = (self.get_clock().now() - t).nanoseconds * 1e-9
|
age = (self.get_clock().now() - t).nanoseconds * 1e-9
|
||||||
@@ -816,7 +815,7 @@ class PerceptionNode(Node):
|
|||||||
band_m=self.plane_band_m,
|
band_m=self.plane_band_m,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f"inner3d feature: {exc}", throttle_duration_sec=2.0)
|
self.get_logger().warn(f"inner3d 特征计算失败:{exc}", throttle_duration_sec=2.0)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _plane_center(self, xyxy):
|
def _plane_center(self, xyxy):
|
||||||
@@ -846,13 +845,13 @@ class PerceptionNode(Node):
|
|||||||
band_m=self.plane_band_m,
|
band_m=self.plane_band_m,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f"plane feature: {exc}", throttle_duration_sec=2.0)
|
self.get_logger().warn(f"平面特征计算失败:{exc}", throttle_duration_sec=2.0)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _metric_z(self, z_meas: Optional[float], note: str) -> tuple:
|
def _metric_z(self, z_meas: Optional[float], note: str) -> tuple:
|
||||||
"""Z used for pixel→metre. Never use hole/floor depth (inflates XY error).
|
"""用于像素→米的 Z。绝不能用孔 / 地面的深度(会放大 XY 误差)。
|
||||||
Returns (None, note) when this target NEVER had usable depth — publishing
|
当这个目标**从未**有过可用深度时返回 (None, note) —— 那种情况下发布
|
||||||
a z_ref point then would fake-lock the servo onto a phantom XY."""
|
z_ref 点会让伺服假锁到一个幻影 XY 上。"""
|
||||||
z_ref = max(0.12, float(self.z_ref))
|
z_ref = max(0.12, float(self.z_ref))
|
||||||
if z_meas is not None and self.min_depth <= z_meas <= self.work_z_max:
|
if z_meas is not None and self.min_depth <= z_meas <= self.work_z_max:
|
||||||
self._last_good_z = float(z_meas)
|
self._last_good_z = float(z_meas)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""PID with integral clamp, derivative-on-measurement, and integral separation."""
|
"""PID 控制器,带积分限幅、微分作用于测量值(derivative-on-measurement)以及积分分离。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,10 +7,10 @@ from typing import Optional
|
|||||||
|
|
||||||
|
|
||||||
class PidController:
|
class PidController:
|
||||||
"""``u = Kp e + Ki ∫e + Kd (−dmeas/dt)``, then saturate.
|
"""``u = Kp e + Ki ∫e + Kd (−dmeas/dt)``,然后再做饱和限幅。
|
||||||
|
|
||||||
Integral separation: when ``|error| > i_sep_thresh``, integral is forced to 0
|
积分分离:当 ``|error| > i_sep_thresh`` 时,积分被强制置 0 且不再累积
|
||||||
and does not accumulate (P+D only for fast chase / less overshoot).
|
(只用 P+D,以便快速追赶 / 减少超调)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -50,7 +50,7 @@ class PidController:
|
|||||||
dterm = -(x - self._meas_prev) / dt
|
dterm = -(x - self._meas_prev) / dt
|
||||||
self._meas_prev = x
|
self._meas_prev = x
|
||||||
|
|
||||||
# Integral separation — large error: clear I, do not accumulate
|
# 积分分离 —— 误差较大:清空 I,不再累积
|
||||||
if self.i_sep_thresh > 0.0 and abs(err) > self.i_sep_thresh:
|
if self.i_sep_thresh > 0.0 and abs(err) > self.i_sep_thresh:
|
||||||
self._i = 0.0
|
self._i = 0.0
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Stereo plane Z at the YOLO box center.
|
"""在 YOLO 检测框中心处求立体平面的 Z。
|
||||||
|
|
||||||
The hover target is the box-center ray intersected with the fitted near
|
悬停目标是「检测框中心射线」与「拟合出的近平面」的交点。这样既能留在检测结果上,
|
||||||
plane. That stays on the detection, with metric Z (30° camera: wrong Z
|
又带有真实的米制 Z(30° 相机下,Z 错了会把 base 系的 XY 也带偏)。不使用点云均值
|
||||||
would shift base XY). The cloud-mean centroid is not used — holes / table
|
质心 —— 孔 / 桌面的内点会把它从绿框上拉走。
|
||||||
inliers pull it off the green box.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,9 +16,9 @@ import numpy as np
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlaneFeature:
|
class PlaneFeature:
|
||||||
uv: np.ndarray # (2,) color-pixel coords of the 3D centroid
|
uv: np.ndarray # (2,) 三维质心对应的彩色像素坐标
|
||||||
z: float # camera-frame Z of the 3D centroid
|
z: float # 三维质心在相机坐标系下的 Z
|
||||||
xyz: np.ndarray # (3,) stereo-cloud centroid in camera frame
|
xyz: np.ndarray # (3,) 相机坐标系下的立体点云质心
|
||||||
n_inliers: int
|
n_inliers: int
|
||||||
note: str
|
note: str
|
||||||
|
|
||||||
@@ -128,10 +127,10 @@ def workpiece_xyz_in_box(
|
|||||||
inner: float = 0.40,
|
inner: float = 0.40,
|
||||||
band_m: float = 0.06,
|
band_m: float = 0.06,
|
||||||
) -> Optional[PlaneFeature]:
|
) -> Optional[PlaneFeature]:
|
||||||
"""Physical workpiece point: median 3D of the near surface in the inner box.
|
"""工件上的实体点:内层检测框里近表面深度的三维中位数。
|
||||||
|
|
||||||
Not a monocular ray (holes punch through to the table) and not the
|
不是单目射线(孔会穿透到桌面),也不是整个 ROI 的点云均值
|
||||||
whole-ROI cloud mean (table / one-sided depth pulls it off the part).
|
(桌面 / 单侧深度会把它从工件上拉走)。
|
||||||
"""
|
"""
|
||||||
meters, _ = _depth_meters(depth_buf, encoding)
|
meters, _ = _depth_meters(depth_buf, encoding)
|
||||||
if meters.ndim != 2:
|
if meters.ndim != 2:
|
||||||
@@ -212,7 +211,7 @@ def geometric_center_on_plane(
|
|||||||
inlier_m: float = 0.008,
|
inlier_m: float = 0.008,
|
||||||
band_m: float = 0.06,
|
band_m: float = 0.06,
|
||||||
) -> Optional[PlaneFeature]:
|
) -> Optional[PlaneFeature]:
|
||||||
"""Box-center point on the fitted near plane, or None."""
|
"""拟合出的近平面上、位于检测框中心的点,拿不到则返回 None。"""
|
||||||
meters, _ = _depth_meters(depth_buf, encoding)
|
meters, _ = _depth_meters(depth_buf, encoding)
|
||||||
if meters.ndim != 2:
|
if meters.ndim != 2:
|
||||||
return None
|
return None
|
||||||
@@ -224,7 +223,7 @@ def geometric_center_on_plane(
|
|||||||
if dw != img_w or dh != img_h:
|
if dw != img_w or dh != img_h:
|
||||||
sx, sy = dw / max(float(img_w), 1.0), dh / max(float(img_h), 1.0)
|
sx, sy = dw / max(float(img_w), 1.0), dh / max(float(img_h), 1.0)
|
||||||
x1, y1, x2, y2 = x1 * sx, y1 * sy, x2 * sx, y2 * sy
|
x1, y1, x2, y2 = x1 * sx, y1 * sy, x2 * sx, y2 * sy
|
||||||
# Keep uv in color pixels: work in depth pixels then scale back.
|
# uv 保持用彩色像素坐标:先在深度像素下计算,再缩放回来。
|
||||||
scale_u, scale_v = 1.0 / sx, 1.0 / sy
|
scale_u, scale_v = 1.0 / sx, 1.0 / sy
|
||||||
else:
|
else:
|
||||||
scale_u = scale_v = 1.0
|
scale_u = scale_v = 1.0
|
||||||
@@ -246,7 +245,7 @@ def geometric_center_on_plane(
|
|||||||
|
|
||||||
yy, xx = np.nonzero(near)
|
yy, xx = np.nonzero(near)
|
||||||
zs = roi[yy, xx]
|
zs = roi[yy, xx]
|
||||||
# Color-equivalent pixel for back-projection (aligned depth uses color K).
|
# 反投影用的彩色等效像素(对齐后的深度使用的是彩色内参 K)。
|
||||||
us = (xx + x0) * scale_u
|
us = (xx + x0) * scale_u
|
||||||
vs = (yy + y0) * scale_v
|
vs = (yy + y0) * scale_v
|
||||||
pts = np.stack(
|
pts = np.stack(
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Publish RealSense color stream to ROS topics (no realsense2_camera apt pkg).
|
"""把 RealSense 彩色流发布到 ROS 话题(不依赖 realsense2_camera 的 apt 包)。
|
||||||
|
|
||||||
Use when ``ros-humble-realsense2-camera`` is not installed but ``pyrealsense2`` works.
|
当你没装 ``ros-humble-realsense2-camera``、但 ``pyrealsense2`` 能正常工作时用它。
|
||||||
|
|
||||||
Default topics (camera_name=d405):
|
默认话题(camera_name=d405):
|
||||||
/d405/color/image_raw
|
/d405/color/image_raw
|
||||||
/d405/color/camera_info
|
/d405/color/camera_info
|
||||||
/d405/aligned_depth_to_color/image_raw (16UC1 mm, if enable_depth)
|
/d405/aligned_depth_to_color/image_raw (16UC1 mm,需开启 enable_depth)
|
||||||
|
|
||||||
Performance notes
|
性能说明
|
||||||
-----------------
|
--------
|
||||||
* Publishes from the capture thread (no timer lag).
|
* 在采集线程里直接发布(没有定时器带来的延迟)。
|
||||||
* Uses SensorDataQoS (BEST_EFFORT, depth=1) so slow subscribers drop old frames.
|
* 使用 SensorDataQoS(BEST_EFFORT,depth=1),这样慢的订阅者会主动丢旧帧。
|
||||||
* Optional SDK preview: ``-p show_preview:=true`` (Intel sample path: pipeline → numpy → window).
|
* 可选的 SDK 预览:``-p show_preview:=true``(沿用 Intel 示例的链路:pipeline → numpy → 窗口)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,7 +34,7 @@ from std_msgs.msg import Float32MultiArray
|
|||||||
|
|
||||||
|
|
||||||
def _param_as_str(node: Node, name: str, default: str = '') -> str:
|
def _param_as_str(node: Node, name: str, default: str = '') -> str:
|
||||||
"""String param that also accepts numeric CLI overrides (e.g. serial numbers)."""
|
"""字符串参数,同时兼容命令行传数字的情况(例如序列号)。"""
|
||||||
node.declare_parameter(
|
node.declare_parameter(
|
||||||
name, default, ParameterDescriptor(dynamic_typing=True)
|
name, default, ParameterDescriptor(dynamic_typing=True)
|
||||||
)
|
)
|
||||||
@@ -82,7 +82,7 @@ def _site_yaml_path() -> Path:
|
|||||||
class RealsenseColorPub(Node):
|
class RealsenseColorPub(Node):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__('realsense_color_pub')
|
super().__init__('realsense_color_pub')
|
||||||
self.declare_parameter('camera_name', 'd405') # topic namespace
|
self.declare_parameter('camera_name', 'd405') # 话题命名空间
|
||||||
self._name = str(self.get_parameter('camera_name').value).strip().strip('/')
|
self._name = str(self.get_parameter('camera_name').value).strip().strip('/')
|
||||||
self._serial = _param_as_str(self, 'camera_serial', '')
|
self._serial = _param_as_str(self, 'camera_serial', '')
|
||||||
self.declare_parameter('width', 1280)
|
self.declare_parameter('width', 1280)
|
||||||
@@ -94,8 +94,8 @@ class RealsenseColorPub(Node):
|
|||||||
self.declare_parameter('enable_depth', True)
|
self.declare_parameter('enable_depth', True)
|
||||||
self.declare_parameter('enable_infra', True)
|
self.declare_parameter('enable_infra', True)
|
||||||
self.declare_parameter('preview_stereo', True)
|
self.declare_parameter('preview_stereo', True)
|
||||||
# Render the Qt preview only every Nth frame — publishing stays at full
|
# Qt 预览每 N 帧才渲染一次 —— 发布始终保持满帧率;
|
||||||
# rate; the CPU colorizer + 4-pane paint must not slow the stream.
|
# CPU 上色 + 四窗格绘制绝不能拖慢数据流。
|
||||||
self.declare_parameter('preview_decimation', 2)
|
self.declare_parameter('preview_decimation', 2)
|
||||||
self.declare_parameter('auto_exposure', True)
|
self.declare_parameter('auto_exposure', True)
|
||||||
self.declare_parameter('ae_limit_us', 3000.0)
|
self.declare_parameter('ae_limit_us', 3000.0)
|
||||||
@@ -122,7 +122,7 @@ class RealsenseColorPub(Node):
|
|||||||
self._laser_power = float(self.get_parameter('laser_power').value)
|
self._laser_power = float(self.get_parameter('laser_power').value)
|
||||||
self._exposure_us = float(self.get_parameter('exposure_us').value)
|
self._exposure_us = float(self.get_parameter('exposure_us').value)
|
||||||
self._gain = float(self.get_parameter('gain').value)
|
self._gain = float(self.get_parameter('gain').value)
|
||||||
# FPS meter: hardware frame numbers vs actually published (loop rate)
|
# FPS 表:硬件帧号对比实际发布帧数(循环速率)
|
||||||
self._fps_t0 = 0.0
|
self._fps_t0 = 0.0
|
||||||
self._fps_pub_color = 0
|
self._fps_pub_color = 0
|
||||||
self._fps_pub_depth = 0
|
self._fps_pub_depth = 0
|
||||||
@@ -132,7 +132,7 @@ class RealsenseColorPub(Node):
|
|||||||
self._t_align = 0.0
|
self._t_align = 0.0
|
||||||
self._t_pub = 0.0
|
self._t_pub = 0.0
|
||||||
|
|
||||||
qos = qos_profile_sensor_data # BEST_EFFORT, keep last 1
|
qos = qos_profile_sensor_data # BEST_EFFORT,只保留最后 1 帧
|
||||||
base = f'/{self._name}/color'
|
base = f'/{self._name}/color'
|
||||||
self._img_pub = self.create_publisher(
|
self._img_pub = self.create_publisher(
|
||||||
Image, f'{base}/image_raw', qos
|
Image, f'{base}/image_raw', qos
|
||||||
@@ -143,8 +143,8 @@ class RealsenseColorPub(Node):
|
|||||||
self._depth_pub = self.create_publisher(
|
self._depth_pub = self.create_publisher(
|
||||||
Image, f'/{self._name}/aligned_depth_to_color/image_raw', qos
|
Image, f'/{self._name}/aligned_depth_to_color/image_raw', qos
|
||||||
)
|
)
|
||||||
# IR streams are published too so the preview can live in a separate
|
# IR 流也会一起发布,这样预览可以放在独立的查看器进程里
|
||||||
# viewer process (Qt must not share the GIL with the capture loop).
|
# (Qt 不能和采集循环共享 GIL)。
|
||||||
self._ir1_pub = None
|
self._ir1_pub = None
|
||||||
self._ir2_pub = None
|
self._ir2_pub = None
|
||||||
if self._enable_infra_param and self._preview_stereo:
|
if self._enable_infra_param and self._preview_stereo:
|
||||||
@@ -163,8 +163,8 @@ class RealsenseColorPub(Node):
|
|||||||
self._infra_mode = 'none'
|
self._infra_mode = 'none'
|
||||||
self._colorizer = None
|
self._colorizer = None
|
||||||
self._logged_depth = False
|
self._logged_depth = False
|
||||||
# Device depth scale → multiply raw uint16 by this to get true mm.
|
# 设备深度尺度 → 用原始 uint16 乘上它才是真实的 mm。
|
||||||
# D405 = 0.1 mm/unit, D435 = 1.0 mm/unit. Read from the sensor at startup.
|
# D405 = 0.1 mm/单位,D435 = 1.0 mm/单位。启动时从传感器读取。
|
||||||
self._depth_to_mm = 1.0
|
self._depth_to_mm = 1.0
|
||||||
self._running = True
|
self._running = True
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@@ -205,13 +205,13 @@ class RealsenseColorPub(Node):
|
|||||||
det_qos,
|
det_qos,
|
||||||
)
|
)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
'SDK preview ON — COLOR|DEPTH|IR-L|IR-R in this process '
|
'SDK 预览已开启 —— 本进程内显示 COLOR|DEPTH|IR-L|IR-R '
|
||||||
f'(hold {self._overlay_hold_sec:.1f}s). Close window ≠ stop camera.'
|
f'(保留 {self._overlay_hold_sec:.1f}s)。关窗口 ≠ 停相机。'
|
||||||
)
|
)
|
||||||
self._start_pipeline()
|
self._start_pipeline()
|
||||||
|
|
||||||
def _pick_device(self, devices, rs):
|
def _pick_device(self, devices, rs):
|
||||||
"""Prefer configured serial, then D405 by name (this PC also has a D435)."""
|
"""优先用配置的序列号,其次按名字选 D405(这台机器上还接了 D435)。"""
|
||||||
hint = (self._name or 'd405').lower()
|
hint = (self._name or 'd405').lower()
|
||||||
if self._serial:
|
if self._serial:
|
||||||
for d in devices:
|
for d in devices:
|
||||||
@@ -222,7 +222,7 @@ class RealsenseColorPub(Node):
|
|||||||
for d in devices
|
for d in devices
|
||||||
)
|
)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Serial {self._serial!r} not found. Connected: {avail}'
|
f'找不到序列号 {self._serial!r}。当前已连接:{avail}'
|
||||||
)
|
)
|
||||||
for d in devices:
|
for d in devices:
|
||||||
name = str(d.get_info(rs.camera_info.name) or '').lower()
|
name = str(d.get_info(rs.camera_info.name) or '').lower()
|
||||||
@@ -231,7 +231,7 @@ class RealsenseColorPub(Node):
|
|||||||
return devices[0]
|
return devices[0]
|
||||||
|
|
||||||
def _reload_site_preview(self, stream_w: int = 0, stream_h: int = 0) -> None:
|
def _reload_site_preview(self, stream_w: int = 0, stream_h: int = 0) -> None:
|
||||||
"""Pick up overlay_hold_sec (and warn if width/height changed) without restart."""
|
"""不重启就能读到新的 overlay_hold_sec(宽高变了则给出警告)。"""
|
||||||
path = self._site_yaml
|
path = self._site_yaml
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return
|
return
|
||||||
@@ -248,7 +248,7 @@ class RealsenseColorPub(Node):
|
|||||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
cam = data.get("camera") or {}
|
cam = data.get("camera") or {}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f"site.yaml reload failed: {exc}")
|
self.get_logger().warn(f"site.yaml 重新加载失败:{exc}")
|
||||||
return
|
return
|
||||||
if "overlay_hold_sec" in cam:
|
if "overlay_hold_sec" in cam:
|
||||||
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
|
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
|
||||||
@@ -264,14 +264,14 @@ class RealsenseColorPub(Node):
|
|||||||
if (want_w, want_h) != (live_w, live_h) and not self._site_wh_warned:
|
if (want_w, want_h) != (live_w, live_h) and not self._site_wh_warned:
|
||||||
self._site_wh_warned = True
|
self._site_wh_warned = True
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"preview stream is {live_w}x{live_h}, site.yaml camera is "
|
f"预览流是 {live_w}x{live_h},而 site.yaml 里的 camera 是 "
|
||||||
f"{want_w}x{want_h} — restart: bash ~/ros2/scripts/run_camera_d405.sh --preview"
|
f"{want_w}x{want_h} —— 请重启:bash ~/ros2/scripts/run_camera_d405.sh --preview"
|
||||||
)
|
)
|
||||||
elif (want_w, want_h) == (live_w, live_h):
|
elif (want_w, want_h) == (live_w, live_h):
|
||||||
self._site_wh_warned = False
|
self._site_wh_warned = False
|
||||||
|
|
||||||
def _on_det(self, msg: Float32MultiArray) -> None:
|
def _on_det(self, msg: Float32MultiArray) -> None:
|
||||||
# Empty payload = perception miss → clear green box immediately
|
# 空负载 = 感知漏检 → 立刻清掉绿框
|
||||||
if not msg.data or len(msg.data) < 8:
|
if not msg.data or len(msg.data) < 8:
|
||||||
self._overlay = None
|
self._overlay = None
|
||||||
self._overlay_stamp = 0.0
|
self._overlay_stamp = 0.0
|
||||||
@@ -295,8 +295,8 @@ class RealsenseColorPub(Node):
|
|||||||
img.encoding = 'bgr8'
|
img.encoding = 'bgr8'
|
||||||
img.is_bigendian = 0
|
img.is_bigendian = 0
|
||||||
img.step = w * 3
|
img.step = w * 3
|
||||||
# array.array('B', ...) hits the setter's fast path — plain bytes gets
|
# array.array('B', ...) 会走 setter 的快路径 —— 直接传 bytes 会触发
|
||||||
# an element-wise debug check that costs ~40 ms per frame here.
|
# 逐元素的 debug 检查,在这里每帧要多花约 40 ms。
|
||||||
img.data = array.array('B', np.ascontiguousarray(bgr).tobytes())
|
img.data = array.array('B', np.ascontiguousarray(bgr).tobytes())
|
||||||
self._img_pub.publish(img)
|
self._img_pub.publish(img)
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ class RealsenseColorPub(Node):
|
|||||||
if not self._logged_depth:
|
if not self._logged_depth:
|
||||||
self._logged_depth = True
|
self._logged_depth = True
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'first aligned depth {dw}x{dh} → '
|
f'首个对齐深度 {dw}x{dh} → '
|
||||||
f'/{self._name}/aligned_depth_to_color/image_raw'
|
f'/{self._name}/aligned_depth_to_color/image_raw'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -333,10 +333,10 @@ class RealsenseColorPub(Node):
|
|||||||
import pyrealsense2 as rs
|
import pyrealsense2 as rs
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
'pyrealsense2 not available. conda activate ros_flexiv'
|
'pyrealsense2 不可用。请执行 conda activate ros_flexiv'
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# After killing a previous publisher, USB may take 1–3s to re-enumerate.
|
# 杀掉上一个发布进程后,USB 需要 1–3s 才能重新枚举。
|
||||||
ctx = rs.context()
|
ctx = rs.context()
|
||||||
devices = []
|
devices = []
|
||||||
for attempt in range(8):
|
for attempt in range(8):
|
||||||
@@ -347,9 +347,9 @@ class RealsenseColorPub(Node):
|
|||||||
ctx = rs.context()
|
ctx = rs.context()
|
||||||
if not devices:
|
if not devices:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
'No RealSense connected (USB empty after retries). '
|
'没有连接 RealSense(多次重试后 USB 仍为空)。'
|
||||||
'Plug D405 into THIS machine on a USB3 (blue) port, unplug/replug, '
|
'请把 D405 插到**本机**的 USB3(蓝色)口,拔插一次,'
|
||||||
'and close realsense-viewer / the 192.168.0.108 capture site if it holds the camera.'
|
'并关闭 realsense-viewer / 192.168.0.108 上占用该相机的采集端。'
|
||||||
)
|
)
|
||||||
|
|
||||||
found = self._pick_device(devices, rs)
|
found = self._pick_device(devices, rs)
|
||||||
@@ -375,8 +375,8 @@ class RealsenseColorPub(Node):
|
|||||||
)
|
)
|
||||||
if usb2:
|
if usb2:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'{cam_name} is on USB {usb_desc} — keeping depth/IR at '
|
f'{cam_name} 接在 USB {usb_desc} 上 —— 深度/IR 将保持 '
|
||||||
'640x480@15. Plug D405 into a USB3 (blue) port for 848@30.'
|
'640x480@15。把 D405 插到 USB3(蓝色)口可获得 848@30。'
|
||||||
)
|
)
|
||||||
self._width, self._height, self._fps = 640, 480, 15
|
self._width, self._height, self._fps = 640, 480, 15
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ class RealsenseColorPub(Node):
|
|||||||
]
|
]
|
||||||
if usb2:
|
if usb2:
|
||||||
candidates = [(640, 480, 15), (640, 480, 5), (424, 240, 15)]
|
candidates = [(640, 480, 15), (640, 480, 5), (424, 240, 15)]
|
||||||
# Drop firmware junk (848@90 etc.) that ioctl-fails and wedges USB.
|
# 丢弃固件里的垃圾模式(848@90 之类),它们会 ioctl 失败并卡死 USB。
|
||||||
seen = set()
|
seen = set()
|
||||||
trimmed = []
|
trimmed = []
|
||||||
for item in candidates:
|
for item in candidates:
|
||||||
@@ -425,8 +425,8 @@ class RealsenseColorPub(Node):
|
|||||||
have_depth = False
|
have_depth = False
|
||||||
have_infra = False
|
have_infra = False
|
||||||
|
|
||||||
# USB3: try real IR-L+IR-R first (same stereo AE). Color+IR-L often
|
# USB3:先尝试真正的 IR-L+IR-R(共享立体自动曝光)。彩色+IR-L 在 D405 上
|
||||||
# fails on D405 — then IR-R + color-as-left (different look, not AE).
|
# 经常失败 —— 那就退回 IR-R + 把彩色当左目(观感不同,且不共享自动曝光)。
|
||||||
def _modes():
|
def _modes():
|
||||||
if want_depth and want_infra and not usb2:
|
if want_depth and want_infra and not usb2:
|
||||||
yield True, 'both'
|
yield True, 'both'
|
||||||
@@ -461,25 +461,25 @@ class RealsenseColorPub(Node):
|
|||||||
self._infra_mode = infra_mode
|
self._infra_mode = infra_mode
|
||||||
if (w, h, fps) != want:
|
if (w, h, fps) != want:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'Requested {want[0]}x{want[1]}@{want[2]}Hz '
|
f'请求 {want[0]}x{want[1]}@{want[2]}Hz '
|
||||||
f'→ using {w}x{h}@{fps}Hz'
|
f'→ 改用 {w}x{h}@{fps}Hz'
|
||||||
)
|
)
|
||||||
if want_depth and not with_depth:
|
if want_depth and not with_depth:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'{w}x{h}@{fps}Hz color+depth failed — color only'
|
f'{w}x{h}@{fps}Hz 彩色+深度失败 —— 只用彩色'
|
||||||
)
|
)
|
||||||
elif want_infra and infra_mode == 'none':
|
elif want_infra and infra_mode == 'none':
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'{w}x{h}@{fps}Hz stereo IR failed — color+depth preview'
|
f'{w}x{h}@{fps}Hz 立体 IR 失败 —— 改用彩色+深度预览'
|
||||||
)
|
)
|
||||||
elif infra_mode == 'both':
|
elif infra_mode == 'both':
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'{w}x{h}@{fps}Hz stereo IR-L+IR-R (shared AE) + depth'
|
f'{w}x{h}@{fps}Hz 立体 IR-L+IR-R(共享自动曝光)+ 深度'
|
||||||
)
|
)
|
||||||
elif infra_mode == 'right':
|
elif infra_mode == 'right':
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'{w}x{h}@{fps}Hz IR-L=color gray, IR-R=raw IR '
|
f'{w}x{h}@{fps}Hz IR-L=彩色灰度,IR-R=原始 IR '
|
||||||
'(looks brighter/darker — not two AE loops)'
|
'(明暗不同 —— 这不是两路自动曝光)'
|
||||||
)
|
)
|
||||||
self._width, self._height, self._fps = w, h, fps
|
self._width, self._height, self._fps = w, h, fps
|
||||||
self._last_ok = (w, h, fps)
|
self._last_ok = (w, h, fps)
|
||||||
@@ -487,7 +487,7 @@ class RealsenseColorPub(Node):
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
last_err = exc
|
last_err = exc
|
||||||
kind = f'color+depth+IR-{infra_mode}' if with_depth else 'color'
|
kind = f'color+depth+IR-{infra_mode}' if with_depth else 'color'
|
||||||
self.get_logger().warn(f'{w}x{h}@{fps}Hz {kind} failed: {exc}')
|
self.get_logger().warn(f'{w}x{h}@{fps}Hz {kind} 失败:{exc}')
|
||||||
profile = None
|
profile = None
|
||||||
have_depth = False
|
have_depth = False
|
||||||
have_infra = False
|
have_infra = False
|
||||||
@@ -501,7 +501,7 @@ class RealsenseColorPub(Node):
|
|||||||
break
|
break
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'No supported color profile for D405. Last error: {last_err}'
|
f'D405 没有可用的彩色配置。最后的错误:{last_err}'
|
||||||
)
|
)
|
||||||
|
|
||||||
self._pipe = pipe
|
self._pipe = pipe
|
||||||
@@ -509,18 +509,18 @@ class RealsenseColorPub(Node):
|
|||||||
self._have_depth = have_depth
|
self._have_depth = have_depth
|
||||||
self._have_infra = have_infra
|
self._have_infra = have_infra
|
||||||
self._logged_depth = False
|
self._logged_depth = False
|
||||||
# Publish depth in TRUE millimetres. D405 raw units are 0.1 mm (D435 1 mm);
|
# 深度按**真实毫米**发布。D405 的原始单位是 0.1 mm(D435 是 1 mm);
|
||||||
# downstream (perception/servo) assumes mm, so normalise by the device scale.
|
# 下游(感知/伺服)都按 mm 处理,所以要按设备尺度归一化。
|
||||||
self._depth_to_mm = 1.0
|
self._depth_to_mm = 1.0
|
||||||
if have_depth:
|
if have_depth:
|
||||||
try:
|
try:
|
||||||
ds = float(profile.get_device().first_depth_sensor().get_depth_scale())
|
ds = float(profile.get_device().first_depth_sensor().get_depth_scale())
|
||||||
self._depth_to_mm = ds * 1000.0 # m/unit → mm/unit
|
self._depth_to_mm = ds * 1000.0 # m/单位 → mm/单位
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'depth scale {ds * 1000.0:.4f} mm/unit → publishing true mm'
|
f'深度尺度 {ds * 1000.0:.4f} mm/单位 → 按真实 mm 发布'
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f'depth scale read failed, assume mm: {exc}')
|
self.get_logger().warn(f'读取深度尺度失败,按 mm 处理:{exc}')
|
||||||
self._depth_to_mm = 1.0
|
self._depth_to_mm = 1.0
|
||||||
self._align = rs.align(rs.stream.color) if have_depth else None
|
self._align = rs.align(rs.stream.color) if have_depth else None
|
||||||
self._colorizer = None
|
self._colorizer = None
|
||||||
@@ -546,11 +546,11 @@ class RealsenseColorPub(Node):
|
|||||||
log=self.get_logger().info,
|
log=self.get_logger().info,
|
||||||
)
|
)
|
||||||
apply_realsense_exposure(profile.get_device(), **kwargs)
|
apply_realsense_exposure(profile.get_device(), **kwargs)
|
||||||
# Firmware often resets AE on the first frames; stamp it again.
|
# 固件经常会在最初几帧重置自动曝光;所以再设置一次。
|
||||||
_time.sleep(0.25)
|
_time.sleep(0.25)
|
||||||
apply_realsense_exposure(profile.get_device(), **kwargs)
|
apply_realsense_exposure(profile.get_device(), **kwargs)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f'exposure apply: {exc}')
|
self.get_logger().warn(f'应用曝光参数失败:{exc}')
|
||||||
if self._preview is not None:
|
if self._preview is not None:
|
||||||
if self._infra_mode == 'both':
|
if self._infra_mode == 'both':
|
||||||
self._preview.set_pane_titles(
|
self._preview.set_pane_titles(
|
||||||
@@ -575,8 +575,8 @@ class RealsenseColorPub(Node):
|
|||||||
dist = list(intr.coeffs[:5]) if intr.coeffs else [0.0] * 5
|
dist = list(intr.coeffs[:5]) if intr.coeffs else [0.0] * 5
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'get_intrinsics failed ({exc}); using D405 defaults for '
|
f'get_intrinsics 失败({exc});改用 D405 的默认内参,'
|
||||||
f'{self._width}x{self._height}'
|
f'分辨率 {self._width}x{self._height}'
|
||||||
)
|
)
|
||||||
w, h = float(self._width), float(self._height)
|
w, h = float(self._width), float(self._height)
|
||||||
if w >= 1200:
|
if w >= 1200:
|
||||||
@@ -595,10 +595,10 @@ class RealsenseColorPub(Node):
|
|||||||
depth_txt = (
|
depth_txt = (
|
||||||
f' + aligned depth /{self._name}/aligned_depth_to_color/image_raw'
|
f' + aligned depth /{self._name}/aligned_depth_to_color/image_raw'
|
||||||
if have_depth
|
if have_depth
|
||||||
else ' (color only)'
|
else ' (仅彩色)'
|
||||||
)
|
)
|
||||||
if have_infra:
|
if have_infra:
|
||||||
depth_txt += ' + IR-L/IR-R preview'
|
depth_txt += ' + IR-L/IR-R 预览'
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'{model} serial={sn} USB={usb_desc} → /{self._name}/color/image_raw '
|
f'{model} serial={sn} USB={usb_desc} → /{self._name}/color/image_raw '
|
||||||
f'{self._width}x{self._height}@{self._fps}Hz '
|
f'{self._width}x{self._height}@{self._fps}Hz '
|
||||||
@@ -611,30 +611,30 @@ class RealsenseColorPub(Node):
|
|||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _enable_color_ae_fast(self, profile, rs) -> None:
|
def _enable_color_ae_fast(self, profile, rs) -> None:
|
||||||
"""Color auto-exposure that prefers short exposure (less motion blur)."""
|
"""彩色自动曝光,优先用短曝光(减少运动模糊)。"""
|
||||||
try:
|
try:
|
||||||
sensor = profile.get_device().first_color_sensor()
|
sensor = profile.get_device().first_color_sensor()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f'color AE: no color sensor ({exc})')
|
self.get_logger().warn(f'彩色 AE:没有彩色传感器({exc})')
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if sensor.supports(rs.option.enable_auto_exposure):
|
if sensor.supports(rs.option.enable_auto_exposure):
|
||||||
sensor.set_option(rs.option.enable_auto_exposure, 1.0)
|
sensor.set_option(rs.option.enable_auto_exposure, 1.0)
|
||||||
if sensor.supports(rs.option.auto_exposure_priority):
|
if sensor.supports(rs.option.auto_exposure_priority):
|
||||||
# 0 = keep FPS, shorten exposure when the scene is bright enough
|
# 0 = 保住帧率,场景够亮时缩短曝光时间
|
||||||
sensor.set_option(rs.option.auto_exposure_priority, 0.0)
|
sensor.set_option(rs.option.auto_exposure_priority, 0.0)
|
||||||
limit = getattr(rs.option, "auto_exposure_limit", None)
|
limit = getattr(rs.option, "auto_exposure_limit", None)
|
||||||
if limit is not None and sensor.supports(limit):
|
if limit is not None and sensor.supports(limit):
|
||||||
try:
|
try:
|
||||||
sensor.set_option(limit, 5000.0) # µs; less motion blur at 30 Hz
|
sensor.set_option(limit, 5000.0) # µs;30 Hz 下减少运动模糊
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.get_logger().info('color auto-exposure ON (prefer FPS / less blur)')
|
self.get_logger().info('彩色自动曝光已开启(优先帧率 / 减少模糊)')
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f'color AE: {exc}')
|
self.get_logger().warn(f'彩色 AE:{exc}')
|
||||||
|
|
||||||
def _enable_stereo_ae(self, profile, rs) -> None:
|
def _enable_stereo_ae(self, profile, rs) -> None:
|
||||||
"""One auto-exposure for both IR imagers (stereo module)."""
|
"""两路 IR 成像器共用一路自动曝光(立体模块)。"""
|
||||||
try:
|
try:
|
||||||
sensor = profile.get_device().first_depth_sensor()
|
sensor = profile.get_device().first_depth_sensor()
|
||||||
if sensor.supports(rs.option.enable_auto_exposure):
|
if sensor.supports(rs.option.enable_auto_exposure):
|
||||||
@@ -642,15 +642,15 @@ class RealsenseColorPub(Node):
|
|||||||
limit = getattr(rs.option, "auto_exposure_limit", None)
|
limit = getattr(rs.option, "auto_exposure_limit", None)
|
||||||
if limit is not None and sensor.supports(limit):
|
if limit is not None and sensor.supports(limit):
|
||||||
try:
|
try:
|
||||||
# Cap IR exposure well under the 33 ms frame period so a
|
# 把 IR 曝光上限压到远低于 33 ms 的帧周期,这样光线暗的场景
|
||||||
# dim scene cannot silently drop the stream below 30 Hz.
|
# 也不会悄悄把数据流掉到 30 Hz 以下。
|
||||||
sensor.set_option(limit, 20000.0)
|
sensor.set_option(limit, 20000.0)
|
||||||
self.get_logger().info('stereo IR exposure limit 20ms')
|
self.get_logger().info('立体 IR 曝光上限 20ms')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.get_logger().info('stereo IR auto-exposure ON (shared L/R)')
|
self.get_logger().info('立体 IR 自动曝光已开启(左右共享)')
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f'stereo AE: {exc}')
|
self.get_logger().warn(f'立体 AE:{exc}')
|
||||||
|
|
||||||
def _colorize_depth(self, depth_frame, depth_u16: np.ndarray) -> np.ndarray:
|
def _colorize_depth(self, depth_frame, depth_u16: np.ndarray) -> np.ndarray:
|
||||||
if self._colorizer is not None:
|
if self._colorizer is not None:
|
||||||
@@ -695,7 +695,7 @@ class RealsenseColorPub(Node):
|
|||||||
return self._pipe is not None
|
return self._pipe is not None
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'reconnect failed: {exc}. Unplug/replug USB3, slack the cable.',
|
f'重连失败:{exc}。请拔插 USB3,并给线缆留点余量。',
|
||||||
throttle_duration_sec=5.0,
|
throttle_duration_sec=5.0,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
@@ -738,15 +738,15 @@ class RealsenseColorPub(Node):
|
|||||||
if dropped or misses >= 4:
|
if dropped or misses >= 4:
|
||||||
usb = getattr(self, '_usb_desc', '?')
|
usb = getattr(self, '_usb_desc', '?')
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'Camera stream lost (USB={usb}): {text[:120]}. '
|
f'相机数据流中断(USB={usb}):{text[:120]}。'
|
||||||
'Reconnecting — slack the USB cable so the arm does not yank it.'
|
'正在重连 —— 请给 USB 线缆留点余量,别让机械臂把它拽掉。'
|
||||||
)
|
)
|
||||||
got = False
|
got = False
|
||||||
misses = 0
|
misses = 0
|
||||||
self._reconnect()
|
self._reconnect()
|
||||||
continue
|
continue
|
||||||
if misses == 1:
|
if misses == 1:
|
||||||
self.get_logger().warn(f'wait_for_frames: {exc}')
|
self.get_logger().warn(f'wait_for_frames:{exc}')
|
||||||
continue
|
continue
|
||||||
if not color:
|
if not color:
|
||||||
misses += 1
|
misses += 1
|
||||||
@@ -756,14 +756,14 @@ class RealsenseColorPub(Node):
|
|||||||
depth = frames.get_depth_frame()
|
depth = frames.get_depth_frame()
|
||||||
depth_u16 = np.asanyarray(depth.get_data()) if depth else None
|
depth_u16 = np.asanyarray(depth.get_data()) if depth else None
|
||||||
if depth_u16 is not None and self._depth_to_mm != 1.0:
|
if depth_u16 is not None and self._depth_to_mm != 1.0:
|
||||||
# raw device units → true mm (D405 raw is 0.1 mm/unit)
|
# 原始设备单位 → 真实 mm(D405 原始为 0.1 mm/单位)
|
||||||
depth_u16 = np.clip(
|
depth_u16 = np.clip(
|
||||||
depth_u16.astype(np.float32) * self._depth_to_mm, 0, 65535
|
depth_u16.astype(np.float32) * self._depth_to_mm, 0, 65535
|
||||||
).astype(np.uint16)
|
).astype(np.uint16)
|
||||||
self._frame_i += 1
|
self._frame_i += 1
|
||||||
self._fps_meter(color, depth, depth_u16)
|
self._fps_meter(color, depth, depth_u16)
|
||||||
# Preview work (CPU colorizer, IR copies, 4-pane paint) only every
|
# 预览相关的工作(CPU 上色、IR 拷贝、四窗格绘制)每 N 帧才做一次
|
||||||
# Nth frame — it must not slow down publishing.
|
# —— 它绝不能拖慢发布。
|
||||||
do_push = (
|
do_push = (
|
||||||
self._preview is not None
|
self._preview is not None
|
||||||
and (self._frame_i % self._preview_every) == 0
|
and (self._frame_i % self._preview_every) == 0
|
||||||
@@ -776,13 +776,13 @@ class RealsenseColorPub(Node):
|
|||||||
depth_bgr = self._colorize_depth(depth, depth_u16)
|
depth_bgr = self._colorize_depth(depth, depth_u16)
|
||||||
ir_l_img = np.asanyarray(ir_l.get_data()).copy() if ir_l else None
|
ir_l_img = np.asanyarray(ir_l.get_data()).copy() if ir_l else None
|
||||||
ir_r_img = np.asanyarray(ir_r.get_data()).copy() if ir_r else None
|
ir_r_img = np.asanyarray(ir_r.get_data()).copy() if ir_r else None
|
||||||
# Only substitute color-gray when the raw left IR stream is unavailable.
|
# 只有在拿不到原始左目 IR 流时,才用彩色灰度来顶替。
|
||||||
if ir_l_img is None and self._infra_mode != 'both':
|
if ir_l_img is None and self._infra_mode != 'both':
|
||||||
ir_l_img = np.mean(bgr, axis=2).astype(np.uint8)
|
ir_l_img = np.mean(bgr, axis=2).astype(np.uint8)
|
||||||
if not got:
|
if not got:
|
||||||
got = True
|
got = True
|
||||||
sw, sh = int(bgr.shape[1]), int(bgr.shape[0])
|
sw, sh = int(bgr.shape[1]), int(bgr.shape[0])
|
||||||
self.get_logger().info(f'first frame {sw}x{sh}')
|
self.get_logger().info(f'首帧 {sw}x{sh}')
|
||||||
if self._preview is not None:
|
if self._preview is not None:
|
||||||
self._preview.set_pane_titles(
|
self._preview.set_pane_titles(
|
||||||
f"COLOR {sw}x{sh}",
|
f"COLOR {sw}x{sh}",
|
||||||
@@ -809,7 +809,7 @@ class RealsenseColorPub(Node):
|
|||||||
self._overlay is None or age > hold
|
self._overlay is None or age > hold
|
||||||
):
|
):
|
||||||
self._preview.set_status(
|
self._preview.set_status(
|
||||||
'NO box — check perception_node / conf'
|
'没有框 —— 检查 perception_node / conf'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._preview.set_status('')
|
self._preview.set_status('')
|
||||||
@@ -817,15 +817,15 @@ class RealsenseColorPub(Node):
|
|||||||
bgr.copy(), depth_bgr, ir_l_img, ir_r_img
|
bgr.copy(), depth_bgr, ir_l_img, ir_r_img
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
# Context may be shutting down
|
# 上下文可能正在关闭
|
||||||
if not self._running:
|
if not self._running:
|
||||||
break
|
break
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f'publish failed: {exc}', throttle_duration_sec=2.0
|
f'发布失败:{exc}', throttle_duration_sec=2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
def _publish_ir(self, frame, pub) -> None:
|
def _publish_ir(self, frame, pub) -> None:
|
||||||
"""Raw IR imager as mono8 for the external viewer process."""
|
"""把原始 IR 成像器以 mono8 发布,供外部查看器进程使用。"""
|
||||||
arr = np.asanyarray(frame.get_data())
|
arr = np.asanyarray(frame.get_data())
|
||||||
h, w = int(arr.shape[0]), int(arr.shape[1])
|
h, w = int(arr.shape[0]), int(arr.shape[1])
|
||||||
msg = Image()
|
msg = Image()
|
||||||
@@ -840,8 +840,8 @@ class RealsenseColorPub(Node):
|
|||||||
pub.publish(msg)
|
pub.publish(msg)
|
||||||
|
|
||||||
def _fps_meter(self, color, depth, depth_u16) -> None:
|
def _fps_meter(self, color, depth, depth_u16) -> None:
|
||||||
"""Log published vs hardware frame rate — separates loop slowness
|
"""记录发布帧率与硬件帧率 —— 用来区分循环太慢
|
||||||
(preview/CPU) from SDK-side drops (AE long exposure / USB)."""
|
(预览/CPU)和 SDK 侧掉帧(自动曝光长曝光 / USB)。"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
try:
|
try:
|
||||||
fn_c = int(color.get_frame_number())
|
fn_c = int(color.get_frame_number())
|
||||||
@@ -886,13 +886,13 @@ def main(args=None) -> None:
|
|||||||
preview = getattr(node, '_preview', None)
|
preview = getattr(node, '_preview', None)
|
||||||
if preview is not None:
|
if preview is not None:
|
||||||
app = node._qt_app
|
app = node._qt_app
|
||||||
# Closing the window must NOT stop the camera — track needs the stream.
|
# 关闭窗口绝不能停掉相机 —— 跟踪需要这条数据流。
|
||||||
while rclpy.ok() and node._running:
|
while rclpy.ok() and node._running:
|
||||||
rclpy.spin_once(node, timeout_sec=0.01)
|
rclpy.spin_once(node, timeout_sec=0.01)
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
if node._preview is not None and not node._preview.isVisible():
|
if node._preview is not None and not node._preview.isVisible():
|
||||||
node.get_logger().warn(
|
node.get_logger().warn(
|
||||||
'Preview closed — camera keeps publishing'
|
'预览已关闭 —— 相机继续发布'
|
||||||
)
|
)
|
||||||
node._preview = None
|
node._preview = None
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""RealSense SDK preview: fixed 2x2 Qt panes (color / depth / IR-L / IR-R).
|
"""RealSense SDK 预览:固定的 2x2 Qt 窗格(彩色 / 深度 / IR-L / IR-R)。
|
||||||
|
|
||||||
Stitching four streams into one pixmap looked like a single camera view when
|
把四路画面拼成一张 pixmap 时,一旦缺了深度 / IR 看起来就像单相机视图。
|
||||||
depth/IR were missing. Each stream now has its own labeled pane.
|
现在每路画面都有自己带标题的独立窗格。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -221,15 +221,15 @@ class SdkPreviewWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
color = self._draw_overlay(color, ov, status)
|
color = self._draw_overlay(color, ov, status)
|
||||||
labs = self._img_labels
|
labs = self._img_labels
|
||||||
self._set_pane(labs[0], color, "NO COLOR")
|
self._set_pane(labs[0], color, "无彩色")
|
||||||
self._set_pane(labs[1], depth, "NO DEPTH")
|
self._set_pane(labs[1], depth, "无深度")
|
||||||
self._set_pane(labs[2], ir_l, "NO IR-L")
|
self._set_pane(labs[2], ir_l, "无 IR-L")
|
||||||
self._set_pane(labs[3], ir_r, "NO IR-R")
|
self._set_pane(labs[3], ir_r, "无 IR-R")
|
||||||
|
|
||||||
|
|
||||||
def ensure_app() -> "QApplication":
|
def ensure_app() -> "QApplication":
|
||||||
if QApplication is None:
|
if QApplication is None:
|
||||||
raise RuntimeError("PySide6 not available for RealSense preview window")
|
raise RuntimeError("RealSense 预览窗口缺少 PySide6,无法创建")
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is None:
|
if app is None:
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""realsense_view — D405 4-pane preview (COLOR|DEPTH|IR-L|IR-R), own process.
|
"""realsense_view —— D405 四窗格预览(COLOR|DEPTH|IR-L|IR-R),独立进程运行。
|
||||||
|
|
||||||
The camera node (``realsense_color_pub``) only publishes topics. All Qt work
|
相机节点(``realsense_color_pub``)只负责发布话题。所有 Qt 工作都放在这里,
|
||||||
runs here so painting can never starve the camera capture loop (Python GIL
|
这样绘制永远不会拖垮相机采集循环(两者同进程时,Python 的 GIL 会让 ``publish``
|
||||||
made ``publish`` wall-time jump from 1 ms to ~70 ms when they shared a
|
的墙钟耗时从 1 ms 跳到约 70 ms)。关闭窗口只是隐藏预览;Ctrl+C 才停止查看器
|
||||||
process). Closing the window only hides the preview; Ctrl+C stops the viewer
|
(相机仍在持续发布)。
|
||||||
(the camera keeps publishing).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -31,7 +30,7 @@ from arm_application.realsense_color_pub import _site_yaml_path
|
|||||||
|
|
||||||
|
|
||||||
def _colorize_depth_u16(d: np.ndarray) -> np.ndarray:
|
def _colorize_depth_u16(d: np.ndarray) -> np.ndarray:
|
||||||
"""Fast numpy JET colorize for the preview pane (no SDK colorizer)."""
|
"""为预览窗格做快速 numpy JET 上色(不使用 SDK 的 colorizer)。"""
|
||||||
vis = np.zeros((d.shape[0], d.shape[1], 3), dtype=np.uint8)
|
vis = np.zeros((d.shape[0], d.shape[1], 3), dtype=np.uint8)
|
||||||
valid = d > 0
|
valid = d > 0
|
||||||
if not valid.any():
|
if not valid.any():
|
||||||
@@ -79,7 +78,7 @@ class RealsenseView(Node):
|
|||||||
self._stage2_stamp = 0.0
|
self._stage2_stamp = 0.0
|
||||||
self._site_yaml = _site_yaml_path()
|
self._site_yaml = _site_yaml_path()
|
||||||
self._site_mtime = 0.0
|
self._site_mtime = 0.0
|
||||||
# RX rate meter — visible stutter diagnosis (transport vs paint)
|
# RX 速率表 —— 用来直观诊断卡顿(是传输问题还是绘制问题)
|
||||||
self._rx_color = 0
|
self._rx_color = 0
|
||||||
self._rx_depth = 0
|
self._rx_depth = 0
|
||||||
self._rx_ir = 0
|
self._rx_ir = 0
|
||||||
@@ -115,7 +114,7 @@ class RealsenseView(Node):
|
|||||||
self._qt_app = ensure_app()
|
self._qt_app = ensure_app()
|
||||||
hud = self._hud or self._name
|
hud = self._hud or self._name
|
||||||
self._preview = SdkPreviewWindow(
|
self._preview = SdkPreviewWindow(
|
||||||
title=f'{hud} /{self._name} COLOR|DEPTH|IR (viewer)'
|
title=f'{hud} /{self._name} COLOR|DEPTH|IR (查看器)'
|
||||||
)
|
)
|
||||||
self._preview.set_pane_titles(
|
self._preview.set_pane_titles(
|
||||||
'COLOR', 'DEPTH', 'IR-L (左红外)', 'IR-R (右红外)'
|
'COLOR', 'DEPTH', 'IR-L (左红外)', 'IR-R (右红外)'
|
||||||
@@ -124,11 +123,11 @@ class RealsenseView(Node):
|
|||||||
hz = max(5.0, min(30.0, float(self.get_parameter('view_hz').value)))
|
hz = max(5.0, min(30.0, float(self.get_parameter('view_hz').value)))
|
||||||
self.create_timer(1.0 / hz, self._push)
|
self.create_timer(1.0 / hz, self._push)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'viewer ON — subscribing /{self._name}/color|depth|infra1|infra2 '
|
f'查看器已启动 —— 订阅 /{self._name}/color|depth|infra1|infra2 '
|
||||||
f'(hold {self._hold:.2f}s). Close window = hide only.'
|
f'(保留 {self._hold:.2f}s)。关闭窗口 = 仅隐藏。'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Callbacks only store the newest message; conversion happens in _push.
|
# 回调只存最新消息;转换在 _push 里做。
|
||||||
def _on_color(self, msg: Image) -> None:
|
def _on_color(self, msg: Image) -> None:
|
||||||
self._color = msg
|
self._color = msg
|
||||||
self._rx_color += 1
|
self._rx_color += 1
|
||||||
@@ -184,7 +183,7 @@ class RealsenseView(Node):
|
|||||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
cam = data.get("camera") or {}
|
cam = data.get("camera") or {}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.get_logger().warn(f"site.yaml reload failed: {exc}")
|
self.get_logger().warn(f"site.yaml 重新加载失败:{exc}")
|
||||||
return
|
return
|
||||||
if "overlay_hold_sec" in cam:
|
if "overlay_hold_sec" in cam:
|
||||||
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
|
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
|
||||||
@@ -216,7 +215,7 @@ class RealsenseView(Node):
|
|||||||
elif now - self._rx_t0 >= 5.0:
|
elif now - self._rx_t0 >= 5.0:
|
||||||
dt = now - self._rx_t0
|
dt = now - self._rx_t0
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'viewer rx color={self._rx_color / dt:.1f}/s '
|
f'查看器接收 color={self._rx_color / dt:.1f}/s '
|
||||||
f'depth={self._rx_depth / dt:.1f}/s ir={self._rx_ir / dt:.1f}/s'
|
f'depth={self._rx_depth / dt:.1f}/s ir={self._rx_ir / dt:.1f}/s'
|
||||||
)
|
)
|
||||||
self._rx_t0 = now
|
self._rx_t0 = now
|
||||||
@@ -228,7 +227,7 @@ class RealsenseView(Node):
|
|||||||
self._overlay = None
|
self._overlay = None
|
||||||
self._preview.set_overlay(None)
|
self._preview.set_overlay(None)
|
||||||
if self._overlay is None or age > self._hold:
|
if self._overlay is None or age > self._hold:
|
||||||
miss = 'NO box — check perception_node / conf'
|
miss = '没有框 —— 检查 perception_node / conf'
|
||||||
self._preview.set_status(f'{self._hud} {miss}' if self._hud else miss)
|
self._preview.set_status(f'{self._hud} {miss}' if self._hud else miss)
|
||||||
else:
|
else:
|
||||||
self._preview.set_status(self._hud)
|
self._preview.set_status(self._hud)
|
||||||
@@ -244,7 +243,7 @@ class RealsenseView(Node):
|
|||||||
int(self._depth.height), int(self._depth.width)
|
int(self._depth.height), int(self._depth.width)
|
||||||
)
|
)
|
||||||
depth_bgr = _colorize_depth_u16(d)
|
depth_bgr = _colorize_depth_u16(d)
|
||||||
# Mono→BGR here (executor thread) so the Qt paint thread stays cheap.
|
# 在这里(执行器线程)做 Mono→BGR,让 Qt 绘制线程保持轻量。
|
||||||
ir1 = None if self._ir1 is None else _mono_to_bgr(self._mono(self._ir1))
|
ir1 = None if self._ir1 is None else _mono_to_bgr(self._mono(self._ir1))
|
||||||
ir2 = None if self._ir2 is None else _mono_to_bgr(self._mono(self._ir2))
|
ir2 = None if self._ir2 is None else _mono_to_bgr(self._mono(self._ir2))
|
||||||
self._preview.push_views(color, depth_bgr, ir1, ir2)
|
self._preview.push_views(color, depth_bgr, ir1, ir2)
|
||||||
@@ -254,8 +253,8 @@ def main(args=None) -> None:
|
|||||||
rclpy.init(args=args)
|
rclpy.init(args=args)
|
||||||
node = RealsenseView()
|
node = RealsenseView()
|
||||||
app = node._qt_app
|
app = node._qt_app
|
||||||
# ROS callbacks (image deserialization, depth colorize, push) run on
|
# ROS 回调(图像反序列化、深度上色、推送)跑在执行器线程上;
|
||||||
# executor threads; the main thread only paints Qt — no shared GIL bursts.
|
# 主线程只负责绘制 Qt —— 不会有共享的 GIL 突发。
|
||||||
executor = MultiThreadedExecutor(num_threads=3)
|
executor = MultiThreadedExecutor(num_threads=3)
|
||||||
executor.add_node(node)
|
executor.add_node(node)
|
||||||
spin_t = threading.Thread(target=executor.spin, daemon=True)
|
spin_t = threading.Thread(target=executor.spin, daemon=True)
|
||||||
@@ -265,7 +264,7 @@ def main(args=None) -> None:
|
|||||||
app.processEvents()
|
app.processEvents()
|
||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
if node._preview is not None and not node._preview.isVisible():
|
if node._preview is not None and not node._preview.isVisible():
|
||||||
node.get_logger().warn('Preview closed — viewer idles (camera keeps publishing)')
|
node.get_logger().warn('预览已关闭 —— 查看器挂起(相机仍在发布)')
|
||||||
node._preview = None
|
node._preview = None
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Send a multi-point joint trajectory via /follow_joint_trajectory (real + mock)."""
|
"""通过 ``/follow_joint_trajectory`` 发送多点关节轨迹(真机 + mock 都适用)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ DEFAULT_JOINTS = [
|
|||||||
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
|
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
|
||||||
]
|
]
|
||||||
NUM_JOINTS = 7
|
NUM_JOINTS = 7
|
||||||
# flat positions (3 pts x 7 joints) + times — ROS params do not support dict lists
|
# 扁平化存放的位置(3 个点 x 7 个关节)+ 时间 —— ROS 参数不支持列表套字典
|
||||||
DEFAULT_TIMES = [3.0, 6.0, 9.0]
|
DEFAULT_TIMES = [3.0, 6.0, 9.0]
|
||||||
DEFAULT_POSITIONS = [
|
DEFAULT_POSITIONS = [
|
||||||
0.0, -0.698, 0.0, 1.57, 0.0, 0.698, 0.0,
|
0.0, -0.698, 0.0, 1.57, 0.0, 0.698, 0.0,
|
||||||
@@ -34,7 +34,7 @@ class RunJointTrajectory(Node):
|
|||||||
self.declare_parameter('action_name', 'follow_joint_trajectory')
|
self.declare_parameter('action_name', 'follow_joint_trajectory')
|
||||||
self.declare_parameter('waypoint_times', DEFAULT_TIMES)
|
self.declare_parameter('waypoint_times', DEFAULT_TIMES)
|
||||||
self.declare_parameter('waypoint_positions', DEFAULT_POSITIONS)
|
self.declare_parameter('waypoint_positions', DEFAULT_POSITIONS)
|
||||||
# 1 = run once (default); 0 = loop until Ctrl+C; N = repeat N times
|
# 1 = 只跑一次(默认);0 = 循环直到 Ctrl+C;N = 重复 N 次
|
||||||
self.declare_parameter('loop_count', 1)
|
self.declare_parameter('loop_count', 1)
|
||||||
self.declare_parameter('pause_between_loops', 0.5)
|
self.declare_parameter('pause_between_loops', 0.5)
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ class RunJointTrajectory(Node):
|
|||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
if not self._client.wait_for_server(timeout_sec=15.0):
|
if not self._client.wait_for_server(timeout_sec=15.0):
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'Action [{self._action_name}] not available. '
|
f'Action [{self._action_name}] 不可用。'
|
||||||
'Start real stack first: bash scripts/run_real.sh'
|
'请先启动真机栈:bash scripts/run_real.sh'
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -62,12 +62,12 @@ class RunJointTrajectory(Node):
|
|||||||
n_j = len(self.joint_names)
|
n_j = len(self.joint_names)
|
||||||
n_pts = len(times)
|
n_pts = len(times)
|
||||||
if n_pts < 1:
|
if n_pts < 1:
|
||||||
self.get_logger().error('waypoint_times is empty')
|
self.get_logger().error('waypoint_times 为空')
|
||||||
return 1
|
return 1
|
||||||
if len(positions) != n_pts * n_j:
|
if len(positions) != n_pts * n_j:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f'waypoint_positions length {len(positions)} != '
|
f'waypoint_positions 长度 {len(positions)} != '
|
||||||
f'{n_pts} points x {n_j} joints = {n_pts * n_j}'
|
f'{n_pts} 个点 x {n_j} 个关节 = {n_pts * n_j}'
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -83,35 +83,35 @@ class RunJointTrajectory(Node):
|
|||||||
duration = goal.time_from_start[-1]
|
duration = goal.time_from_start[-1]
|
||||||
if loop_count == 0:
|
if loop_count == 0:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Loop mode: {goal.num_points} points, ~{duration:.1f}s/cycle '
|
f'循环模式:{goal.num_points} 个点,约 {duration:.1f}s/圈 '
|
||||||
f'(Ctrl+C to stop)'
|
f'(Ctrl+C 停止)'
|
||||||
)
|
)
|
||||||
elif loop_count > 1:
|
elif loop_count > 1:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Loop mode: {loop_count} cycles, {goal.num_points} points, '
|
f'循环模式:{loop_count} 圈,{goal.num_points} 个点,'
|
||||||
f'~{duration:.1f}s/cycle'
|
f'约 {duration:.1f}s/圈'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Sending {goal.num_points} points, v_scale={goal.velocity_scale}, '
|
f'发送 {goal.num_points} 个点,v_scale={goal.velocity_scale},'
|
||||||
f'duration={duration:.1f}s'
|
f'时长={duration:.1f}s'
|
||||||
)
|
)
|
||||||
|
|
||||||
iteration = 0
|
iteration = 0
|
||||||
while rclpy.ok():
|
while rclpy.ok():
|
||||||
iteration += 1
|
iteration += 1
|
||||||
if loop_count > 1:
|
if loop_count > 1:
|
||||||
self.get_logger().info(f'Cycle {iteration}/{loop_count}')
|
self.get_logger().info(f'第 {iteration}/{loop_count} 圈')
|
||||||
|
|
||||||
code = self._send_once(goal, duration)
|
code = self._send_once(goal, duration)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
return code
|
return code
|
||||||
|
|
||||||
if loop_count == 1:
|
if loop_count == 1:
|
||||||
self.get_logger().info('Done: OK')
|
self.get_logger().info('完成:OK')
|
||||||
return 0
|
return 0
|
||||||
if loop_count > 1 and iteration >= loop_count:
|
if loop_count > 1 and iteration >= loop_count:
|
||||||
self.get_logger().info(f'Done: completed {loop_count} cycles')
|
self.get_logger().info(f'完成:已跑完 {loop_count} 圈')
|
||||||
return 0
|
return 0
|
||||||
if pause > 0:
|
if pause > 0:
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
@@ -122,11 +122,11 @@ class RunJointTrajectory(Node):
|
|||||||
while rclpy.ok() and not send.done():
|
while rclpy.ok() and not send.done():
|
||||||
rclpy.spin_once(self, timeout_sec=0.05)
|
rclpy.spin_once(self, timeout_sec=0.05)
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
self.get_logger().error('Timed out sending goal')
|
self.get_logger().error('发送目标超时')
|
||||||
return 2
|
return 2
|
||||||
handle = send.result()
|
handle = send.result()
|
||||||
if handle is None or not handle.accepted:
|
if handle is None or not handle.accepted:
|
||||||
self.get_logger().error('Goal rejected')
|
self.get_logger().error('目标被拒绝')
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
res_fut = handle.get_result_async()
|
res_fut = handle.get_result_async()
|
||||||
@@ -138,18 +138,18 @@ class RunJointTrajectory(Node):
|
|||||||
if now >= next_log:
|
if now >= next_log:
|
||||||
left = max(0.0, deadline - now)
|
left = max(0.0, deadline - now)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Executing trajectory (~{duration:.0f}s planned), '
|
f'正在执行轨迹(计划约 {duration:.0f}s),'
|
||||||
f'waiting for robot... ({left:.0f}s timeout left)'
|
f'等待机器人……(剩余超时 {left:.0f}s)'
|
||||||
)
|
)
|
||||||
next_log = now + 10.0
|
next_log = now + 10.0
|
||||||
if now > deadline:
|
if now > deadline:
|
||||||
self.get_logger().error('Timed out waiting for result')
|
self.get_logger().error('等待结果超时')
|
||||||
return 4
|
return 4
|
||||||
result = res_fut.result().result
|
result = res_fut.result().result
|
||||||
if not result.success:
|
if not result.success:
|
||||||
self.get_logger().error(f'Failed: {result.message}')
|
self.get_logger().error(f'失败:{result.message}')
|
||||||
return 5
|
return 5
|
||||||
self.get_logger().info(f'Cycle finished: {result.message or "OK"}')
|
self.get_logger().info(f'本圈结束:{result.message or "OK"}')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""servo_node — 100 Hz Twist PBVS (own process, no YOLO).
|
"""servo_node —— 100 Hz 的 Twist 视觉伺服(PBVS,独立进程,不跑 YOLO)。
|
||||||
|
|
||||||
Hard rules:
|
硬性规则:
|
||||||
- MultiThreadedExecutor + Reentrant ControlCallbackGroup for 100 Hz timer
|
- 用 ``MultiThreadedExecutor`` + ``Reentrant`` 的 ``ControlCallbackGroup`` 驱动 100 Hz 定时器
|
||||||
- Subscribe PointStamped only (no Service)
|
- 只订阅 ``PointStamped``(不用 Service)
|
||||||
- cmd_vel: SystemDefaults + keep_last(1) so driver always sees latest
|
- ``cmd_vel`` 用 ``SystemDefaults`` + ``keep_last(1)``,保证驱动器总能看到最新指令
|
||||||
- Watchdog: lost target → freeze extrapolate, zero vel, clear I, zero traj
|
- 看门狗:目标丢失 → 冻结外推、速度置零、清 I、轨迹置零
|
||||||
- Latency compensation only while target is fresh
|
- 只有在目标数据新鲜时才做延迟补偿
|
||||||
- Never sendTransform in the 100 Hz loop
|
- 绝不在 100 Hz 循环里调用 ``sendTransform``
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -59,7 +59,7 @@ class ServoNode(Node):
|
|||||||
super().__init__("servo_node")
|
super().__init__("servo_node")
|
||||||
tid = threading.get_ident()
|
tid = threading.get_ident()
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"servo_node started pid={os.getpid()} thread_id={tid}"
|
f"servo_node 已启动 pid={os.getpid()} thread_id={tid}"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.declare_parameter("joint_prefix", "Rizon4-063989_")
|
self.declare_parameter("joint_prefix", "Rizon4-063989_")
|
||||||
@@ -87,9 +87,9 @@ class ServoNode(Node):
|
|||||||
self.declare_parameter("predict_v_max", 0.35)
|
self.declare_parameter("predict_v_max", 0.35)
|
||||||
self.declare_parameter("predict_jump_m", 0.08)
|
self.declare_parameter("predict_jump_m", 0.08)
|
||||||
|
|
||||||
# --- Watchdog (lost target hard stop) ---
|
# --- 看门狗(丢失目标即硬停) ---
|
||||||
self.declare_parameter("watchdog_timeout_sec", 1.2)
|
self.declare_parameter("watchdog_timeout_sec", 1.2)
|
||||||
self.declare_parameter("stale_hold_sec", 0.28) # freeze XY before watchdog trip
|
self.declare_parameter("stale_hold_sec", 0.28) # 触发看门狗前先冻结 XY
|
||||||
|
|
||||||
# --- PID ---
|
# --- PID ---
|
||||||
self.declare_parameter("kp_xy", 2.00)
|
self.declare_parameter("kp_xy", 2.00)
|
||||||
@@ -100,7 +100,7 @@ class ServoNode(Node):
|
|||||||
self.declare_parameter("kd_z", 0.45)
|
self.declare_parameter("kd_z", 0.45)
|
||||||
self.declare_parameter("i_limit_xy", 0.03)
|
self.declare_parameter("i_limit_xy", 0.03)
|
||||||
self.declare_parameter("i_limit_z", 0.025)
|
self.declare_parameter("i_limit_z", 0.025)
|
||||||
# Integral separation thresholds
|
# 积分分离阈值
|
||||||
self.declare_parameter("i_sep_pos_m", 0.005)
|
self.declare_parameter("i_sep_pos_m", 0.005)
|
||||||
self.declare_parameter("i_sep_ang_rad", 0.02)
|
self.declare_parameter("i_sep_ang_rad", 0.02)
|
||||||
|
|
||||||
@@ -109,27 +109,27 @@ class ServoNode(Node):
|
|||||||
self.declare_parameter("v_max_xy", 0.06)
|
self.declare_parameter("v_max_xy", 0.06)
|
||||||
self.declare_parameter("v_max_z", 0.04)
|
self.declare_parameter("v_max_z", 0.04)
|
||||||
self.declare_parameter("a_max", 0.45)
|
self.declare_parameter("a_max", 0.45)
|
||||||
self.declare_parameter("lost_timeout_sec", 1.2) # alias / stamp-age backup
|
self.declare_parameter("lost_timeout_sec", 1.2) # 别名 / 用时间戳年龄兜底
|
||||||
self.declare_parameter("e_xy_clip_m", 0.080) # never treat error as >8 cm
|
self.declare_parameter("e_xy_clip_m", 0.080) # 误差绝不按大于 8 cm 处理
|
||||||
|
|
||||||
# --- Velocity LPF (smooth 100Hz → traj) ---
|
# --- 速度低通滤波(把 100Hz 平滑成轨迹) ---
|
||||||
self.declare_parameter("vel_lpf_alpha", 0.40)
|
self.declare_parameter("vel_lpf_alpha", 0.40)
|
||||||
self.declare_parameter("wz_lpf_alpha", 0.40)
|
self.declare_parameter("wz_lpf_alpha", 0.40)
|
||||||
|
|
||||||
# --- JTC dq sliding window ---
|
# --- JTC 的 dq 滑动窗口 ---
|
||||||
self.declare_parameter("dq_window_size", 5)
|
self.declare_parameter("dq_window_size", 5)
|
||||||
|
|
||||||
self.declare_parameter("min_ee_z_m", 0.20)
|
self.declare_parameter("min_ee_z_m", 0.20)
|
||||||
self.declare_parameter("workspace_xyz_min", [0.15, -0.50, 0.18])
|
self.declare_parameter("workspace_xyz_min", [0.15, -0.50, 0.18])
|
||||||
self.declare_parameter("workspace_xyz_max", [0.80, 0.50, 0.60])
|
self.declare_parameter("workspace_xyz_max", [0.80, 0.50, 0.60])
|
||||||
# Base-frame EE hold (flange). Z/XY in metres, editable via site.yaml.
|
# base 系下的末端保持(法兰)。Z/XY 单位为米,可通过 site.yaml 修改。
|
||||||
self.declare_parameter("ee_x_m", 0.25)
|
self.declare_parameter("ee_x_m", 0.25)
|
||||||
self.declare_parameter("ee_y_m", 0.00)
|
self.declare_parameter("ee_y_m", 0.00)
|
||||||
self.declare_parameter("ee_z_m", 0.25)
|
self.declare_parameter("ee_z_m", 0.25)
|
||||||
self.declare_parameter("ee_hold_xy", False)
|
self.declare_parameter("ee_hold_xy", False)
|
||||||
self.declare_parameter("ee_vertical_down", True)
|
self.declare_parameter("ee_vertical_down", True)
|
||||||
# hover = flange/gripper-center XY on the 3D feature (any lock height).
|
# hover = 让法兰/夹爪中心 XY 停在三维特征点上(任意锁定高度都可)。
|
||||||
# lookat = image center. hover_offset_ee_xy_m is flange-XY to jaw center.
|
# lookat = 对准图像中心。hover_offset_ee_xy_m 是法兰 XY 到爪中心的偏移。
|
||||||
self.declare_parameter("xy_track", "hover")
|
self.declare_parameter("xy_track", "hover")
|
||||||
self.declare_parameter("hover_offset_ee_xy_m", [0.0, 0.0])
|
self.declare_parameter("hover_offset_ee_xy_m", [0.0, 0.0])
|
||||||
self.declare_parameter("arm_label", "")
|
self.declare_parameter("arm_label", "")
|
||||||
@@ -144,14 +144,14 @@ class ServoNode(Node):
|
|||||||
self.declare_parameter("camera_fy", 390.0)
|
self.declare_parameter("camera_fy", 390.0)
|
||||||
self.declare_parameter("camera_cx", 320.0)
|
self.declare_parameter("camera_cx", 320.0)
|
||||||
self.declare_parameter("camera_cy", 240.0)
|
self.declare_parameter("camera_cy", 240.0)
|
||||||
# Seek: at lock height with no valid target, scan the work area so the
|
# Seek:到达锁定高度却没有有效目标时,扫描工作区域,让 eye-in-hand
|
||||||
# eye-in-hand camera can find the workpiece on its own (自适应找工件).
|
# 相机自己去找到工件(自适应找工件)。
|
||||||
self.declare_parameter("seek_enabled", True)
|
self.declare_parameter("seek_enabled", True)
|
||||||
self.declare_parameter("seek_radius_max_m", 0.16)
|
self.declare_parameter("seek_radius_max_m", 0.16)
|
||||||
self.declare_parameter("seek_speed_mps", 0.05)
|
self.declare_parameter("seek_speed_mps", 0.05)
|
||||||
self.declare_parameter("seek_timeout_sec", 45.0)
|
self.declare_parameter("seek_timeout_sec", 45.0)
|
||||||
# Hover integral: breaks the ~4mm joint-command deadband that otherwise
|
# 悬停积分:用来突破约 4mm 的关节指令死区,否则 XY 收敛到最后几毫米
|
||||||
# stalls the last few mm of XY convergence onto the workpiece.
|
# 时就会停滞、贴不到工件上。
|
||||||
self.declare_parameter("ki_hover", 0.8)
|
self.declare_parameter("ki_hover", 0.8)
|
||||||
# 串联用:XY 入死区 + Z 到锁高持续这么久 → /visual_servo/locked=True
|
# 串联用:XY 入死区 + Z 到锁高持续这么久 → /visual_servo/locked=True
|
||||||
self.declare_parameter("lock_hold_sec", 2.0)
|
self.declare_parameter("lock_hold_sec", 2.0)
|
||||||
@@ -253,7 +253,7 @@ class ServoNode(Node):
|
|||||||
f"HANDEYE yaml={he_path} "
|
f"HANDEYE yaml={he_path} "
|
||||||
f"cam_in_flange=({t_he[0]*1000:.1f},{t_he[1]*1000:.1f},{t_he[2]*1000:.1f})mm "
|
f"cam_in_flange=({t_he[0]*1000:.1f},{t_he[1]*1000:.1f},{t_he[2]*1000:.1f})mm "
|
||||||
f"optical_vs_flangeZ={self._cam_tilt_deg:.1f}deg "
|
f"optical_vs_flangeZ={self._cam_tilt_deg:.1f}deg "
|
||||||
f"— hover uses this T, not TF lookup"
|
f"— 悬停使用这个 T,而不是用 TF 查询"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.pid_x = PidController(
|
self.pid_x = PidController(
|
||||||
@@ -287,12 +287,12 @@ class ServoNode(Node):
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._p_meas = np.zeros(3, dtype=float)
|
self._p_meas = np.zeros(3, dtype=float)
|
||||||
self._stamp_sec = -1.0
|
self._stamp_sec = -1.0
|
||||||
self._last_seen_time = -1.0 # monotonic, updated on every PointStamped
|
self._last_seen_time = -1.0 # 单调时钟,每次收到 PointStamped 都会更新
|
||||||
self._have_target = False
|
self._have_target = False
|
||||||
self._target_lost_flag = True
|
self._target_lost_flag = True
|
||||||
self._v_raw = np.zeros(3, dtype=float)
|
self._v_raw = np.zeros(3, dtype=float)
|
||||||
self._v_filt = np.zeros(3, dtype=float) # LPF linear (cam)
|
self._v_filt = np.zeros(3, dtype=float) # 线速度低通(相机系)
|
||||||
self._wz_filt = 0.0 # reserved angular z LPF
|
self._wz_filt = 0.0 # 预留的角速度 z 低通
|
||||||
self._wz_raw = 0.0
|
self._wz_raw = 0.0
|
||||||
self._zeroed = True
|
self._zeroed = True
|
||||||
self._last_js: Optional[JointState] = None
|
self._last_js: Optional[JointState] = None
|
||||||
@@ -305,14 +305,14 @@ class ServoNode(Node):
|
|||||||
self._p_feat_base: Optional[np.ndarray] = None
|
self._p_feat_base: Optional[np.ndarray] = None
|
||||||
self._he_cmp_done = False
|
self._he_cmp_done = False
|
||||||
self._feat_kf = KalmanFilter3D(q_pos=2.0e-3, q_vel=1.5e-1, r_pos=4.0e-4)
|
self._feat_kf = KalmanFilter3D(q_pos=2.0e-3, q_vel=1.5e-1, r_pos=4.0e-4)
|
||||||
# XY hover only after flange Z is on the lock plane (fresh depth).
|
# 只有当法兰 Z 已经落到锁定平面上(深度新鲜)才开启 XY 悬停。
|
||||||
self._xy_armed = bool(self.ee_z_m <= 0.0)
|
self._xy_armed = bool(self.ee_z_m <= 0.0)
|
||||||
# Seek (search) state: scan for the workpiece when at lock_z with no target.
|
# Seek(搜索)状态:在锁定高度且没有目标时扫描工件。
|
||||||
self._seek_t0 = 0.0
|
self._seek_t0 = 0.0
|
||||||
self._seek_theta = 0.0
|
self._seek_theta = 0.0
|
||||||
self._seek_origin = (0.0, 0.0)
|
self._seek_origin = (0.0, 0.0)
|
||||||
self._seek_warned = False
|
self._seek_warned = False
|
||||||
self._hover_ixy = np.zeros(2, dtype=float) # hover integral (anti-deadband)
|
self._hover_ixy = np.zeros(2, dtype=float) # 悬停积分(抗死区)
|
||||||
|
|
||||||
point_topic = str(self.get_parameter("point_topic").value)
|
point_topic = str(self.get_parameter("point_topic").value)
|
||||||
point_qos = QoSProfile(
|
point_qos = QoSProfile(
|
||||||
@@ -363,10 +363,10 @@ class ServoNode(Node):
|
|||||||
callback_group=self._control_cbg,
|
callback_group=self._control_cbg,
|
||||||
)
|
)
|
||||||
|
|
||||||
mode = "CONTROL ON" if self.enable_control else "cmd_vel only"
|
mode = "控制已开启" if self.enable_control else "仅 cmd_vel"
|
||||||
arm = self.arm_label or self.ee_frame
|
arm = self.arm_label or self.ee_frame
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"servo ready [{arm}] [{mode}] {control_hz:.0f}Hz watchdog={self.watchdog_timeout:.2f}s "
|
f"伺服就绪 [{arm}] [{mode}] {control_hz:.0f}Hz watchdog={self.watchdog_timeout:.2f}s "
|
||||||
f"vel_lpf_α={self.vel_lpf_alpha:.2f} dq_win={self.dq_window_size} "
|
f"vel_lpf_α={self.vel_lpf_alpha:.2f} dq_win={self.dq_window_size} "
|
||||||
f"lock_z={self.ee_z_m:.3f}m (site servo.ee_z_m) "
|
f"lock_z={self.ee_z_m:.3f}m (site servo.ee_z_m) "
|
||||||
f"xy_track={self.xy_track} cam_tilt={self._cam_tilt_deg:.1f}° "
|
f"xy_track={self.xy_track} cam_tilt={self._cam_tilt_deg:.1f}° "
|
||||||
@@ -381,7 +381,7 @@ class ServoNode(Node):
|
|||||||
self._last_js = msg
|
self._last_js = msg
|
||||||
|
|
||||||
def _on_point(self, msg: PointStamped) -> None:
|
def _on_point(self, msg: PointStamped) -> None:
|
||||||
"""Fresh vision measurement — arm the watchdog and clear lost flag."""
|
"""新的视觉测量 —— 给看门狗“上弦”并清除丢失标志。"""
|
||||||
p_cam = np.array(
|
p_cam = np.array(
|
||||||
[float(msg.point.x), float(msg.point.y), float(msg.point.z)], dtype=float
|
[float(msg.point.x), float(msg.point.y), float(msg.point.z)], dtype=float
|
||||||
)
|
)
|
||||||
@@ -417,7 +417,7 @@ class ServoNode(Node):
|
|||||||
self._last_seen_time = now_m
|
self._last_seen_time = now_m
|
||||||
self._have_target = True
|
self._have_target = True
|
||||||
self._target_lost_flag = False
|
self._target_lost_flag = False
|
||||||
self._seek_t0 = 0.0 # target acquired — reset seek for next time
|
self._seek_t0 = 0.0 # 已捕获目标 —— 重置 seek,供下次使用
|
||||||
self._seek_warned = False
|
self._seek_warned = False
|
||||||
if msg.header.frame_id:
|
if msg.header.frame_id:
|
||||||
self.camera_frame = msg.header.frame_id
|
self.camera_frame = msg.header.frame_id
|
||||||
@@ -432,12 +432,12 @@ class ServoNode(Node):
|
|||||||
if was_lost:
|
if was_lost:
|
||||||
self._reacq_mono = time.monotonic()
|
self._reacq_mono = time.monotonic()
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
"target reacquired — resume servo",
|
"已重新捕获目标 —— 继续伺服",
|
||||||
throttle_duration_sec=0.5,
|
throttle_duration_sec=0.5,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _reset_motion_state(self) -> None:
|
def _reset_motion_state(self) -> None:
|
||||||
"""Hard stop internals: I=0, filt vel=0, dq window clear."""
|
"""硬停内部状态:I=0、滤波速度=0、dq 窗口清空。"""
|
||||||
self.pid_x.reset()
|
self.pid_x.reset()
|
||||||
self.pid_y.reset()
|
self.pid_y.reset()
|
||||||
self.pid_z.reset()
|
self.pid_z.reset()
|
||||||
@@ -455,9 +455,9 @@ class ServoNode(Node):
|
|||||||
self._twist_pub.publish(tw)
|
self._twist_pub.publish(tw)
|
||||||
|
|
||||||
def _watchdog_trip(self, reason: str) -> None:
|
def _watchdog_trip(self, reason: str) -> None:
|
||||||
"""Priority-1 hard stop: no extrapolate, zero cmd, zero traj delta."""
|
"""优先级 1 的硬停:不外推、指令置零、轨迹增量为零。"""
|
||||||
if not self._target_lost_flag:
|
if not self._target_lost_flag:
|
||||||
self.get_logger().warn(f"WATCHDOG STOP: {reason}")
|
self.get_logger().warn(f"看门狗急停:{reason}")
|
||||||
self._target_lost_flag = True
|
self._target_lost_flag = True
|
||||||
self._reset_motion_state()
|
self._reset_motion_state()
|
||||||
self._zeroed = True
|
self._zeroed = True
|
||||||
@@ -475,7 +475,7 @@ class ServoNode(Node):
|
|||||||
if self._ctrl_count >= int(round(1.0 / self._ctrl_dt)):
|
if self._ctrl_count >= int(round(1.0 / self._ctrl_dt)):
|
||||||
elapsed = (now_msg - self._ctrl_t0).nanoseconds * 1e-9
|
elapsed = (now_msg - self._ctrl_t0).nanoseconds * 1e-9
|
||||||
hz = self._ctrl_count / max(elapsed, 1e-6)
|
hz = self._ctrl_count / max(elapsed, 1e-6)
|
||||||
self.get_logger().info(f"cmd_vel rate ~{hz:.1f} Hz", throttle_duration_sec=1.0)
|
self.get_logger().info(f"cmd_vel 速率约 {hz:.1f} Hz", throttle_duration_sec=1.0)
|
||||||
self._ctrl_count = 0
|
self._ctrl_count = 0
|
||||||
self._ctrl_t0 = now_msg
|
self._ctrl_t0 = now_msg
|
||||||
|
|
||||||
@@ -486,9 +486,9 @@ class ServoNode(Node):
|
|||||||
p_meas = self._p_meas.copy()
|
p_meas = self._p_meas.copy()
|
||||||
lost = self._target_lost_flag
|
lost = self._target_lost_flag
|
||||||
|
|
||||||
# ----- Startup: descend to lock height FIRST, even before any detection.
|
# ----- 启动阶段:**先**下降到锁定高度,甚至在任何检测到来之前。
|
||||||
# have=False only until the first PointStamped ever arrives, so this is
|
# 在第一个 PointStamped 到达之前 have 一直是 False,所以这是一个纯启动
|
||||||
# a pure startup phase: Z-only descent, XY pinned, vertical-down.
|
# 阶段:只动 Z 下降,XY 锁死,姿态朝下。
|
||||||
if (
|
if (
|
||||||
self.enable_control
|
self.enable_control
|
||||||
and self.ee_z_m > 0.0
|
and self.ee_z_m > 0.0
|
||||||
@@ -497,22 +497,21 @@ class ServoNode(Node):
|
|||||||
):
|
):
|
||||||
T_ee0 = self._lookup_T(self.base_frame, self.ee_frame)
|
T_ee0 = self._lookup_T(self.base_frame, self.ee_frame)
|
||||||
if T_ee0 is None:
|
if T_ee0 is None:
|
||||||
return # TF not ready yet — wait, do not hold-trip
|
return # TF 还没就绪 —— 等待,不要触发保持急停
|
||||||
self._update_xy_arm(float(T_ee0[2, 3]))
|
self._update_xy_arm(float(T_ee0[2, 3]))
|
||||||
if not self._xy_armed:
|
if not self._xy_armed:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"startup descend to lock_z {self.ee_z_m:.3f}m "
|
f"启动下降至 lock_z {self.ee_z_m:.3f}m "
|
||||||
f"now={T_ee0[2, 3]:.3f}m (no target yet, XY pinned)",
|
f"当前={T_ee0[2, 3]:.3f}m(还没有目标,XY 锁死)",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
self._target_lost_flag = False # allow execute during blind descent
|
self._target_lost_flag = False # 盲降期间允许执行
|
||||||
self._execute_twist(np.zeros(3, dtype=float), p_cam=None, hold_xy=True)
|
self._execute_twist(np.zeros(3, dtype=float), p_cam=None, hold_xy=True)
|
||||||
self._lock_feedback(False)
|
self._lock_feedback(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ----- Seek: at lock height but no valid target yet. The eye-in-hand
|
# ----- Seek:已在锁定高度但还没有有效目标。eye-in-hand 相机只能看到
|
||||||
# camera only sees a small patch, so scan the work area (drift to the
|
# 一小块区域,所以扫描工作区(先漂到标称工作 XY,再向外螺旋)直到工件进入视野。
|
||||||
# nominal work XY, then spiral out) until the workpiece enters view.
|
|
||||||
if (
|
if (
|
||||||
self.enable_control
|
self.enable_control
|
||||||
and self.seek_enabled
|
and self.seek_enabled
|
||||||
@@ -522,53 +521,53 @@ class ServoNode(Node):
|
|||||||
):
|
):
|
||||||
T_ee0 = self._lookup_T(self.base_frame, self.ee_frame)
|
T_ee0 = self._lookup_T(self.base_frame, self.ee_frame)
|
||||||
if T_ee0 is None:
|
if T_ee0 is None:
|
||||||
return # TF not ready — wait, do not hold-trip
|
return # TF 还没就绪 —— 等待,不要触发保持急停
|
||||||
v_seek = self._seek_velocity(T_ee0[:3, 3])
|
v_seek = self._seek_velocity(T_ee0[:3, 3])
|
||||||
if v_seek is None:
|
if v_seek is None:
|
||||||
# timed out — give up searching, hold in place
|
# 超时了 —— 放弃搜索,原地保持
|
||||||
if not self._seek_warned:
|
if not self._seek_warned:
|
||||||
self._seek_warned = True
|
self._seek_warned = True
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"seek timeout {self.seek_timeout:.0f}s — workpiece not found. "
|
f"seek 超时 {self.seek_timeout:.0f}s —— 没找到工件。"
|
||||||
"Hold position. Put the workpiece on the table in view, or widen "
|
"保持当前位置。请把工件放到桌面视野内,或调大 "
|
||||||
"seek_radius_max_m / check camera mount & handeye."
|
"seek_radius_max_m / 检查相机安装与手眼标定。"
|
||||||
)
|
)
|
||||||
self._lock_feedback(False)
|
self._lock_feedback(False)
|
||||||
self._pub_zero_twist()
|
self._pub_zero_twist()
|
||||||
self._send_hold()
|
self._send_hold()
|
||||||
return
|
return
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"seek workpiece… ee_xy=({T_ee0[0,3]:.3f},{T_ee0[1,3]:.3f}) "
|
f"寻找工件…… ee_xy=({T_ee0[0,3]:.3f},{T_ee0[1,3]:.3f}) "
|
||||||
f"v=({v_seek[0]:+.3f},{v_seek[1]:+.3f})",
|
f"v=({v_seek[0]:+.3f},{v_seek[1]:+.3f})",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
self._target_lost_flag = False # allow execute during seek
|
self._target_lost_flag = False # seek 期间允许执行
|
||||||
self._execute_twist(
|
self._execute_twist(
|
||||||
np.zeros(3, dtype=float), p_cam=None, v_base_override=v_seek
|
np.zeros(3, dtype=float), p_cam=None, v_base_override=v_seek
|
||||||
)
|
)
|
||||||
self._lock_feedback(False)
|
self._lock_feedback(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ----- Item 1: Watchdog (receive-time) — freeze ALL motion if stale -----
|
# ----- 第 1 项:看门狗(按接收时间)—— 数据过期就冻结所有运动 -----
|
||||||
if (not have) or last_seen < 0.0:
|
if (not have) or last_seen < 0.0:
|
||||||
self._lock_feedback(False)
|
self._lock_feedback(False)
|
||||||
self._watchdog_trip("no PointStamped yet")
|
self._watchdog_trip("还没有收到 PointStamped")
|
||||||
return
|
return
|
||||||
|
|
||||||
seen_age = now_mono - last_seen
|
seen_age = now_mono - last_seen
|
||||||
stamp_age = now_sec - stamp if stamp >= 0.0 else 1e9
|
stamp_age = now_sec - stamp if stamp >= 0.0 else 1e9
|
||||||
if seen_age > self.watchdog_timeout or stamp_age > self.lost_timeout:
|
if seen_age > self.watchdog_timeout or stamp_age > self.lost_timeout:
|
||||||
self._watchdog_trip(
|
self._watchdog_trip(
|
||||||
f"target lost seen_age={seen_age:.3f}s stamp_age={stamp_age:.3f}s"
|
f"目标丢失 seen_age={seen_age:.3f}s stamp_age={stamp_age:.3f}s"
|
||||||
)
|
)
|
||||||
self._lock_feedback(False)
|
self._lock_feedback(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Frozen box / USB stall: pin joints, do not keep driving last error.
|
# 检测框冻结 / USB 卡住:锁住关节,不要继续按上一个误差驱动。
|
||||||
stale = max(0.15, float(self.stale_hold_sec))
|
stale = max(0.15, float(self.stale_hold_sec))
|
||||||
if seen_age > stale or stamp_age > stale:
|
if seen_age > stale or stamp_age > stale:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"stale hold seen_age={seen_age:.2f}s — not tracking a frozen box",
|
f"过期保持 seen_age={seen_age:.2f}s —— 不跟踪冻结的检测框",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
self._v_raw[:] = 0.0
|
self._v_raw[:] = 0.0
|
||||||
@@ -580,13 +579,13 @@ class ServoNode(Node):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if lost:
|
if lost:
|
||||||
# Should have been cleared on _on_point; belt-and-suspenders
|
# 本应在 _on_point 里清掉;这里再加一道保险
|
||||||
self._lock_feedback(False)
|
self._lock_feedback(False)
|
||||||
self._pub_zero_twist()
|
self._pub_zero_twist()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Hover uses the vision 3D point as-is. Do not add cmd_vel (wrong frame)
|
# Hover 直接使用视觉三维点。不要把 cmd_vel(坐标系不对)
|
||||||
# onto p_cam — that walks the target off the workpiece.
|
# 叠加到 p_cam 上 —— 那样会把目标从工件上带跑。
|
||||||
p = p_meas.copy()
|
p = p_meas.copy()
|
||||||
if self.xy_track == "lookat":
|
if self.xy_track == "lookat":
|
||||||
dt_lat = float(np.clip(stamp_age, 0.0, self.max_extrap))
|
dt_lat = float(np.clip(stamp_age, 0.0, self.max_extrap))
|
||||||
@@ -618,7 +617,7 @@ class ServoNode(Node):
|
|||||||
ex *= s
|
ex *= s
|
||||||
ey *= s
|
ey *= s
|
||||||
r_xy = clip_xy
|
r_xy = clip_xy
|
||||||
# Camera-depth Z is unused when holding flange height in base frame
|
# 在 base 系里保持法兰高度时,相机深度 Z 就不再使用
|
||||||
z_dead = True if self.ee_z_m > 0.0 else (abs(ez) <= self.deadzone_z_m)
|
z_dead = True if self.ee_z_m > 0.0 else (abs(ez) <= self.deadzone_z_m)
|
||||||
if xy_dead:
|
if xy_dead:
|
||||||
ex = ey = 0.0
|
ex = ey = 0.0
|
||||||
@@ -628,7 +627,7 @@ class ServoNode(Node):
|
|||||||
ez = 0.0
|
ez = 0.0
|
||||||
self.pid_z.reset()
|
self.pid_z.reset()
|
||||||
|
|
||||||
# ----- Item 2: PID with integral separation (inside PidController) -----
|
# ----- 第 2 项:带积分分离的 PID(分离逻辑在 PidController 内部) -----
|
||||||
v_des = np.array(
|
v_des = np.array(
|
||||||
[
|
[
|
||||||
0.0 if self.ee_hold_xy else self.pid_x.update(ex, float(p[0]), dt),
|
0.0 if self.ee_hold_xy else self.pid_x.update(ex, float(p[0]), dt),
|
||||||
@@ -644,7 +643,7 @@ class ServoNode(Node):
|
|||||||
v_des[:2] = _saturate_vec(v_des[:2].copy(), self.v_max_xy)
|
v_des[:2] = _saturate_vec(v_des[:2].copy(), self.v_max_xy)
|
||||||
v_des[2] = float(np.clip(v_des[2], -self.v_max_z, self.v_max_z))
|
v_des[2] = float(np.clip(v_des[2], -self.v_max_z, self.v_max_z))
|
||||||
|
|
||||||
# Base-plane mode: never command camera +Z (that dives into the part)
|
# base 平面模式:绝不指令相机 +Z(那会直接扎进工件里)
|
||||||
if self.ee_z_m > 0.0:
|
if self.ee_z_m > 0.0:
|
||||||
v_des[2] = 0.0
|
v_des[2] = 0.0
|
||||||
else:
|
else:
|
||||||
@@ -661,7 +660,7 @@ class ServoNode(Node):
|
|||||||
if z_meas < (self.z_min_m - 0.02):
|
if z_meas < (self.z_min_m - 0.02):
|
||||||
v_des[2] = min(v_des[2], -0.03)
|
v_des[2] = min(v_des[2], -0.03)
|
||||||
|
|
||||||
# Accel limit on raw desire, then LPF
|
# 先对原始期望速度做加速度限制,再做低通滤波
|
||||||
dv = v_des - self._v_raw
|
dv = v_des - self._v_raw
|
||||||
n_dv = float(np.linalg.norm(dv))
|
n_dv = float(np.linalg.norm(dv))
|
||||||
dv_max = self.a_max * dt
|
dv_max = self.a_max * dt
|
||||||
@@ -669,10 +668,10 @@ class ServoNode(Node):
|
|||||||
dv *= dv_max / n_dv
|
dv *= dv_max / n_dv
|
||||||
self._v_raw = self._v_raw + dv
|
self._v_raw = self._v_raw + dv
|
||||||
|
|
||||||
# ----- Item 3: first-order LPF on linear (+ reserved wz) -----
|
# ----- 第 3 项:对线速度做一阶低通(+ 预留的 wz) -----
|
||||||
a = self.vel_lpf_alpha
|
a = self.vel_lpf_alpha
|
||||||
self._v_filt = a * self._v_raw + (1.0 - a) * self._v_filt
|
self._v_filt = a * self._v_raw + (1.0 - a) * self._v_filt
|
||||||
# Angular reserved (PBVS currently linear-only); still filter for API completeness
|
# 角速度目前预留(PBVS 当前只做线速度);仍做滤波以保持接口完整
|
||||||
self._wz_raw = 0.0
|
self._wz_raw = 0.0
|
||||||
aw = self.wz_lpf_alpha
|
aw = self.wz_lpf_alpha
|
||||||
self._wz_filt = aw * self._wz_raw + (1.0 - aw) * self._wz_filt
|
self._wz_filt = aw * self._wz_raw + (1.0 - aw) * self._wz_filt
|
||||||
@@ -706,11 +705,11 @@ class ServoNode(Node):
|
|||||||
if xy_dead and z_dead:
|
if xy_dead and z_dead:
|
||||||
self._lock_feedback(True) # 串联信号:活目标 + 死区驻留 = 收敛
|
self._lock_feedback(True) # 串联信号:活目标 + 死区驻留 = 收敛
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"deadzone hold |e_xy|={r_xy_raw*1000:.1f}mm ez={ez*1000:.1f}mm",
|
f"死区保持 |e_xy|={r_xy_raw*1000:.1f}mm ez={ez*1000:.1f}mm",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
if self.enable_control:
|
if self.enable_control:
|
||||||
# Still pin height / vertical-down if slightly off, else freeze
|
# 若高度 / 垂直朝下稍有偏差仍要修正,否则彻底冻结
|
||||||
if need_base:
|
if need_base:
|
||||||
self._execute_twist(np.zeros(3, dtype=float), p_cam=p, hold_xy=True)
|
self._execute_twist(np.zeros(3, dtype=float), p_cam=p, hold_xy=True)
|
||||||
elif not self._zeroed:
|
elif not self._zeroed:
|
||||||
@@ -728,7 +727,7 @@ class ServoNode(Node):
|
|||||||
f" feat=({look[1][0]:.3f},{look[1][1]:.3f})"
|
f" feat=({look[1][0]:.3f},{look[1][1]:.3f})"
|
||||||
)
|
)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"twist cam v=({v_cam[0]:+.3f},{v_cam[1]:+.3f},{v_cam[2]:+.3f}) "
|
f"twist 相机系 v=({v_cam[0]:+.3f},{v_cam[1]:+.3f},{v_cam[2]:+.3f}) "
|
||||||
f"|e_xy|={r_xy_raw*1000:.0f}mm ez={ez*1000:.0f}mm age={stamp_age*1000:.0f}ms"
|
f"|e_xy|={r_xy_raw*1000:.0f}mm ez={ez*1000:.0f}mm age={stamp_age*1000:.0f}ms"
|
||||||
f"{extra}",
|
f"{extra}",
|
||||||
throttle_duration_sec=0.5,
|
throttle_duration_sec=0.5,
|
||||||
@@ -739,14 +738,14 @@ class ServoNode(Node):
|
|||||||
self._execute_twist(v_cam, p_cam=p)
|
self._execute_twist(v_cam, p_cam=p)
|
||||||
|
|
||||||
def _e_stop(self, reason: str) -> None:
|
def _e_stop(self, reason: str) -> None:
|
||||||
self.get_logger().warn(f"E-STOP: {reason}")
|
self.get_logger().warn(f"急停:{reason}")
|
||||||
self._watchdog_trip(reason)
|
self._watchdog_trip(reason)
|
||||||
|
|
||||||
def _R_cam_to_base(self, T_ee: np.ndarray) -> np.ndarray:
|
def _R_cam_to_base(self, T_ee: np.ndarray) -> np.ndarray:
|
||||||
return (T_ee @ self._T_ee_cam())[:3, :3]
|
return (T_ee @ self._T_ee_cam())[:3, :3]
|
||||||
|
|
||||||
def _T_ee_cam(self) -> np.ndarray:
|
def _T_ee_cam(self) -> np.ndarray:
|
||||||
# Always the calibrated yaml. TF is only for RViz / a sanity print.
|
# 始终使用标定出的 yaml。TF 只用于 RViz / 打印做核对。
|
||||||
return np.array(self.T_cam_to_gripper, dtype=float)
|
return np.array(self.T_cam_to_gripper, dtype=float)
|
||||||
|
|
||||||
def _log_handeye_tf_vs_yaml(self) -> None:
|
def _log_handeye_tf_vs_yaml(self) -> None:
|
||||||
@@ -754,7 +753,7 @@ class ServoNode(Node):
|
|||||||
T_tf = self._lookup_T(self.ee_frame, self.handeye_child)
|
T_tf = self._lookup_T(self.ee_frame, self.handeye_child)
|
||||||
if T_tf is None:
|
if T_tf is None:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
"HANDEYE TF lookup failed (ok) — servo uses yaml "
|
"HANDEYE TF 查询失败(无妨)—— 伺服使用 yaml "
|
||||||
f"t=({t_yaml[0]*1000:.1f},{t_yaml[1]*1000:.1f},{t_yaml[2]*1000:.1f})mm"
|
f"t=({t_yaml[0]*1000:.1f},{t_yaml[1]*1000:.1f},{t_yaml[2]*1000:.1f})mm"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -767,8 +766,8 @@ class ServoNode(Node):
|
|||||||
)
|
)
|
||||||
if d > 0.015:
|
if d > 0.015:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
"HANDEYE TF ≠ yaml by >15mm — duplicate parent on camera frame? "
|
"HANDEYE TF 与 yaml 的偏差 >15mm —— 相机坐标系上挂了重复的父节点? "
|
||||||
"Servo still uses yaml."
|
"伺服仍然使用 yaml。"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _intersect_z(
|
def _intersect_z(
|
||||||
@@ -789,11 +788,11 @@ class ServoNode(Node):
|
|||||||
def _lookat_vs_feature(
|
def _lookat_vs_feature(
|
||||||
self, T_ee: np.ndarray, p_cam: np.ndarray
|
self, T_ee: np.ndarray, p_cam: np.ndarray
|
||||||
) -> Optional[tuple]:
|
) -> Optional[tuple]:
|
||||||
"""Base XY error that centers the feature in the image.
|
"""让特征点居中到图像里所需的 base 系 XY 误差。
|
||||||
|
|
||||||
30° eye-in-hand: image center is the optical-axis hit on the work
|
30° 的 eye-in-hand 安装:图像中心是光轴打到工作平面上的点,
|
||||||
plane, not the flange XY. Translating the flange in XY at fixed
|
而不是法兰的 XY。在固定高度 / 姿态下平移法兰 XY,会把该 look-at 点
|
||||||
height/attitude moves that look-at 1:1.
|
按 1:1 一起带走。
|
||||||
"""
|
"""
|
||||||
T_bc = T_ee @ self._T_ee_cam()
|
T_bc = T_ee @ self._T_ee_cam()
|
||||||
R = T_bc[:3, :3]
|
R = T_bc[:3, :3]
|
||||||
@@ -812,7 +811,7 @@ class ServoNode(Node):
|
|||||||
return err, p_feat, p_look
|
return err, p_feat, p_look
|
||||||
|
|
||||||
def _feature_in_base(self, T_ee: np.ndarray, p_cam: np.ndarray) -> np.ndarray:
|
def _feature_in_base(self, T_ee: np.ndarray, p_cam: np.ndarray) -> np.ndarray:
|
||||||
"""Workpiece 3D point (camera frame) → base XYZ. Keep XYZ consistent."""
|
"""工件三维点(相机系)→ base 系 XYZ。保持 XYZ 一致。"""
|
||||||
T_bc = T_ee @ self._T_ee_cam()
|
T_bc = T_ee @ self._T_ee_cam()
|
||||||
ph = np.array(
|
ph = np.array(
|
||||||
[float(p_cam[0]), float(p_cam[1]), float(p_cam[2]), 1.0], dtype=float
|
[float(p_cam[0]), float(p_cam[1]), float(p_cam[2]), 1.0], dtype=float
|
||||||
@@ -822,11 +821,11 @@ class ServoNode(Node):
|
|||||||
def _hover_vs_feature(
|
def _hover_vs_feature(
|
||||||
self, T_ee: np.ndarray, p_cam: np.ndarray
|
self, T_ee: np.ndarray, p_cam: np.ndarray
|
||||||
) -> Optional[tuple]:
|
) -> Optional[tuple]:
|
||||||
"""Park flange (and coaxial gripper center) XY on the workpiece XY in base.
|
"""把法兰(以及同轴的夹爪中心)的 XY 停在 base 系里的工件 XY 上。
|
||||||
|
|
||||||
hover_offset_ee_xy_m shifts the tracked point in the flange frame
|
hover_offset_ee_xy_m 会在法兰坐标系里平移被跟踪点
|
||||||
(jaw center if it is not on the flange origin). RGI is coaxial: leave [0,0].
|
(当爪中心不在法兰原点时用它)。RGI 是同轴的:保持 [0,0] 即可。
|
||||||
Must NOT re-multiply a stale p_cam by the live T_ee.
|
绝不能拿实时的 T_ee 去重复乘一个过期的 p_cam。
|
||||||
"""
|
"""
|
||||||
p_ee = np.asarray(T_ee[:3, 3], dtype=float)
|
p_ee = np.asarray(T_ee[:3, 3], dtype=float)
|
||||||
off = self.hover_offset_ee_xy
|
off = self.hover_offset_ee_xy
|
||||||
@@ -887,7 +886,7 @@ class ServoNode(Node):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _xy_deadzone(self, r_xy: float, *, update: bool = True) -> bool:
|
def _xy_deadzone(self, r_xy: float, *, update: bool = True) -> bool:
|
||||||
"""Hysteresis: enter at deadzone_m, leave at 1.6× so noise does not chatter."""
|
"""滞回:在 deadzone_m 处进入,在 1.6 倍处退出,避免噪声来回抖。"""
|
||||||
enter = max(0.002, float(self.deadzone_m))
|
enter = max(0.002, float(self.deadzone_m))
|
||||||
leave = enter * 1.6
|
leave = enter * 1.6
|
||||||
if self._xy_in_dead:
|
if self._xy_in_dead:
|
||||||
@@ -925,7 +924,7 @@ class ServoNode(Node):
|
|||||||
self._lock_since = None
|
self._lock_since = None
|
||||||
|
|
||||||
def _w_align_flange_down(self, R_ee: np.ndarray) -> np.ndarray:
|
def _w_align_flange_down(self, R_ee: np.ndarray) -> np.ndarray:
|
||||||
"""Angular vel in base to put flange +Z along world −Z (tool down)."""
|
"""base 系下的角速度,用来把法兰 +Z 转到世界 −Z 方向(工具朝下)。"""
|
||||||
z_now = np.asarray(R_ee, dtype=float).reshape(3, 3)[:, 2]
|
z_now = np.asarray(R_ee, dtype=float).reshape(3, 3)[:, 2]
|
||||||
z_des = np.array([0.0, 0.0, -1.0], dtype=float)
|
z_des = np.array([0.0, 0.0, -1.0], dtype=float)
|
||||||
c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0))
|
c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0))
|
||||||
@@ -942,7 +941,7 @@ class ServoNode(Node):
|
|||||||
return axis * mag
|
return axis * mag
|
||||||
|
|
||||||
def _shape_xy_vel(self, v_des_xy: np.ndarray, dt: float) -> np.ndarray:
|
def _shape_xy_vel(self, v_des_xy: np.ndarray, dt: float) -> np.ndarray:
|
||||||
"""Slew-rate + LPF on the hover command that actually moves the arm."""
|
"""真正驱动机械臂的那条悬停指令,外加变化率限制 + 低通滤波。"""
|
||||||
v_des = np.asarray(v_des_xy, dtype=float).reshape(2)
|
v_des = np.asarray(v_des_xy, dtype=float).reshape(2)
|
||||||
prev = self._v_xy_cmd
|
prev = self._v_xy_cmd
|
||||||
dv = v_des - prev
|
dv = v_des - prev
|
||||||
@@ -961,7 +960,7 @@ class ServoNode(Node):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
def _update_xy_arm(self, ee_z: float) -> bool:
|
def _update_xy_arm(self, ee_z: float) -> bool:
|
||||||
"""XY hover only after flange is on lock_z (real depth, adaptive hover)."""
|
"""只有当法兰到达 lock_z(真实深度、自适应悬停)后才做 XY 悬停。"""
|
||||||
if self.ee_z_m <= 0.0:
|
if self.ee_z_m <= 0.0:
|
||||||
self._xy_armed = True
|
self._xy_armed = True
|
||||||
return True
|
return True
|
||||||
@@ -977,8 +976,8 @@ class ServoNode(Node):
|
|||||||
self.pid_x.reset()
|
self.pid_x.reset()
|
||||||
self.pid_y.reset()
|
self.pid_y.reset()
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"lock-z ready ee_z={z:.3f}m (want {self.ee_z_m:.3f}, no climb) "
|
f"lock-z 就绪 ee_z={z:.3f}m(期望 {self.ee_z_m:.3f},不再回抬) "
|
||||||
"— start XY hover on live handeye"
|
"—— 在实时 handeye 上开始 XY 悬停"
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
err = abs(z - float(self.ee_z_m))
|
err = abs(z - float(self.ee_z_m))
|
||||||
@@ -991,8 +990,8 @@ class ServoNode(Node):
|
|||||||
self.pid_x.reset()
|
self.pid_x.reset()
|
||||||
self.pid_y.reset()
|
self.pid_y.reset()
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"lock-z reached ee_z={z:.3f}m (want {self.ee_z_m:.3f}) "
|
f"已到达 lock-z ee_z={z:.3f}m(期望 {self.ee_z_m:.3f})"
|
||||||
"— start XY hover on live handeye"
|
" —— 在实时 handeye 上开始 XY 悬停"
|
||||||
)
|
)
|
||||||
elif err > gate + 0.05:
|
elif err > gate + 0.05:
|
||||||
self._xy_armed = False
|
self._xy_armed = False
|
||||||
@@ -1000,20 +999,19 @@ class ServoNode(Node):
|
|||||||
self._feat_kf.initialized = False
|
self._feat_kf.initialized = False
|
||||||
self._v_xy_cmd[:] = 0.0
|
self._v_xy_cmd[:] = 0.0
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"left lock-z band ee_z={z:.3f}m — XY hold, re-descend",
|
f"已离开 lock-z 区间 ee_z={z:.3f}m —— XY 保持,重新下降",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
return self._xy_armed
|
return self._xy_armed
|
||||||
|
|
||||||
def _seek_velocity(self, p_ee: np.ndarray) -> Optional[np.ndarray]:
|
def _seek_velocity(self, p_ee: np.ndarray) -> Optional[np.ndarray]:
|
||||||
"""Base-frame XY velocity to scan for the workpiece: spiral outward around
|
"""base 系下的 XY 速度,用来扫描寻找工件:以「机械臂到达锁定高度时的位置」
|
||||||
the point where the arm reached lock height, so the eye-in-hand camera
|
为中心向外螺旋,让 eye-in-hand 相机扫过桌面。超时后返回 None(由调用方保持)。"""
|
||||||
sweeps the table. Returns None when the seek has timed out (caller holds)."""
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if self._seek_t0 <= 0.0:
|
if self._seek_t0 <= 0.0:
|
||||||
self._seek_t0 = now
|
self._seek_t0 = now
|
||||||
self._seek_theta = 0.0
|
self._seek_theta = 0.0
|
||||||
self._seek_origin = (float(p_ee[0]), float(p_ee[1])) # search near descent XY
|
self._seek_origin = (float(p_ee[0]), float(p_ee[1])) # 在下降点附近搜索 XY
|
||||||
if (now - self._seek_t0) > self.seek_timeout:
|
if (now - self._seek_t0) > self.seek_timeout:
|
||||||
return None
|
return None
|
||||||
ox, oy = self._seek_origin
|
ox, oy = self._seek_origin
|
||||||
@@ -1037,7 +1035,7 @@ class ServoNode(Node):
|
|||||||
def _clip_workspace_vel(
|
def _clip_workspace_vel(
|
||||||
self, p_ee: np.ndarray, v_base: np.ndarray, T: float
|
self, p_ee: np.ndarray, v_base: np.ndarray, T: float
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Keep commands inside the box; if already outside, allow recovery in."""
|
"""把指令限制在盒子里;如果本来就在盒子外,允许它往回恢复。"""
|
||||||
v = np.asarray(v_base, dtype=float).copy()
|
v = np.asarray(v_base, dtype=float).copy()
|
||||||
dt = max(float(T), 1e-3)
|
dt = max(float(T), 1e-3)
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
@@ -1063,7 +1061,7 @@ class ServoNode(Node):
|
|||||||
hold_xy: bool = False,
|
hold_xy: bool = False,
|
||||||
v_base_override: Optional[np.ndarray] = None,
|
v_base_override: Optional[np.ndarray] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Hard freeze if watchdog already tripped mid-cycle
|
# 如果看门狗在本周期中途已经触发,就硬冻结
|
||||||
if self._target_lost_flag:
|
if self._target_lost_flag:
|
||||||
self._dq_hist.clear()
|
self._dq_hist.clear()
|
||||||
return
|
return
|
||||||
@@ -1078,7 +1076,7 @@ class ServoNode(Node):
|
|||||||
T_ee = self._lookup_T(self.base_frame, self.ee_frame)
|
T_ee = self._lookup_T(self.base_frame, self.ee_frame)
|
||||||
if q is None or T_ee is None:
|
if q is None or T_ee is None:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
"execute skip: missing joint_states or TF base→ee",
|
"execute 跳过:缺少 joint_states 或 base→ee 的 TF",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -1097,8 +1095,8 @@ class ServoNode(Node):
|
|||||||
if self._reacq_mono > 0.0 and (now_m - self._reacq_mono) < 0.12:
|
if self._reacq_mono > 0.0 and (now_m - self._reacq_mono) < 0.12:
|
||||||
v_xy *= 0.85
|
v_xy *= 0.85
|
||||||
|
|
||||||
# hover: park flange XY on the 3D feature at any lock height.
|
# hover:在任意锁定高度上把法兰 XY 停在三维特征点上。
|
||||||
# lookat: drive optical-axis hit (image center) onto the feature.
|
# lookat:把光轴命中点(图像中心)驱到特征点上。
|
||||||
xy_ok = self._update_xy_arm(float(p_ee[2]))
|
xy_ok = self._update_xy_arm(float(p_ee[2]))
|
||||||
if (
|
if (
|
||||||
xy_ok
|
xy_ok
|
||||||
@@ -1120,11 +1118,11 @@ class ServoNode(Node):
|
|||||||
v_base[1] = 0.0
|
v_base[1] = 0.0
|
||||||
self._hover_ixy[:] = 0.0
|
self._hover_ixy[:] = 0.0
|
||||||
else:
|
else:
|
||||||
# Taper near the deadzone so P-control does not bang-bang.
|
# 在死区附近做渐变,避免 P 控制来回 bang-bang。
|
||||||
enter = max(0.002, float(self.deadzone_m))
|
enter = max(0.002, float(self.deadzone_m))
|
||||||
taper = float(np.clip((nerr - enter) / max(enter, 1e-3), 0.0, 1.0))
|
taper = float(np.clip((nerr - enter) / max(enter, 1e-3), 0.0, 1.0))
|
||||||
# Integral (anti-windup ±4cm) breaks the joint-command deadband
|
# 积分项(抗积分饱和 ±4cm)用来突破关节指令死区,
|
||||||
# that otherwise stalls the last few mm of XY convergence.
|
# 否则 XY 收敛的最后几毫米就会停滞。
|
||||||
self._hover_ixy[0] = float(np.clip(
|
self._hover_ixy[0] = float(np.clip(
|
||||||
self._hover_ixy[0] + float(err[0]) * dt_use, -0.04, 0.04))
|
self._hover_ixy[0] + float(err[0]) * dt_use, -0.04, 0.04))
|
||||||
self._hover_ixy[1] = float(np.clip(
|
self._hover_ixy[1] = float(np.clip(
|
||||||
@@ -1149,8 +1147,8 @@ class ServoNode(Node):
|
|||||||
v_base[0] *= v_xy / nxy
|
v_base[0] *= v_xy / nxy
|
||||||
v_base[1] *= v_xy / nxy
|
v_base[1] *= v_xy / nxy
|
||||||
|
|
||||||
# Hold flange XYZ in base (config, default ~250 mm). Visual XY is
|
# 在 base 系里保持法兰 XYZ(可配置,默认约 250 mm)。当 ee_hold_xy
|
||||||
# overwritten when ee_hold_xy is true.
|
# 为真时,视觉算出的 XY 会被覆盖。
|
||||||
if self.ee_hold_xy:
|
if self.ee_hold_xy:
|
||||||
v_base[0] = float(np.clip(kp * (self.ee_x_m - p_ee[0]), -self.v_max_xy, self.v_max_xy))
|
v_base[0] = float(np.clip(kp * (self.ee_x_m - p_ee[0]), -self.v_max_xy, self.v_max_xy))
|
||||||
v_base[1] = float(np.clip(kp * (self.ee_y_m - p_ee[1]), -self.v_max_xy, self.v_max_xy))
|
v_base[1] = float(np.clip(kp * (self.ee_y_m - p_ee[1]), -self.v_max_xy, self.v_max_xy))
|
||||||
@@ -1164,7 +1162,7 @@ class ServoNode(Node):
|
|||||||
self._v_xy_cmd[:] = 0.0
|
self._v_xy_cmd[:] = 0.0
|
||||||
if self.ee_z_m > 0.0:
|
if self.ee_z_m > 0.0:
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"descend to lock_z {self.ee_z_m:.3f}m now={p_ee[2]:.3f}m XY hold",
|
f"下降至 lock_z {self.ee_z_m:.3f}m 当前={p_ee[2]:.3f}m XY 保持",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
shaped = self._shape_xy_vel(v_base[:2], dt_use)
|
shaped = self._shape_xy_vel(v_base[:2], dt_use)
|
||||||
@@ -1189,7 +1187,7 @@ class ServoNode(Node):
|
|||||||
|
|
||||||
p_next = p_ee + v_base * T
|
p_next = p_ee + v_base * T
|
||||||
if self.ee_z_m > 0.0:
|
if self.ee_z_m > 0.0:
|
||||||
# Never command below the configured plane
|
# 绝不指令到配置平面以下
|
||||||
floor = max(self.min_ee_z, self.ee_z_m - 0.02)
|
floor = max(self.min_ee_z, self.ee_z_m - 0.02)
|
||||||
if p_next[2] < floor and v_base[2] < 0.0:
|
if p_next[2] < floor and v_base[2] < 0.0:
|
||||||
v_base[2] = 0.0
|
v_base[2] = 0.0
|
||||||
@@ -1205,7 +1203,7 @@ class ServoNode(Node):
|
|||||||
Tl = self._lookup_T(self.base_frame, fr)
|
Tl = self._lookup_T(self.base_frame, fr)
|
||||||
if Tl is None:
|
if Tl is None:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"execute skip: TF miss {fr}",
|
f"execute 跳过:TF 缺 {fr}",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -1217,10 +1215,10 @@ class ServoNode(Node):
|
|||||||
)
|
)
|
||||||
dq = qdot * T
|
dq = qdot * T
|
||||||
|
|
||||||
# ----- Item 5: sliding-window average on joint displacement -----
|
# ----- 第 5 项:对关节位移做滑动窗口平均 -----
|
||||||
self._dq_hist.append(dq.copy())
|
self._dq_hist.append(dq.copy())
|
||||||
dq_avg = np.mean(np.stack(list(self._dq_hist), axis=0), axis=0)
|
dq_avg = np.mean(np.stack(list(self._dq_hist), axis=0), axis=0)
|
||||||
# Keep end-velocity consistent with averaged step
|
# 让末端速度与平均后的步长保持一致
|
||||||
qdot_avg = dq_avg / T
|
qdot_avg = dq_avg / T
|
||||||
|
|
||||||
mag = float(np.max(np.abs(dq_avg)))
|
mag = float(np.max(np.abs(dq_avg)))
|
||||||
@@ -1281,7 +1279,7 @@ def main(args=None) -> None:
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
node._e_stop("node shutdown")
|
node._e_stop("节点关闭")
|
||||||
executor.shutdown()
|
executor.shutdown()
|
||||||
node.destroy_node()
|
node.destroy_node()
|
||||||
if rclpy.ok():
|
if rclpy.ok():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Load ~/ros2/config/site.yaml from application nodes."""
|
"""供应用节点加载 ``~/ros2/config/site.yaml``。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ class Stage2Locate(Node):
|
|||||||
|
|
||||||
self.declare_parameter("robot_sn", os.environ.get("SITE_ROBOT_SN") or "")
|
self.declare_parameter("robot_sn", os.environ.get("SITE_ROBOT_SN") or "")
|
||||||
self.declare_parameter("hover_only", False)
|
self.declare_parameter("hover_only", False)
|
||||||
self.declare_parameter("target_hole", "") # "" → site stage2.target_hole
|
self.declare_parameter("target_hole", "") # 空字符串 → 用 site 的 stage2.target_hole
|
||||||
self.declare_parameter("hover_z_m", -1.0) # >0 → 覆盖 site
|
self.declare_parameter("hover_z_m", -1.0) # 大于 0 → 覆盖 site 里的值
|
||||||
self.declare_parameter("preview", True)
|
self.declare_parameter("preview", True)
|
||||||
self.declare_parameter("action_name", "rizon_arm_controller/follow_joint_trajectory")
|
self.declare_parameter("action_name", "rizon_arm_controller/follow_joint_trajectory")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Orisys visuo-tactile CLI. Ports / config from site.yaml tactile:."""
|
"""Orisys 视触觉命令行工具。端口 / 配置来自 ``site.yaml`` 的 ``tactile`` 段。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from arm_application.site_io import load_site_dict, site_path
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Orisys tactile CLI (site.yaml)")
|
parser = argparse.ArgumentParser(description="Orisys 触觉命令行工具(site.yaml)")
|
||||||
parser.add_argument("--duration", type=float, default=10.0)
|
parser.add_argument("--duration", type=float, default=10.0)
|
||||||
parser.add_argument("--site", default="")
|
parser.add_argument("--site", default="")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -29,7 +29,7 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
tactile.connect()
|
tactile.connect()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"错误:{exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
n = 0
|
n = 0
|
||||||
@@ -49,10 +49,10 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("interrupt")
|
print("已中断")
|
||||||
finally:
|
finally:
|
||||||
tactile.disconnect()
|
tactile.disconnect()
|
||||||
print(f"done frames={n}")
|
print(f"完成 frames={n}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tactile sample / command types. Backend (Orisys / mock) is swappable."""
|
"""触觉采样 / 指令类型。后端(Orisys / mock)可替换。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Ultralytics YOLO / TensorRT wrapper.
|
"""Ultralytics YOLO / TensorRT 封装。
|
||||||
|
|
||||||
Public: ``resolve_weights``, ``YoloDetector``, ``Detection``, ``pick_best``,
|
对外接口:``resolve_weights``、``YoloDetector``、``Detection``、``pick_best``、
|
||||||
``MpYoloDetector``. TensorRT loads the sibling ``.engine`` next to the
|
``MpYoloDetector``。当 ``backend='tensorrt'`` 时,TensorRT 会加载与
|
||||||
``.onnx`` / ``.pt`` path when ``backend='tensorrt'``.
|
``.onnx`` / ``.pt`` 路径同级的 ``.engine`` 文件。
|
||||||
|
|
||||||
类别名(标签)由 ``model_labels`` 统一提供:优先读权重旁边的
|
类别名(标签)由 ``model_labels`` 统一提供:优先读权重旁边的
|
||||||
``<模型名>.labels.yaml``(用户可随时手改),其次读模型内嵌 metadata。
|
``<模型名>.labels.yaml``(用户可随时手改),其次读模型内嵌 metadata。
|
||||||
@@ -33,13 +33,13 @@ from .model_labels import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Sentinel: worker still on the previous frame — caller must NOT count a miss.
|
# 哨兵值:worker 还停在上一次的那帧 —— 调用方不能把它算作漏检。
|
||||||
INFER_BUSY = object()
|
INFER_BUSY = object()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Detection:
|
class Detection:
|
||||||
xyxy: np.ndarray # (4,) float: x1,y1,x2,y2
|
xyxy: np.ndarray # (4,) float:x1,y1,x2,y2
|
||||||
conf: float
|
conf: float
|
||||||
cls_id: int
|
cls_id: int
|
||||||
cls_name: str
|
cls_name: str
|
||||||
@@ -69,7 +69,7 @@ def _resolve_device(device: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_weights(weights: Union[str, Path], backend: str = "auto") -> Path:
|
def resolve_weights(weights: Union[str, Path], backend: str = "auto") -> Path:
|
||||||
"""Prefer TensorRT .engine next to the .pt when backend=auto/tensorrt."""
|
"""当 backend=auto/tensorrt 时,优先使用与 .pt 同级的 TensorRT .engine。"""
|
||||||
p = Path(weights).expanduser()
|
p = Path(weights).expanduser()
|
||||||
b = (backend or "auto").strip().lower()
|
b = (backend or "auto").strip().lower()
|
||||||
engine = p.with_suffix(".engine")
|
engine = p.with_suffix(".engine")
|
||||||
@@ -78,13 +78,13 @@ def resolve_weights(weights: Union[str, Path], backend: str = "auto") -> Path:
|
|||||||
if engine.is_file():
|
if engine.is_file():
|
||||||
return engine
|
return engine
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"TensorRT engine missing: {engine}. "
|
f"缺少 TensorRT engine:{engine}。"
|
||||||
f"Export: bash ~/ros2/scripts/weights.sh convert {p} --name {p.stem}"
|
f"导出命令:bash ~/ros2/scripts/weights.sh convert {p} --name {p.stem}"
|
||||||
)
|
)
|
||||||
if b == "onnx":
|
if b == "onnx":
|
||||||
if onnx.is_file():
|
if onnx.is_file():
|
||||||
return onnx
|
return onnx
|
||||||
raise FileNotFoundError(f"ONNX missing: {onnx}")
|
raise FileNotFoundError(f"缺少 ONNX 文件:{onnx}")
|
||||||
if b in ("", "auto"):
|
if b in ("", "auto"):
|
||||||
if engine.is_file():
|
if engine.is_file():
|
||||||
return engine
|
return engine
|
||||||
@@ -155,19 +155,19 @@ class YoloDetector:
|
|||||||
from ultralytics import YOLO
|
from ultralytics import YOLO
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"ultralytics not installed. In ros_flexiv: pip install ultralytics"
|
"未安装 ultralytics。在 ros_flexiv 环境里执行:pip install ultralytics"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
path = Path(weights).expanduser()
|
path = Path(weights).expanduser()
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"YOLO weights not found: {path}. "
|
f"找不到 YOLO 权重:{path}。"
|
||||||
f"Place your .pt at ~/ros2/models/yolo/workpiece.pt"
|
f"请把你的 .pt 放到 ~/ros2/models/yolo/workpiece.pt"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
# Do not let PyTorch occupy all CPU cores (camera + ROS need them).
|
# 别让 PyTorch 占满所有 CPU 核心(相机 + ROS 都要用)。
|
||||||
torch.set_num_threads(1)
|
torch.set_num_threads(1)
|
||||||
torch.set_num_interop_threads(1)
|
torch.set_num_interop_threads(1)
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
@@ -243,7 +243,7 @@ class YoloDetector:
|
|||||||
imgsz: int | None = None,
|
imgsz: int | None = None,
|
||||||
**_kwargs,
|
**_kwargs,
|
||||||
) -> List[Detection]:
|
) -> List[Detection]:
|
||||||
"""Alias used by hole detectors (xyxy + conf)."""
|
"""给孔检测器用的别名接口(xyxy + conf)。"""
|
||||||
if conf is not None:
|
if conf is not None:
|
||||||
self.conf = float(conf)
|
self.conf = float(conf)
|
||||||
if iou is not None:
|
if iou is not None:
|
||||||
@@ -302,7 +302,7 @@ class YoloDetector:
|
|||||||
|
|
||||||
|
|
||||||
def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
|
def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
|
||||||
"""Spawned process: GPU YOLO only. Never import rclpy here."""
|
"""spawn 出来的子进程:只跑 GPU 上的 YOLO。这里绝不能 import rclpy。"""
|
||||||
try:
|
try:
|
||||||
path = resolve_weights(cfg["weights"], cfg.get("backend") or "auto")
|
path = resolve_weights(cfg["weights"], cfg.get("backend") or "auto")
|
||||||
det = YoloDetector(
|
det = YoloDetector(
|
||||||
@@ -321,7 +321,7 @@ def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
|
|||||||
item = q_in.get()
|
item = q_in.get()
|
||||||
if item is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
# Drain to the newest queued frame — never spend GPU on stale images.
|
# 一直排空到队列里最新的帧 —— 绝不把 GPU 浪费在过期图像上。
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
newer = q_in.get_nowait()
|
newer = q_in.get_nowait()
|
||||||
@@ -345,7 +345,7 @@ def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _put_drop_old(q, item) -> None:
|
def _put_drop_old(q, item) -> None:
|
||||||
"""Never block the GPU process on a full result queue."""
|
"""绝不让 GPU 进程因为结果队列满了而阻塞。"""
|
||||||
try:
|
try:
|
||||||
q.put_nowait(item)
|
q.put_nowait(item)
|
||||||
return
|
return
|
||||||
@@ -362,7 +362,7 @@ def _put_drop_old(q, item) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class YoloInferProcess:
|
class YoloInferProcess:
|
||||||
"""YOLO in a spawn() child so ROS callbacks never share the PyTorch GIL."""
|
"""把 YOLO 放进 spawn() 子进程,让 ROS 回调永远不用和 PyTorch 抢 GIL。"""
|
||||||
|
|
||||||
def __init__(self, cfg: dict) -> None:
|
def __init__(self, cfg: dict) -> None:
|
||||||
self._cfg = dict(cfg)
|
self._cfg = dict(cfg)
|
||||||
@@ -371,9 +371,9 @@ class YoloInferProcess:
|
|||||||
|
|
||||||
def _start(self) -> None:
|
def _start(self) -> None:
|
||||||
ctx = mp.get_context("spawn")
|
ctx = mp.get_context("spawn")
|
||||||
# maxsize=2 + worker drains to newest: parent never has to drop frames
|
# maxsize=2 + worker 排空到最新:父进程永远不需要主动丢帧
|
||||||
# (Queue put_nowait/get_nowait races were the source of multi-second
|
# (之前 Queue put_nowait/get_nowait 的竞争,正是 worker 明明活着
|
||||||
# "silent" freezes with the worker alive).
|
# 却出现数秒“静默”冻结的根源)。
|
||||||
self._q_in = ctx.Queue(maxsize=2)
|
self._q_in = ctx.Queue(maxsize=2)
|
||||||
self._q_out = ctx.Queue(maxsize=4)
|
self._q_out = ctx.Queue(maxsize=4)
|
||||||
self._p = ctx.Process(
|
self._p = ctx.Process(
|
||||||
@@ -386,12 +386,12 @@ class YoloInferProcess:
|
|||||||
msg = self._q_out.get(timeout=90.0)
|
msg = self._q_out.get(timeout=90.0)
|
||||||
if not msg or msg[0] != "ready":
|
if not msg or msg[0] != "ready":
|
||||||
err = msg[1] if msg and len(msg) > 1 else "worker failed"
|
err = msg[1] if msg and len(msg) > 1 else "worker failed"
|
||||||
raise RuntimeError(f"YOLO worker: {err}")
|
raise RuntimeError(f"YOLO 工作进程:{err}")
|
||||||
self.weights_path = str(msg[1])
|
self.weights_path = str(msg[1])
|
||||||
self.imgsz = int(self._cfg.get("imgsz") or 416)
|
self.imgsz = int(self._cfg.get("imgsz") or 416)
|
||||||
|
|
||||||
def restart(self) -> None:
|
def restart(self) -> None:
|
||||||
"""Kill a hung/dead worker and spawn a fresh one (engine reload ~2-5 s)."""
|
"""杀掉卡死 / 挂掉的 worker,再起一个新的(重新加载 engine 约 2-5 s)。"""
|
||||||
self.close()
|
self.close()
|
||||||
self._start()
|
self._start()
|
||||||
|
|
||||||
@@ -400,19 +400,19 @@ class YoloInferProcess:
|
|||||||
return bool(self._p is not None and self._p.is_alive())
|
return bool(self._p is not None and self._p.is_alive())
|
||||||
|
|
||||||
def submit(self, image_bgr: np.ndarray, seq: int = 0) -> None:
|
def submit(self, image_bgr: np.ndarray, seq: int = 0) -> None:
|
||||||
"""Queue the newest frame. If the worker is busy, skip — it drains to
|
"""把最新一帧排进队列。如果 worker 正忙就跳过 —— 它在每次推理后都会
|
||||||
the newest queued frame after every infer, so nothing stale runs."""
|
排空到队列里最新的帧,所以不会跑任何过期数据。"""
|
||||||
if not self.alive:
|
if not self.alive:
|
||||||
raise RuntimeError("YOLO worker died")
|
raise RuntimeError("YOLO 工作进程已死亡")
|
||||||
try:
|
try:
|
||||||
self._q_in.put((int(seq), image_bgr), timeout=0.010)
|
self._q_in.put((int(seq), image_bgr), timeout=0.010)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def wait_result(self, timeout: float = 0.045):
|
def wait_result(self, timeout: float = 0.045):
|
||||||
"""Return ``(seq, list[Detection], gpu_ms)`` or ``None``."""
|
"""返回 ``(seq, list[Detection], gpu_ms)`` 或 ``None``。"""
|
||||||
if not self.alive:
|
if not self.alive:
|
||||||
raise RuntimeError("YOLO worker died")
|
raise RuntimeError("YOLO 工作进程已死亡")
|
||||||
deadline = time.monotonic() + max(0.004, float(timeout))
|
deadline = time.monotonic() + max(0.004, float(timeout))
|
||||||
latest = None
|
latest = None
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
@@ -456,7 +456,7 @@ class YoloInferProcess:
|
|||||||
return seq, dets, gpu_ms
|
return seq, dets, gpu_ms
|
||||||
|
|
||||||
def infer(self, image_bgr: np.ndarray, *, half: bool = False, seq: int = 0):
|
def infer(self, image_bgr: np.ndarray, *, half: bool = False, seq: int = 0):
|
||||||
"""Compat: submit + wait. Prefer submit/wait_result for pipelining."""
|
"""兼容接口:提交 + 等待。要做流水线的话优先用 submit/wait_result。"""
|
||||||
del half
|
del half
|
||||||
self.submit(image_bgr, seq)
|
self.submit(image_bgr, seq)
|
||||||
got = self.wait_result(timeout=0.08)
|
got = self.wait_result(timeout=0.08)
|
||||||
@@ -476,7 +476,7 @@ class YoloInferProcess:
|
|||||||
self._p.terminate()
|
self._p.terminate()
|
||||||
self._p.join(timeout=1.0)
|
self._p.join(timeout=1.0)
|
||||||
if self._p.is_alive():
|
if self._p.is_alive():
|
||||||
self._p.kill() # stuck in a CUDA call — SIGKILL
|
self._p.kill() # 卡在 CUDA 调用里出不来 —— 直接 SIGKILL
|
||||||
self._p.join(timeout=1.0)
|
self._p.join(timeout=1.0)
|
||||||
self._p = None
|
self._p = None
|
||||||
|
|
||||||
@@ -507,16 +507,16 @@ def pick_best(
|
|||||||
min_conf: float = 0.12,
|
min_conf: float = 0.12,
|
||||||
min_area: float = 4000.0,
|
min_area: float = 4000.0,
|
||||||
) -> Optional[Detection]:
|
) -> Optional[Detection]:
|
||||||
"""Keep the current workpiece under fast motion.
|
"""让当前工件在快速运动下也能被持续跟踪。
|
||||||
|
|
||||||
Match against a *predicted* previous box with a wide gate. If IoU fails
|
用一个较宽的匹配门限去和**预测出的**上一帧检测框做匹配。如果 IoU 不达标、
|
||||||
but YOLO still fired above min_conf, keep that detection — do not return
|
但 YOLO 仍以高于 min_conf 的置信度报出了目标,就保留该检测 —— 不要返回
|
||||||
None (that was the main source of dropouts while moving).
|
None(那正是运动过程中目标丢失的主要来源)。
|
||||||
Tiny boxes (logo / 通止规尖) are rejected so the arm does not lock on the mat.
|
过小的框(logo / 通止规尖)会被拒掉,避免机械臂锁到垫子上。
|
||||||
"""
|
"""
|
||||||
if not dets:
|
if not dets:
|
||||||
return None
|
return None
|
||||||
# Locked track may dip below min_conf while moving (blur). New locks cannot.
|
# 已锁定的跟踪在运动(模糊)时可能掉到 min_conf 以下;但新锁定不允许这样。
|
||||||
floor = max(0.06, 0.50 * float(min_conf))
|
floor = max(0.06, 0.50 * float(min_conf))
|
||||||
usable = [d for d in dets if float(d.conf) >= floor and float(d.area) >= float(min_area)]
|
usable = [d for d in dets if float(d.conf) >= floor and float(d.area) >= float(min_area)]
|
||||||
if not usable:
|
if not usable:
|
||||||
@@ -553,7 +553,7 @@ def pick_best(
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
best = max(usable, key=score_new)
|
best = max(usable, key=score_new)
|
||||||
# Fast move: keep a live YOLO even if it jumped far from the last box.
|
# 快速移动:即使 YOLO 报出的框离上一帧很远,也保留这个实时检测。
|
||||||
need = max(float(min_conf), float(reacquire_conf))
|
need = max(float(min_conf), float(reacquire_conf))
|
||||||
if float(best.conf) >= need:
|
if float(best.conf) >= need:
|
||||||
return best
|
return best
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Optional fallback for DualArmGripperManager(load_dual_arm_gripper_config(path)).
|
# DualArmGripperManager(load_dual_arm_gripper_config(path)) 的可选兜底配置。
|
||||||
# Daily use reads ~/ros2/config/site.yaml via load_from_site().
|
# 日常使用通过 load_from_site() 读取 ~/ros2/config/site.yaml。
|
||||||
left_arm:
|
left_arm:
|
||||||
robot_sn: "Rizon4-063989"
|
robot_sn: "Rizon4-063989"
|
||||||
gripper_port: "/dev/ttyUSB0"
|
gripper_port: "/dev/ttyUSB0"
|
||||||
|
|||||||
@@ -21,26 +21,26 @@ perception_node:
|
|||||||
min_conf: 0.12
|
min_conf: 0.12
|
||||||
vision_hz: 20.0
|
vision_hz: 20.0
|
||||||
z_ref_m: 0.30
|
z_ref_m: 0.30
|
||||||
# Large gate + reacquire: fast motion must NOT freeze track
|
# 大跳变门限 + 重新捕获:快速运动绝不能把跟踪卡死
|
||||||
jump_px: 280.0
|
jump_px: 280.0
|
||||||
jump_z_m: 0.20
|
jump_z_m: 0.20
|
||||||
min_depth_m: 0.05
|
min_depth_m: 0.05
|
||||||
max_depth_m: 2.50
|
max_depth_m: 2.50
|
||||||
object_frame: "tracked_object"
|
object_frame: "tracked_object"
|
||||||
camera_frame: "d405_color_optical_frame"
|
camera_frame: "d405_color_optical_frame"
|
||||||
# Snappy KF + blend toward raw measurement
|
# 灵敏的 KF + 向原始观测融合
|
||||||
kf_q_pos: 0.002
|
kf_q_pos: 0.002
|
||||||
kf_q_vel: 0.05
|
kf_q_vel: 0.05
|
||||||
kf_r_pos: 0.0008
|
kf_r_pos: 0.0008
|
||||||
meas_blend: 0.65
|
meas_blend: 0.65
|
||||||
# Faster infer + tight box follow
|
# 更快的推理 + 更紧的框跟随
|
||||||
infer_max_width: 640
|
infer_max_width: 640
|
||||||
box_ema_alpha: 0.85
|
box_ema_alpha: 0.85
|
||||||
miss_clear_frames: 2
|
miss_clear_frames: 2
|
||||||
# Item 4 — adaptive Z depth LPF
|
# 第 4 项 — 自适应 Z 深度低通滤波
|
||||||
z_alpha_min: 0.10
|
z_alpha_min: 0.10
|
||||||
z_alpha_max: 0.60
|
z_alpha_max: 0.60
|
||||||
z_alpha_gain: 2.0
|
z_alpha_gain: 2.0
|
||||||
feature_mode: "plane" # stereo 3D centroid; box = monocular box-center ray
|
feature_mode: "plane" # stereo 为 3D 质心;box = 单目框中心的射线
|
||||||
plane_inlier_m: 0.008
|
plane_inlier_m: 0.008
|
||||||
plane_band_m: 0.06
|
plane_band_m: 0.06
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# servo_node defaults. Launch overlays ~/ros2/config/site.yaml → servo:
|
# servo_node 的默认值。launch 会用 ~/ros2/config/site.yaml 的 servo: 覆盖。
|
||||||
# Do not edit this file for day-to-day tuning.
|
# 日常调参不要改这个文件。
|
||||||
|
|
||||||
servo_node:
|
servo_node:
|
||||||
ros__parameters:
|
ros__parameters:
|
||||||
@@ -19,7 +19,7 @@ servo_node:
|
|||||||
enable_control: false
|
enable_control: false
|
||||||
max_extrapolate_sec: 0.08
|
max_extrapolate_sec: 0.08
|
||||||
|
|
||||||
# Item 1 — lost-target hard stop
|
# 第 1 项 — 丢失目标硬停
|
||||||
watchdog_timeout_sec: 0.3
|
watchdog_timeout_sec: 0.3
|
||||||
lost_timeout_sec: 0.3
|
lost_timeout_sec: 0.3
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ servo_node:
|
|||||||
kd_z: 0.45
|
kd_z: 0.45
|
||||||
i_limit_xy: 0.03
|
i_limit_xy: 0.03
|
||||||
i_limit_z: 0.025
|
i_limit_z: 0.025
|
||||||
# Item 2 — integral separation
|
# 第 2 项 — 积分分离
|
||||||
i_sep_pos_m: 0.005
|
i_sep_pos_m: 0.005
|
||||||
i_sep_ang_rad: 0.02
|
i_sep_ang_rad: 0.02
|
||||||
|
|
||||||
@@ -41,11 +41,11 @@ servo_node:
|
|||||||
v_max_z: 0.07
|
v_max_z: 0.07
|
||||||
a_max: 0.70
|
a_max: 0.70
|
||||||
|
|
||||||
# Item 3 — velocity LPF
|
# 第 3 项 — 速度低通滤波
|
||||||
vel_lpf_alpha: 0.40
|
vel_lpf_alpha: 0.40
|
||||||
wz_lpf_alpha: 0.40
|
wz_lpf_alpha: 0.40
|
||||||
|
|
||||||
# Item 5 — dq sliding window
|
# 第 5 项 — dq 滑动窗口
|
||||||
dq_window_size: 5
|
dq_window_size: 5
|
||||||
|
|
||||||
min_ee_z_m: 0.20
|
min_ee_z_m: 0.20
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Launch DH PGEA Modbus node. Config from ~/ros2/config/site.yaml gripper:."""
|
"""启动 DH PGEA Modbus 节点。配置来自 ``~/ros2/config/site.yaml`` 的 ``gripper`` 段。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ def _setup(context, *args, **kwargs):
|
|||||||
),
|
),
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg=(
|
msg=(
|
||||||
"[dh_pgea] Flexiv load_gripper stays false — this is DH Modbus, "
|
"[dh_pgea] Flexiv 的 load_gripper 保持 false —— 这里是 DH Modbus,"
|
||||||
"not a Flexiv Studio gripper."
|
"不是 Flexiv Studio 夹爪。"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
Node(
|
Node(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Launch dual DH grippers. Ports come from site.yaml robots[].gripper."""
|
"""启动双路 DH 夹爪。端口来自 ``site.yaml`` 的 ``robots[].gripper``。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
[
|
[
|
||||||
DeclareLaunchArgument("sides", default_value="left,right"),
|
DeclareLaunchArgument("sides", default_value="left,right"),
|
||||||
DeclareLaunchArgument("allow_offline", default_value="true"),
|
DeclareLaunchArgument("allow_offline", default_value="true"),
|
||||||
LogInfo(msg=f"[dual_gripper] site={site} (arm_msgs, IPC Modbus, not Flexiv gripper)"),
|
LogInfo(msg=f"[dual_gripper] site={site}(arm_msgs,IPC Modbus,不是 Flexiv 夹爪)"),
|
||||||
Node(
|
Node(
|
||||||
package="arm_application",
|
package="arm_application",
|
||||||
executable="dual_gripper",
|
executable="dual_gripper",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Launch hole_detect on the current-arm color topic."""
|
"""在当前机械臂的彩色话题上启动 hole_detect。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Launch: perception_node + servo_node + static hand-eye TF.
|
"""启动:perception_node + servo_node + 静态手眼 TF。
|
||||||
|
|
||||||
All tunables come from ~/ros2/config/site.yaml (override via launch args if needed).
|
所有可调参数都来自 ``~/ros2/config/site.yaml``(必要时可用 launch 参数覆盖)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,12 +20,12 @@ from launch_ros.actions import Node
|
|||||||
|
|
||||||
|
|
||||||
def _ws_root() -> Path:
|
def _ws_root() -> Path:
|
||||||
# Prefer env from env.sh / load_site_config
|
# 优先使用 env.sh / load_site_config 提供的环境变量
|
||||||
for key in ("ROS2_WS", "SITE_ROS2_ROOT"):
|
for key in ("ROS2_WS", "SITE_ROS2_ROOT"):
|
||||||
v = os.environ.get(key, "").strip()
|
v = os.environ.get(key, "").strip()
|
||||||
if v:
|
if v:
|
||||||
return Path(v)
|
return Path(v)
|
||||||
# launch file lives in install/.../share/arm_application/launch or src/...
|
# launch 文件可能位于 install/.../share/arm_application/launch 或 src/...
|
||||||
here = Path(__file__).resolve()
|
here = Path(__file__).resolve()
|
||||||
for p in [here.parents[i] for i in range(2, 8)]:
|
for p in [here.parents[i] for i in range(2, 8)]:
|
||||||
if (p / "config" / "site.yaml").is_file():
|
if (p / "config" / "site.yaml").is_file():
|
||||||
@@ -46,7 +46,7 @@ def _load_site():
|
|||||||
|
|
||||||
cfg = os.environ.get("SITE_CONFIG", "").strip()
|
cfg = os.environ.get("SITE_CONFIG", "").strip()
|
||||||
path = Path(cfg).expanduser() if cfg else site_path()
|
path = Path(cfg).expanduser() if cfg else site_path()
|
||||||
# site_path() uses script location; force workspace root file
|
# site_path() 依赖脚本所在位置;这里强制使用工作区根目录下的文件
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
path = root / "config" / "site.yaml"
|
path = root / "config" / "site.yaml"
|
||||||
data = load_site(path)
|
data = load_site(path)
|
||||||
@@ -97,7 +97,7 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
share = Path(get_package_share_directory("arm_application"))
|
share = Path(get_package_share_directory("arm_application"))
|
||||||
site, site_path, perc_over, servo_over, robot = _load_site()
|
site, site_path, perc_over, servo_over, robot = _load_site()
|
||||||
|
|
||||||
# Launch args override site when explicitly passed (non-empty / non-default handled below)
|
# launch 参数显式传入时覆盖 site 配置(空值 / 默认值的处理见下)
|
||||||
handeye = LaunchConfiguration("handeye_file").perform(context).strip()
|
handeye = LaunchConfiguration("handeye_file").perform(context).strip()
|
||||||
site_he = str(servo_over.get("handeye_file") or (site.get("paths") or {}).get("handeye_file") or "")
|
site_he = str(servo_over.get("handeye_file") or (site.get("paths") or {}).get("handeye_file") or "")
|
||||||
if not handeye or handeye in (
|
if not handeye or handeye in (
|
||||||
@@ -107,8 +107,8 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
handeye = site_he or handeye
|
handeye = site_he or handeye
|
||||||
if not Path(handeye).is_file():
|
if not Path(handeye).is_file():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"hand-eye result missing: {handeye}. "
|
f"手眼标定结果缺失:{handeye}。"
|
||||||
"Re-run: bash ~/ros2/scripts/run_handeye.sh calibrate"
|
"请重新运行:bash ~/ros2/scripts/run_handeye.sh calibrate"
|
||||||
)
|
)
|
||||||
|
|
||||||
enable = LaunchConfiguration("enable_control").perform(context).lower() in (
|
enable = LaunchConfiguration("enable_control").perform(context).lower() in (
|
||||||
@@ -197,8 +197,8 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
),
|
),
|
||||||
LogInfo(msg=f"[site] YOLO weights: {weights}"),
|
LogInfo(msg=f"[site] YOLO weights: {weights}"),
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg="nodes: perception_node (YOLO) | servo_node (100Hz) — "
|
msg="节点:perception_node(YOLO)| servo_node(100Hz)—— "
|
||||||
"separate processes, topic /visual_servo/filtered_point"
|
"各自独立进程,话题 /visual_servo/filtered_point"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
pub_tf = LaunchConfiguration("publish_handeye_tf").perform(context).lower() in (
|
pub_tf = LaunchConfiguration("publish_handeye_tf").perform(context).lower() in (
|
||||||
@@ -206,7 +206,7 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
)
|
)
|
||||||
if pub_tf:
|
if pub_tf:
|
||||||
nodes.append(
|
nodes.append(
|
||||||
LogInfo(msg=f"static hand-eye {ee_frame} → {cam_frame} t=[{tx:.3f},{ty:.3f},{tz:.3f}]")
|
LogInfo(msg=f"静态手眼 {ee_frame} → {cam_frame} t=[{tx:.3f},{ty:.3f},{tz:.3f}]")
|
||||||
)
|
)
|
||||||
nodes.append(static_tf)
|
nodes.append(static_tf)
|
||||||
nodes.extend([perception, servo])
|
nodes.extend([perception, servo])
|
||||||
|
|||||||
@@ -55,28 +55,28 @@ setup(
|
|||||||
'move_to_pose_client = arm_application.move_to_pose_client:main',
|
'move_to_pose_client = arm_application.move_to_pose_client:main',
|
||||||
'run_joint_trajectory = arm_application.run_joint_trajectory:main',
|
'run_joint_trajectory = arm_application.run_joint_trajectory:main',
|
||||||
'goto_ee_plane = arm_application.goto_ee_plane:main',
|
'goto_ee_plane = arm_application.goto_ee_plane:main',
|
||||||
# Camera
|
# 相机
|
||||||
'realsense_color_pub = arm_application.realsense_color_pub:main',
|
'realsense_color_pub = arm_application.realsense_color_pub:main',
|
||||||
'realsense_view = arm_application.realsense_view:main',
|
'realsense_view = arm_application.realsense_view:main',
|
||||||
# Hand-eye
|
# 手眼标定
|
||||||
'handeye_teach = arm_application.handeye_teach:main',
|
'handeye_teach = arm_application.handeye_teach:main',
|
||||||
'handeye_waypoints = arm_application.handeye_waypoints:main',
|
'handeye_waypoints = arm_application.handeye_waypoints:main',
|
||||||
'handeye_calibrate = arm_application.handeye_calibrate:main',
|
'handeye_calibrate = arm_application.handeye_calibrate:main',
|
||||||
# Visual servo (current stack)
|
# 视觉伺服(当前主链路)
|
||||||
'perception_node = arm_application.perception_node:main',
|
'perception_node = arm_application.perception_node:main',
|
||||||
'servo_node = arm_application.servo_node:main',
|
'servo_node = arm_application.servo_node:main',
|
||||||
# Gripper
|
# 夹爪
|
||||||
'dh_pgea = arm_application.dh_pgea_node:main',
|
'dh_pgea = arm_application.dh_pgea_node:main',
|
||||||
'dh_pgea_cli = arm_application.dh_pgea:main',
|
'dh_pgea_cli = arm_application.dh_pgea:main',
|
||||||
'dual_gripper = arm_application.dual_gripper_node:main',
|
'dual_gripper = arm_application.dual_gripper_node:main',
|
||||||
'dual_gripper_cli = arm_application.dual_gripper_cli:main',
|
'dual_gripper_cli = arm_application.dual_gripper_cli:main',
|
||||||
# Hole detect (no insert motion)
|
# 入孔检测(只检测,不做插入动作)
|
||||||
'hole_detect = arm_application.hole_detect_node:main',
|
'hole_detect = arm_application.hole_detect_node:main',
|
||||||
# Stage-2 one-shot hole localization (k1/k2) + gauge alignment
|
# 第二阶段:一次性孔定位(k1/k2)+ 对规
|
||||||
'stage2_locate = arm_application.stage2_locate_node:main',
|
'stage2_locate = arm_application.stage2_locate_node:main',
|
||||||
# Stage-3 compliant hole search (Orisys tactile spiral + insert)
|
# 第三阶段:柔顺找孔(Orisys 触觉螺旋 + 插入)
|
||||||
'stage3_search = arm_application.stage3_search_node:main',
|
'stage3_search = arm_application.stage3_search_node:main',
|
||||||
# Three-stage assembly pipeline (track → locate → search, one process)
|
# 三段式装配流水线(跟踪 → 定位 → 找孔,单进程)
|
||||||
'assembly_pipeline = arm_application.pipeline_node:main',
|
'assembly_pipeline = arm_application.pipeline_node:main',
|
||||||
'tactile = arm_application.tactile_node:main',
|
'tactile = arm_application.tactile_node:main',
|
||||||
'tactile_cli = arm_application.tactile_cli:main',
|
'tactile_cli = arm_application.tactile_cli:main',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Site defaults for Flexiv Rizon 4 (SN: Rizon4-063989)
|
# Flexiv Rizon 4 的站点默认值(SN: Rizon4-063989)
|
||||||
robot_sn: Rizon4-063989
|
robot_sn: Rizon4-063989
|
||||||
rizon_type: Rizon4
|
rizon_type: Rizon4
|
||||||
# Flexiv Studio gripper (GN01) — keep false. DH PGEA uses run_gripper.sh.
|
# Flexiv Studio 夹爪(GN01)—— 保持 false。DH PGEA 使用 run_gripper.sh。
|
||||||
load_gripper: false
|
load_gripper: false
|
||||||
use_fake_hardware: false
|
use_fake_hardware: false
|
||||||
start_rviz: true
|
start_rviz: true
|
||||||
|
|
||||||
# Official Flexiv joint naming: Rizon4-063989_joint1 ... joint7
|
# Flexiv 官方关节命名:Rizon4-063989_joint1 ... joint7
|
||||||
joint_prefix: Rizon4-063989_
|
joint_prefix: Rizon4-063989_
|
||||||
|
|
||||||
base_frame: base_link
|
base_frame: base_link
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from ament_index_python.packages import get_package_share_directory, PackageNotF
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Site robot — DH PGEA is a separate Modbus node (run_gripper.sh).
|
# 现场机械臂 —— DH PGEA 是独立的 Modbus 节点(run_gripper.sh)。
|
||||||
# Keep Flexiv load_gripper false (that flag is Flexiv-GN01 in Studio).
|
# 保持 Flexiv 的 load_gripper 为 false(该开关在 Studio 里对应 Flexiv-GN01)。
|
||||||
SITE_ROBOT_SN = os.environ.get('SITE_ROBOT_SN', 'Rizon4-063989')
|
SITE_ROBOT_SN = os.environ.get('SITE_ROBOT_SN', 'Rizon4-063989')
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
|
|
||||||
if not robot_sn:
|
if not robot_sn:
|
||||||
return [
|
return [
|
||||||
LogInfo(msg=f'[arm_bringup] ERROR: robot_sn is required, e.g. robot_sn:={SITE_ROBOT_SN}')
|
LogInfo(msg=f'[arm_bringup] 错误:必须提供 robot_sn,例如 robot_sn:={SITE_ROBOT_SN}')
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -30,8 +30,8 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
return [
|
return [
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg=(
|
msg=(
|
||||||
'[arm_bringup] flexiv_bringup not found. '
|
'[arm_bringup] 找不到 flexiv_bringup。'
|
||||||
'Run scripts/fetch_flexiv.sh and build, or use sim.launch.py for mock.'
|
'请运行 scripts/fetch_flexiv.sh 并编译,或改用 sim.launch.py 跑 mock。'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -39,7 +39,7 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
return [
|
return [
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg=(
|
msg=(
|
||||||
f'[arm_bringup] Starting Flexiv {rizon_type} ({robot_sn}), '
|
f'[arm_bringup] 启动 Flexiv {rizon_type}({robot_sn}),'
|
||||||
f'load_gripper={load_gripper}'
|
f'load_gripper={load_gripper}'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -61,16 +61,16 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def generate_launch_description():
|
def generate_launch_description():
|
||||||
"""Real Rizon4 bringup via official flexiv_bringup (RDK v1 / humble-v1)."""
|
"""通过官方 flexiv_bringup 启动真机 Rizon4(RDK v1 / humble-v1)。"""
|
||||||
return LaunchDescription([
|
return LaunchDescription([
|
||||||
DeclareLaunchArgument(
|
DeclareLaunchArgument(
|
||||||
'robot_sn',
|
'robot_sn',
|
||||||
default_value=SITE_ROBOT_SN,
|
default_value=SITE_ROBOT_SN,
|
||||||
description='Site robot serial number (no spaces)',
|
description='现场机械臂序列号(不能有空格)',
|
||||||
),
|
),
|
||||||
DeclareLaunchArgument('rizon_type', default_value='Rizon4'),
|
DeclareLaunchArgument('rizon_type', default_value='Rizon4'),
|
||||||
DeclareLaunchArgument('use_fake_hardware', default_value='false'),
|
DeclareLaunchArgument('use_fake_hardware', default_value='false'),
|
||||||
# Flexiv-GN01 only. DH PGEA: bash ~/ros2/scripts/run_gripper.sh
|
# 仅适用于 Flexiv-GN01。DH PGEA 用: bash ~/ros2/scripts/run_gripper.sh
|
||||||
DeclareLaunchArgument('load_gripper', default_value='false'),
|
DeclareLaunchArgument('load_gripper', default_value='false'),
|
||||||
DeclareLaunchArgument('start_rviz', default_value='true'),
|
DeclareLaunchArgument('start_rviz', default_value='true'),
|
||||||
OpaqueFunction(function=_launch_setup),
|
OpaqueFunction(function=_launch_setup),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from launch_ros.substitutions import FindPackageShare
|
|||||||
|
|
||||||
|
|
||||||
def generate_launch_description():
|
def generate_launch_description():
|
||||||
"""Offline mock bringup for Flexiv Rizon 4 (7-DOF), no real robot required."""
|
"""Flexiv Rizon 4(7 自由度)的离线 mock 启动,无需真机。"""
|
||||||
use_rviz = LaunchConfiguration('use_rviz')
|
use_rviz = LaunchConfiguration('use_rviz')
|
||||||
use_moveit = LaunchConfiguration('use_moveit')
|
use_moveit = LaunchConfiguration('use_moveit')
|
||||||
robot_sn = LaunchConfiguration('robot_sn')
|
robot_sn = LaunchConfiguration('robot_sn')
|
||||||
@@ -39,7 +39,7 @@ def generate_launch_description():
|
|||||||
DeclareLaunchArgument(
|
DeclareLaunchArgument(
|
||||||
'robot_sn',
|
'robot_sn',
|
||||||
default_value='Rizon4-063989',
|
default_value='Rizon4-063989',
|
||||||
description='Site SN; joints become Rizon4-063989_joint1..joint7',
|
description='现场 SN;关节名会变成 Rizon4-063989_joint1..joint7',
|
||||||
),
|
),
|
||||||
mock,
|
mock,
|
||||||
moveit,
|
moveit,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unified stack launch: backend:=mock|moveit|real|isaac
|
"""统一的功能栈启动:backend:=mock|moveit|real|isaac
|
||||||
|
|
||||||
Application interfaces stay the same across backends:
|
各后端下应用接口保持不变:
|
||||||
/set_named_pose, /get_arm_state, /arm_status, /follow_joint_trajectory
|
/set_named_pose, /get_arm_state, /arm_status, /follow_joint_trajectory
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -82,9 +82,9 @@ def _setup(context, *args, **kwargs):
|
|||||||
actions.append(
|
actions.append(
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg=(
|
msg=(
|
||||||
'[arm_bringup] MoveIt on top of mock. '
|
'[arm_bringup] 在 mock 之上叠加 MoveIt。'
|
||||||
'Named poses still via /set_named_pose; '
|
'命名位姿仍走 /set_named_pose;'
|
||||||
'MoveIt plans execute through trajectory_bridge.'
|
'MoveIt 的规划通过 trajectory_bridge 执行。'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -96,9 +96,9 @@ def _setup(context, *args, **kwargs):
|
|||||||
actions.append(
|
actions.append(
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg=(
|
msg=(
|
||||||
'[arm_bringup] flexiv_bringup missing. '
|
'[arm_bringup] 缺少 flexiv_bringup。'
|
||||||
'Run: ./scripts/fetch_flexiv.sh && rebuild. '
|
'请运行: ./scripts/fetch_flexiv.sh && 重新编译。'
|
||||||
'Falling back tip: use backend:=mock for now.'
|
'临时退回建议:先用 backend:=mock。'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -119,7 +119,7 @@ def _setup(context, *args, **kwargs):
|
|||||||
}.items(),
|
}.items(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Keep application services on top of real HW when possible
|
# 尽量把应用服务叠在真机之上
|
||||||
actions.append(
|
actions.append(
|
||||||
Node(
|
Node(
|
||||||
package='arm_control',
|
package='arm_control',
|
||||||
@@ -167,9 +167,9 @@ def _setup(context, *args, **kwargs):
|
|||||||
actions.append(
|
actions.append(
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg=(
|
msg=(
|
||||||
'[arm_bringup] REAL mode: flexiv_bringup + named_pose_server '
|
'[arm_bringup] REAL 模式:flexiv_bringup + named_pose_server '
|
||||||
'+ arm_status_node. '
|
'+ arm_status_node。'
|
||||||
'Ensure RDK is enabled and network reaches the robot.'
|
'请确保 RDK 已使能,且网络能连到机械臂。'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -178,17 +178,17 @@ def _setup(context, *args, **kwargs):
|
|||||||
actions.append(
|
actions.append(
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg=(
|
msg=(
|
||||||
'[arm_bringup] ISAAC mode: mock ROS stack is up for bridge testing. '
|
'[arm_bringup] ISAAC 模式:已起 mock ROS 栈用于桥接测试。'
|
||||||
'Start Isaac Sim + flexiv_sim_plugin separately '
|
'请另行启动 Isaac Sim + flexiv_sim_plugin '
|
||||||
'(see docs/INTERFACES.md). '
|
'(见 docs/INTERFACES.md)。'
|
||||||
'Sync joints via /joint_states or RDK bridge topics.'
|
'通过 /joint_states 或 RDK 桥接话题同步关节。'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if backend not in ('mock', 'moveit', 'real', 'isaac'):
|
if backend not in ('mock', 'moveit', 'real', 'isaac'):
|
||||||
actions.append(
|
actions.append(
|
||||||
LogInfo(msg=f'[arm_bringup] Unknown backend "{backend}". Use mock|moveit|real|isaac')
|
LogInfo(msg=f'[arm_bringup] 未知 backend "{backend}"。可用 mock|moveit|real|isaac')
|
||||||
)
|
)
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
"""Package init for arm_control."""
|
"""arm_control 的包初始化。"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Publish ArmStatus + GetArmState from joint_states, TF, and Flexiv tcp_pose."""
|
"""根据 joint_states、TF 与 Flexiv 的 tcp_pose 发布 ArmStatus,并提供 GetArmState 服务。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class ArmStatusNode(Node):
|
|||||||
self.create_timer(1.0 / rate, self._tick)
|
self.create_timer(1.0 / rate, self._tick)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'ArmStatus: base={self.base_frame} ee={self.ee_frame} '
|
f'ArmStatus: base={self.base_frame} ee={self.ee_frame} '
|
||||||
f'tcp_topic={self.tcp_pose_topic or "(none)"}'
|
f'tcp_topic={self.tcp_pose_topic or "(无)"}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_js(self, msg: JointState) -> None:
|
def _on_js(self, msg: JointState) -> None:
|
||||||
@@ -55,7 +55,7 @@ class ArmStatusNode(Node):
|
|||||||
self._last_tcp = msg
|
self._last_tcp = msg
|
||||||
|
|
||||||
def _ee(self) -> Optional[PoseStamped]:
|
def _ee(self) -> Optional[PoseStamped]:
|
||||||
# Prefer Flexiv-published TCP pose (reliable on real stack)
|
# 优先用 Flexiv 发布的 TCP 位姿(真机栈上更可靠)
|
||||||
if self._last_tcp is not None:
|
if self._last_tcp is not None:
|
||||||
return self._last_tcp
|
return self._last_tcp
|
||||||
try:
|
try:
|
||||||
@@ -101,7 +101,7 @@ class ArmStatusNode(Node):
|
|||||||
and abs(s.ee_pose.pose.position.y) < 1e-9
|
and abs(s.ee_pose.pose.position.y) < 1e-9
|
||||||
and abs(s.ee_pose.pose.position.z) < 1e-9
|
and abs(s.ee_pose.pose.position.z) < 1e-9
|
||||||
):
|
):
|
||||||
response.message = 'ok (ee pose unavailable — check TF/tcp_pose)'
|
response.message = 'ok(末端位姿不可用 —— 请检查 TF/tcp_pose)'
|
||||||
else:
|
else:
|
||||||
response.message = 'ok'
|
response.message = 'ok'
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Service server: SetNamedPose -> FollowJointTrajectory (backend-agnostic)."""
|
"""服务端:SetNamedPose -> FollowJointTrajectory(与底层后端无关)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -65,18 +65,18 @@ class NamedPoseServer(Node):
|
|||||||
SetNamedPose, 'set_named_pose', self._on_set, callback_group=self._cb
|
SetNamedPose, 'set_named_pose', self._on_set, callback_group=self._cb
|
||||||
)
|
)
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f'Named poses {list(self.poses)} joints={self.joint_names}'
|
f'命名位姿 {list(self.poses)} joints={self.joint_names}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_set(self, request: SetNamedPose.Request, response: SetNamedPose.Response):
|
def _on_set(self, request: SetNamedPose.Request, response: SetNamedPose.Response):
|
||||||
name = request.pose_name
|
name = request.pose_name
|
||||||
if name not in self.poses:
|
if name not in self.poses:
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = f'Unknown pose "{name}". Available: {list(self.poses)}'
|
response.message = f'未知位姿"{name}"。可用: {list(self.poses)}'
|
||||||
return response
|
return response
|
||||||
if not self._client.wait_for_server(timeout_sec=3.0):
|
if not self._client.wait_for_server(timeout_sec=3.0):
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = 'follow_joint_trajectory action not available'
|
response.message = 'follow_joint_trajectory action 不可用'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
goal = FollowJointTrajectory.Goal()
|
goal = FollowJointTrajectory.Goal()
|
||||||
@@ -87,30 +87,30 @@ class NamedPoseServer(Node):
|
|||||||
goal.time_from_start = [duration]
|
goal.time_from_start = [duration]
|
||||||
goal.velocity_scale = float(request.velocity_scale)
|
goal.velocity_scale = float(request.velocity_scale)
|
||||||
|
|
||||||
self.get_logger().info(f'-> pose [{name}] T={duration:.2f}s')
|
self.get_logger().info(f'-> 位姿 [{name}] T={duration:.2f}s')
|
||||||
send_fut = self._client.send_goal_async(goal)
|
send_fut = self._client.send_goal_async(goal)
|
||||||
if not _wait_future(send_fut, 5.0):
|
if not _wait_future(send_fut, 5.0):
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = 'Timed out waiting for goal accept'
|
response.message = '等待目标被接受超时'
|
||||||
return response
|
return response
|
||||||
handle = send_fut.result()
|
handle = send_fut.result()
|
||||||
if handle is None or not handle.accepted:
|
if handle is None or not handle.accepted:
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = 'Goal rejected'
|
response.message = '目标被拒绝'
|
||||||
return response
|
return response
|
||||||
res_fut = handle.get_result_async()
|
res_fut = handle.get_result_async()
|
||||||
if not _wait_future(res_fut, 60.0):
|
if not _wait_future(res_fut, 60.0):
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = 'Timed out waiting for trajectory result'
|
response.message = '等待轨迹结果超时'
|
||||||
return response
|
return response
|
||||||
wrapped = res_fut.result()
|
wrapped = res_fut.result()
|
||||||
if wrapped is None:
|
if wrapped is None:
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = 'Empty result'
|
response.message = '结果为空'
|
||||||
return response
|
return response
|
||||||
result = wrapped.result
|
result = wrapped.result
|
||||||
response.success = bool(result.success)
|
response.success = bool(result.success)
|
||||||
response.message = result.message or f'Moved to {name}'
|
response.message = result.message or f'已移动到位姿 {name}'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Expose arm_msgs/FollowJointTrajectory for apps; drive flexiv JTC (control_msgs)."""
|
"""对应用侧暴露 arm_msgs/FollowJointTrajectory;实际驱动 Flexiv JTC(control_msgs)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -45,19 +45,19 @@ class RealTrajectoryAdapter(Node):
|
|||||||
cancel_callback=lambda _g: CancelResponse.ACCEPT,
|
cancel_callback=lambda _g: CancelResponse.ACCEPT,
|
||||||
callback_group=self._cb,
|
callback_group=self._cb,
|
||||||
)
|
)
|
||||||
self.get_logger().info(f'Real adapter {arm} -> {ctrl}')
|
self.get_logger().info(f'真机适配器 {arm} -> {ctrl}')
|
||||||
|
|
||||||
def _execute(self, goal_handle):
|
def _execute(self, goal_handle):
|
||||||
result = ArmFJT.Result()
|
result = ArmFJT.Result()
|
||||||
req = goal_handle.request
|
req = goal_handle.request
|
||||||
if not req.joint_names or req.num_points < 1:
|
if not req.joint_names or req.num_points < 1:
|
||||||
result.success = False
|
result.success = False
|
||||||
result.message = 'Invalid goal'
|
result.message = '目标非法'
|
||||||
goal_handle.abort()
|
goal_handle.abort()
|
||||||
return result
|
return result
|
||||||
if not self._ctrl.wait_for_server(timeout_sec=5.0):
|
if not self._ctrl.wait_for_server(timeout_sec=5.0):
|
||||||
result.success = False
|
result.success = False
|
||||||
result.message = f'Controller action not available'
|
result.message = f'控制器 action 不可用'
|
||||||
goal_handle.abort()
|
goal_handle.abort()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ class RealTrajectoryAdapter(Node):
|
|||||||
pt = JointTrajectoryPoint()
|
pt = JointTrajectoryPoint()
|
||||||
pt.positions = pos[idx : idx + n_j]
|
pt.positions = pos[idx : idx + n_j]
|
||||||
idx += n_j
|
idx += n_j
|
||||||
# Point-to-point named poses must end at rest — nonzero end vel
|
# 点到点的命名位姿必须在静止状态下结束 —— 末速度非零
|
||||||
# + open_loop JTC makes the arm coast / shake after arrival.
|
# 再叠加开环 JTC,会让机械臂到位后滑行/抖动。
|
||||||
pt.velocities = [0.0] * n_j
|
pt.velocities = [0.0] * n_j
|
||||||
pt.accelerations = [0.0] * n_j
|
pt.accelerations = [0.0] * n_j
|
||||||
sec = int(t)
|
sec = int(t)
|
||||||
@@ -86,13 +86,13 @@ class RealTrajectoryAdapter(Node):
|
|||||||
send = self._ctrl.send_goal_async(ctrl_goal)
|
send = self._ctrl.send_goal_async(ctrl_goal)
|
||||||
if not _wait(send, 5.0):
|
if not _wait(send, 5.0):
|
||||||
result.success = False
|
result.success = False
|
||||||
result.message = 'Timed out sending to controller'
|
result.message = '发送给控制器超时'
|
||||||
goal_handle.abort()
|
goal_handle.abort()
|
||||||
return result
|
return result
|
||||||
handle = send.result()
|
handle = send.result()
|
||||||
if handle is None or not handle.accepted:
|
if handle is None or not handle.accepted:
|
||||||
result.success = False
|
result.success = False
|
||||||
result.message = 'Controller rejected goal'
|
result.message = '控制器拒绝了目标'
|
||||||
goal_handle.abort()
|
goal_handle.abort()
|
||||||
return result
|
return result
|
||||||
traj_duration = 0.0
|
traj_duration = 0.0
|
||||||
@@ -102,13 +102,13 @@ class RealTrajectoryAdapter(Node):
|
|||||||
res_fut = handle.get_result_async()
|
res_fut = handle.get_result_async()
|
||||||
if not _wait(res_fut, max(120.0, traj_duration * 3.0)):
|
if not _wait(res_fut, max(120.0, traj_duration * 3.0)):
|
||||||
result.success = False
|
result.success = False
|
||||||
result.message = 'Timed out waiting for controller'
|
result.message = '等待控制器超时'
|
||||||
goal_handle.abort()
|
goal_handle.abort()
|
||||||
return result
|
return result
|
||||||
ctrl_res = res_fut.result().result
|
ctrl_res = res_fut.result().result
|
||||||
ok = ctrl_res.error_code == CtrlFJT.Result.SUCCESSFUL
|
ok = ctrl_res.error_code == CtrlFJT.Result.SUCCESSFUL
|
||||||
result.success = ok
|
result.success = ok
|
||||||
result.message = 'OK' if ok else f'controller error_code={ctrl_res.error_code}'
|
result.message = 'OK' if ok else f'控制器 error_code={ctrl_res.error_code}'
|
||||||
if ok:
|
if ok:
|
||||||
goal_handle.succeed()
|
goal_handle.succeed()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Bridge control_msgs/FollowJointTrajectory -> arm_msgs/FollowJointTrajectory."""
|
"""把 control_msgs/FollowJointTrajectory 桥接到 arm_msgs/FollowJointTrajectory。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ class TrajectoryBridge(Node):
|
|||||||
cancel_callback=lambda _g: CancelResponse.ACCEPT,
|
cancel_callback=lambda _g: CancelResponse.ACCEPT,
|
||||||
callback_group=self._cb,
|
callback_group=self._cb,
|
||||||
)
|
)
|
||||||
self.get_logger().info(f'Bridge {ctrl} -> {arm}')
|
self.get_logger().info(f'桥接 {ctrl} -> {arm}')
|
||||||
|
|
||||||
def _execute(self, goal_handle):
|
def _execute(self, goal_handle):
|
||||||
result = CtrlFJT.Result()
|
result = CtrlFJT.Result()
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ controller_manager:
|
|||||||
joint_state_broadcaster:
|
joint_state_broadcaster:
|
||||||
type: joint_state_broadcaster/JointStateBroadcaster
|
type: joint_state_broadcaster/JointStateBroadcaster
|
||||||
|
|
||||||
# Name aligned with Flexiv official bringup
|
# 名字与 Flexiv 官方 bringup 对齐
|
||||||
rizon_arm_controller:
|
rizon_arm_controller:
|
||||||
type: joint_trajectory_controller/JointTrajectoryController
|
type: joint_trajectory_controller/JointTrajectoryController
|
||||||
|
|
||||||
rizon_arm_controller:
|
rizon_arm_controller:
|
||||||
ros__parameters:
|
ros__parameters:
|
||||||
# Site robot Rizon4-063989 (official flexiv_description keeps hyphen)
|
# 站点机器人 Rizon4-063989(官方 flexiv_description 保留连字符)
|
||||||
joints:
|
joints:
|
||||||
- Rizon4-063989_joint1
|
- Rizon4-063989_joint1
|
||||||
- Rizon4-063989_joint2
|
- Rizon4-063989_joint2
|
||||||
@@ -26,8 +26,8 @@ rizon_arm_controller:
|
|||||||
- position
|
- position
|
||||||
- velocity
|
- velocity
|
||||||
allow_partial_joints_goal: false
|
allow_partial_joints_goal: false
|
||||||
# Must be false: true interpolates from the last *command* (already at target
|
# 必须为 false:若为 true,会从最后一次 *命令* 开始插值(在硬件没动
|
||||||
# after a "success" with no hardware motion) so the arm never moves.
|
# 却报"成功"之后,命令已经落在目标点上),于是机械臂永远不动。
|
||||||
open_loop_control: false
|
open_loop_control: false
|
||||||
constraints:
|
constraints:
|
||||||
stopped_velocity_tolerance: 0.05
|
stopped_velocity_tolerance: 0.05
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Named poses — site robot Rizon4-063989 (7-DOF), radians
|
# 命名位姿 — 站点机器人 Rizon4-063989(7 自由度),弧度
|
||||||
# Joint base names match flexiv_description: joint1..joint7
|
# 关节基础名与 flexiv_description 一致:joint1..joint7
|
||||||
named_pose_server:
|
named_pose_server:
|
||||||
ros__parameters:
|
ros__parameters:
|
||||||
joint_prefix: "Rizon4-063989_"
|
joint_prefix: "Rizon4-063989_"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Right arm 91 (Rizon4-063991) named poses (radians).
|
# 右臂 91(Rizon4-063991)的命名位姿(弧度)。
|
||||||
# Starting values match the generic home; teach cell-specific poses on 91.
|
# 起始值与通用 home 一致;在 91 上示教该工位专用的位姿。
|
||||||
named_pose_server:
|
named_pose_server:
|
||||||
ros__parameters:
|
ros__parameters:
|
||||||
joint_prefix: "Rizon4-063991_"
|
joint_prefix: "Rizon4-063991_"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from launch_ros.actions import Node
|
|||||||
from launch_ros.parameter_descriptions import ParameterValue
|
from launch_ros.parameter_descriptions import ParameterValue
|
||||||
from launch_ros.substitutions import FindPackageShare
|
from launch_ros.substitutions import FindPackageShare
|
||||||
|
|
||||||
# Site robot. Official flexiv_description keeps the hyphen in the SN prefix.
|
# 现场机械臂。官方 flexiv_description 要求 SN 前缀里**保留连字符**。
|
||||||
SITE_ROBOT_SN = os.environ.get('SITE_ROBOT_SN', 'Rizon4-063989')
|
SITE_ROBOT_SN = os.environ.get('SITE_ROBOT_SN', 'Rizon4-063989')
|
||||||
SITE_JOINT_PREFIX = os.environ.get('SITE_JOINT_PREFIX', f'{SITE_ROBOT_SN}_')
|
SITE_JOINT_PREFIX = os.environ.get('SITE_JOINT_PREFIX', f'{SITE_ROBOT_SN}_')
|
||||||
SITE_NAMED_POSES = os.environ.get('SITE_NAMED_POSES_FILE', '').strip()
|
SITE_NAMED_POSES = os.environ.get('SITE_NAMED_POSES_FILE', '').strip()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user