diff --git a/config/site.example.yaml b/config/site.example.yaml index 39dff64..2dc0aee 100644 --- a/config/site.example.yaml +++ b/config/site.example.yaml @@ -26,6 +26,7 @@ robot: # >>> 切换机械臂:改成 robots 里某一台的完整 SN <<< sn: "Rizon4-XXXXXX" # Studio Tools 名(一字不差)。空=不切换。查:bash ~/ros2/scripts/rdk_switch_tool.sh --list + # 左臂 89 的激活工具写在 robots[] 里(tool10120938);这里只给未覆盖的臂当缺省。 tool_name: "tool1" # 每台登记 SN + IP,以及该臂自己的相机/夹爪/手眼/点位(覆盖下面的全局段)。 robots: @@ -33,11 +34,13 @@ robot: arm: left ip: "192.168.2.XXX" pc_ip: "192.168.2.XXX" - iface: "enxXXXXXXXXXXXX" + iface: "eth0" label: "左臂89" # 本臂装了 Orisys 视触觉(只有左臂有)。脚本靠这一项决定要不要拉触觉服务, # 而不是靠"SN 等于某个具体值"这种写死的判断(换台臂就会失效)。 tactile: true + # 2026-10-12 示教的工具 TCP(tool + 1012 + 0938)。眼在手上 AX=XB 用这份 TCP。 + tool_name: "tool10120938" camera: name: d405_left # 话题 /d405_left/... 与 91 分开 serial: "<序列号>" # 左腕 D405(一直在用的那台) @@ -63,6 +66,11 @@ robot: handeye_file: "/home/USER/ros2/handeye/results/89_eye_in_hand.yaml" handeye_waypoints: "/home/USER/ros2/handeye/poses/89_eye_in_hand.yaml" handeye_workdir: "/home/USER/ros2/handeye/tmp/89" + handeye: + # Studio tcp_location(法兰系,米 + qwxyz)。与 rdk Tool.params 必须一致。 + gripper_tool: "tool10120938" + gripper_tcp_xyz: [-0.0005, 0.0067, 0.2245] + gripper_tcp_qwxyz: [1.0, 0.0, 0.0, 0.0] # 左臂跟踪高度(只在 --arm 89 时生效,改 91 的 servo 不影响这里) # 锚点 `&servo_left`:下面的"仿真台"条目直接引用它 —— 仿真建的就是左臂, # 悬停/锁高必须与 89 **一模一样**,抄两份迟早漂移。 @@ -70,8 +78,10 @@ robot: # 2026-09-29 底座加高后由 0.35 → 0.36:工件上表面实测抬高 10mm # (0.012 → 0.022),锁高同步 +0.011 ⇒ 相机到工件的视距**与加高前一致** # (≈0.213m,跟踪那一档已实拍验证过检出稳定),不是随手抬的。 - # 规矩:ee_z_m 要高于 servo.hole_hover_z_m(二阶段得往**下**走,不能往上返)。 - ee_z_m: 0.36 # 跟踪锁高:法兰相对基座 Z [m] + # 跟踪锁高与 hole_hover_z_m 相同:先到这个悬高,一阶段只动 XY。 + # 对孔完成后再下降。以前 ee_z 高于悬停(0.36),对孔前会先降一截, + # 倾斜相机下孔位被重投影,偏差变大。 + ee_z_m: 0.320 # 跟踪锁高:法兰相对基座 Z [m],与 hole_hover_z_m 同一值 min_ee_z_m: 0.20 safe_z_m: 0.45 z_min_m: 0.22 @@ -276,7 +286,7 @@ robot: arm: right ip: "192.168.2.XXX" pc_ip: "192.168.2.XXX" - iface: "enxXXXXXXXXXXXX" + iface: "eth0" label: "右臂91" camera: name: d405_right # 话题 /d405_right/... 禁止和 89 的 /d405_left 混用 @@ -356,9 +366,9 @@ handeye: # 内角点 = 方格数 − 1;列/行顺序不用纠结,检测端两种顺序都会试 #(见 handeye_lib.detect_chessboard)。 boards: - eye_in_hand: # A5 板:9×12 方格 - cols: 8 # 内角点列数 - rows: 11 # 内角点行数 + eye_in_hand: # A5 板:12×9 方格 → 内角点 11×8 + cols: 11 # 内角点列数 + rows: 8 # 内角点行数 square_size_m: 0.015 # 方格边长 [m] eye_to_hand: # A4 板:12×9 方格 cols: 11 @@ -579,9 +589,10 @@ servo: deadzone_m: 0.003 # 3 mm;收紧提高对准精度。若抖动调回 0.006 deadzone_z_m: 0.012 lock_hold_sec: 0.7 # 旧死区驻留(混合模式不再用它进二阶段) + # D405 跟住后,法兰 XY 连续这么久位移不超过 lock_still_move_m → /visual_servo/locked + # 顶视一次性把臂迁过去的那段路不算:必须是腕部有效目标。大幅一动就重计时。 lock_still_sec: 3.0 - lock_still_move_m: 0.015 - + lock_still_move_m: 0.015 # 15 mm;超过就重计时,避免"晃一下刚好满 3 秒" e_xy_clip_m: 0.200 # 大误差仍按 v_max 走,靠 a_max 加速 v_max_xy: 0.45 # 法兰 XY 上限 450 mm/s v_max_z: 0.04 @@ -732,7 +743,9 @@ hole_detect: # 是拿法兰对的孔)。收到 0.25mm 才逼它必须真的对上规尖。 align_max_iters: 12 # 拍照→修正→复拍 迭代上限(收紧容差后需要更多拍) align_tip_vision: true # true=精对准用「视觉规尖」;false=退回旧的「法兰+偏置」 - tip_near_m: 0.15 # 规尖量取:近端阈值,规比这更近 [m] + tip_near_m: 0.15 # 规尖量取:无工件面深度时的近端上限 [m]; + # 精对准同一帧有 z_wp 时改用「工件面 − 15mm」 + # (3mm 挡不住噪声,会把工件面当成规尖) tip_min_m: 0.04 # 有效深度下限 [m] tip_max_m: 0.60 # 有效深度上限 [m] tip_search_band_frac: 0.25 # 中央竖带半宽 = 该比例 × 图宽(规夹在爪里,不会靠边) @@ -752,7 +765,10 @@ hole_detect: # —— 柔顺运动(夹爪持规,防急加速/急停/过冲把规震脱):速度放慢 + 平滑刹车 —— align_speed: 0.014 # 对准段笛卡尔速度 [m/s](原0.03,放缓防冲) move_speed: 0.022 # 升降/粗居中段速度 [m/s](原0.05,放缓防震规) - final_gap_mm: 2.0 # 对准后轻降目标:规尖悬在孔面上方这么高 [mm](不接触;安全下限1.2mm自动停) + final_gap_mm: 2.0 # 对准后贴面轻降目标:规尖悬在孔面上方这么高 [mm](不接触) + observe_gap_mm: 15.0 # --no-descent 时的观测下降:只降到离面这么高 [mm], + # 方便看规尖偏没偏。必须 ≥ final_gap_mm。 + # 左臂89:悬停间隙约 75mm(0.320−0.223−0.022)⇒ 大约下降 60mm。 final_descent_speed: 0.005 # 最后轻降速度 [m/s](很慢,柔顺) # ✅ 2026-10-08 定稿:下降量**只由几何决定**(法兰 FK − 工具长 − 工件面 − final_gap), # 深度读数只做交叉核对、不再参与任何决策。两段真机事故方向相反、都指向这条: @@ -769,7 +785,7 @@ hole_detect: # 三道**物理/几何**保险:descent_budget_m(配置上限)+ 腕部 FT 接触检测(停+退 1mm) # + 任一步 _goto 走不到位就停。 # 缺几何(工具长/工件面没配)时**拒绝下降**并报错(保持悬停),绝不拿测距去赌。 - final_descent_enable: true # 对准后是否轻降到 final_gap_mm(false=停在悬停高) + final_descent_enable: true # true=对准后降到 final_gap_mm;false / --no-descent=只降到 observe_gap_mm verticalize_tol_deg: 0.8 # 垂直朝下判据 [deg] settle_sec: 0.45 # 到位后静止多久再拍照 snap_frames: 5 # 每次精拍取中值的帧数 @@ -842,22 +858,26 @@ hole_detect: # ============================================================================= perception: source: "yolo" - # 一阶段:wrist=D405 主跟踪,画面内无 conf≥wrist_handoff_conf 则 D435 一次性定 K0。 - # overhead=整段只用顶视(实验)。非法值启动即报错。 + # 一阶段跟踪用哪台相机。wrist=腕部 D405(主跟踪)+ 顶视 D435 一次性兜底; + # overhead=整段只用 D435(实验分支才切)。非法值启动即报错,不会静默退回。 track_camera: "wrist" + # 画面内最高检测 conf 低于此 → 马上拍一张 D435,把工件 / k0 定到基座系。 + # 与 perception / servo 共用这一处,不许两边各写一个 0.5。 wrist_handoff_conf: 0.5 + # 顶视深度量程 + 一次性参数。腕部那组 0.04–0.55m 会把 D435 的点全丢掉。 + # 缺键启动即红,不许借腕部量程、不许用代码默认冷却时间。 overhead: min_depth_m: 0.25 max_depth_m: 1.80 feature_min_range_m: 0.30 work_z_max_m: 1.50 z_ref_m: 0.70 - feature_mode: "plane" - hold_point_on_miss: true - oneshot_conf: 0.5 - cooldown_s: 1.5 - frame_max_age_s: 0.4 - oneshot_hold_s: 8.0 + feature_mode: "plane" # 顶视与腕部视角差太大,pose 的 k0 经常看不见;优先 k0,否则框心 + hold_point_on_miss: true # 仅 overhead 整段跟踪时用;混合模式一次性不续发 + oneshot_conf: 0.5 # D435 这一张也必须 conf≥此值,不许拿噪声框当 K0 + cooldown_s: 1.5 # 两次一次性之间的最短间隔,避免跟 D405 抢同一条 YOLO 队列 + frame_max_age_s: 0.4 # 彩图/深度比这更旧就拒绝,不许拿过期图反投影 + oneshot_hold_s: 8.0 # 伺服保持这一次设定的最长时间;超时丢掉,等下一张或 D405 truth: # 真值目标物体。留空 = 场景文件里的 operating.part_object(推荐留空: # "哪个是工件"是场景的知识,换场景不该改这里)。 @@ -1089,10 +1109,11 @@ go_home: network: # 2026-09-29 改线:两台臂 → 交换机 → USB-C 千兆网卡(ASIX AX88179) - # enxXXXXXXXXXXXX = 该 USB 网卡(名字由网卡 MAC 派生,换网卡会变) + # MAC 6c:1f:f7:d0:6f:17。内核现在叫 eth0(不再用 enxXXXXXXXXXXXX)。 # 本机 ↔ <左臂SN>(左89)=.101 / <右臂SN>(右91)=.100 + # NM 配置名 robot-usb-lan(静态 .10/24,不当默认路由)。 # 说明:enp4s0 仍是办公网(192.168.1.x);两台臂已不在 enp4s0 上。 - prefer_iface: "enxXXXXXXXXXXXX" + prefer_iface: "eth0" # 仿真平台(当前 Gazebo 尚未接线,详见 devices/sim/gazebo/README.md) # 设计约束:仿真与真机共用同一套参数(servo.* / hole_detect.stage2.*), diff --git a/docs/HANDBOOK.md b/docs/HANDBOOK.md index 3e56955..dd796bf 100644 --- a/docs/HANDBOOK.md +++ b/docs/HANDBOOK.md @@ -68,10 +68,26 @@ bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview # T2 相机预览, bash ~/ros2/scripts/run_all.sh --arm 89 # T3 三段串联(默认不拧入) ``` -一阶段默认 **D405 主跟踪**:画面里没有 conf≥`perception.wrist_handoff_conf`(0.5)的目标, -马上用顶视 D435 **拍一张** 把工件 / k0 定到基座系(眼在手外 T,不再乘法兰),把臂迁过去 -让 D405 重新看见。D405 跟住后,左臂法兰 XY 连续 `servo.lock_still_sec`(3 s)位移不超过 -`lock_still_move_m`(15 mm)→ `/visual_servo/locked` → 二阶段对孔。 +一阶段默认 **D405 实时推理**,D435 待命。连续 `D405_MISS_BEFORE_D435_S`(0.5 s)没有 +conf≥`perception.wrist_handoff_conf`(0.5)的目标,才用顶视 D435 **拍一张** 把工件 / k0 +定到基座系(眼在手外 T,不再乘法兰),左臂按二阶段同一套 **`FollowJointTrajectory` action** +先收齐锁高,再把全部 XY 串成一条关节折线,余弦加速(100 mm)/ +巡航封顶 60 mm/s / 余弦减速(80 mm),头尾速度为 0。中途不重测、不停住。 +40 mm 爬到 80 mm/s 会在真机上像弹射,那是加速段太短,不是初速度或角速度。 +工件上方(剩差 ≤80 mm)巡航改 30 mm/s,只走 XY,不混 Z、不夹收垂直。 +结果 + 0.25 s 再重测 TF(带符号 `dxy`)。冻结 J 若还剩 >15 mm,只允许 **一刀补** +(`tag=fix`)。XY 进 15 mm 门槛后再单独收法兰朝下。结果回来之前不往话题插点。 +到位成功不再发话题 hold。几乎没走近就钉住,不许按同一剩差再刷。 +走到该点并且法兰 XY 连续 `servo.lock_still_sec`(3 s)位移不超过 +`lock_still_move_m`(15 mm)之前,D405 的点不下发(推理不关)。停稳之后改回 D405 +跟踪;再按同一静止判据 → `/visual_servo/locked` → 二阶段对孔。 +只测 D435 这一段位移时加 `--d435-only`:D405 点不下发,到位停稳后钉住并结束一阶段, +不交 D405、不进二阶段。眼外标定误差只会让冻结点整体偏一截,不会造成中途启停; +启停来自以前按 80/15 mm 一跳一跳 rest-to-rest。 + +```bash +bash ~/ros2/scripts/run_all.sh --arm 89 --no-camera --d435-only +``` ```bash bash ~/ros2/scripts/run_real.sh --arm 89 @@ -93,7 +109,7 @@ bash ~/ros2/scripts/run_visual_servo_rt.sh --arm 89 --control | 按螺距旋入(螺纹规) | ✅ 有参数,`--depth`/`--pitch` 控制 | `run_stage3_search.sh --arm 89 --depth 6 --pitch 1.0` | | Orisys 双指触觉(法向 + 两路切向) | ⚠️ 能读,但**轴向映射尚未实测确认** | 见 [`TACTILE.md`](TACTILE.md) | | Orisys 的深度图 / 力矢量图 | ⚠️ 消息结构与话题已在,真机值待确认 | 同上 | -| D435 手外相机(一阶段一次性 + S03) | ⚠️ **89 已标定**;一阶段 D405 看不见时拍一张定 K0(混合跟踪);S03 粗定位真机未对过 | `handeye/results/89_d435_eye_to_hand.yaml` | +| D435 手外相机(一阶段一次性 + S03) | ⚠️ 89 已按 `tool10120938` 重标(24 视角,10-09 旧结果在 `results/backup/`);S03 粗定位真机未对过 | `handeye/results/89_d435_eye_to_hand.yaml` | | 自动启动全部服务(一条命令拉起所有进程) | ❌ 还没做,目前靠三个终端手工起 | 见第 6 节待办 | | 「规有没有被夹住」自检 | ❌ 还没做 | 见第 6 节待办 | | 装配合格判定(通/止) | ⚠️ **通止规装配流程**能按力矩判据拧到底/拧出来,但"通/止合格"结论还没做 | `run_gauge_assembly.sh --arm 89 --dry-run` | @@ -485,7 +501,7 @@ bash scripts/run_unit_tests.sh -k gauge # 只跑名字带 gauge 的 |----|------| | **通止规** | 检验螺纹的量具。通端应能旋到底,止端应旋不进(或只进几圈) | | **89 / 91** | 左臂 / 右臂的现场编号,**各有独立的手眼、相机、夹爪、锁高,禁止混用** | -| **法兰** | 机械臂末端安装面;手眼标定求的就是相机→法兰的 `T_4x4` | +| **法兰** | 机械臂末端安装面;手眼 `T_4x4` 仍是相机→法兰。左臂 89 现行按 Studio `tool10120938` 的 TCP 求解,再折回法兰 | | **eye-in-hand / eye-to-hand** | 相机装在手上 / 装在手上之外(D405 在手上,D435 在外部) | | **stage1/2/3** | 跟踪 / 对孔 / 搜孔,见 1.3 | | **S03 / S04 / S07 / S08** | 整体装配任务书里的阶段编号(S03 粗定位、S04 跟踪、S07 柔顺下压、S08 夹爪旋转循环)。代码里对应的是 stage1/2/3 的拆分实现,**没有一一对应**,别硬套 | @@ -501,7 +517,7 @@ bash scripts/run_unit_tests.sh -k gauge # 只跑名字带 gauge 的 且顺序错了会出"控制器没起来但脚本继续跑"这类问题。 2. **夹爪持规自检** —— 启动后先用触觉判断"规真的被夹着",没有就报错停机 (现在没有这一步,规掉了会一路跑到撞工件才发现)。 -3. **D435 手眼标定(91)** —— 89 已写入 `handeye/results/89_d435_eye_to_hand.yaml`(18 视角)。91 还没标。S03 粗定位要用这份 TF 在真机上对一次工件才算链路验过。 +3. **D435 手眼标定(91)** —— 89 按 `tool10120938` 重标(旧 10-09 / 18 视角在 `handeye/results/backup/`)。91 还没标。S03 粗定位要用这份 TF 在真机上对一次工件才算链路验过。 4. **装配合格判定** —— 把力曲线转成"通/止"结论。当前只输出原始几何与力, 判定逻辑(含螺纹旋入圈数)还没写。 diff --git a/docs/HANDEYE.md b/docs/HANDEYE.md index 87ec40f..218f3b5 100644 --- a/docs/HANDEYE.md +++ b/docs/HANDEYE.md @@ -4,8 +4,8 @@ | 模式 | 相机 | 板 | 板怎么摆 | 结果文件 | `T_4x4` 含义 | |------|------|----|----------|----------|--------------| -| `eye_in_hand` | 腕部 D405 | A5,内角点 8×11,15 mm | 板放台面不动 | `handeye/results/<臂号>_eye_in_hand.yaml` | 相机 → 法兰 | -| `eye_to_hand` | 顶置 D435 | A4,内角点 11×8,20 mm | **板装到法兰上** | `handeye/results/<臂号>_d435_eye_to_hand.yaml` | 相机 → 这条臂的基座 | +| `eye_in_hand` | 腕部 D405 | A5,内角点 11×8,15 mm | 板放台面不动 | `handeye/results/<臂号>_eye_in_hand.yaml` | 相机 → 法兰(89 按 `tool10120938` TCP 求解后再折算) | +| `eye_to_hand` | 顶置 D435 | A4,内角点 11×8,20 mm | **板夹在 `tool10120938` 上** | `handeye/results/<臂号>_d435_eye_to_hand.yaml` | 相机 → 这条臂的基座 | | 臂 | 眼在手上结果 | 眼在手外结果 | 示教关节 | 工作目录 | |----|--------------|--------------|----------|----------| @@ -18,9 +18,19 @@ ## 眼在手上(腕部 D405 + A5 板) +左臂 89 现行工具是 Studio **`tool10120938`**(2026-10-12 示教的 TCP)。 +`run_real.sh --arm 89` 会切到这个工具;标定用 `/tcp_pose`(即该 TCP)做 +AX=XB,再左乘 `T_flange_to_tool` 折成 **相机 → 法兰** 写入 `T_4x4` +(伺服 / 静态 TF 的父系仍是法兰)。旧的 2026-09-04 / tool1 结果在 +`handeye/results/backup/89_eye_in_hand_flange_tool1_20260904.yaml`。 + `teach`(RDK 软拖拽)与 `run_real.sh` **互斥**。全程带同一 `--arm`。 +板:A5,内角点 **11×8**,方格 15 mm,放台面后不要动。 ```bash +# 0) 确认配置(应看到 tool10120938 与 11x8 / 0.015 m) +bash ~/ros2/scripts/run_handeye.sh --arm 89 show + # 1) 停掉 run_real.sh 和视觉伺服 # 2) 示教 8–12 个姿态(棋盘清晰、占画面 30–70%,姿态拉开) bash ~/ros2/scripts/run_handeye.sh --arm 89 teach @@ -30,7 +40,7 @@ bash ~/ros2/scripts/run_handeye.sh --arm 89 teach # 3) 写入该臂 handeye/poses/89_eye_in_hand.yaml bash ~/ros2/scripts/run_handeye.sh --arm 89 merge -# 4) 再开实机 + 腕部相机,棋盘不要动 +# 4) 再开实机(会切到 tool10120938)+ 腕部相机,棋盘不要动 bash ~/ros2/scripts/run_real.sh --arm 89 bash ~/ros2/scripts/run_camera_d405.sh --arm 89 --preview bash ~/ros2/scripts/run_handeye.sh --arm 89 calibrate @@ -43,32 +53,36 @@ bash ~/ros2/scripts/run_handeye.sh --arm 89 calibrate 物理摆法与上面**反过来**:相机固定,板跟着臂动。板若还摆在台面上, `target→camera` 不变,方程退化(解不出来或解出垃圾)。 +左臂 89 的“手”同样是 **`tool10120938`**:把 A4 板**刚性夹在该工具的夹爪里** +(夹正、标定过程不要松)。`run_real.sh` 会切到这个工具,AX=XB 用 `/tcp_pose`。 +`T_4x4` 仍是 **相机 → 这条臂的基座**,**不要**再乘 `T_flange_to_tool` +(相机和基座都在世界里不动)。2026-10-09 那份在 +`handeye/results/backup/89_d435_eye_to_hand_20261009.yaml`。 + +板:A4,内角点 **11×8**,方格 20 mm(12×9 格)。印面朝上对着 D435。 + ```bash -# 1) 把 A4 板刚性固定在法兰 / 夹爪上(夹在正中间,标定过程不要松) -# 2) 起臂 + 顶置相机(两个终端,常驻) -bash ~/ros2/scripts/run_real.sh --arm 89 -bash ~/ros2/scripts/run_camera_d435.sh --preview +# 0) 确认配置(应看到 d435、11x8 / 0.02 m、89_d435_eye_to_hand.yaml、tool10120938) +bash ~/ros2/scripts/run_handeye.sh --arm 89 show --mode eye_to_hand -# 3) 采点:推着臂走,让板在 D435 画面里出现在不同位置、不同角度 -# teach 与 run_real.sh 互斥 —— 用 RDK 拖拽前先停掉 run_real.sh +# 1) 夹好 A4 板。停掉 run_real.sh、视觉伺服、腕部 D405(RDK + USB 独占) +# 2) 示教 8–12 个姿态:板在 D435 画面里换位置、换倾角,占画面 30–70% bash ~/ros2/scripts/run_handeye.sh --arm 89 teach --mode eye_to_hand --points 12 +# 窗口:d=拖拽 Enter=拍照 q=结束(窗口只显示画面,不做棋盘检测) -# 4) 合并 + 自动走点求解(此时再开 run_real.sh + D435) +# 3) 写入 handeye/poses/89_eye_to_hand.yaml(不要手抄眼在手上那份) bash ~/ros2/scripts/run_handeye.sh --arm 89 merge --mode eye_to_hand + +# 4) 再开实机(会切到 tool10120938)+ 顶置 D435,板不要松 bash ~/ros2/scripts/run_real.sh --arm 89 bash ~/ros2/scripts/run_camera_d435.sh --preview bash ~/ros2/scripts/run_handeye.sh --arm 89 calibrate --mode eye_to_hand ``` -查看当前模式选中的板 / 相机 / 文件: +`show` 必须同时看到 `camera=d435`、`board 11x8 inner, square=0.02 m`、 +`89_d435_eye_to_hand.yaml`、`tool10120938`。不是这四样就停。 -```bash -bash ~/ros2/scripts/run_handeye.sh --arm 89 show --mode eye_to_hand -``` - -应看到 `camera=d435`、`board 11x8 inner, square=0.02 m`、 -结果路径 `handeye/results/89_d435_eye_to_hand.yaml`。不是这三样就停, -不要带着错配置开标定。 +**不要**改 `paths.handeye_file` 去指向这份结果 —— 那会把腕部 D405 的 TF 换掉。 ## 结果怎么用 diff --git a/handeye/README.md b/handeye/README.md index 561478d..20b3ce9 100644 --- a/handeye/README.md +++ b/handeye/README.md @@ -65,8 +65,8 @@ handeye/ | 模式 | 物理板 | 内角点(列 × 行) | 方格边长 | `site.yaml` 里的位置 | |------|--------|-------------------|----------|----------------------| -| `eye_in_hand` | A5(腕部相机看台面) | 8 × 11 | 15 mm (`0.015`) | `handeye.boards.eye_in_hand` | -| `eye_to_hand` | A4(板装在法兰上) | 11 × 8 | 20 mm (`0.020`) | `handeye.boards.eye_to_hand` | +| `eye_in_hand` | A5(腕部相机看台面) | 11 × 8 | 15 mm (`0.015`) | `handeye.boards.eye_in_hand` | +| `eye_to_hand` | A4(板夹在 `tool10120938` 上) | 11 × 8 | 20 mm (`0.020`) | `handeye.boards.eye_to_hand` | 权威只有 `config/site.yaml` 的 `handeye.boards.<模式>`。`run_handeye.sh` 按 `--mode` 取对应那一组,用 `-p` 传给节点。`handeye/calibrate.yaml` 和节点默认值 @@ -74,8 +74,8 @@ handeye/ 尺寸去标(那会让结果系统性偏,还不报错)。 怎么看内角点:把棋盘当成一张网格,**数内部的黑白交叉点**, -是「方格数 − 1」。A5 板上画了 9×12 个格子 → 内角点 8×11; -A4 板上画了 12×9 个格子 → 内角点 11×8。列/行顺序不用纠结,检测端两种顺序都会试。 +是「方格数 − 1」。A5 板上画了 12×9 个格子 → 内角点 11×8; +A4 板上画了 12×9 个格子 → 内角点 11×8(方格 20 mm)。列/行顺序不用纠结,检测端两种顺序都会试。 > ⚠️ **历史上这里出过问题**:曾经三处分别写着 `9×6/0.025`、`11×16/0.011`、 > `8×11/0.015`,谁生效取决于覆盖顺序。后来又把两块板合成一组键,于是标眼在手外 diff --git a/handeye/calibrate.yaml b/handeye/calibrate.yaml index 592d112..dd1730f 100644 --- a/handeye/calibrate.yaml +++ b/handeye/calibrate.yaml @@ -49,11 +49,11 @@ handeye_calibrate: camera_dist: [0.0, 0.0, 0.0, 0.0, 0.0] # 板尺寸的权威在 site.yaml → handeye.boards.<模式>。 - # 这里只写眼在手上那块板的兜底(A5 / 8×11 / 15mm);run_handeye.sh 会按 + # 这里只写眼在手上那块板的兜底(A5 / 11×8 / 15mm);run_handeye.sh 会按 # --mode 用 -p 覆盖成对应模式的值。没配过的模式会被脚本当场拦下, # 不会静默用这份兜底去标另一块板。 - board_cols: 8 - board_rows: 11 + board_cols: 11 + board_rows: 8 square_size_m: 0.015 settle_sec: 0.4 diff --git a/handeye/poses/89_eye_in_hand.yaml b/handeye/poses/89_eye_in_hand.yaml index 57c3e1d..c4642b3 100644 --- a/handeye/poses/89_eye_in_hand.yaml +++ b/handeye/poses/89_eye_in_hand.yaml @@ -8,31 +8,49 @@ /**: ros__parameters: - num_poses: 12 + num_poses: 21 # 2026-09-02 通过 handeye_teach 示教(RDK 自由驱动) waypoint_positions: [ - # 位姿 0 - -0.751865, -0.769544, 0.713051, 1.140021, -0.189690, 0.133294, -0.342184, - # 位姿 1 - -0.541318, -0.867448, 0.864868, 1.233428, -0.348623, 0.146046, -0.249719, - # 位姿 2 - -0.343594, -0.948720, 0.848287, 1.273279, -0.954339, 0.019624, 0.071314, - # 位姿 3 - -0.379884, -1.002193, 1.166394, 1.579194, -1.097644, -0.115142, -0.347640, - # 位姿 4 - -0.694826, -0.943610, 0.820178, 0.963907, -0.427763, 0.165888, -0.761562, - # 位姿 5 - -0.964023, -1.025531, 1.013898, 1.032490, -0.167151, 0.017429, -0.566073, - # 位姿 6 - -0.812664, -0.984951, 1.072695, 1.473414, -0.772970, 0.267382, 0.401278, - # 位姿 7 - -0.967527, -1.067191, 1.101680, 1.434183, -0.915388, 0.259486, 0.205330, - # 位姿 8 - -0.451483, -1.257408, 0.895481, 0.806594, -0.652401, -0.221453, 0.184234, - # 位姿 9 - -0.981877, -1.415295, 1.339004, 1.131348, -0.863194, 0.170952, 1.615403, - # 位姿 10 - -0.967163, -1.377051, 1.337671, 1.399175, -1.241886, 0.284547, 1.927756, - # 位姿 11 - -0.575271, -1.141574, 1.121935, 1.502172, -1.452029, 0.088544, 2.200019, + # pose 0 + -0.928421, -1.237477, 1.528823, 1.526334, -1.230146, 0.098562, 0.538531, + # pose 1 + -0.970203, -1.315554, 1.553230, 1.669383, -1.407869, 0.125907, 0.578173, + # pose 2 + -0.967819, -1.525572, 1.706057, 1.357062, -1.211051, 0.023232, 0.193541, + # pose 3 + -1.065106, -1.624218, 1.793644, 1.279402, -1.120929, 0.019151, 0.139658, + # pose 4 + -0.386279, -1.551838, 1.563578, 1.552444, -1.775337, -0.341974, 0.957445, + # pose 5 + -0.227779, -1.219414, 1.026882, 0.828801, -0.916809, -0.166985, 0.034154, + # pose 6 + -0.324968, -1.273082, 1.092344, 0.723132, -0.922095, -0.206413, 0.152082, + # pose 7 + -0.421966, -1.309101, 1.143065, 0.606456, -0.906673, -0.219282, 0.237953, + # pose 8 + -0.851598, -1.260600, 1.302895, 1.127033, -0.969809, 0.032856, 0.642673, + # pose 9 + -0.951418, -0.550124, 0.848598, 2.139787, -1.025778, 1.184418, 0.598394, + # pose 10 + -0.834498, -0.635767, 0.970038, 2.058410, -1.035138, 1.032040, 0.738619, + # pose 11 + -0.519528, -0.883321, 1.135035, 1.830274, -1.100901, 0.234065, -0.069068, + # pose 12 + -0.347358, -1.081426, 1.192233, 1.514557, -1.244594, -0.116668, -0.564362, + # pose 13 + -0.364832, -1.102689, 1.224844, 1.520320, -1.265760, -0.136855, -0.582459, + # pose 14 + -1.130796, -1.316186, 1.711089, 1.821409, -1.278003, 0.010885, -1.337210, + # pose 15 + -0.732222, -1.376363, 1.812339, 1.293854, -1.410826, -0.226771, -0.217230, + # pose 16 + -0.885017, -1.493416, 1.894070, 1.379021, -1.520015, -0.257115, 1.025331, + # pose 17 + -0.911769, -1.532695, 1.944123, 1.427787, -1.560513, -0.267425, 1.004398, + # pose 18 + -1.436271, -1.768595, 1.559397, 2.244717, -2.057112, 0.341785, 0.749883, + # pose 19 + -1.511828, -1.759337, 1.620075, 2.131574, -1.946595, 0.328227, 0.584657, + # pose 20 + -1.180482, -1.643241, 1.865851, 1.597699, -1.464982, 0.097605, -0.217780, ] diff --git a/handeye/poses/89_eye_to_hand.yaml b/handeye/poses/89_eye_to_hand.yaml index 4c2cb72..b2261ea 100644 --- a/handeye/poses/89_eye_to_hand.yaml +++ b/handeye/poses/89_eye_to_hand.yaml @@ -1,4 +1,4 @@ -# 眼在手外示教关节(7 关节 × N)。板装在法兰上,顶置 D435 看它。 +# 眼在手外示教关节(7 关节 × N)。板夹在 tool10120938 上,顶置 D435 看它。 # # 由 teach → merge 写入,不要手抄眼在手上那份(两种模式的姿态完全不同)。 # @@ -7,44 +7,54 @@ /**: ros__parameters: - num_poses: 19 + num_poses: 24 waypoint_positions: [ # pose 0 - -1.045969, -0.575935, 0.132913, 2.251024, -0.297265, 1.274437, -0.219817, + -1.241563, -0.359631, 0.346153, 2.172565, -0.179027, 0.929920, -0.778876, # pose 1 - -1.221272, -0.582225, 0.269777, 2.060852, -0.180051, 1.157081, -0.070498, + -1.388832, -0.554698, 0.416075, 1.897617, -0.358341, 0.827495, -0.762302, # pose 2 - -1.222672, -0.605722, 0.273858, 2.111021, -0.181634, 1.193047, -0.059758, + -1.436048, -0.573046, 0.461762, 1.870967, -0.387696, 0.813090, -0.762917, # pose 3 - -1.219127, -0.576923, 0.281368, 2.310613, -0.162188, 1.339573, -0.026065, + -1.614491, -0.519365, 0.693991, 2.156193, -0.499113, 0.968762, 0.073287, # pose 4 - -1.100853, -0.552692, 0.291246, 2.473326, -0.116403, 1.504852, 0.051283, + -2.020087, -0.869088, 1.059166, 2.155478, -1.198473, 0.740567, 0.478840, # pose 5 - -1.022050, -0.394038, 0.375788, 2.311711, -0.503617, 1.217768, 1.268088, + -2.082145, -1.122210, 1.203011, 1.950272, -1.269847, 0.434138, -0.641975, # pose 6 - -1.180556, -0.574370, 0.471095, 2.055400, -0.490664, 1.086456, 1.216527, + -1.983153, -0.886082, 0.999329, 2.324821, -1.272773, 0.754127, 0.313613, # pose 7 - -2.039707, -0.492556, 1.462952, 2.222064, -0.329990, 0.209789, 0.523258, + -2.021479, -0.886609, 1.055679, 2.337716, -1.282773, 0.669319, -0.028355, # pose 8 - -1.959748, -0.554596, 1.469405, 2.293419, -0.165008, 0.422761, 0.844081, + -2.022792, -0.947211, 1.118194, 2.127275, -1.211995, 0.527528, -0.169900, # pose 9 - -1.666347, -0.747468, 1.236192, 2.176000, -0.303646, 0.773895, 1.279040, + -2.004766, -1.065820, 1.183799, 1.901084, -1.217488, 0.380285, -0.312733, # pose 10 - -1.689100, -0.879982, 1.168013, 2.057620, -0.424417, 0.752234, 1.241290, + -1.942863, -1.219617, 1.256733, 1.653113, -1.222302, 0.261818, -0.420706, # pose 11 - -1.691875, -0.889908, 1.154014, 2.062930, -0.549154, 0.850985, 1.257704, + -2.039558, -1.082441, 1.189833, 1.996967, -1.241252, 0.521591, 0.526563, # pose 12 - -1.715420, -0.986765, 1.125361, 1.948461, -0.638012, 0.809355, 1.173290, + -2.098074, -1.006236, 1.538183, 2.118382, -0.737422, 0.019903, 0.648802, # pose 13 - -1.729057, -1.004106, 1.126932, 1.961482, -0.727920, 0.900058, 1.188069, + -2.039609, -1.159222, 1.488501, 1.908579, -1.168793, -0.140451, 0.034889, # pose 14 - -2.034166, -0.748356, 1.234076, 2.252718, -1.542249, 0.093972, 0.640260, + -1.880783, -1.104601, 1.579382, 2.036427, -0.665049, 0.254105, 1.518504, # pose 15 - -2.155450, -0.769613, 1.249491, 2.275528, -1.648348, 0.073256, 0.495109, + -2.015530, -1.535468, 1.711220, 1.836908, -1.707450, -0.400234, -0.708683, # pose 16 - -1.959788, -0.979802, 1.271261, 2.222882, -1.560991, 0.057204, 0.296625, + -1.997672, -1.626362, 1.529443, 2.200206, -1.291013, 0.398664, 1.954208, # pose 17 - -1.933247, -0.398944, 1.211885, 2.312756, -0.424981, 0.358678, 0.347203, + -1.981875, -1.620651, 1.545443, 2.373140, -1.167365, 0.396487, 2.012547, # pose 18 - -1.944530, -0.399220, 1.244635, 2.293245, -0.423596, 0.317991, 0.358565, + -2.179415, -1.511968, 1.578832, 2.401706, -1.326480, -0.051353, 1.137181, + # pose 19 + -2.173463, -1.530413, 1.575534, 2.173673, -1.289673, -0.090117, 0.867214, + # pose 20 + -2.096314, -1.569352, 1.579820, 1.942057, -1.458598, -0.057003, 0.572953, + # pose 21 + -2.129376, -1.566787, 1.903585, 1.962858, -1.454137, -0.335990, -0.148441, + # pose 22 + -2.020280, -1.527165, 1.887130, 1.728957, -1.491867, -0.370585, -0.277940, + # pose 23 + -2.140817, -1.607357, 1.617236, 2.153850, -1.571057, -0.147540, 0.770535, ] diff --git a/handeye/results/89_d435_eye_to_hand.yaml b/handeye/results/89_d435_eye_to_hand.yaml index 71f826a..8039d08 100644 --- a/handeye/results/89_d435_eye_to_hand.yaml +++ b/handeye/results/89_d435_eye_to_hand.yaml @@ -1,13 +1,22 @@ # 手眼标定结果 # mode: eye_to_hand # camera_source: d435 -# views: 18 -# utc: 2026-10-09T02:56:24.621687+00:00 +# views: 24 +# utc: 2026-10-12T05:20:30.137123+00:00 # board: 11x8 square_m=0.02 -# T_cam_to_base (eye_to_hand) -translation_m: [0.37495201, -0.52156937, 0.72589194] -T_4x4: - - [-0.04575158, 0.99819969, -0.03878355, 0.37495201] - - [0.99893621, 0.04594039, 0.00399073, -0.52156937] - - [0.00576527, -0.03855971, -0.99923967, 0.72589194] +# gripper_tool: tool10120938 +# T_4x4 = cam→基座(AX=XB 的手是 TCP,不折到法兰) +# 旧结果备份在 results/backup/ +gripper_tool: tool10120938 +T_flange_to_tool: + - [1.00000000, 0.00000000, 0.00000000, -0.00050000] + - [0.00000000, 1.00000000, 0.00000000, 0.00670000] + - [0.00000000, 0.00000000, 1.00000000, 0.22450000] + - [0.00000000, 0.00000000, 0.00000000, 1.00000000] +# T_cam_to_base (eye_to_hand) +translation_m: [0.37094529, -0.52805928, 0.73745585] +T_4x4: + - [-0.00990083, 0.99909706, -0.04131632, 0.37094529] + - [0.99991380, 0.01024832, 0.00820703, -0.52805928] + - [0.00862304, -0.04123150, -0.99911241, 0.73745585] - [0.00000000, 0.00000000, 0.00000000, 1.00000000] diff --git a/handeye/results/89_eye_in_hand.bak_20260904_ee-was-tool1.yaml b/handeye/results/89_eye_in_hand.bak_20260904_ee-was-tool1.yaml new file mode 100644 index 0000000..f575141 --- /dev/null +++ b/handeye/results/89_eye_in_hand.bak_20260904_ee-was-tool1.yaml @@ -0,0 +1,18 @@ +# 【备份】2026-10-12 从 89_eye_in_hand.yaml 挪出。 +# 这是 2026-09-04 那份:AX=XB 的 gripper 用的是 GetArmState.ee_pose +# (当时激活工具是 tool1,不是法兰,也不是后来的 tool10120938)。 +# 棋盘当时按 8x11 / 15mm 写。新标定不要覆盖本文件。 +# 左臂 89 eye-in-hand. 不要用于 91。 +# 手眼标定结果 +# mode: eye_in_hand +# camera_source: d405 +# views: 12 +# utc: 2026-09-04T10:15:27.480727+00:00 +# board: 8x11 square_m=0.015 +# T_cam_to_gripper (eye_in_hand) +translation_m: [-0.06085181, -0.00864320, 0.12364093] +T_4x4: + - [0.01784499, -0.86878381, 0.49486994, -0.06085181] + - [0.99977570, 0.02115147, 0.00108124, -0.00864320] + - [-0.01140659, 0.49473964, 0.86896638, 0.12364093] + - [0.00000000, 0.00000000, 0.00000000, 1.00000000] diff --git a/handeye/results/89_eye_in_hand.yaml b/handeye/results/89_eye_in_hand.yaml index ea5d2c2..9cfa770 100644 --- a/handeye/results/89_eye_in_hand.yaml +++ b/handeye/results/89_eye_in_hand.yaml @@ -1,14 +1,28 @@ -# 左臂 89 eye-in-hand. 不要用于 91。 # 手眼标定结果 # mode: eye_in_hand -# camera_source: d405 -# views: 12 -# utc: 2026-09-04T10:15:27.480727+00:00 -# board: 8x11 square_m=0.015 -# T_cam_to_gripper (eye_in_hand) -translation_m: [-0.06085181, -0.00864320, 0.12364093] -T_4x4: - - [0.01784499, -0.86878381, 0.49486994, -0.06085181] - - [0.99977570, 0.02115147, 0.00108124, -0.00864320] - - [-0.01140659, 0.49473964, 0.86896638, 0.12364093] +# camera_source: d405_left +# views: 21 +# utc: 2026-10-12T03:29:51.243487+00:00 +# board: 11x8 square_m=0.015 +# gripper_tool: tool10120938 +# T_4x4 = cam→法兰(AX=XB 在 TCP 上解,再左乘 T_flange_to_tool) +# T_cam_to_tool 是原始求解;旧的法兰/tool1 备份在 results/backup/ +# 2026-10-12 第一次写出的 T_4x4 乘序反了(tz≈-296 mm),已按同一份 T_cam_to_tool 重折。 +gripper_tool: tool10120938 +T_flange_to_tool: + - [1.00000000, 0.00000000, 0.00000000, -0.00050000] + - [0.00000000, 1.00000000, 0.00000000, 0.00670000] + - [0.00000000, 0.00000000, 1.00000000, 0.22450000] + - [0.00000000, 0.00000000, 0.00000000, 1.00000000] +T_cam_to_tool: + - [-0.03435184, -0.86951953, 0.49270249, -0.05961995] + - [0.99932767, -0.03620511, 0.00577974, -0.01523625] + - [0.01281276, 0.49256977, 0.87017863, -0.09761517] + - [0.00000000, 0.00000000, 0.00000000, 1.00000000] +# T_cam_to_flange (eye_in_hand,由工具 TCP 折算) +translation_m: [-0.06011995, -0.00853625, 0.12688483] +T_4x4: + - [-0.03435184, -0.86951953, 0.49270249, -0.06011995] + - [0.99932767, -0.03620511, 0.00577974, -0.00853625] + - [0.01281276, 0.49256977, 0.87017863, 0.12688483] - [0.00000000, 0.00000000, 0.00000000, 1.00000000] diff --git a/handeye/results/backup/89_d435_eye_to_hand_20261009.yaml b/handeye/results/backup/89_d435_eye_to_hand_20261009.yaml new file mode 100644 index 0000000..0202175 --- /dev/null +++ b/handeye/results/backup/89_d435_eye_to_hand_20261009.yaml @@ -0,0 +1,19 @@ +# 备份:不要当现行结果用。 +# 原文件:handeye/results/89_d435_eye_to_hand.yaml +# 备份原因:2026-10-12 起左臂 89 眼在手外改按 Studio 工具 tool10120938 +# 的 TCP 做 AX=XB(板夹在该工具上)。本份是 2026-10-09、18 视角、 +# A4 11×8 / 20 mm 的结果,当时激活工具未经这次对齐。 +# T_4x4 仍是 cam→基座。现行结果仍写回 89_d435_eye_to_hand.yaml。 +# 手眼标定结果 +# mode: eye_to_hand +# camera_source: d435 +# views: 18 +# utc: 2026-10-09T02:56:24.621687+00:00 +# board: 11x8 square_m=0.02 +# T_cam_to_base (eye_to_hand) +translation_m: [0.37495201, -0.52156937, 0.72589194] +T_4x4: + - [-0.04575158, 0.99819969, -0.03878355, 0.37495201] + - [0.99893621, 0.04594039, 0.00399073, -0.52156937] + - [0.00576527, -0.03855971, -0.99923967, 0.72589194] + - [0.00000000, 0.00000000, 0.00000000, 1.00000000] diff --git a/handeye/results/backup/89_eye_in_hand_flange_tool1_20260904.yaml b/handeye/results/backup/89_eye_in_hand_flange_tool1_20260904.yaml new file mode 100644 index 0000000..e2a8fe1 --- /dev/null +++ b/handeye/results/backup/89_eye_in_hand_flange_tool1_20260904.yaml @@ -0,0 +1,20 @@ +# 备份:不要当现行结果用。 +# 原文件:handeye/results/89_eye_in_hand.yaml +# 备份原因:2026-10-12 起左臂 89 眼在手上改按 Studio 工具 tool10120938 求解。 +# 本份是 2026-09-04 那次标定:AX=XB 的 gripper 按当时激活的 tool1 / 法兰口径, +# 板为内角点 8×11、方格 15 mm。T_4x4 当时标注为 T_cam_to_gripper。 +# 现行结果仍写回 89_eye_in_hand.yaml;要比对旧外参请打开本文件。 +# 左臂 89 eye-in-hand. 不要用于 91。 +# 手眼标定结果 +# mode: eye_in_hand +# camera_source: d405 +# views: 12 +# utc: 2026-09-04T10:15:27.480727+00:00 +# board: 8x11 square_m=0.015 +# T_cam_to_gripper (eye_in_hand) +translation_m: [-0.06085181, -0.00864320, 0.12364093] +T_4x4: + - [0.01784499, -0.86878381, 0.49486994, -0.06085181] + - [0.99977570, 0.02115147, 0.00108124, -0.00864320] + - [-0.01140659, 0.49473964, 0.86896638, 0.12364093] + - [0.00000000, 0.00000000, 0.00000000, 1.00000000] diff --git a/scripts/fix_robot_lan.sh b/scripts/fix_robot_lan.sh index 6bc3dde..655a7ee 100755 --- a/scripts/fix_robot_lan.sh +++ b/scripts/fix_robot_lan.sh @@ -42,7 +42,29 @@ pick_iface() { echo "${SITE_NET_IFACE}" return fi - for d in enp4s0 enp5s0 eth0; do + # 内核有时把 USB 网卡叫 eth0,不再用 enx+MAC。按 MAC 找回,禁止误选办公网 enp4s0。 + local want="${SITE_NET_IFACE:-}" + if [[ "${want}" == enx* && ${#want} -ge 15 ]]; then + local mac + mac="$(printf '%s' "${want#enx}" | sed 's/../&:/g;s/:$//')" + local d + for d in /sys/class/net/*; do + [[ -f "${d}/address" ]] || continue + if [[ "$(cat "${d}/address")" == "${mac}" ]]; then + echo "$(basename "$d")" + return + fi + done + fi + local d + for d in /sys/class/net/*; do + [[ -f "${d}/address" ]] || continue + if ip -4 -o addr show "$(basename "$d")" 2>/dev/null | grep -q '192.168.2.10'; then + echo "$(basename "$d")" + return + fi + done + for d in eth0 enp4s0 enp5s0; do [[ -d /sys/class/net/$d ]] || continue echo "$d" return diff --git a/scripts/lib/export_site_env.py b/scripts/lib/export_site_env.py index 400d655..742b157 100644 --- a/scripts/lib/export_site_env.py +++ b/scripts/lib/export_site_env.py @@ -627,6 +627,13 @@ def flatten(data: dict) -> dict: # 两个模式各自的名字在 *_EYE_IN_HAND / *_EYE_TO_HAND 里。 "SITE_HANDEYE_FILE": he_modes_out["SITE_HANDEYE_FILE_EYE_IN_HAND"], "SITE_HANDEYE_MODE": he_mode, + "SITE_HANDEYE_GRIPPER_TOOL": str(he.get("gripper_tool") or ""), + "SITE_HANDEYE_GRIPPER_TCP_XYZ": ",".join( + f"{float(v):.6f}" for v in list(he.get("gripper_tcp_xyz") or []) + ), + "SITE_HANDEYE_GRIPPER_TCP_QWXYZ": ",".join( + f"{float(v):.6f}" for v in list(he.get("gripper_tcp_qwxyz") or []) + ), **he_modes_out, # 不带后缀的板尺寸 = **当前模式**那一组,供 `show` 之类只看当前模式的调用方; # 标定脚本用带后缀的那三组(按 --mode 选),不依赖这个。 @@ -985,6 +992,10 @@ def main() -> int: print(f" 手眼 : {flat['SITE_HANDEYE_FILE']}") print(f" 手眼路点 : {flat['SITE_HANDEYE_WAYPOINTS']}") print(f" 手眼目录 : {flat['SITE_HANDEYE_WORKDIR']}") + print( + f" 手眼工具 : {flat.get('SITE_HANDEYE_GRIPPER_TOOL') or '(无,ee_pose 原样)'} " + f"tcp=[{flat.get('SITE_HANDEYE_GRIPPER_TCP_XYZ') or ''}]" + ) print(f" 命名位姿 : {flat.get('SITE_NAMED_POSES_FILE')}") print(f" 眼在手外结果 : {flat['SITE_HANDEYE_FILE_EYE_TO_HAND']}") print(f" 眼在手外路点 : {flat['SITE_HANDEYE_WAYPOINTS_EYE_TO_HAND']}") diff --git a/scripts/lib/rdk_helpers.py b/scripts/lib/rdk_helpers.py index 7ff86ab..622f16d 100644 --- a/scripts/lib/rdk_helpers.py +++ b/scripts/lib/rdk_helpers.py @@ -23,6 +23,7 @@ RDK 对同一台机器人是**独占访问**:同时有两个进程连着同一 from __future__ import annotations +import os import time from typing import Any, Dict, List, Optional @@ -155,6 +156,77 @@ def tool_params(tool: Any, group: Any) -> Dict[str, Any]: } +def iface_ipv4s(name: str) -> List[str]: + """网卡名 → 其上的 IPv4。FastDDS 白名单只认地址,不认 ``eth0`` / ``enx…``。""" + import fcntl + import socket + import struct + import subprocess + + name = (name or "").strip() + if not name: + return [] + if name.count(".") == 3: + return [name] + addrs: List[str] = [] + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ifreq = struct.pack("256s", name.encode("utf-8")[:15]) + addrs.append(socket.inet_ntoa(fcntl.ioctl(s.fileno(), 0x8915, ifreq)[20:24])) + s.close() + except OSError: + pass + try: + out = subprocess.check_output( + ["ip", "-4", "-o", "addr", "show", "dev", name], + text=True, + stderr=subprocess.DEVNULL, + ) + for line in out.splitlines(): + parts = line.split() + if "inet" in parts: + addrs.append(parts[parts.index("inet") + 1].split("/")[0]) + except Exception: + pass + seen = set() + uniq: List[str] = [] + for a in addrs: + if a and a not in seen: + seen.add(a) + uniq.append(a) + return uniq + + +def rdk_whitelist_lists(iface: str = "", pc_ip: str = "") -> List[List[str]]: + """给 ``flexivrdk.Robot(sn, listen)`` 用的白名单候选。 + + 只放 IPv4。把 ``eth0`` 这种网卡名塞进去,FastDDS 会把真地址全部滤掉, + 症状是 ping 通、RDK 报 Failed to connect。 + """ + lists: List[List[str]] = [] + pc = (pc_ip or os.environ.get("SITE_PC_IP") or "").strip() + if pc.count(".") == 3: + lists.append([pc]) + iface = (iface or "").strip() + if iface.count(".") == 3: + lists.append([iface]) + elif iface: + lan = [a for a in iface_ipv4s(iface) if a.startswith("192.168.2.")] + if lan: + lists.append(lan) + all_ips = iface_ipv4s(iface) + if all_ips and all_ips != lan: + lists.append(all_ips) + seen = set() + out: List[List[str]] = [] + for item in lists: + key = tuple(item) + if key not in seen: + seen.add(key) + out.append(item) + return out + + def switch_tool(tool: Any, group: Any, target: str) -> None: """切换工具。失败抛异常。""" if group is None: diff --git a/scripts/merge_handeye_poses.py b/scripts/merge_handeye_poses.py index 0d1b89d..3891958 100755 --- a/scripts/merge_handeye_poses.py +++ b/scripts/merge_handeye_poses.py @@ -61,6 +61,16 @@ def parse_poses(path: Path) -> list[list[float]]: return poses +def unique_poses(poses: list[list[float]], tol: float = 0.015) -> list[list[float]]: + """关节几乎不动的连拍只留一条。阈值 0.015 rad ≈ 0.86°。""" + kept: list[list[float]] = [] + for pose in poses: + if any(max(abs(a - b) for a, b in zip(pose, old)) < tol for old in kept): + continue + kept.append(pose) + return kept + + def format_positions_block(poses: list[list[float]], indent: str = " ") -> str: rows = [f"{indent}waypoint_positions: ["] for i, q in enumerate(poses): @@ -131,9 +141,19 @@ def main(argv: list[str]) -> int: print(f"Not found: {dst}", file=sys.stderr) return 3 poses = parse_poses(src) - if len(poses) < 5: - print(f"Need >=5 poses, got {len(poses)}", file=sys.stderr) + uniq = unique_poses(poses) + if len(uniq) < 5: + print( + f"Need >=5 unique poses, got {len(poses)} lines / {len(uniq)} unique", + file=sys.stderr, + ) return 4 + if len(uniq) < len(poses): + print( + f"警告: {len(poses)} 条里只有 {len(uniq)} 个姿态不同,已丢掉连按重复", + file=sys.stderr, + ) + poses = uniq merge(dst, poses) print(f"Merged {len(poses)} poses -> {dst}") return 0 diff --git a/scripts/rdk_drag_bridge.py b/scripts/rdk_drag_bridge.py index e6e2004..bde0e4e 100755 --- a/scripts/rdk_drag_bridge.py +++ b/scripts/rdk_drag_bridge.py @@ -55,6 +55,7 @@ from rdk_helpers import ( # noqa: E402 clear_fault as rdk_clear_fault, ensure_idle, ensure_operational, + rdk_whitelist_lists, resolve_tool_name, single_arm_groups, switch_tool, @@ -228,8 +229,11 @@ def main() -> int: robot = None last_err = None attempts = [] - if iface: - attempts.extend([(sn, [iface]), (sn, [iface], True)]) + # 白名单必须是 IPv4。传 eth0 / enx… 会被 FastDDS 整段滤掉, + # ping 通也会 Failed to connect。 + for listen in rdk_whitelist_lists(iface): + log('白名单: ' + ', '.join(listen)) + attempts.extend([(sn, listen), (sn, listen, True)]) attempts.append((sn,)) for args in attempts: try: diff --git a/scripts/rdk_goto_xyz.py b/scripts/rdk_goto_xyz.py new file mode 100644 index 0000000..82e0891 --- /dev/null +++ b/scripts/rdk_goto_xyz.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""用厂家 Flexiv RDK 的原语 MoveL,把当前工具尖走到世界系里的一个点。 + +必须用 RDK 自带的 Python 3.10,不能用 ROS 的 3.11: + + # 先在跑 run_real.sh 的那个终端 Ctrl-C。RDK 同时只能有一个连接。 + /home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_goto_xyz.py + /home/cc/rdk_py310/bin/python ~/ros2/scripts/rdk_goto_xyz.py --xyz 0.45 -0.30 0.40 + +不给 --xyz 时只打印当前工具尖坐标,不动臂。 + +这个脚本做的事,按顺序只有这些: + +1. 用网卡上的 IPv4 连上序列号那台臂。网卡名(eth0)不能当白名单。 +2. 清故障、使能,等到 operational。示教器要在 AUTO Remote,急停松开。 +3. 读 states().tcp_pose。这 7 个数是 + [x, y, z, qw, qx, qy, qz],单位米,坐标系是机器人世界系(基座那套 WORLD)。 + 位置是当前 Studio 工具的 TCP(现在是 tool10120938 的尖),不是法兰,也不是相机。 +4. 目标只换 x、y、z。姿态沿用现在的朝向,折成厂家 MoveL 要的欧拉角(度,ZYX)。 +5. SwitchMode(NRT_PRIMITIVE_EXECUTION),ExecutePrimitive("MoveL", ...)。 +6. 轮询 primitive_states()["reachedTarget"]。到了再读一次 tcp_pose,印出还差多少毫米。 + +厂家示例:flexiv_rdk example_py/basics3_primitive_execution.py 第 (3) 段。 +""" + +from __future__ import annotations + +import argparse +import math +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts" / "lib")) + +from rdk_helpers import rdk_whitelist_lists # noqa: E402 + + +def site_robot() -> tuple[str, str]: + """读 config/site.yaml 里第一台臂的序列号和这台电脑的机器人网 IP。""" + text = (ROOT / "config" / "site.yaml").read_text(encoding="utf-8") + sn = "" + pc_ip = "" + for raw in text.splitlines(): + line = raw.split("#", 1)[0].strip() + if line.startswith("sn:") and not sn: + sn = line.split(":", 1)[1].strip().strip('"').strip("'") + elif line.startswith("pc_ip:") and not pc_ip: + pc_ip = line.split(":", 1)[1].strip().strip('"').strip("'") + if sn and pc_ip: + break + if not sn: + raise RuntimeError(f"config/site.yaml 里没有 sn:{ROOT / 'config' / 'site.yaml'}") + return sn, pc_ip + + +def quat_wxyz_to_euler_xyz_deg(q) -> list[float]: + """[qw,qx,qy,qz] → MoveL 要的 [rot_x, rot_y, rot_z] 度。 + + 与厂家 example_py/utility.py 的 quat2eulerZYX 相同: + scipy 的 from_quat([x,y,z,w]).as_euler('xyz', degrees=True)。 + 这里不 import scipy,避免和 ROS 环境的 scipy 打架。 + """ + qw, qx, qy, qz = (float(v) for v in q) + n = math.sqrt(qw * qw + qx * qx + qy * qy + qz * qz) + if n < 1e-12: + raise RuntimeError(f"四元数是零:{q}") + qw, qx, qy, qz = qw / n, qx / n, qy / n, qz / n + xx, yy, zz = qx * qx, qy * qy, qz * qz + xy, xz, yz = qx * qy, qx * qz, qy * qz + wx, wy, wz = qw * qx, qw * qy, qw * qz + r00 = 1.0 - 2.0 * (yy + zz) + r10 = 2.0 * (xy + wz) + r20 = 2.0 * (xz - wy) + r21 = 2.0 * (yz + wx) + r22 = 1.0 - 2.0 * (xx + yy) + r01 = 2.0 * (xy - wz) + r11 = 1.0 - 2.0 * (xx + zz) + r12 = 2.0 * (yz - wx) + # R = Rx(x) @ Ry(y) @ Rz(z) + sy = math.sqrt(r00 * r00 + r10 * r10) + if sy > 1e-6: + x = math.atan2(r21, r22) + y = math.atan2(-r20, sy) + z = math.atan2(r10, r00) + else: + x = math.atan2(-r12, r11) + y = math.atan2(-r20, sy) + z = 0.0 + return [math.degrees(x), math.degrees(y), math.degrees(z)] + + +def connect(flexivrdk, sn: str, pc_ip: str): + """白名单只放 IPv4。连不上就换下一组,最后再试不带白名单。""" + last = None + attempts = [] + for listen in rdk_whitelist_lists(pc_ip=pc_ip): + attempts.append((sn, listen)) + attempts.append((sn,)) + for args in attempts: + try: + print(f"连接 {args}") + return flexivrdk.Robot(*args) + except Exception as exc: # noqa: BLE001 — 下一组白名单再试,最后一组失败才抛 + last = exc + print(f" 失败:{exc}") + raise RuntimeError(f"RDK 连不上 {sn}:{last}") + + +def wait_operational(robot, timeout_s: float) -> None: + if robot.fault(): + print("有故障,尝试 ClearFault") + if not robot.ClearFault(): + raise RuntimeError("故障清不掉。看示教器。") + print("Enable …(示教器:松开急停,AUTO Remote)") + robot.Enable() + t0 = time.monotonic() + while not robot.operational(): + if time.monotonic() - t0 > timeout_s: + raise RuntimeError(f"{timeout_s:.0f}s 内没有 operational") + time.sleep(0.2) + print("operational") + + +def fmt_pose(pose) -> str: + x, y, z = pose[0], pose[1], pose[2] + return f"xyz=({x:.4f}, {y:.4f}, {z:.4f}) m qwxyz=({pose[3]:.4f}, {pose[4]:.4f}, {pose[5]:.4f}, {pose[6]:.4f})" + + +def main() -> int: + parser = argparse.ArgumentParser(description="RDK MoveL:工具尖走到世界系的一个点") + parser.add_argument("--xyz", nargs=3, type=float, metavar=("X", "Y", "Z"), help="目标点,米,机器人世界系") + parser.add_argument("--vel", type=float, default=0.05, help="TCP 线速度 m/s,默认 0.05") + parser.add_argument("--max-step-m", type=float, default=0.40, help="离当前点超过这么多米就拒绝,除非 --go") + parser.add_argument("--go", action="store_true", help="允许超过 --max-step-m") + parser.add_argument("--timeout", type=float, default=60.0, help="等 reachedTarget 的秒数") + args = parser.parse_args() + + sn, pc_ip = site_robot() + print(f"臂 {sn} 本机 {pc_ip or '(site 里没有 pc_ip)'}") + + import flexivrdk + + robot = connect(flexivrdk, sn, pc_ip) + try: + wait_operational(robot, args.timeout) + pose = list(robot.states().tcp_pose) + if len(pose) < 7: + raise RuntimeError(f"tcp_pose 不是 7 个数:{pose}") + print("当前 TCP(世界系):", fmt_pose(pose)) + if args.xyz is None: + print("没有 --xyz,只看坐标,不动。") + return 0 + + target_xyz = [float(v) for v in args.xyz] + dist = math.dist(pose[:3], target_xyz) + print(f"目标 xyz=({target_xyz[0]:.4f}, {target_xyz[1]:.4f}, {target_xyz[2]:.4f}) m") + print(f"直线距离 {dist * 1000:.0f} mm 速度 {args.vel:.3f} m/s 姿态保持当前") + if dist > args.max_step_m and not args.go: + print(f"超过 {args.max_step_m * 1000:.0f} mm,拒绝。确认无误后加 --go。") + return 2 + + euler_deg = quat_wxyz_to_euler_xyz_deg(pose[3:7]) + print(f"MoveL 欧拉角 deg xyz=({euler_deg[0]:.2f}, {euler_deg[1]:.2f}, {euler_deg[2]:.2f})") + + robot.SwitchMode(flexivrdk.Mode.NRT_PRIMITIVE_EXECUTION) + robot.ExecutePrimitive( + "MoveL", + { + "target": flexivrdk.Coord( + target_xyz, + euler_deg, + ["WORLD", "WORLD_ORIGIN"], + ), + "vel": float(args.vel), + }, + ) + print("MoveL 已下发,等 reachedTarget …") + t0 = time.monotonic() + while True: + states = robot.primitive_states() + if states.get("reachedTarget"): + break + if robot.fault(): + raise RuntimeError(f"运动中故障:{states}") + if time.monotonic() - t0 > args.timeout: + raise RuntimeError(f"{args.timeout:.0f}s 内没有 reachedTarget。states={states}") + time.sleep(0.2) + + end = list(robot.states().tcp_pose) + err_mm = math.dist(end[:3], target_xyz) * 1000.0 + print("到达 TCP:", fmt_pose(end)) + print(f"相对目标还差 {err_mm:.1f} mm") + return 0 + finally: + try: + robot.Stop() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/rdk_switch_tool.py b/scripts/rdk_switch_tool.py index bb14e17..2336dd4 100755 --- a/scripts/rdk_switch_tool.py +++ b/scripts/rdk_switch_tool.py @@ -158,27 +158,11 @@ def _iface_ipv4s(name: str) -> list: def _rdk_whitelist(iface: str) -> list[list[str]]: - """RDK 手册要的是「连机器人的那张网卡」。enp4s0 上还有办公网 IP,不能整张卡的地址都塞进白名单。""" - import os + """只放 IPv4。网卡名进白名单会被 FastDDS 滤掉(见 rdk_helpers)。""" + sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent / "lib")) + from rdk_helpers import rdk_whitelist_lists # noqa: PLC0415 - lists: list[list[str]] = [] - pc = (os.environ.get("SITE_PC_IP") or "").strip() - if pc: - lists.append([pc]) - if iface: - lists.append([iface]) - robot_lan = [a for a in _iface_ipv4s(iface) if a.startswith("192.168.2.")] - if robot_lan: - lists.append(robot_lan) - # 去重 - seen: set[tuple[str, ...]] = set() - out: list[list[str]] = [] - for item in lists: - key = tuple(item) - if key not in seen: - seen.add(key) - out.append(item) - return out + return rdk_whitelist_lists(iface) def connect_rdk(sn: str, iface: str = ""): diff --git a/scripts/run_all.sh b/scripts/run_all.sh index d201ba9..48c9844 100755 --- a/scripts/run_all.sh +++ b/scripts/run_all.sh @@ -20,7 +20,8 @@ # bash ~/ros2/scripts/run_all.sh --arm 89 --camera-preview # 相机带 Qt 预览窗 # bash ~/ros2/scripts/run_all.sh --arm 89 --no-camera # 相机已在别处拉起了 # bash ~/ros2/scripts/run_all.sh --arm 89 --no-tactile # 不起触觉服务 -# bash ~/ros2/scripts/run_all.sh --arm 89 --no-descent # 只对准不轻降(验证精对准) +# bash ~/ros2/scripts/run_all.sh --arm 89 --no-descent # 对准后只降到观测高度(默认离面 15mm) +# bash ~/ros2/scripts/run_all.sh --arm 89 --d435-only # 只测 D435 单次位移,到位后不交 D405、不进二阶段 # # 前置(另开一个终端,先跑起来): # bash ~/ros2/scripts/run_real.sh --arm 89 @@ -43,6 +44,7 @@ WITH_CAMERA=true WITH_TACTILE=true CAMERA_PREVIEW=false NO_DESCENT=false +D435_ONLY=false # --sn/--arm 必须先解析:load_site_config.sh 靠 ROBOT_SN 决定加载哪套机器人配置 # shellcheck disable=SC1091 @@ -59,14 +61,15 @@ while [[ $# -gt 0 ]]; do --depth) DEPTH="${2:?--depth 需要毫米数}"; shift 2 ;; --pitch) PITCH="${2:?--pitch 需要螺距毫米数}"; shift 2 ;; --resnap) RESNAP=true; shift ;; - # 只验证**精对准**、不轻降。对准逻辑改完先这么跑,确认规真被对到孔心再放开下降 - # (对不准就下压会把规顶在孔沿上:2026-10-08 真机顶到 63N)。 + # 不到贴面 2mm。对准后仍降到 site 的 observe_gap_mm(默认离面 15mm), + # 方便看规尖偏没偏;对不准就下到 2mm 会把规顶在孔沿上(2026-10-08 真机 63N)。 --no-descent) NO_DESCENT=true; shift ;; + --d435-only) D435_ONLY=true; SKIP_STAGE2=true; SKIP_STAGE3=true; shift ;; --no-preview) PIPELINE_PREVIEW=false; shift ;; --camera-preview) CAMERA_PREVIEW=true; shift ;; --no-camera) WITH_CAMERA=false; shift ;; --no-tactile) WITH_TACTILE=false; shift ;; - -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + -h|--help) sed -n '2,31p' "$0"; exit 0 ;; *) echo "Unknown: $1" >&2; exit 1 ;; esac done @@ -174,12 +177,21 @@ else echo "=== --no-camera:跳过相机检查 ===" fi -# ---- 3) 触觉服务。只有左臂装了传感器 ---- +# ---- 3) 触觉服务。只有左臂装了传感器;只有三阶段搜孔才用力 ---- +# --skip-stage3 时如果还去等,USB 打不开会假死:话题在、connected 却是 false, +# 每次 echo 吃满超时 × 60 次 ≈ 3 分钟,终端上没有任何新行(2026-10-09 真机)。 +_SKIP_TACTILE_REASON="" +if [[ "${SKIP_STAGE3}" == true && "${WITH_TACTILE}" == true ]]; then + echo "=== --skip-stage3:一/二阶段不用触觉,跳过拉起 ===" + WITH_TACTILE=false + _SKIP_TACTILE_REASON="stage3" +fi + # 同样先查 topic 列表再 echo,避免服务没起时反复撞超时。 TACTILE_TOPIC="/tactile/left/status" _tactile_ok() { timeout 2 ros2 topic list 2>/dev/null | grep -qx "${TACTILE_TOPIC}" || return 1 - timeout 3 ros2 topic echo "${TACTILE_TOPIC}" --once 2>/dev/null | grep -q "connected: true" + timeout 1 ros2 topic echo "${TACTILE_TOPIC}" --once 2>/dev/null | grep -q "connected: true" } if [[ "${WITH_TACTILE}" == true ]]; then @@ -199,12 +211,19 @@ if [[ "${WITH_TACTILE}" == true ]]; then sleep 1.0 nohup ros2 launch arm_application tactile.launch.py > /tmp/run_all_tactile.log 2>&1 & echo " tactile pid=$! log=/tmp/run_all_tactile.log" + echo " 等触觉 connected(USB 打不开会在十几秒内跳过,不会空等几分钟)" t_ok=false - for _i in $(seq 1 60); do # 最多等 ~30s;传感器要在 Python 侧加载模型 + for _i in $(seq 1 8); do if _tactile_ok; then t_ok=true break fi + # 日志已经写明打不开:再给两轮自愈,然后放行。不许在这里空转到超时。 + if [[ "${_i}" -ge 4 ]] && grep -q "connect failed" /tmp/run_all_tactile.log 2>/dev/null; then + echo " 触觉 USB 打不开,停止等待" >&2 + break + fi + echo " 触觉还未 connected(${_i}/8)" sleep 0.5 done if [[ "${t_ok}" != true ]]; then @@ -219,7 +238,7 @@ if [[ "${WITH_TACTILE}" == true ]]; then else echo "=== 触觉只有左臂装了;当前 ${SITE_ROBOT_ARM:-?},跳过 ===" fi -else +elif [[ "${_SKIP_TACTILE_REASON}" != "stage3" ]]; then echo "=== --no-tactile:跳过触觉检查 ===" fi @@ -239,10 +258,11 @@ ARGS=( "preview:=${PIPELINE_PREVIEW}" "resnap:=${RESNAP}" "no_descent:=${NO_DESCENT}" + "d435_move_only:=${D435_ONLY}" ) [[ -n "${HOLE}" ]] && ARGS+=("target_hole:=${HOLE}") [[ -n "${DEPTH}" ]] && ARGS+=("insert_depth_mm:=${DEPTH}") [[ -n "${PITCH}" ]] && ARGS+=("screw_pitch_mm:=${PITCH}") -echo "=== launch pipeline hole=${HOLE:-site} depth=${DEPTH:-site} pitch=${PITCH:-site} skip1=${SKIP_STAGE1} skip2=${SKIP_STAGE2} skip3=${SKIP_STAGE3} no_descent=${NO_DESCENT} ===" +echo "=== launch pipeline hole=${HOLE:-site} depth=${DEPTH:-site} pitch=${PITCH:-site} skip1=${SKIP_STAGE1} skip2=${SKIP_STAGE2} skip3=${SKIP_STAGE3} no_descent=${NO_DESCENT} d435_only=${D435_ONLY} ===" exec ros2 launch arm_application pipeline.launch.py "${ARGS[@]}" diff --git a/scripts/run_handeye.sh b/scripts/run_handeye.sh index 3f7860b..4740c95 100755 --- a/scripts/run_handeye.sh +++ b/scripts/run_handeye.sh @@ -202,6 +202,7 @@ cmd_show() { echo " result : ${RES}" echo " workdir : ${WORKDIR}" echo " camera : ${CAM} serial=${CAM_SERIAL:-(空)}" + echo " tool : ${SITE_ROBOT_TOOL_NAME:-(不切换)} gripper_tool=${SITE_HANDEYE_GRIPPER_TOOL:-(无,ee_pose 原样)}" echo " board : $(_he BOARD_COLS)x$(_he BOARD_ROWS) inner, square=$(_he SQUARE) m (来源=$(_he BOARD_SRC))" echo " (另一种模式的板见下面 export 的 标定板[...] 两行)" python3 "${ROOT}/scripts/lib/export_site_env.py" --print || true @@ -240,6 +241,20 @@ cmd_calibrate() { exit 2 fi _need_files "${CAL}" "${WP}" + local grip_tool="${SITE_HANDEYE_GRIPPER_TOOL:-}" + local grip_xyz="${SITE_HANDEYE_GRIPPER_TCP_XYZ:-}" + local grip_q="${SITE_HANDEYE_GRIPPER_TCP_QWXYZ:-}" + if [[ -n "${grip_tool}" ]]; then + if [[ "${SITE_ROBOT_TOOL_NAME:-}" != "${grip_tool}" ]]; then + echo "ERROR: 手眼要按工具 ${grip_tool} 求解,但 site 当前激活工具是 '${SITE_ROBOT_TOOL_NAME:-}'。" >&2 + echo " robots[].tool_name 必须和 handeye.gripper_tool 同一份名字。" >&2 + exit 2 + fi + if [[ -z "${grip_xyz}" || -z "${grip_q}" ]]; then + echo "ERROR: 配了 gripper_tool=${grip_tool} 但没写 gripper_tcp_xyz / gripper_tcp_qwxyz。" >&2 + exit 2 + fi + fi _need_robot _need_camera echo "=== kill visual_servo (avoid traj fight) ===" @@ -260,6 +275,19 @@ cmd_calibrate() { echo " motion v=${vel} T≥${mt}s settle=${st}s qd≤${qd} pin=${ph}x${ps}s" echo " poses=${WP}" echo " result → ${RES}" + local grip_args=() + if [[ -n "${grip_tool}" ]]; then + if [[ "${HANDEYE_MODE}" == "eye_in_hand" ]]; then + echo " gripper_tool=${grip_tool} tcp_xyz=[${grip_xyz}] (AX=XB 用 TCP,T_4x4 折成 cam→法兰)" + else + echo " gripper_tool=${grip_tool} tcp_xyz=[${grip_xyz}] (AX=XB 用 TCP,T_4x4 仍是 cam→基座,不折)" + fi + grip_args+=( + -p gripper_tool:="${grip_tool}" + -p gripper_tcp_xyz:="[${grip_xyz}]" + -p gripper_tcp_qwxyz:="[${grip_q}]" + ) + fi exec ros2 run arm_application handeye_calibrate --ros-args \ --params-file "${CAL}" \ --params-file "${WP}" \ @@ -280,7 +308,8 @@ cmd_calibrate() { -p settle_timeout_sec:="${sto}" \ -p pin_holds:="${ph}" \ -p pin_hold_sec:="${ps}" \ - -p skip_unstable_poses:="${skip}" + -p skip_unstable_poses:="${skip}" \ + "${grip_args[@]}" } cmd_waypoints() { @@ -359,6 +388,7 @@ cmd_teach() { -p joint_prefix:="${PREFIX}" -p camera_source:="${CAM}" -p output_dir:="${WORKDIR}" + -p poses_file:="${TAUGHT}" -p target_points:="${points}" ) if [[ -n "${CAM_SERIAL}" ]]; then diff --git a/src/arm_application/arm_application/handeye_calibrate.py b/src/arm_application/arm_application/handeye_calibrate.py index eef7068..e7e827d 100644 --- a/src/arm_application/arm_application/handeye_calibrate.py +++ b/src/arm_application/arm_application/handeye_calibrate.py @@ -33,8 +33,11 @@ from arm_application.handeye_lib import ( BoardConfig, CameraIntrinsics, ChessboardStabilizer, + T_from_flexiv_tcp, calibrate_hand_eye, + cam_to_flange_via_tool, detect_chessboard, + format_matrix_yaml, format_transform_yaml, is_pose_filled, pose_to_Rt, @@ -113,11 +116,10 @@ class HandeyeCalibrate(TolerantNode): # 棋盘(可自定义) # 默认值必须与 site.yaml 的 handeye.board_* 及结果文件头里的实际板子一致。 - # 真实使用的板:内角点 8x11,方格 15mm(见 handeye/results/*.yaml 的头部注释)。 - # 这里原先写的是 9x6 / 0.025(另一张早期的板),一旦 site.yaml 漏配就会被 - # 静默启用 —— 症状是"标定能顺利跑完,但结果明显不对",极难排查。 - self.declare_parameter('board_cols', 8) # 内角点 - self.declare_parameter('board_rows', 11) + # 真实使用的板:A5 内角点 11x8,方格 15mm(site.yaml handeye.boards.eye_in_hand)。 + # 这里原先写的是 9x6 / 0.025,后来是 8x11;漏配就会静默用错尺寸。 + self.declare_parameter('board_cols', 11) # 内角点 + self.declare_parameter('board_rows', 8) self.declare_parameter('square_size_m', 0.015) self.declare_parameter('stable_window', 8) @@ -138,6 +140,11 @@ class HandeyeCalibrate(TolerantNode): self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp') self.declare_parameter('result_file', '') self.declare_parameter('cleanup_images', True) + # 眼在手上:AX=XB 的 gripper 用 Studio 工具 TCP,再折成 cam→法兰写 T_4x4。 + # 空 = 旧口径(ee_pose 当 gripper,T_4x4 原样写出)。配了就必须带 TCP。 + self.declare_parameter('gripper_tool', '') + self.declare_parameter('gripper_tcp_xyz', [0.0, 0.0, 0.0]) + self.declare_parameter('gripper_tcp_qwxyz', [1.0, 0.0, 0.0, 0.0]) prefix = str(self.get_parameter('joint_prefix').value or '') base = list( @@ -152,6 +159,21 @@ class HandeyeCalibrate(TolerantNode): self.start_index = max(0, int(self.get_parameter('start_index').value)) self.min_poses = int(self.get_parameter('min_poses').value) self.cleanup_images = bool(self.get_parameter('cleanup_images').value) + self.gripper_tool = str(self.get_parameter('gripper_tool').value or '').strip() + tcp_xyz = list( + self.get_parameter('gripper_tcp_xyz').get_parameter_value().double_array_value + or [0.0, 0.0, 0.0] + ) + tcp_q = list( + self.get_parameter('gripper_tcp_qwxyz').get_parameter_value().double_array_value + or [1.0, 0.0, 0.0, 0.0] + ) + self.T_flange_to_tool = T_from_flexiv_tcp(tcp_xyz[:3], tcp_q[:4]) + if self.gripper_tool and float(np.linalg.norm(self.T_flange_to_tool[:3, 3])) < 1e-4: + raise RuntimeError( + f'gripper_tool={self.gripper_tool!r} 但 gripper_tcp_xyz 几乎是零:' + '等于没把 Studio TCP 传进来。不许按法兰口径假装在用工具系。' + ) self.camera_source = str(self.get_parameter('camera_source').value).strip().lower() self.camera_serial = str(self.get_parameter('camera_serial').value or '').strip() @@ -265,10 +287,12 @@ class HandeyeCalibrate(TolerantNode): self.images_dir.mkdir(parents=True, exist_ok=True) eye_to_hand = self.mode == 'eye_to_hand' + grip = self.gripper_tool or '(ee_pose 原样,无工具折算)' self.get_logger().info( f'手眼标定 [{self.mode}] camera_source={self.camera_source}:' f'{len(poses)} 个位姿,棋盘={self.board.cols}x{self.board.rows} ' - f'方格={self.board.square_size}m,v={self.velocity_scale}' + f'方格={self.board.square_size}m,v={self.velocity_scale} ' + f'gripper_tool={grip}' ) self.get_logger().warn('清理工作空间;确认急停就绪。Ctrl+C 可中止。') @@ -368,12 +392,58 @@ class HandeyeCalibrate(TolerantNode): self.get_logger().info(f'用 {len(Rg)} 个视角求解手眼标定...') R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=eye_to_hand) - name = ( - 'T_cam_to_base (eye_to_hand)' - if eye_to_hand - else 'T_cam_to_gripper (eye_in_hand)' - ) - yaml_txt = format_transform_yaml(R, t, name) + extra = '' + if eye_to_hand: + name = 'T_cam_to_base (eye_to_hand)' + yaml_txt = format_transform_yaml(R, t, name) + if self.gripper_tool: + extra = ( + f'gripper_tool: {self.gripper_tool}\n' + + format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool') + ) + self.get_logger().info( + f'gripper={self.gripper_tool}:AX=XB 的手是 TCP;' + f'T_4x4 是 cam→基座,不折 t={t.reshape(3)*1000} mm' + ) + elif self.gripper_tool: + T_cam_tool = np.eye(4) + T_cam_tool[:3, :3] = R + T_cam_tool[:3, 3] = t.reshape(3) + T_cam_flange = cam_to_flange_via_tool(T_cam_tool, self.T_flange_to_tool) + delta = float(np.linalg.norm(T_cam_flange[:3, 3] - T_cam_tool[:3, 3])) + tool_len = float(np.linalg.norm(self.T_flange_to_tool[:3, 3])) + if tool_len > 0.05 and delta < 1e-4: + self.get_logger().error( + 'T_cam_to_flange 与 T_cam_to_tool 平移几乎相同,' + '折算没生效。结果作废。' + ) + return 11 + # 腕部相机在法兰前方(法兰 +Z)。乘反了 Z 会落到 −200 mm 一带。 + if ( + self.T_flange_to_tool[2, 3] > 0.05 + and float(T_cam_flange[2, 3]) < 0.0 + ): + self.get_logger().error( + f'T_cam_to_flange tz={float(T_cam_flange[2, 3])*1000:.1f} mm ' + '在法兰后方,乘序反了。结果作废。' + ) + return 12 + R, t = T_cam_flange[:3, :3], T_cam_flange[:3, 3].reshape(3, 1) + name = 'T_cam_to_flange (eye_in_hand,由工具 TCP 折算)' + yaml_txt = format_transform_yaml(R, t, name) + extra = ( + f'gripper_tool: {self.gripper_tool}\n' + + format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool') + + format_matrix_yaml(T_cam_tool, 'T_cam_to_tool') + ) + self.get_logger().info( + f'gripper={self.gripper_tool}:' + f'T_cam_to_tool t={T_cam_tool[:3, 3]*1000} mm → ' + f'T_cam_to_flange t={T_cam_flange[:3, 3]*1000} mm' + ) + else: + name = 'T_cam_to_gripper (eye_in_hand)' + yaml_txt = format_transform_yaml(R, t, name) header = ( f'# 手眼标定结果\n' f'# mode: {self.mode}\n' @@ -383,8 +453,20 @@ class HandeyeCalibrate(TolerantNode): f'# board: {self.board.cols}x{self.board.rows} ' f'square_m={self.board.square_size}\n' ) + if self.gripper_tool and eye_to_hand: + header += ( + f'# gripper_tool: {self.gripper_tool}\n' + f'# T_4x4 = cam→基座(AX=XB 的手是 TCP,不折到法兰)\n' + f'# 旧结果备份在 results/backup/\n' + ) + elif self.gripper_tool: + header += ( + f'# gripper_tool: {self.gripper_tool}\n' + f'# T_4x4 = cam→法兰(AX=XB 在 TCP 上解,再左乘 T_flange_to_tool)\n' + f'# T_cam_to_tool 是原始求解;旧的法兰/tool1 备份在 results/backup/\n' + ) self.result_file.parent.mkdir(parents=True, exist_ok=True) - self.result_file.write_text(header + yaml_txt, encoding='utf-8') + self.result_file.write_text(header + extra + yaml_txt, encoding='utf-8') (self.output_dir / 'captures.json').write_text( json.dumps(meta, indent=2), encoding='utf-8' ) diff --git a/src/arm_application/arm_application/handeye_lib.py b/src/arm_application/arm_application/handeye_lib.py index 1efccd1..048b358 100644 --- a/src/arm_application/arm_application/handeye_lib.py +++ b/src/arm_application/arm_application/handeye_lib.py @@ -342,6 +342,38 @@ def calibrate_hand_eye( return calibrate_hand_eye_tsai(Rg, tg, R_target2cam, t_target2cam) +def T_from_flexiv_tcp(xyz_m, qwxyz) -> np.ndarray: + """Studio ``tcp_location`` → 法兰→工具 4×4。 + + Flexiv 顺序是 ``[x, y, z, q_w, q_x, q_y, q_z]``,四元数是 wxyz, + 和本仓库其余地方的 xyzw 不一样。只在这里换一次。 + """ + xyz = np.asarray(xyz_m, dtype=float).reshape(3) + q = np.asarray(qwxyz, dtype=float).reshape(7) if np.asarray(qwxyz).size == 7 else np.asarray(qwxyz, dtype=float).reshape(4) + if q.size == 7: + qw, qx, qy, qz = (float(q[3]), float(q[4]), float(q[5]), float(q[6])) + else: + qw, qx, qy, qz = (float(q[0]), float(q[1]), float(q[2]), float(q[3])) + R = quat_xyzw_to_R((qx, qy, qz, qw)) + return Rt_to_T(R, xyz.reshape(3, 1)) + + +def cam_to_flange_via_tool(T_cam_to_tool: np.ndarray, T_flange_to_tool: np.ndarray) -> np.ndarray: + """AX=XB 在工具 TCP 上解出 cam→工具后,再折成 cam→法兰。 + + ``T_flange_to_tool`` 是 Studio TCP:``p_法兰 = T_flange_to_tool · p_工具``。 + OpenCV 的 ``T_cam_to_tool`` 是 ``p_工具 = T_cam_to_tool · p_相机``。 + 所以 ``T_cam_to_flange = T_flange_to_tool · T_cam_to_tool``。 + + 乘反了(``T_cam_to_tool · inv(T_ft)``)旋转看着还像,平移会把相机 + 扔到法兰后方约 30 cm。忘了乘则 ``T_4x4`` 停在 TCP 上,沿工具偏 ~225 mm。 + 两种都不报错。 + """ + T_ct = np.asarray(T_cam_to_tool, dtype=float).reshape(4, 4) + T_ft = np.asarray(T_flange_to_tool, dtype=float).reshape(4, 4) + return T_ft @ T_ct + + def format_transform_yaml(R: np.ndarray, t: np.ndarray, name: str) -> str: T = Rt_to_T(R, t) lines = [ @@ -354,6 +386,14 @@ def format_transform_yaml(R: np.ndarray, t: np.ndarray, name: str) -> str: return '\n'.join(lines) + '\n' +def format_matrix_yaml(T: np.ndarray, key: str) -> str: + arr = np.asarray(T, dtype=float).reshape(4, 4) + lines = [f'{key}:'] + for row in arr: + lines.append(' - [' + ', '.join(f'{v:.8f}' for v in row) + ']') + return '\n'.join(lines) + '\n' + + def is_pose_filled(joints: Sequence[float]) -> bool: """若全为零 / NaN 占位则返回 False。""" arr = np.asarray(joints, dtype=float) diff --git a/src/arm_application/arm_application/handeye_teach.py b/src/arm_application/arm_application/handeye_teach.py index 7528e44..a78b0fc 100644 --- a/src/arm_application/arm_application/handeye_teach.py +++ b/src/arm_application/arm_application/handeye_teach.py @@ -122,6 +122,8 @@ class RdkBridge: 'PATH': f'{Path(py).parent}:/usr/bin:/bin', 'LD_LIBRARY_PATH': flexiv_lib, 'LANG': 'C.UTF-8', + 'SITE_PC_IP': os.environ.get('SITE_PC_IP', ''), + 'SITE_NET_IFACE': os.environ.get('SITE_NET_IFACE', ''), } # 不要继承 conda 的 PYTHONPATH。 # 重要:stderr 不能用 PIPE —— RDK 日志会填满管道并让桥接进程死锁 @@ -254,6 +256,9 @@ class HandeyeTeach(TolerantNode): # 设了就只是个"提醒":采够时提示可以收工,不强制关窗 —— # 常常采到一半发现某个角度还没覆盖,强制退出反而要重开。 self.declare_parameter('target_points', 0) + # 必须和 run_handeye.sh 的 TAUGHT 是同一个文件。只写 taught_poses.txt、 + # 脚本去读 taught_poses_<模式>.txt,merge 就会「文件在、内容空」。 + self.declare_parameter('poses_file', '') self.backend = str(self.get_parameter('backend').value).strip().lower() self.robot_sn = str(self.get_parameter('robot_sn').value) @@ -261,8 +266,12 @@ class HandeyeTeach(TolerantNode): self.output_dir = Path(str(self.get_parameter('output_dir').value)).expanduser() self.images_dir = self.output_dir / 'images' - self.poses_file = self.output_dir / 'taught_poses.txt' + _pf = str(self.get_parameter('poses_file').value).strip() + self.poses_file = ( + Path(_pf).expanduser() if _pf else self.output_dir / 'taught_poses.txt' + ) self.yaml_file = self.output_dir / 'taught_waypoint_positions.yaml' + self._last_capture_t = 0.0 self.images_dir.mkdir(parents=True, exist_ok=True) self._captures: List[dict] = [] @@ -531,6 +540,20 @@ class HandeyeTeach(TolerantNode): if q is None: self.get_logger().error('读取关节失败') return + now = time.monotonic() + if now - self._last_capture_t < 0.8: + self.get_logger().warn('按太快,忽略(防 Enter 连发)') + return + if self._captures: + last = self._captures[-1]['joints'] + delta = max(abs(a - b) for a, b in zip(q, last)) + if delta < 0.015: + self.get_logger().warn( + f'关节几乎没动(Δ={delta:.4f} rad),忽略这次拍照。' + '先拖到另一个姿态再按 Enter。' + ) + return + self._last_capture_t = now idx = len(self._captures) stamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') diff --git a/src/arm_application/arm_application/overhead_oneshot.py b/src/arm_application/arm_application/overhead_oneshot.py index 651125a..1c47c5a 100644 --- a/src/arm_application/arm_application/overhead_oneshot.py +++ b/src/arm_application/arm_application/overhead_oneshot.py @@ -17,6 +17,32 @@ from typing import Iterable, Optional import numpy as np from arm_application.handeye_lib import cam_point_to_base +from arm_application.path_profile import ( # 伺服和测试从本模块再导出,不另抄一份 + D435_FULL_ACC_M, + D435_FULL_CRUISE_MPS, + D435_FULL_DEC_M, + D435_MID_HOP_MAX_M, + D435_MID_LEFTOVER_M, + D435_MOVE_ACC_MPS2, + D435_MOVE_SPEED_SCALE, + D435_NEAR_CRUISE_MPS, + D435_NEAR_HOP_MAX_M, + D435_NEAR_LEFTOVER_M, + D435_VERT_TILT_RAD, + D435_SETTLE_S, + FROZEN_J_HOP_MAX_M, + build_arc_xy_move, + flange_down_w, + flange_tilt_rad, + hybrid_cruise_vxy, + oneshot_hop_limits, + smooth_joint_schedule, + trap_acc_for_hop, + translate_hold_ori_waypoints, +) + +# 上一跳实际少走这么多,就认定冻结 J 在当前姿态走不动,不许按同一剩差再刷。 +D435_MIN_PROGRESS_FRAC = 0.40 def best_det_conf(dets: Optional[Iterable]) -> Optional[float]: @@ -30,6 +56,301 @@ def best_det_conf(dets: Optional[Iterable]) -> Optional[float]: return max(vals) +def z_locked_qos(): + """``visual_servo/z_locked`` 的 QoS。发布端和订阅端必须用这一份。 + + 感知比伺服晚启动。默认 volatile 会丢掉伺服启动时已经发出的「已到锁高」, + 感知就停在初值 False,D435 永远不拍。 + """ + from rclpy.qos import ( # noqa: PLC0415 — 纯函数测试不需要在导入时拉起 ROS + DurabilityPolicy, + HistoryPolicy, + QoSProfile, + ReliabilityPolicy, + ) + + return QoSProfile( + history=HistoryPolicy.KEEP_LAST, + depth=1, + reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.TRANSIENT_LOCAL, + ) + + +def at_lock_height(z_m: float, lock_z_m: float, gate_m: float) -> bool: + """法兰是否在锁高带内。 + + 低于锁高**不算**到位。上一轮观测下降停在 0.26 m 时,若把「不高于锁高」 + 当成到位,XY 会在低位开跑,D435 又因高度不对拒绝拍照,两边都不再动。 + ``lock_z_m <= 0`` 表示没启用锁高。 + """ + lock = float(lock_z_m) + if lock <= 0.0: + return True + gate = max(0.02, float(gate_m)) + return abs(float(z_m) - lock) <= gate + + +def lock_z_velocity(z_m: float, lock_z_m: float, *, kp: float, v_lim: float) -> float: + """回到锁高的竖直速度 [m/s],基座 +Z 朝上。 + + 低于锁高为正(上抬),高于为负(下降)。4 mm 内视为到位。 + 不许在低于锁高时把速度写成 0:那会和「没到锁高不拍 D435」卡死。 + """ + lock = float(lock_z_m) + if lock <= 0.0: + return 0.0 + ez = lock - float(z_m) + if abs(ez) < 0.004: + return 0.0 + lim = abs(float(v_lim)) + return float(np.clip(float(kp) * ez, -lim, lim)) + + +def hold_translation_vz(z_m: float, z_hold_m: float, *, kp: float, v_lim: float) -> float: + """平移时把法兰钉在**这一段开始时的高度**。 + + 只写 v_z=0 不够:大范围平移加姿态校正时,阻尼求逆会把竖直速度吃掉, + 2026-10-09 真机从 0.327 m 漂到 0.307 m 又抬到 0.337 m。这里用开始那一 + 帧的高度当设定,4 mm 内不动,避免来回抖。不追 site 里的锁高数字, + 也不因此停掉 XY。下臂仍只在二阶段。 + """ + ez = float(z_hold_m) - float(z_m) + if abs(ez) < 0.004: + return 0.0 + lim = abs(float(v_lim)) + return float(np.clip(float(kp) * ez, -lim, lim)) + + +# D405 连续这么久没有高置信度目标,才让待命的 D435 拍一张。 +# 单帧漏检就拍,会在平移半路把目标换掉。 +D405_MISS_BEFORE_D435_S = 0.5 +# 拍了但伺服没 ``d435_commanding``(工作盒拒收等):超过这时限就允许再拍。 +# 不清的话 ``_oneshot_shot`` 会永远真,臂停着、感知还以为在赶路。 +ONESHOT_ACCEPT_TIMEOUT_S = 0.8 + + +def oneshot_shot_timed_out( + *, + shot: bool, + commanding: bool, + age_s: float, + timeout_s: float = ONESHOT_ACCEPT_TIMEOUT_S, +) -> bool: + """拍过一张、伺服却没接住 → True。已经在赶路不许清。""" + if not bool(shot) or bool(commanding): + return False + return float(age_s) >= float(timeout_s) + + +def d405_commands_arm( + *, + using_d435: bool, + arrived: bool, + settled: bool, + move_only: bool = False, +) -> bool: + """D405 这一帧的点能不能下发给左臂。 + + 推理一直开着。正在用 D435 那一个点时,点到并且停稳之前不下发。 + 停稳沿用一阶段的 ``StillLock``(site 里的 ``lock_still_sec`` / + ``lock_still_move_m``),不另写一套计时。 + ``move_only``:只测 D435 位移,D405 点一律不下发(到位之后也不交)。 + """ + if move_only: + return False + if not using_d435: + return True + return bool(arrived) and bool(settled) + + +def oneshot_needs_verticalize( + tilt_rad: float, + leftover_m: float, + arrive_m: float, + *, + tilt_lim: float = D435_VERT_TILT_RAD, + already_done: bool = False, +) -> bool: + """XY 已经进到位门槛、倾角还大,才单独收垂直。不许和平移捆在一起。""" + if already_done: + return False + if float(leftover_m) > float(arrive_m): + return False + return float(tilt_rad) > float(tilt_lim) + + +def oneshot_next_hop_m( + dist_m: float, hop_max_m: float = FROZEN_J_HOP_MAX_M +) -> float: + """这一跳最多走 ``hop_max_m``。冻结 J 一次规划 400 mm 会少走一大截。""" + return min(max(0.0, float(dist_m)), float(hop_max_m)) + + +def oneshot_cartesian_dp( + p_ee, + goal_xy, + lock_z: float, + *, + gate_m: float, + hop_max_m: Optional[float] = None, + leftover_xy_m: Optional[float] = None, +): + """这一跳的笛卡尔位移。没到锁高只收 Z;远处 XY 带锁高;近处只走 XY。 + + 工件上方最后一跳若再把 7 mm 的 Z 和 60 mm 的 XY 捆在一起,冻结 J 会把腕部带偏。 + 远处仍带 ``ee_z_m``,避免长跳把高度抬出锁高带。 + """ + p = np.asarray(p_ee, dtype=float).reshape(3) + z_lock = float(lock_z) + leftover = ( + float(leftover_xy_m) + if leftover_xy_m is not None + else float( + np.hypot(float(goal_xy[0]) - float(p[0]), float(goal_xy[1]) - float(p[1])) + ) + ) + hop_cap = ( + float(hop_max_m) + if hop_max_m is not None + else oneshot_hop_limits(leftover)[0] + ) + if not at_lock_height(float(p[2]), z_lock, float(gate_m)): + dz = z_lock - float(p[2]) + mag = oneshot_next_hop_m(abs(dz), hop_cap) + if mag < 1e-6: + return np.zeros(3, dtype=float) + return np.array([0.0, 0.0, float(np.sign(dz)) * mag], dtype=float) + dz = 0.0 if leftover <= D435_NEAR_LEFTOVER_M else (z_lock - float(p[2])) + dp = np.array( + [ + float(goal_xy[0]) - float(p[0]), + float(goal_xy[1]) - float(p[1]), + dz, + ], + dtype=float, + ) + n = float(np.linalg.norm(dp)) + hop = oneshot_next_hop_m(n, hop_cap) + if n > hop > 1e-9: + dp = dp * (hop / n) + return dp + + +def oneshot_full_segments( + p_ee, + goal_xy, + lock_z: float, + *, + gate_m: float, +): + """整段一次走完的笛卡尔分段。先收齐锁高,再走全部 XY,不切 80/15 mm。 + + 近处(剩差 ≤80 mm)仍然只走 XY,不混 Z。收垂直不在这里。 + 返回 ``[(dp, tag), ...]``,``tag`` 是 ``lift_z`` 或 ``xy``。 + """ + p = np.asarray(p_ee, dtype=float).reshape(3).copy() + z_lock = float(lock_z) + goal = np.asarray(goal_xy, dtype=float).reshape(2) + out = [] + if not at_lock_height(float(p[2]), z_lock, float(gate_m)): + dz = z_lock - float(p[2]) + if abs(dz) >= 1e-6: + out.append((np.array([0.0, 0.0, dz], dtype=float), "lift_z")) + p[2] = z_lock + leftover = float(np.hypot(float(goal[0]) - float(p[0]), float(goal[1]) - float(p[1]))) + dz = 0.0 if leftover <= D435_NEAR_LEFTOVER_M else (z_lock - float(p[2])) + dp = np.array( + [float(goal[0]) - float(p[0]), float(goal[1]) - float(p[1]), dz], + dtype=float, + ) + if float(np.linalg.norm(dp)) >= 1e-6: + out.append((dp, "xy")) + return out + + +def oneshot_concat_hold_ori(q0, J, segments, *, R=None): + """把分段 ``dp`` 用同一份冻结 J 串成一条关节折线。中途不回零速。""" + q = np.asarray(q0, dtype=float).copy() + wps = [q.copy()] + planned = 0.0 + asked = 0.0 + tags = [] + for dp, tag in segments: + hop = float(np.linalg.norm(np.asarray(dp, dtype=float).reshape(3))) + if hop < 1e-6: + continue + asked += hop + part, remain = translate_hold_ori_waypoints( + q, J, dp, R=R, verticalize=False + ) + if len(part) < 2: + break + wps.extend(part[1:]) + q = wps[-1] + planned += hop - float(remain) + tags.append(str(tag)) + tag = "full" + if tags == ["lift_z"]: + tag = "lift_z" + elif "lift_z" in tags and "xy" in tags: + tag = "full" + elif tags == ["xy"]: + tag = "full" + return wps, planned, asked, tag + + +def oneshot_full_cruise_mps(leftover_m: float, far_cruise_mps: float) -> float: + """整段巡航:工件上方 30 mm/s;远处封顶 ``D435_FULL_CRUISE_MPS``,不许再弹到 80。""" + far = max(1e-4, float(far_cruise_mps)) + if float(leftover_m) <= D435_NEAR_LEFTOVER_M: + return min(D435_NEAR_CRUISE_MPS, far) + return min(D435_FULL_CRUISE_MPS, far) + + +def oneshot_may_send_another( + leftover_m: float, + last_dist_m: Optional[float], + *, + arrive_m: float, + hop_max_m: float = FROZEN_J_HOP_MAX_M, + min_progress_frac: float = D435_MIN_PROGRESS_FRAC, +) -> bool: + """上一跳走完后还要不要再发一条。 + + 剩差已经进到位门槛 → 钉住。 + 上一跳几乎没缩短剩差 → 不许按同一距离再刷(真机 28 mm 死循环)。 + """ + if float(leftover_m) <= float(arrive_m): + return False + if last_dist_m is None: + return True + progressed = float(last_dist_m) - float(leftover_m) + planned = min(float(last_dist_m), float(hop_max_m)) + if progressed < float(min_progress_frac) * max(planned, 1e-6): + return False + return True + + +def oneshot_should_submit( + *, + handoff: bool, + low_for_s: float, + already_shot: bool, + wait_s: float = D405_MISS_BEFORE_D435_S, +) -> bool: + """腕部连续 ``wait_s`` 秒没有高置信度目标、这一轮还没拍成,才让 D435 拍一张。 + + 高度不在这里再判一次。进一阶段之前已经抬到锁高;再等伺服报「已到锁高」 + 会和「没到锁高就只收 Z、不平移」卡死。拍成一张之后不许连拍,顶视不是第二套跟踪。 + """ + return ( + bool(handoff) + and (not already_shot) + and float(low_for_s) >= float(wait_s) + ) + + def select_oneshot_det(dets: Optional[Iterable], min_conf: float): """顶视一次性:只收 conf≥门槛的框,取最高分。没有就 None,不许降级。""" if not dets: diff --git a/src/arm_application/arm_application/path_profile.py b/src/arm_application/arm_application/path_profile.py new file mode 100644 index 0000000..1189568 --- /dev/null +++ b/src/arm_application/arm_application/path_profile.py @@ -0,0 +1,550 @@ +"""路径弧长上的梯形速度,以及 XY 圆弧。 + +二阶段下臂和一阶段 D435 迁臂共用这一份。不许再抄一套加减速。 +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Optional, Tuple + +import numpy as np + +# 整段同一巡航:site ``v_slow_xy`` × 本系数。1.0 → 80 mm/s(二阶段对孔同档上限)。 +D435_MOVE_SPEED_SCALE = 1.0 +# 缺路程时的兜底加速度。真机 hop 用 ``trap_acc_for_hop``,和二阶段同一条刹车。 +D435_MOVE_ACC_MPS2 = 0.125 +# action 走完后再停这么久,才重测 TF。二阶段 ``_goto`` 的 settle_s。 +D435_SETTLE_S = 0.25 +# 冻结雅可比一次只信这么远。二阶段 ``_plan_and_send`` 默认 ``max_seg`` 同源。 +# 400 mm 一次规划,真机剩 117 mm,随后 28 mm 死循环刷轨迹。 +FROZEN_J_HOP_MAX_M = 0.08 +# 剩差进入这一带 = 工件上方。真机最后一跳 37–75 mm @ 80 mm/s 会晃并偏转。 +D435_NEAR_LEFTOVER_M = 0.08 +D435_NEAR_HOP_MAX_M = 0.015 +D435_NEAR_CRUISE_MPS = 0.030 +# 法兰倾角超过这个才单独收垂直。和 XY 捆在同一跳会把腕部漏成 Y 过冲。 +D435_VERT_TILT_RAD = 0.015 +D435_MID_LEFTOVER_M = 0.15 +D435_MID_HOP_MAX_M = 0.040 +# 弦长的 12% 当矢高,封顶 50 mm,避免大圆弧甩出工作盒。 +D435_ARC_SAGITTA_FRAC = 0.12 +D435_ARC_SAGITTA_MAX_M = 0.05 +# 整段一次走完。起步必须够长:40 mm 就爬到 80 mm/s 是真机那下弹射 +# (头尾速度本来就是 0,不是初速度;平移也不下发角速度)。 +# 峰值加速度 a_max = π v² / (4 d_acc);100 mm @ 60 mm/s → 约 0.028 m/s²。 +D435_FULL_ACC_M = 0.100 +D435_FULL_DEC_M = 0.080 +D435_FULL_CRUISE_MPS = 0.060 + + +def hybrid_cruise_vxy( + v_slow_xy: float, scale: float = D435_MOVE_SPEED_SCALE +) -> float: + """远处巡航。近处由 ``oneshot_hop_limits`` 另给一档,不许再抄 15 mm/s。""" + return max(1e-4, float(v_slow_xy) * float(scale)) + + +def oneshot_hop_limits( + leftover_m: float, far_cruise_mps: float = 0.08 +) -> Tuple[float, float]: + """这一跳的 ``(hop_max_m, cruise_mps)``。近处必须比 80 mm / 80 mm/s 短且慢。""" + d = max(0.0, float(leftover_m)) + far = max(1e-4, float(far_cruise_mps)) + if d <= D435_NEAR_LEFTOVER_M: + return D435_NEAR_HOP_MAX_M, min(D435_NEAR_CRUISE_MPS, far) + if d <= D435_MID_LEFTOVER_M: + return D435_MID_HOP_MAX_M, far + return FROZEN_J_HOP_MAX_M, far + + +def flange_down_w( + R, + *, + dead_rad: float = 0.006, + gain: float = 2.0, + mag_lim: float = 0.5, +) -> Tuple[np.ndarray, float]: + """让法兰 +Z 指向世界 −Z 的角速度 + 当前倾角。 + + 和二阶段 ``_w_down`` 同一份。死区 0.006 rad(约 0.34°)以下不再修。 + """ + z_now = np.asarray(R, dtype=float).reshape(3, 3)[:, 2] + 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)) + ang = float(math.acos(c)) + if ang < float(dead_rad): + return np.zeros(3, dtype=float), ang + axis = np.cross(z_now, z_des) + n = float(np.linalg.norm(axis)) + axis = axis / n if n > 1e-9 else np.array([1.0, 0.0, 0.0], dtype=float) + mag = float(np.clip(float(gain) * ang, -float(mag_lim), float(mag_lim))) + return axis * mag, ang + + +def flange_tilt_rad(R) -> float: + """法兰 +Z 相对世界 −Z 的倾角 [rad]。只读角度,不带死区。""" + z_now = np.asarray(R, dtype=float).reshape(3, 3)[:, 2] + 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)) + return float(math.acos(c)) + + +def trap_acc_for_hop(dist: float, v: float) -> float: + """二阶段 ``_plan_and_send`` 同一条刹车:路程越长刹车越远,不许另写一套。""" + v_cmd = max(min(float(v), 0.08), 1e-3) + d_brake = min(0.030, max(0.006, 0.32 * max(float(dist), 1e-6))) + return max(v_cmd * v_cmd / (2.0 * d_brake), 0.030) + + +def trap_params(dist: float, v: float, a: float) -> Tuple[float, float, float, float, float]: + """Rest-to-rest 梯形:返回 (T, v_peak, t_acc, t_cruise, a)。巡航速度保持 v。""" + dist = max(float(dist), 1e-6) + v = max(float(v), 1e-4) + a = max(float(a), 1e-3) + d_acc = v * v / (2.0 * a) + if 2.0 * d_acc >= dist: + v_peak = math.sqrt(max(a * dist, 1e-12)) + t_acc = v_peak / a + return 2.0 * t_acc, v_peak, t_acc, 0.0, a + t_acc = v / a + t_cruise = (dist - 2.0 * d_acc) / v + return 2.0 * t_acc + t_cruise, v, t_acc, t_cruise, a + + +def trap_sv( + t: float, dist: float, v_peak: float, t_acc: float, t_cruise: float, a: float, +) -> Tuple[float, float]: + """时刻 t 的路径弧长 s 与切向速度。""" + t = max(0.0, float(t)) + t_dec0 = t_acc + t_cruise + t_end = t_dec0 + t_acc + if t >= t_end: + return dist, 0.0 + if t < t_acc: + return 0.5 * a * t * t, a * t + if t < t_dec0: + return 0.5 * a * t_acc * t_acc + v_peak * (t - t_acc), v_peak + td = t - t_dec0 + s0 = 0.5 * a * t_acc * t_acc + v_peak * t_cruise + return s0 + v_peak * td - 0.5 * a * td * td, max(0.0, v_peak - a * td) + + +def smooth_path_params( + dist: float, + v: float, + *, + d_acc: float = D435_FULL_ACC_M, + d_dec: float = D435_FULL_DEC_M, +) -> Tuple[float, float, float, float, float, float]: + """整段一次走完的余弦加减速:(T, v_peak, t_acc, t_cruise, d_acc, d_dec)。 + + 加速/减速段速度按半个余弦走,两端加速度为 0,没有梯形那种加速度台阶。 + 路程不够巡航时整段一条余弦,峰值不超过 ``v``。 + """ + dist = max(float(dist), 1e-6) + v = max(float(v), 1e-4) + acc = min(max(float(d_acc), 1e-4), 0.45 * dist) + dec = min(max(float(d_dec), 1e-4), 0.45 * dist) + if acc + dec >= dist - 1e-6: + # 整段一条正弦速度:v = v_peak sin(π t / T),s(T)=2 v_peak T / π。 + T = math.pi * dist / (2.0 * v) + return T, v, 0.0, 0.0, 0.0, dist + t_acc = 2.0 * acc / v + t_dec = 2.0 * dec / v + t_cruise = (dist - acc - dec) / v + T = t_acc + t_cruise + t_dec + return T, v, t_acc, t_cruise, acc, dec + + +def cosine_peak_acc(v: float, d_acc: float) -> float: + """余弦加速段的峰值切向加速度 [m/s²]。``d_acc`` 越长,起步越不弹。""" + d = max(float(d_acc), 1e-6) + return math.pi * float(v) * float(v) / (4.0 * d) + + +def smooth_path_sv( + t: float, + dist: float, + v_peak: float, + t_acc: float, + t_cruise: float, + d_acc: float, + d_dec: float, +) -> Tuple[float, float]: + """时刻 t 的弧长与切向速度。头尾速度为 0。""" + t = max(0.0, float(t)) + dist = max(float(dist), 1e-6) + v_peak = max(float(v_peak), 1e-4) + if t_acc <= 1e-9 and t_cruise <= 1e-9: + T = math.pi * dist / (2.0 * v_peak) + if t >= T: + return dist, 0.0 + u = math.pi * t / T + s = (v_peak * T / math.pi) * (1.0 - math.cos(u)) + vs = v_peak * math.sin(u) + return min(s, dist), max(0.0, vs) + t_dec = 2.0 * float(d_dec) / v_peak if v_peak > 1e-9 else 0.0 + t_end = float(t_acc) + float(t_cruise) + t_dec + if t >= t_end: + return dist, 0.0 + if t < t_acc: + u = math.pi * t / t_acc + s = 0.5 * v_peak * (t - t_acc / math.pi * math.sin(u)) + vs = 0.5 * v_peak * (1.0 - math.cos(u)) + return s, vs + if t < t_acc + t_cruise: + return float(d_acc) + v_peak * (t - t_acc), v_peak + td = t - t_acc - t_cruise + u = math.pi * td / t_dec if t_dec > 1e-9 else math.pi + s0 = float(d_acc) + v_peak * float(t_cruise) + s = s0 + 0.5 * v_peak * (td + t_dec / math.pi * math.sin(u)) + vs = 0.5 * v_peak * (1.0 + math.cos(u)) + return min(s, dist), max(0.0, vs) + + +def _circle_3( + a: np.ndarray, b: np.ndarray, c: np.ndarray +) -> Optional[Tuple[np.ndarray, float]]: + ax, ay = float(a[0]), float(a[1]) + bx, by = float(b[0]), float(b[1]) + cx, cy = float(c[0]), float(c[1]) + d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) + if abs(d) < 1e-9: + return None + a2 = ax * ax + ay * ay + b2 = bx * bx + by * by + c2 = cx * cx + cy * cy + ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d + uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d + center = np.array([ux, uy], dtype=float) + r = float(np.hypot(ax - ux, ay - uy)) + if r < 1e-4: + return None + return center, r + + +def _ccw(a0: float, a1: float) -> float: + return (a1 - a0) % (2.0 * math.pi) + + +def _xy_in_box(xy: np.ndarray, lo: np.ndarray, hi: np.ndarray) -> bool: + return bool( + float(lo[0]) <= float(xy[0]) <= float(hi[0]) + and float(lo[1]) <= float(xy[1]) <= float(hi[1]) + ) + + +@dataclass +class ArcXyMove: + """从 p0 走到 p1 的 XY 圆弧(或退化成直线),弧长上跑梯形速度。""" + + p0: np.ndarray + p1: np.ndarray + length: float + v_cruise: float + T: float + v_peak: float + t_acc: float + t_cruise: float + a: float + t0: float + linear: bool + center: Optional[np.ndarray] = None + radius: float = 0.0 + ang0: float = 0.0 + sweep: float = 0.0 + s_last: float = 0.0 + _dir: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0])) + + def sample(self, now: float) -> Tuple[np.ndarray, np.ndarray, bool]: + """返回 (计划 XY, 切向速度, 是否走完)。""" + t = max(0.0, float(now) - float(self.t0)) + s, sdot = trap_sv( + t, self.length, self.v_peak, self.t_acc, self.t_cruise, self.a + ) + s = min(float(s), float(self.length)) + self.s_last = s + done = t >= float(self.T) - 1e-4 or s >= float(self.length) - 1e-5 + if self.linear or self.radius <= 1e-6 or self.center is None: + L = max(float(self.length), 1e-6) + u = s / L + xy = (1.0 - u) * self.p0 + u * self.p1 + vxy = self._dir * float(sdot) + return xy, vxy, done + frac = 0.0 if abs(self.sweep) < 1e-9 else s / (self.radius * abs(self.sweep)) + frac = min(1.0, max(0.0, frac)) + ang = self.ang0 + self.sweep * frac + xy = self.center + self.radius * np.array( + [math.cos(ang), math.sin(ang)], dtype=float + ) + tang = np.array([-math.sin(ang), math.cos(ang)], dtype=float) + if self.sweep < 0.0: + tang = -tang + return xy, tang * float(sdot), done + + +def build_arc_xy_move( + p0, + p1, + *, + v: float, + a: float, + now: float, + ws_min=None, + ws_max=None, + sag_frac: float = D435_ARC_SAGITTA_FRAC, + sag_max: float = D435_ARC_SAGITTA_MAX_M, +) -> ArcXyMove: + """p0→p1 的圆弧。工作盒装不下就换侧,再不行走直线。速度整段同一档。""" + p0 = np.asarray(p0, dtype=float).reshape(2) + p1 = np.asarray(p1, dtype=float).reshape(2) + chord = p1 - p0 + L = float(np.hypot(chord[0], chord[1])) + direction = ( + chord / L if L > 1e-6 else np.array([1.0, 0.0], dtype=float) + ) + cruise = max(float(v), 1e-4) + acc = max(float(a), 1e-3) + + def _linear() -> ArcXyMove: + T, v_peak, t_acc, t_cruise, a_use = trap_params(max(L, 1e-6), cruise, acc) + return ArcXyMove( + p0=p0.copy(), + p1=p1.copy(), + length=max(L, 1e-6), + v_cruise=cruise, + T=T, + v_peak=v_peak, + t_acc=t_acc, + t_cruise=t_cruise, + a=a_use, + t0=float(now), + linear=True, + _dir=direction, + ) + + if L < 0.003: + return _linear() + + n = np.array([-direction[1], direction[0]], dtype=float) + h = min(float(sag_max), float(sag_frac) * L) + mid = 0.5 * (p0 + p1) + lo = None if ws_min is None else np.asarray(ws_min, dtype=float).reshape(-1)[:2] + hi = None if ws_max is None else np.asarray(ws_max, dtype=float).reshape(-1)[:2] + + def _try(sign: float) -> Optional[ArcXyMove]: + apex = mid + n * (h * sign) + cir = _circle_3(p0, apex, p1) + if cir is None: + return None + center, r = cir + a0 = math.atan2(p0[1] - center[1], p0[0] - center[0]) + a1 = math.atan2(p1[1] - center[1], p1[0] - center[0]) + a_via = math.atan2(apex[1] - center[1], apex[0] - center[0]) + ccw_end = _ccw(a0, a1) + ccw_via = _ccw(a0, a_via) + if 1e-6 < ccw_via < ccw_end - 1e-6: + sweep = ccw_end + else: + sweep = ccw_end - 2.0 * math.pi + if abs(sweep) < 1e-6: + return None + length = r * abs(sweep) + if lo is not None and hi is not None: + for k in range(9): + frac = k / 8.0 + ang = a0 + sweep * frac + xy = center + r * np.array([math.cos(ang), math.sin(ang)]) + if not _xy_in_box(xy, lo, hi): + return None + T, v_peak, t_acc, t_cruise, a_use = trap_params(length, cruise, acc) + return ArcXyMove( + p0=p0.copy(), + p1=p1.copy(), + length=length, + v_cruise=cruise, + T=T, + v_peak=v_peak, + t_acc=t_acc, + t_cruise=t_cruise, + a=a_use, + t0=float(now), + linear=False, + center=center, + radius=r, + ang0=a0, + sweep=sweep, + ) + + for sign in (1.0, -1.0): + got = _try(sign) + if got is not None: + return got + return _linear() + + +def path_interp(waypoints, u: float): + """沿关节路点折线插值。u∈[0,1] → (q, dq/du)。""" + nseg = len(waypoints) - 1 + if nseg <= 0: + z = np.zeros_like(waypoints[0]) + return waypoints[0].copy(), z + u = float(min(1.0, max(0.0, u))) + x = u * nseg + i = min(nseg - 1, int(math.floor(x))) + a = x - i + q = (1.0 - a) * waypoints[i] + a * waypoints[i + 1] + dq_du = (waypoints[i + 1] - waypoints[i]) * nseg + return q, dq_du + + +def translate_hold_ori_waypoints( + q0, + J, + dp, + *, + damping: float = 0.08, + max_dq: float = 0.06, + R=None, + verticalize: bool = False, +): + """冻结当前雅可比,把 ``dp`` 拆成关节路点。 + + 和二阶段 ``_plan_and_send`` 同一份逆解。``verticalize`` 时顺带收法兰朝下, + 不许另抄一份 ``_w_down``。返回 ``(路点, 还剩多少米)``。 + """ + from arm_application.geom_ik import dls_translate_hold_ori, dls_twist + + q = np.asarray(q0, dtype=float).copy() + remain = np.asarray(dp, dtype=float).reshape(3).copy() + dist = float(np.linalg.norm(remain)) + wps = [q.copy()] + inner_tol = min(0.00012, max(0.00005, 0.05 * max(dist, 1e-6))) + n_max = int(math.ceil(dist / 0.008)) + 16 + Jj = np.asarray(J, dtype=float) + w_step = np.zeros(3, dtype=float) + ang = 0.0 + if verticalize: + if R is None: + raise ValueError("verticalize 必须传入当前法兰 R,不许假装已经朝下") + w_step, ang = flange_down_w(R) + for _ in range(n_max): + if float(np.linalg.norm(remain)) < inner_tol and ( + (not verticalize) or ang < 0.006 + ): + break + nseg = max(1, int(math.ceil(float(np.linalg.norm(remain)) / 0.012))) + step = remain / nseg + if verticalize and ang > 0.008: + qdot = dls_twist(Jj, step / 0.12, w_base=w_step, damping=damping, max_qdot=0.45) + dq = np.clip(qdot * 0.12, -float(max_dq), float(max_dq)) + else: + dq = dls_translate_hold_ori(Jj, step, damping=damping, max_dq=max_dq) + if float(np.max(np.abs(dq))) < 1e-5: + break + q = q + dq + wps.append(q.copy()) + remain = remain - (Jj[:3, :] @ dq) + ang = max(0.0, ang - float(np.linalg.norm(w_step)) * 0.12) + return wps, float(np.linalg.norm(remain)) + + +def trap_joint_schedule( + waypoints, + dist: float, + v: float, + a: float, + *, + t0: float = 0.06, + dt: float = 0.04, + t_hold: float = 0.25, +): + """路点 + 梯形速度 → ``[(q, qdot, t), ...]``。头尾速度为 0,时间严格递增。 + + 一次下发整段。中途再发下一条会把这条掐掉,臂就重新加速 —— 那就是抖。 + """ + dist = max(float(dist), 1e-6) + T_move, v_peak, t_acc, t_cruise, a_use = trap_params(dist, v, a) + n_samp = max(8, int(math.ceil(T_move / max(float(dt), 1e-3)))) + zeros = np.zeros_like(waypoints[0]) + out = [] + last_t = -1.0 + for k in range(n_samp + 1): + t_local = T_move * k / n_samp + s, vs = trap_sv(t_local, dist, v_peak, t_acc, t_cruise, a_use) + u = s / dist + qi, dq_du = path_interp(waypoints, u) + qdot = dq_du * (vs / dist) if k < n_samp else zeros + if k >= n_samp: + qi = waypoints[-1] + qdot = zeros + t = float(t0) + t_local + if t <= last_t: + t = last_t + 0.02 + out.append((np.asarray(qi, dtype=float).copy(), np.asarray(qdot, dtype=float).copy(), t)) + last_t = t + hold = max(0.12, float(t_hold)) + for frac in (0.40, 0.70, 1.00): + t = float(t0) + T_move + hold * frac + if t < last_t + 0.05: + t = last_t + 0.05 + out.append((np.asarray(waypoints[-1], dtype=float).copy(), zeros.copy(), t)) + last_t = t + return out, T_move, v_peak + + +def smooth_joint_schedule( + waypoints, + dist: float, + v: float, + *, + t0: float = 0.06, + dt: float = 0.04, + t_hold: float = 0.25, + d_acc: float = D435_FULL_ACC_M, + d_dec: float = D435_FULL_DEC_M, +): + """路点 + 余弦加减速 → ``[(q, qdot, t), ...]``。头尾速度为 0。 + + D435 整段一次下发用这一份。二阶段短跳仍走 ``trap_joint_schedule``。 + """ + dist = max(float(dist), 1e-6) + T_move, v_peak, t_acc, t_cruise, acc_m, dec_m = smooth_path_params( + dist, v, d_acc=d_acc, d_dec=d_dec + ) + n_samp = max(12, int(math.ceil(T_move / max(float(dt), 1e-3)))) + zeros = np.zeros_like(waypoints[0]) + out = [] + last_t = -1.0 + for k in range(n_samp + 1): + t_local = T_move * k / n_samp + s, vs = smooth_path_sv( + t_local, dist, v_peak, t_acc, t_cruise, acc_m, dec_m + ) + u = min(1.0, s / dist) + qi, dq_du = path_interp(waypoints, u) + qdot = dq_du * (vs / dist) if k < n_samp else zeros + if k >= n_samp: + qi = waypoints[-1] + qdot = zeros + t = float(t0) + t_local + if t <= last_t: + t = last_t + 0.02 + out.append( + ( + np.asarray(qi, dtype=float).copy(), + np.asarray(qdot, dtype=float).copy(), + t, + ) + ) + last_t = t + hold = max(0.12, float(t_hold)) + for frac in (0.40, 0.70, 1.00): + t = float(t0) + T_move + hold * frac + if t < last_t + 0.05: + t = last_t + 0.05 + out.append((np.asarray(waypoints[-1], dtype=float).copy(), zeros.copy(), t)) + last_t = t + return out, T_move, v_peak diff --git a/src/arm_application/arm_application/perception_node.py b/src/arm_application/arm_application/perception_node.py index 06ba93a..339fd9e 100644 --- a/src/arm_application/arm_application/perception_node.py +++ b/src/arm_application/arm_application/perception_node.py @@ -31,7 +31,7 @@ from rclpy.qos import ( qos_profile_sensor_data, ) from sensor_msgs.msg import CameraInfo, Image -from std_msgs.msg import Float32, Float32MultiArray +from std_msgs.msg import Bool, Float32, Float32MultiArray from tf2_ros import TransformBroadcaster from arm_application.box_tracker import BoxTracker @@ -46,11 +46,16 @@ from arm_application.handeye_realsense import ( resolve_realsense_topics, ) from arm_application.overhead_oneshot import ( + D405_MISS_BEFORE_D435_S, + ONESHOT_ACCEPT_TIMEOUT_S, best_det_conf, depth_raw_to_m, oneshot_k0_base, + oneshot_shot_timed_out, + oneshot_should_submit, select_oneshot_det, wrist_needs_handoff, + z_locked_qos, ) from arm_application.kalman_filter import KalmanFilter3D from arm_application.plane_feature import ( @@ -161,6 +166,7 @@ class PerceptionNode(TolerantNode): # ---- D405 主跟踪 + D435 一次性(键必须从 site 接入,缺了启动即红)---- self.declare_parameter("wrist_handoff_conf", -1.0) self.declare_parameter("overhead_oneshot_enable", False) + self.declare_parameter("d435_move_only", False) self.declare_parameter("overhead_min_depth_m", -1.0) self.declare_parameter("overhead_max_depth_m", -1.0) self.declare_parameter("overhead_oneshot_conf", -1.0) @@ -210,9 +216,10 @@ class PerceptionNode(TolerantNode): # 跟踪点三道守卫(单一实现见 feature_guard.py) self.keypoint_border_px = float(self.get_parameter("keypoint_border_px").value) self.track_max_step_m = float(self.get_parameter("track_max_step_m").value) + self.reacquire_far_px = float(self.get_parameter("reacquire_far_px").value) self._slew = TrackSlewGuard(self.track_max_step_m) self._far = FarReacquireConfirm( - float(self.get_parameter("reacquire_far_px").value), + self.reacquire_far_px, int(self.get_parameter("reacquire_confirm_frames").value), ) self.plane_inlier_m = float(self.get_parameter("plane_inlier_m").value) @@ -220,6 +227,7 @@ class PerceptionNode(TolerantNode): self.overhead_oneshot_enable = bool( self.get_parameter("overhead_oneshot_enable").value ) + self.d435_move_only = bool(self.get_parameter("d435_move_only").value) self._T_oh: Optional[np.ndarray] = None self._oh_image: Optional[Image] = None self._oh_depth: Optional[Image] = None @@ -230,6 +238,11 @@ class PerceptionNode(TolerantNode): self._oneshot_t = -1.0e9 self._need_oneshot = False self._oneshot_inflight = False + self._oneshot_shot = False + self._oneshot_shot_t = -1.0e9 + self._low_since: Optional[float] = None + self._d435_commanding = False + self._z_locked = False self._oneshot_img: Optional[Image] = None self._oneshot_depth: Optional[Image] = None if self.overhead_oneshot_enable: @@ -441,10 +454,28 @@ class PerceptionNode(TolerantNode): CameraInfo, oh_info, self._on_oh_info, image_qos, callback_group=self._io_cbg, ) + self.create_subscription( + Bool, "visual_servo/z_locked", self._on_z_locked, z_locked_qos(), + callback_group=self._io_cbg, + ) + self.create_subscription( + Bool, + "visual_servo/d435_commanding", + self._on_d435_commanding, + z_locked_qos(), + callback_group=self._io_cbg, + ) + extra = ( + ";d435_move_only:不等 D405 丢目标,0.5s 后必拍一张" + if self.d435_move_only + else "" + ) self.get_logger().info( - f"D435 一次性 image={oh_img} depth={oh_dep} " + f"D435 待命 image={oh_img} depth={oh_dep} " f"handoff_conf={self.wrist_handoff_conf:.2f} " + f"看不到满 {D405_MISS_BEFORE_D435_S:.1f}s 才拍一张 " f"T={self.get_parameter('overhead_handeye_file').value}" + f"{extra}" ) point_qos = QoSProfile( @@ -865,9 +896,52 @@ class PerceptionNode(TolerantNode): self._publish_wrist_conf(best_conf) handoff = bool( self.overhead_oneshot_enable - and wrist_needs_handoff(best_conf, self.wrist_handoff_conf) + and ( + self.d435_move_only + or wrist_needs_handoff(best_conf, self.wrist_handoff_conf) + ) ) - self._need_oneshot = handoff + now_m = time.monotonic() + if self._d435_commanding: + # 臂还在赶 D435 那个点。漏检计时清掉,走到并停稳之后再重新计。 + self._low_since = None + self._need_oneshot = False + elif not handoff: + self._low_since = None + self._oneshot_shot = False + self._need_oneshot = False + else: + if oneshot_shot_timed_out( + shot=self._oneshot_shot, + commanding=self._d435_commanding, + age_s=now_m - float(self._oneshot_shot_t), + timeout_s=ONESHOT_ACCEPT_TIMEOUT_S, + ): + self.get_logger().warn( + "D435 拍了但伺服没接住(工作盒拒收等),允许再拍" + ) + self._oneshot_shot = False + self._low_since = None + if self._low_since is None: + self._low_since = now_m + low_for = now_m - self._low_since + self._need_oneshot = oneshot_should_submit( + handoff=True, + low_for_s=low_for, + already_shot=self._oneshot_shot, + wait_s=D405_MISS_BEFORE_D435_S, + ) + if self._oneshot_shot: + self.get_logger().info( + "D435 这一轮已经拍过一张,等这一段走完,不再连拍", + throttle_duration_sec=2.0, + ) + elif not self._need_oneshot: + self.get_logger().info( + f"D405 已连续 {low_for:.2f}s 没有高置信度目标," + f"未满 {D405_MISS_BEFORE_D435_S:.1f}s,D435 继续待命", + throttle_duration_sec=0.5, + ) now = time.monotonic() pred_at_img = self._box_trk.predict(t_img, self._img_wh()) with self._lock: @@ -1326,6 +1400,21 @@ class PerceptionNode(TolerantNode): msg.data = float("nan") if best_conf is None else float(best_conf) self._wrist_conf_pub.publish(msg) + def _on_z_locked(self, msg: Bool) -> None: + """锁高状态只记着。拍不拍 D435 不看它,看 D405 连续漏检。""" + self._z_locked = bool(msg.data) + + def _on_d435_commanding(self, msg: Bool) -> None: + """伺服还在执行 D435 那一个点时,不再提交下一张。 + + 这一段结束(到位并停稳)后清掉「已拍过」,漏检重新计 0.5 s。 + """ + commanding = bool(msg.data) + if self._d435_commanding and not commanding: + self._oneshot_shot = False + self._low_since = None + self._d435_commanding = commanding + def _publish_overhead_k0(self, p_base: np.ndarray, is_k0: bool) -> None: pt = PointStamped() pt.header.stamp = self.get_clock().now().to_msg() @@ -1447,6 +1536,9 @@ class PerceptionNode(TolerantNode): ) return p_base, is_k0 = got + self._oneshot_shot = True + self._oneshot_shot_t = now + self._need_oneshot = False self._publish_overhead_k0(p_base, is_k0) self.get_logger().info( f"D435 一次性定位 {'k0' if is_k0 else '框心(顶视看不见 k0)'} " diff --git a/src/arm_application/arm_application/pipeline_node.py b/src/arm_application/arm_application/pipeline_node.py index df5c7ff..7287bb4 100644 --- a/src/arm_application/arm_application/pipeline_node.py +++ b/src/arm_application/arm_application/pipeline_node.py @@ -26,16 +26,33 @@ from std_msgs.msg import Bool, String from arm_application.stage3_search_node import Stage3Search -#: 一阶段跟踪高相对二阶段悬停高的**抬升量** [m]。 +#: 悬停时规尖离面不够,跟踪高再抬的量 [m]。 #: -#: 为什么要抬:跟踪时通止规已经夹在爪上,规尖比相机更靠下;悬停高是"相机拍清 -#: 孔"的高度,直接在那儿跟踪会让规尖刮到工件。抬 5cm 让它离开工件,二阶段再 -#: 小幅下降(只需 5cm 以内的行程)。 +#: 只在「悬停高上规尖离工件面 < :data:`TRACK_LOCK_GAP_M`」时加上。仿真悬停按 +#: 工具几何只留约 2mm,不抬就会在跟踪时刮到工件。真机 89 悬停 0.320 时规尖 +#: 已经在面上方约 75mm,再抬 50mm 会让对孔前先改一次高度(0.37→0.32)。 #: #: ⚠️ 抽成常量是因为它不只被流程用:``scripts/check_sim_scene.py`` 要用它核对 #: "跟踪高有没有超出工作盒上界"(仿真里悬停 0.632 + 0.05 = 0.682 > 真机那套 #: 0.65 上界,就是被这条查出来的)。两处各写一个字面量迟早漂移。 TRACK_CLEARANCE_M = 0.05 +#: 悬停时规尖离面已经不少于这个值 [m],一阶段就锁在悬停高,对孔完成后再下降。 +TRACK_LOCK_GAP_M = 0.040 + + +def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float: + """一阶段锁高。 + + 悬停时规尖离面已经 ≥ ``TRACK_LOCK_GAP_M``:锁在悬停高,跟踪和对孔都不改 Z, + 对孔完成后再下降。离面不够(仿真悬停只留 2mm):再抬 ``TRACK_CLEARANCE_M``, + 否则跟踪会刮到工件。工具长或工件面缺失时抬升 —— 缺数时锁在悬停高等于没检查。 + """ + tool = float(tool_len_m) + surf = float(surface_z_m) + hover = float(hover_z) + if tool > 0.0 and surf > 0.0 and (hover - tool - surf) >= TRACK_LOCK_GAP_M: + return hover + return hover + TRACK_CLEARANCE_M def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) -> str: @@ -79,6 +96,7 @@ class AssemblyPipeline(Stage3Search): self.declare_parameter("skip_stage1", False) self.declare_parameter("skip_stage2", False) self.declare_parameter("skip_stage3", False) # 默认搜孔+轻压;拧入靠 --depth + self.declare_parameter("d435_move_only", False) self.declare_parameter("stage1_timeout_sec", 150.0) self.linger3 = 1.2 # 串联结束短保持;单独跑 stage3 仍用 site linger_sec @@ -113,9 +131,11 @@ class AssemblyPipeline(Stage3Search): skip1 = bool(self.get_parameter("skip_stage1").value) skip2 = bool(self.get_parameter("skip_stage2").value) skip3 = bool(self.get_parameter("skip_stage3").value) + self.d435_move_only = bool(self.get_parameter("d435_move_only").value) self.get_logger().info( f"pipeline arm_ee={self.ee_frame} hole={self.target_hole} " - f"skip1={skip1} skip2={skip2} skip3={skip3} insert={self.insert_depth_mm:.1f}mm" + f"skip1={skip1} skip2={skip2} skip3={skip3} " + f"d435_only={self.d435_move_only} insert={self.insert_depth_mm:.1f}mm" ) def _state(self, s: str) -> None: @@ -163,8 +183,12 @@ class AssemblyPipeline(Stage3Search): self._kill_old_stage1() def _track_z(self) -> float: - """跟踪高度 = 孔悬停高 + :data:`TRACK_CLEARANCE_M`,通止规离开工件。""" - return float(self.hover_z) + TRACK_CLEARANCE_M + """一阶段锁高:见 :func:`track_lock_z`。对孔完成前不再改这个高度。""" + return track_lock_z( + float(self.hover_z), + float(getattr(self, "tool_tip_below_flange_m", 0.0) or 0.0), + float(getattr(self, "surface_z_m", 0.0) or 0.0), + ) def _ensure_clearance(self) -> None: """上一轮轻压可能还在孔里:先纯 Z 抬到跟踪高,禁止 0.23↔0.35 来回。""" @@ -177,7 +201,7 @@ class AssemblyPipeline(Stage3Search): return self._state("pre_lift") self.get_logger().info( - f"先离开工件/孔口:Z {z:.3f} → {tz:.3f}m(跟踪高=hover+50mm)" + f"先到锁高:Z {z:.3f} → {tz:.3f}m(跟踪与对孔都停在这个高度,对完孔再下降)" ) _, q, Tee = self._goto( q, Tee, np.array([float(Tee[0, 3]), float(Tee[1, 3]), tz]), @@ -206,10 +230,16 @@ class AssemblyPipeline(Stage3Search): "publish_handeye_tf:=false", f"ee_z_m:={lock_z:.3f}", f"use_sim_time:={str(use_sim).lower()}", + f"d435_move_only:={str(self.d435_move_only).lower()}", ] - self.get_logger().info( - f"启动一阶段跟踪(锁高 {lock_z:.3f}m,锁定后自动交二阶段)" - ) + if self.d435_move_only: + self.get_logger().info( + f"启动 D435 位移测试(锁高 {lock_z:.3f}m,到位后不交 D405、不进二阶段)" + ) + else: + self.get_logger().info( + f"启动一阶段跟踪(锁高 {lock_z:.3f}m,锁定后自动交二阶段)" + ) self._s1 = subprocess.Popen( cmd, stdout=logf, stderr=subprocess.STDOUT, start_new_session=True, ) @@ -229,7 +259,12 @@ class AssemblyPipeline(Stage3Search): stage1_timeout_reason(self._point_msgs, timeout, log_path) ) return 1 - self.get_logger().info("一阶段已锁定 — 停跟踪、钉住当前关节,直接进二阶段") + if self.d435_move_only: + self.get_logger().info( + "D435 位移测试结束 — 停跟踪、钉住当前关节,跳过二阶段" + ) + else: + self.get_logger().info("一阶段已锁定 — 停跟踪、钉住当前关节,直接进二阶段") self._stop_stage1() time.sleep(0.35) q = self._q() @@ -241,6 +276,9 @@ class AssemblyPipeline(Stage3Search): skip1 = bool(self.get_parameter("skip_stage1").value) skip2 = bool(self.get_parameter("skip_stage2").value) skip3 = bool(self.get_parameter("skip_stage3").value) + if self.d435_move_only: + skip2 = True + skip3 = True if not self._cli.wait_for_server(timeout_sec=5.0): self.get_logger().error("JTC action 不在 —— 先开 T1: run_real.sh --arm 89") return 2 @@ -268,17 +306,30 @@ class AssemblyPipeline(Stage3Search): return code self.get_logger().info( f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm" - + (" — 跳过搜孔,保持悬停" if skip3 else " — 立刻下压搜孔并轻压(不拧入)") + + ( + " — 跳过搜孔,停在观测高度(看规尖是否对中;进程退出后臂保持)" + if skip3 + else " — 立刻下压搜孔并轻压(不拧入)" + ) ) else: self._state("skip_stage2") if skip3: self._state("skip_stage3") - self.get_logger().info("跳过搜孔 —— 臂保持在孔上方") + if self.d435_move_only: + self.get_logger().info( + "D435 位移测试结束 —— 臂停在当前高度,不交 D405、不对孔;" + "15s 后本进程退出,臂保持不动" + ) + else: + self.get_logger().info( + "跳过搜孔 —— 臂停在当前高度。请看规尖与孔是否对中;" + "15s 后本进程退出,臂保持不动" + ) if final: self._publish_result(final) - self._linger_hold(final if final else {"ok": True}, seconds=0.8) + self._linger_hold(final if final else {"ok": True}, seconds=15.0) payload = {"ok": True, "stage": "pipeline", "exit": 0, "skipped": "stage3"} try: payload["stage2"] = json.loads( diff --git a/src/arm_application/arm_application/servo_node.py b/src/arm_application/arm_application/servo_node.py index c56e30f..3b3fc52 100644 --- a/src/arm_application/arm_application/servo_node.py +++ b/src/arm_application/arm_application/servo_node.py @@ -24,9 +24,17 @@ import rclpy from geometry_msgs.msg import PointStamped, Twist from std_msgs.msg import Bool, Float32 from rclpy.callback_groups import MutuallyExclusiveCallbackGroup, ReentrantCallbackGroup +from rclpy.action import ActionClient from rclpy.executors import MultiThreadedExecutor from rclpy.node import Node -from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy, qos_profile_system_default +from control_msgs.action import FollowJointTrajectory +from rclpy.qos import ( + DurabilityPolicy, + HistoryPolicy, + QoSProfile, + ReliabilityPolicy, + qos_profile_system_default, +) from sensor_msgs.msg import JointState from tf2_ros import Buffer, TransformListener from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint @@ -39,7 +47,26 @@ from arm_application.geom_ik import ( quat_xyzw_to_R, ) from arm_application.handeye_lib import cam_point_to_base, cam_to_base_T -from arm_application.overhead_oneshot import StillLock, wrist_needs_handoff +from arm_application.overhead_oneshot import ( + D435_NEAR_CRUISE_MPS, + D435_SETTLE_S, + StillLock, + at_lock_height, + d405_commands_arm, + flange_tilt_rad, + hold_translation_vz, + hybrid_cruise_vxy, + lock_z_velocity, + oneshot_concat_hold_ori, + oneshot_full_cruise_mps, + oneshot_full_segments, + oneshot_hop_limits, + oneshot_may_send_another, + oneshot_needs_verticalize, + wrist_needs_handoff, + z_locked_qos, +) +from arm_application.path_profile import smooth_joint_schedule, translate_hold_ori_waypoints from arm_application.pid_controller import PidController from arm_application.kalman_filter import KalmanFilter3D from arm_application.feature_jump_guard import ( @@ -83,6 +110,9 @@ class ServoNode(TolerantNode): self.declare_parameter("ee_frame", ee_frame_default()) self.declare_parameter("camera_frame", "d405_color_optical_frame") self.declare_parameter("traj_topic", "rizon_arm_controller/joint_trajectory") + self.declare_parameter( + "action_name", "rizon_arm_controller/follow_joint_trajectory" + ) self.declare_parameter("point_topic", "visual_servo/filtered_point") # 默认值只作兜底:真机运行一定由 launch 传入 site.yaml 的 paths.handeye_file。 # 指向 /tmp 是历史遗留(标定临时产物),会让人误以为"标定结果在那里"。 @@ -187,6 +217,8 @@ class ServoNode(TolerantNode): self.declare_parameter("lock_still_move_m", -1.0) self.declare_parameter("wrist_handoff_conf", -1.0) self.declare_parameter("overhead_hold_s", -1.0) + # 只测 D435 位移:到位后不交 D405、不靠 D405 静止去锁二阶段。 + self.declare_parameter("d435_move_only", False) self.declare_parameter("overhead_k0_topic", "visual_servo/overhead_k0") self.declare_parameter("wrist_conf_topic", "visual_servo/wrist_conf") @@ -271,6 +303,7 @@ class ServoNode(TolerantNode): self.lock_still_move_m = float(self.get_parameter("lock_still_move_m").value) self.wrist_handoff_conf = float(self.get_parameter("wrist_handoff_conf").value) self.overhead_hold_s = float(self.get_parameter("overhead_hold_s").value) + self.d435_move_only = bool(self.get_parameter("d435_move_only").value) self._hybrid = ( self.lock_still_sec >= 0.0 and self.lock_still_move_m >= 0.0 @@ -281,6 +314,26 @@ class ServoNode(TolerantNode): still_sec=max(0.0, self.lock_still_sec), still_move_m=max(0.0, self.lock_still_move_m), ) + # D435 那一段的「到位并停稳」,和进二阶段用同一套静止判据。 + self._arrive = StillLock( + still_sec=max(0.0, self.lock_still_sec), + still_move_m=max(0.0, self.lock_still_move_m), + ) + self._d435_settled = False + self._d435_exec_until = 0.0 + self._d435_pinned = False + self._d435_sent = False + self._d435_last_dist = None + self._d435_give_up = False + self._d435_plan_lock = threading.Lock() + self._d435_inflight = False + self._d435_settle_until = 0.0 + self._d435_last_z_err = None + self._d435_z_stall = 0 + self._d435_remeasure_log = False + self._d435_vert_done = False + self._d435_full_done = False + self._d435_fix_done = False self._track_source = "wrist" # wrist | overhead self._wrist_conf: Optional[float] = None self._wrist_conf_t = -1.0 @@ -337,6 +390,11 @@ class ServoNode(TolerantNode): f"optical_vs_flangeZ={self._cam_tilt_deg:.1f}deg " f"— 悬停使用这个 T,而不是用 TF 查询" ) + if self.d435_move_only: + self.get_logger().warn( + "d435_move_only:只跑 D435 单次位移。到位停稳后钉住," + "不交 D405;locked 只用来结束一阶段,不进二阶段" + ) self.pid_x = PidController( float(self.get_parameter("kp_xy").value), @@ -391,6 +449,7 @@ class ServoNode(TolerantNode): self._jump_gate = FeatureJumpGate(self.predict_jump_m, self.jump_confirm_frames) # 只有当法兰 Z 已经落到锁定平面上(深度新鲜)才开启 XY 悬停。 self._xy_armed = bool(self.ee_z_m <= 0.0) + self._z_hold: Optional[float] = None # Seek(搜索)状态:在锁定高度且没有目标时扫描工件。 self._seek_t0 = 0.0 self._seek_theta = 0.0 @@ -447,11 +506,30 @@ class ServoNode(TolerantNode): ) # 一阶段锁定信号(串联编排器用它判断可以进入二阶段) self._lock_pub = self.create_publisher(Bool, "visual_servo/locked", 1) + # TRANSIENT_LOCAL:感知节点比伺服晚起来,默认 QoS 会丢掉启动时那一拍 + # True,感知就永远停在「还没到锁高」、D435 不拍。两边必须同一份 QoS。 + self._z_lock_pub = self.create_publisher( + Bool, "visual_servo/z_locked", z_locked_qos() + ) + # 感知用它挡住「平移半路再拍一张」。晚启动也能拿到当前值。 + self._d435_cmd_pub = self.create_publisher( + Bool, "visual_servo/d435_commanding", z_locked_qos() + ) + self._d435_cmd_sent: Optional[bool] = None + self._z_locked_sent: Optional[bool] = None + self._z_lock_pub_mono = 0.0 self._lock_since: Optional[float] = None self._locked = False self._lock_pub_t = 0.0 traj_topic = str(self.get_parameter("traj_topic").value) self._traj_pub = self.create_publisher(JointTrajectory, traj_topic, cmd_qos) + self._jtc_cbg = ReentrantCallbackGroup() + self._jtc = ActionClient( + self, + FollowJointTrajectory, + str(self.get_parameter("action_name").value), + callback_group=self._jtc_cbg, + ) self._tf = Buffer() TransformListener(self._tf, self, spin_thread=True) @@ -477,6 +555,36 @@ class ServoNode(TolerantNode): f"hold_xy={int(self.ee_hold_xy)} down={int(self.ee_vertical_down)} " f"XY_after_lock_z±{self.approach_xy_gate_m*1000:.0f}mm" ) + self._publish_z_locked(self._xy_armed) + self._publish_d435_commanding(False) + + def _publish_z_locked(self, locked: bool) -> None: + """法兰是否在锁高带里。只记日志,不拦 D435。 + + 状态没变也每 0.5 s 再发一次:只发一次的话,晚订阅的感知收不到。 + """ + locked = bool(locked) + now = time.monotonic() + changed = self._z_locked_sent is not locked + if (not changed) and (now - self._z_lock_pub_mono) < 0.5: + return + self._z_locked_sent = locked + self._z_lock_pub_mono = now + self._z_lock_pub.publish(Bool(data=locked)) + if not changed: + return + if locked: + self.get_logger().info("锁高已到位") + else: + self.get_logger().info("离开锁高带 —— 平移仍保持当前高度,不改去追锁高") + + def _publish_d435_commanding(self, commanding: bool) -> None: + """正在用 D435 那一个点赶路。感知看到 True 就不再拍第二张。""" + commanding = bool(commanding) + if self._d435_cmd_sent is commanding: + return + self._d435_cmd_sent = commanding + self._d435_cmd_pub.publish(Bool(data=commanding)) def _on_js(self, msg: JointState) -> None: self._last_js = msg @@ -504,8 +612,19 @@ class ServoNode(TolerantNode): if not np.all(np.isfinite(p_base)): return if not self._feat_workspace_ok(p_base): + m = float(self.feat_ws_margin_m) self.get_logger().warn( - f"D435 一次性点 ({p_base[0]:.3f},{p_base[1]:.3f}) 在工作盒外 —— 丢弃", + f"D435 一次性点 ({p_base[0]:.3f},{p_base[1]:.3f}) 在工作盒外 " + f"(x[{self.ws_min[0]:.2f},{self.ws_max[0]:.2f}] " + f"y[{self.ws_min[1]:.2f},{self.ws_max[1]:.2f}] ±{m:.2f})" + "—— 丢弃,不发轨迹。这不是起步加速度。" + ) + return + with self._lock: + busy = self._track_source == "overhead" and not self._d435_settled + if busy: + self.get_logger().info( + "D435 这一段还没走完,不再换目标", throttle_duration_sec=1.0, ) return @@ -520,15 +639,31 @@ class ServoNode(TolerantNode): self._have_target = True self._target_lost_flag = False self._track_source = "overhead" + self._d435_settled = False self._overhead_t = now_m self._last_seen_time = now_m self._stamp_sec = now_sec self._seek_t0 = 0.0 self._seek_warned = False self._still.reset() + self._arrive.reset() + self._d435_exec_until = 0.0 + self._d435_pinned = False + self._d435_sent = False + self._d435_last_dist = None + self._d435_give_up = False + self._d435_inflight = False + self._d435_settle_until = 0.0 + self._d435_last_z_err = None + self._d435_z_stall = 0 + self._d435_remeasure_log = False + self._d435_vert_done = False + self._d435_full_done = False + self._d435_fix_done = False + self._publish_d435_commanding(True) self.get_logger().info( f"D435 一次性设定 feat_base=({p_base[0]:.3f},{p_base[1]:.3f},{p_base[2]:.3f}) " - f"—— 迁过去等 D405 重新看见" + f"—— 走到这个点并停稳之前,不下发 D405 的点" ) def _on_point(self, msg: PointStamped) -> None: @@ -541,16 +676,26 @@ class ServoNode(TolerantNode): # 已经收到腕部 conf 且低于门槛:这条 D405 点不能当跟踪源。 # conf 还是 None = 感知还没报到,不要把开场几帧也丢掉。 return + with self._lock: + using_d435 = self._track_source == "overhead" + settled = self._d435_settled + if self._hybrid and not d405_commands_arm( + using_d435=using_d435, + arrived=settled, + settled=settled, + move_only=self.d435_move_only, + ): + # 推理服务不关。这一帧的点不下发给臂,目标仍是 D435 那一个点。 + self.get_logger().info( + "D435 位置还没到并且停稳,D405 这帧不下发", + throttle_duration_sec=1.0, + ) + return p_cam = np.array( [float(msg.point.x), float(msg.point.y), float(msg.point.z)], dtype=float ) T_ee = self._lookup_T(self.base_frame, self.ee_frame) p_base = self._feature_in_base(T_ee, p_cam) if T_ee is not None else None - at_lock = True - if self.ee_z_m > 0.0 and T_ee is not None: - at_lock = abs(float(T_ee[2, 3]) - self.ee_z_m) <= max( - 0.02, float(self.approach_xy_gate_m) - ) if not self._he_cmp_done: self._he_cmp_done = True self._log_handeye_tf_vs_yaml() @@ -558,12 +703,9 @@ class ServoNode(TolerantNode): rejected = False with self._lock: was_lost = self._target_lost_flag - if self._track_source == "overhead": - # 从顶视切回腕部:允许一次重定位,跳变门不拿顶视点比。 - was_lost = True self._track_source = "wrist" self._p_meas[:] = p_cam - if p_base is not None and at_lock and self._xy_armed: + if p_base is not None: verdict = self._update_feat_track(p_base, was_lost=was_lost, now_m=now_m) if verdict == ACCEPT: self._p_feat_base = np.asarray(p_base, dtype=float).copy() @@ -747,31 +889,28 @@ class ServoNode(TolerantNode): lost = self._target_lost_flag source = self._track_source if self._hybrid: + self._tick_d435_arrive() self._tick_still_lock() + if self.enable_control and self._d435_oneshot_holds_the_arm(): + return - # ----- 启动阶段:**先**下降到锁定高度,甚至在任何检测到来之前。 - # 在第一个 PointStamped 到达之前 have 一直是 False,所以这是一个纯启动 - # 阶段:只动 Z 下降,XY 锁死,姿态朝下。 - if ( - self.enable_control - and self.ee_z_m > 0.0 - and not have - and not self._xy_armed - ): + # 还没有目标、人又停在锁高下面:只抬一次 Z。已经有 D435 点或腕部点之后 + # 不再插这一步 —— 平移途中改高度会抖,也会把正在走的 XY 掐掉。 + if self.enable_control and self.ee_z_m > 0.0 and not have: T_ee0 = self._lookup_T(self.base_frame, self.ee_frame) if T_ee0 is None: - return # TF 还没就绪 —— 等待,不要触发保持急停 - self._update_xy_arm(float(T_ee0[2, 3])) + return + z_now = float(T_ee0[2, 3]) + self._update_xy_arm(z_now) if not self._xy_armed: self.get_logger().info( - f"启动下降至 lock_z {self.ee_z_m:.3f}m " - f"当前={T_ee0[2, 3]:.3f}m(还没有目标,XY 锁死)", + f"先回锁高 {self.ee_z_m:.3f}m 当前={z_now:.3f}m(还没有目标,只动 Z)", throttle_duration_sec=1.0, ) - self._target_lost_flag = False # 盲降期间允许执行 + self._target_lost_flag = False self._execute_twist(np.zeros(3, dtype=float), p_cam=None, hold_xy=True) - self._lock_feedback(False) - return + self._lock_feedback(False) + return # ----- Seek:已在锁定高度但还没有有效目标。eye-in-hand 相机只能看到 # 一小块区域,所以扫描工作区(先漂到标称工作 XY,再向外螺旋)直到工件进入视野。 @@ -821,13 +960,10 @@ class ServoNode(TolerantNode): seen_age = now_mono - last_seen stamp_age = now_sec - stamp if stamp >= 0.0 else 1e9 if self._hybrid and source == "overhead": - if seen_age > self.overhead_hold_s: - self._lock_feedback(False) - self._watchdog_trip( - f"D435 一次性设定已过期 {seen_age:.1f}s > {self.overhead_hold_s:.1f}s" - ) - return - stamp_age = 0.0 # 一次性不是流:别按图像戳判过期 + # 一次性设定一直用到 D405 接上。按秒数冻住的话,臂还在平移就被看门狗 + # 掐掉,D435 又不再拍,停在半路。 + stamp_age = 0.0 + seen_age = 0.0 elif seen_age > self.watchdog_timeout or stamp_age > self.lost_timeout: self._watchdog_trip( f"目标丢失 seen_age={seen_age:.3f}s stamp_age={stamp_age:.3f}s" @@ -1176,12 +1312,343 @@ class ServoNode(TolerantNode): self._xy_in_dead = dead return dead + def _jacobian_at(self, p_ee: np.ndarray): + link_R: List[np.ndarray] = [] + link_p: List[np.ndarray] = [] + for fr in self.link_frames: + Tl = self._lookup_T(self.base_frame, fr) + if Tl is None: + return None + link_R.append(Tl[:3, :3]) + link_p.append(Tl[:3, 3]) + return geometric_jacobian(link_R, link_p, p_ee, RIZON_LINK_AXES) + + def _d435_oneshot_holds_the_arm(self) -> bool: + """D435 到点平移:action 短跳,等结果再重测。D405 主跟踪仍走原来的伺服。""" + if self.d435_move_only and self._hybrid and self._track_source == "overhead" and self._d435_settled: + # 位移测完了:守着,不许掉进 D405 的 _execute_twist。hold 只在到位那一拍发一次。 + return True + if not (self._hybrid and self._track_source == "overhead" and not self._d435_settled): + return False + if not self._d435_plan_lock.acquire(blocking=False): + return True + try: + return self._d435_oneshot_holds_locked() + finally: + self._d435_plan_lock.release() + + def _d435_pin_and_stop_hops(self, why: str) -> None: + if not self._d435_pinned: + self._send_hold() + self._d435_pinned = True + self._d435_give_up = True + self.get_logger().error(why) + + def _sanitize_d435_traj(self, traj: JointTrajectory, min_dt: float = 0.02) -> JointTrajectory: + """与二阶段 ``_sanitize_traj`` 同一条:time_from_start 必须严格递增。""" + last = -min_dt + for p in traj.points: + t = float(p.time_from_start.sec) + float(p.time_from_start.nanosec) * 1e-9 + if t < last + min_dt: + t = last + min_dt + ns = int(round(t * 1e9)) + p.time_from_start.sec = ns // 1_000_000_000 + p.time_from_start.nanosec = ns % 1_000_000_000 + last = t + return traj + + def _d435_send_action( + self, + sched, + tag: str, + hop_m: float, + leftover_m: float, + v_peak: float, + T_move: float, + *, + ee_z: float, + tilt_deg: float, + max_dq: float, + ) -> None: + """先占住 in-flight 再 ``send_goal_async``。话题插点会把这条掐掉。""" + traj = JointTrajectory() + traj.header.stamp.sec = 0 + traj.header.stamp.nanosec = 0 + traj.joint_names = list(self.joint_names) + for qi, qdot, t_sec in sched: + pt = JointTrajectoryPoint() + pt.positions = [float(v) for v in np.asarray(qi).tolist()] + pt.velocities = [float(v) for v in np.asarray(qdot).tolist()] + ns = int(round(max(0.0, float(t_sec)) * 1e9)) + pt.time_from_start.sec = ns // 1_000_000_000 + pt.time_from_start.nanosec = ns % 1_000_000_000 + traj.points.append(pt) + traj = self._sanitize_d435_traj(traj) + goal = FollowJointTrajectory.Goal() + goal.trajectory = traj + self._d435_inflight = True + self._d435_sent = True + self._d435_pinned = False + fut = self._jtc.send_goal_async(goal) + fut.add_done_callback(self._d435_on_goal) + self.get_logger().info( + f"D435 action 下发 {len(sched)} 点 tag={tag} 本跳={hop_m*1000:.0f}mm " + f"剩={leftover_m*1000:.0f}mm v={v_peak*1000:.0f}mm/s T={T_move:.1f}s " + f"ee_z={ee_z:.3f} tilt={tilt_deg:.2f}deg max|Δq|={max_dq:.4f}rad " + f"—— 结果回来之前不再发下一条" + ) + + def _d435_on_goal(self, fut) -> None: + try: + handle = fut.result() + except Exception as exc: + self.get_logger().error(f"D435 JTC 目标回调失败:{exc}") + self._d435_inflight = False + self._d435_give_up = True + return + if handle is None or not bool(getattr(handle, "accepted", False)): + self.get_logger().error("JTC 拒绝了 D435 轨迹") + self._d435_inflight = False + self._d435_give_up = True + return + handle.get_result_async().add_done_callback(self._d435_on_result) + + def _d435_on_result(self, fut) -> None: + ok = True + try: + r = fut.result() + inner = getattr(r, "result", None) + code = getattr(inner, "error_code", 0) if inner is not None else 0 + if code not in (0, None): + ok = False + msg = getattr(inner, "error_string", "") + self.get_logger().error(f"D435 JTC 执行失败 error_code={code} {msg}") + except Exception as exc: + ok = False + self.get_logger().error(f"D435 JTC 结果失败:{exc}") + self._d435_inflight = False + self._d435_settle_until = time.monotonic() + float(D435_SETTLE_S) + self._d435_remeasure_log = True + if not ok: + self._d435_give_up = True + + def _d435_oneshot_holds_locked(self) -> bool: + now = time.monotonic() + if self._d435_inflight: + return True + if now < self._d435_settle_until: + return True + T_ee = self._lookup_T(self.base_frame, self.ee_frame) + q = self._current_joints() + if T_ee is None or q is None: + return True + # 只更新锁高标志。这一段不许把控制权交给回锁高流式。 + self._update_xy_arm(float(T_ee[2, 3])) + with self._lock: + goal = None if self._p_feat_base is None else self._p_feat_base[:2].copy() + if goal is None: + return True + p = np.asarray(T_ee[:3, 3], dtype=float) + dist = float(np.hypot(float(goal[0]) - p[0], float(goal[1]) - p[1])) + arrive = float(self.lock_still_move_m) + z_lock = float(self.ee_z_m) + tilt = flange_tilt_rad(T_ee[:3, :3]) + at = at_lock_height(float(p[2]), z_lock, self.approach_xy_gate_m) + if self._d435_remeasure_log: + self._d435_remeasure_log = False + self.get_logger().info( + f"D435 重测 leftover={dist*1000:.0f}mm " + f"dxy=({(p[0]-goal[0])*1000:+.0f},{(p[1]-goal[1])*1000:+.0f})mm " + f"ee_xy=({p[0]:.3f},{p[1]:.3f}) goal=({goal[0]:.3f},{goal[1]:.3f}) " + f"ee_z={p[2]:.3f} tilt={math.degrees(tilt):.2f}deg" + ) + if self._d435_give_up: + self._d435_pinned = True + return True + hop_max, _cruise_unused = oneshot_hop_limits( + dist, hybrid_cruise_vxy(self.v_slow_xy) + ) + if at and dist <= arrive: + if oneshot_needs_verticalize( + tilt, dist, arrive, already_done=self._d435_vert_done + ): + return self._d435_send_verticalize(q, T_ee, leftover=dist, tilt=tilt) + # action 已经 rest-to-rest。到位成功不许再往话题插 hold,那会再抖一下。 + self._d435_pinned = True + return True + if self._d435_full_done and self._d435_fix_done: + self._d435_pin_and_stop_hops( + f"D435 整段+补刀后还差 {dist*1000:.0f}mm —— 钉住不再补刀" + ) + return True + if at and not oneshot_may_send_another( + dist, self._d435_last_dist, arrive_m=arrive, hop_max_m=hop_max + ): + self._d435_pin_and_stop_hops( + f"D435 上一跳几乎没走近,剩差还是 {dist*1000:.0f}mm " + f"—— 钉住不再补刀,等停稳后交回 D405" + ) + return True + if not at: + z_err = abs(float(p[2]) - z_lock) + if self._d435_last_z_err is not None and z_err > self._d435_last_z_err * 0.8: + self._d435_z_stall += 1 + else: + self._d435_z_stall = 0 + self._d435_last_z_err = z_err + if self._d435_z_stall >= 2: + self._d435_pin_and_stop_hops( + f"D435 收锁高连续无进展 当前={p[2]:.3f}m 期望={z_lock:.3f}m" + ) + return True + if not self._jtc.server_is_ready(): + self.get_logger().warn("D435 等 JTC action 就绪", throttle_duration_sec=1.0) + return True + J = self._jacobian_at(p) + if J is None: + self.get_logger().warn("D435 平移缺 link TF,等下一拍", throttle_duration_sec=1.0) + return True + segs = oneshot_full_segments( + p, goal, z_lock, gate_m=self.approach_xy_gate_m + ) + if not segs: + return True + # XY / 收 Z 都不许夹收垂直:冻结 J 会把腕部转动漏成 Y 过冲。 + wps, planned, asked, tag = oneshot_concat_hold_ori( + q, J, segs, R=T_ee[:3, :3] + ) + if self._d435_full_done: + tag = "fix" + if len(wps) < 2 or asked < 1e-5 or planned < 0.75 * asked: + self._d435_pin_and_stop_hops( + f"D435 平移规划停滞 {planned*1000:.0f}/{asked*1000:.0f}mm —— 钉住不瞎发" + ) + return True + max_dq = float(np.max(np.abs(wps[-1] - q))) + if max_dq > 1.0: + self._d435_pin_and_stop_hops("D435 平移关节行程 >1 rad,拒绝执行") + return True + cruise = oneshot_full_cruise_mps(dist, hybrid_cruise_vxy(self.v_slow_xy)) + sched, T_move, v_peak = smooth_joint_schedule(wps, asked, cruise) + if tag in ("full", "fix", "xy"): + self._d435_last_dist = dist + if not self._d435_full_done: + self._d435_full_done = True + else: + self._d435_fix_done = True + self._d435_send_action( + sched, + tag, + asked, + dist, + v_peak, + T_move, + ee_z=float(p[2]), + tilt_deg=float(np.degrees(tilt)), + max_dq=max_dq, + ) + return True + + def _d435_send_verticalize(self, q, T_ee, *, leftover: float, tilt: float) -> bool: + """XY 已到位后单独收法兰朝下。和平移拆开,避免 Y 过冲。""" + if not self._jtc.server_is_ready(): + self.get_logger().warn("D435 收垂直等 JTC action 就绪", throttle_duration_sec=1.0) + return True + p = np.asarray(T_ee[:3, 3], dtype=float) + J = self._jacobian_at(p) + if J is None: + self.get_logger().warn("D435 收垂直缺 link TF,等下一拍", throttle_duration_sec=1.0) + return True + wps, _remain = translate_hold_ori_waypoints( + q, J, np.zeros(3, dtype=float), R=T_ee[:3, :3], verticalize=True + ) + if len(wps) < 2: + self._d435_vert_done = True + self.get_logger().info( + f"D435 收垂直无需再动 tilt={math.degrees(tilt):.2f}deg" + ) + return True + max_dq = float(np.max(np.abs(wps[-1] - q))) + if max_dq < 1e-4: + self._d435_vert_done = True + return True + if max_dq > 1.0: + self._d435_pin_and_stop_hops("D435 收垂直关节行程 >1 rad,拒绝执行") + return True + hop = max(0.008, float(tilt) * 0.05) + cruise = D435_NEAR_CRUISE_MPS + sched, T_move, v_peak = smooth_joint_schedule(wps, hop, cruise) + self._d435_vert_done = True + self._d435_send_action( + sched, + "vert", + hop, + leftover, + v_peak, + T_move, + ee_z=float(p[2]), + tilt_deg=float(np.degrees(tilt)), + max_dq=max_dq, + ) + return True + + def _tick_d435_arrive(self) -> None: + """走到 D435 那个点,并且法兰按一阶段的静止判据停稳,才允许下发 D405。""" + if not self._hybrid: + return + if self._d435_inflight or time.monotonic() < self._d435_settle_until: + self._arrive.reset() + return + with self._lock: + if self._track_source != "overhead" or self._d435_settled: + return + goal = None if self._p_feat_base is None else self._p_feat_base[:2].copy() + if goal is None: + return + T = self._lookup_T(self.base_frame, self.ee_frame) + if T is None: + return + xy = np.asarray(T[:2, 3], dtype=float) + dist = float(np.hypot(float(xy[0]) - goal[0], float(xy[1]) - goal[1])) + arrived = dist <= float(self.lock_still_move_m) or self._d435_give_up + settled = self._arrive.update( + xy, time.monotonic(), tracking_ok=arrived + ) + if not settled: + return + with self._lock: + self._d435_settled = True + self._d435_exec_until = 0.0 + if self.d435_move_only: + # 保持 d435_commanding=True,感知不会重拍;D405 被 move_only 挡住。 + # locked 只让 pipeline 结束一阶段,后面会 skip_stage2。 + # action 已 rest-to-rest,到位不再往话题插 hold。 + now_m = time.monotonic() + if not self._locked: + self._locked = True + self.get_logger().info( + f"已到 D435 位置(还差 {dist*1000:.0f}mm)并停稳 " + f"{self.lock_still_sec:.0f}s —— D435 位移测试结束," + "不交 D405、不进二阶段" + ) + self._lock_pub.publish(Bool(data=True)) + self._lock_pub_t = now_m + elif now_m - self._lock_pub_t > 1.0: + self._lock_pub_t = now_m + self._lock_pub.publish(Bool(data=True)) + return + self._publish_d435_commanding(False) + self.get_logger().info( + f"已到 D435 位置(还差 {dist*1000:.0f}mm)并停稳 " + f"{self.lock_still_sec:.0f}s —— 改回下发 D405" + ) + def _tick_still_lock(self) -> None: """D405 跟住且法兰 XY 连续 still_sec 秒没大幅动 → 锁定进二阶段。 顶视一次性迁臂不算。门槛与位移上限只从 site 来,这里不再写死 3 / 15mm。 """ - if not self._hybrid: + if not self._hybrid or self.d435_move_only: return T = self._lookup_T(self.base_frame, self.ee_frame) xy = None if T is None else np.asarray(T[:2, 3], dtype=float) @@ -1284,48 +1751,31 @@ class ServoNode(TolerantNode): return out def _update_xy_arm(self, ee_z: float) -> bool: - """只有当法兰到达 lock_z(真实深度、自适应悬停)后才做 XY 悬停。""" + """只有法兰落在锁高带内才做 XY。低于锁高要先抬回去,不算到位。""" if self.ee_z_m <= 0.0: self._xy_armed = True + self._publish_z_locked(True) return True - gate = max(0.02, float(self.approach_xy_gate_m)) z = float(ee_z) - # 已经在锁高或更低:立刻开 XY,禁止再抬回 0.35(否则二阶段又要降下来)。 - if z <= float(self.ee_z_m) + gate: - if not self._xy_armed: - self._xy_armed = True - self._feat_kf.initialized = False - self._p_feat_base = None - self._v_xy_cmd[:] = 0.0 - self.pid_x.reset() - self.pid_y.reset() - self.get_logger().warn( - f"lock-z 就绪 ee_z={z:.3f}m(期望 {self.ee_z_m:.3f},不再回抬) " - "—— 在实时 handeye 上开始 XY 悬停" - ) - return True - err = abs(z - float(self.ee_z_m)) - if not self._xy_armed: - if err <= gate: - self._xy_armed = True - self._feat_kf.initialized = False - self._p_feat_base = None - self._v_xy_cmd[:] = 0.0 - self.pid_x.reset() - self.pid_y.reset() - self.get_logger().warn( - f"已到达 lock-z ee_z={z:.3f}m(期望 {self.ee_z_m:.3f})" - " —— 在实时 handeye 上开始 XY 悬停" - ) - elif err > gate + 0.05: + at = at_lock_height(z, self.ee_z_m, self.approach_xy_gate_m) + if at and not self._xy_armed: + self._xy_armed = True + self._v_xy_cmd[:] = 0.0 + self.pid_x.reset() + self.pid_y.reset() + self.get_logger().info( + f"锁高到位 ee_z={z:.3f}m(期望 {self.ee_z_m:.3f})" + " —— 开始 XY;腕部无目标时 D435 可以拍一张" + ) + elif (not at) and self._xy_armed: self._xy_armed = False - self._p_feat_base = None - self._feat_kf.initialized = False self._v_xy_cmd[:] = 0.0 self.get_logger().info( - f"已离开 lock-z 区间 ee_z={z:.3f}m —— XY 保持,重新下降", + f"离开锁高 ee_z={z:.3f}m(期望 {self.ee_z_m:.3f})" + " —— 先回高度,XY 暂停", throttle_duration_sec=1.0, ) + self._publish_z_locked(at) return self._xy_armed def _seek_velocity(self, p_ee: np.ndarray) -> Optional[np.ndarray]: @@ -1392,7 +1842,8 @@ class ServoNode(TolerantNode): now_m = time.monotonic() dt_cmd = now_m - self._last_traj_mono - if self._last_traj_mono > 0.0 and dt_cmd < (1.0 / self.traj_stream_hz): + min_dt = 1.0 / self.traj_stream_hz + if self._last_traj_mono > 0.0 and dt_cmd < min_dt: return dt_use = float(np.clip(dt_cmd if self._last_traj_mono > 0.0 else 0.04, 0.015, 0.08)) @@ -1422,6 +1873,7 @@ class ServoNode(TolerantNode): # hover:在任意锁定高度上把法兰 XY 停在三维特征点上。 # lookat:把光轴命中点(图像中心)驱到特征点上。 xy_ok = self._update_xy_arm(float(p_ee[2])) + cruise = hybrid_cruise_vxy(self.v_slow_xy) if ( xy_ok and (not hold_xy) @@ -1437,19 +1889,10 @@ class ServoNode(TolerantNode): if nerr > clip_xy: err = err * (clip_xy / nerr) nerr = clip_xy - # ---- 远处限速:抓错目标时只走一小段 ---- - # 2026-10-08 真机:一阶段被一串高置信度假 k0 顶掉,伺服 0.7s 内以 - # v_max_xy(0.45m/s) 把臂从 home (0.686,-0.113) 带到工作盒角落 - # (0.083,-0.515),之后 148s 全是"检测漏检"。留一道**与"目标真不真" - # 无关**的防线:目标离得远就慢走 —— 真目标(真 k0 离 145mm)只是晚 - # ~1.8s 到位,假目标则最多把臂带走几厘米。 - v_xy, why_slow = self._slow_xy_limit(nerr, v_xy) - if why_slow: - self.get_logger().info( - f"{why_slow} —— 限速 {self.v_slow_xy*1000:.0f}mm/s 逼近" - "(防追错目标大范围移动)", - throttle_duration_sec=1.0, - ) + # 锁定前整段同一巡航速度。不再用 50mm 远近两档: + # 远处本来就慢,近处一放开就按 kp×误差冲,越近越抖。 + if self._hybrid and not self._locked: + v_xy = min(float(v_xy), cruise) if self._xy_deadzone(nerr, update=False): v_base[0] = 0.0 v_base[1] = 0.0 @@ -1480,9 +1923,10 @@ class ServoNode(TolerantNode): ) nxy = float(np.hypot(v_base[0], v_base[1])) - if nxy > v_xy > 0.0: - v_base[0] *= v_xy / nxy - v_base[1] *= v_xy / nxy + cap_xy = cruise if (self._hybrid and not self._locked) else v_xy + if nxy > cap_xy > 0.0: + v_base[0] *= cap_xy / nxy + v_base[1] *= cap_xy / nxy # 在 base 系里保持法兰 XYZ(可配置,默认约 250 mm)。当 ee_hold_xy # 为真时,视觉算出的 XY 会被覆盖。 @@ -1493,28 +1937,33 @@ class ServoNode(TolerantNode): v_base[0] = 0.0 if abs(self.ee_y_m - p_ee[1]) < 0.004: v_base[1] = 0.0 - if (not xy_ok) or hold_xy: + # 有目标:XY 平移,Z 钉在这一段开始的高度。没目标才回锁高。 + translating = (not hold_xy) and self._have_target + if (not translating) and ((not xy_ok) or hold_xy): v_base[0] = 0.0 v_base[1] = 0.0 self._v_xy_cmd[:] = 0.0 if self.ee_z_m > 0.0: self.get_logger().info( - f"下降至 lock_z {self.ee_z_m:.3f}m 当前={p_ee[2]:.3f}m XY 保持", + f"回锁高 {self.ee_z_m:.3f}m 当前={p_ee[2]:.3f}m XY 保持", throttle_duration_sec=1.0, ) shaped = self._shape_xy_vel(v_base[:2], dt_use) v_base[0] = float(shaped[0]) v_base[1] = float(shaped[1]) - if self.ee_z_m > 0.0: - ez_b = float(self.ee_z_m - p_ee[2]) - # 已在锁高以下:不再抬臂。二阶段会降到 hole_hover_z。 - if ez_b > 0.0: - v_base[2] = 0.0 - else: - vz_lim = float(self.v_max_z) if xy_ok else max(float(self.v_max_z), 0.08) - v_base[2] = float(np.clip(kp * ez_b, -vz_lim, vz_lim)) - if abs(ez_b) < 0.004: - v_base[2] = 0.0 + if translating: + if self._z_hold is None: + self._z_hold = float(p_ee[2]) + v_base[2] = hold_translation_vz( + float(p_ee[2]), self._z_hold, kp=kp, v_lim=float(self.v_max_z) + ) + else: + self._z_hold = None + if self.ee_z_m > 0.0: + vz_lim = max(float(self.v_max_z), 0.08) + v_base[2] = lock_z_velocity( + float(p_ee[2]), self.ee_z_m, kp=kp, v_lim=vz_lim + ) w_base = ( self._w_align_flange_down(R_ee) @@ -1523,9 +1972,9 @@ class ServoNode(TolerantNode): ) p_next = p_ee + v_base * T - if self.ee_z_m > 0.0: - # 绝不指令到配置平面以下 - floor = max(self.min_ee_z, self.ee_z_m - 0.02) + if self.ee_z_m > 0.0 and not translating: + # 向下只许到锁高死区内。已经低于锁高时 v_z>0,这条不挡上抬。 + floor = float(self.ee_z_m) - 0.004 if p_next[2] < floor and v_base[2] < 0.0: v_base[2] = 0.0 p_next = p_ee + v_base * T diff --git a/src/arm_application/arm_application/stage2_locate_node.py b/src/arm_application/arm_application/stage2_locate_node.py index e123d44..2fa9603 100644 --- a/src/arm_application/arm_application/stage2_locate_node.py +++ b/src/arm_application/arm_application/stage2_locate_node.py @@ -45,6 +45,12 @@ from arm_application.geom_ik import ( geometric_jacobian, quat_xyzw_to_R, ) +from arm_application.path_profile import ( + FROZEN_J_HOP_MAX_M, + path_interp as _path_interp, + trap_params as _trap_params, + trap_sv as _trap_sv, +) from arm_application.handeye_realsense import resolve_realsense_topics from arm_application.gauge_boot import perception_source_of from arm_application.gauge_fsm import drag_verdict, hover_z_guard, wrench_all_zero @@ -82,54 +88,6 @@ def _pt(q: np.ndarray, qdot: np.ndarray, t_sec: float) -> JointTrajectoryPoint: return pt -def _trap_params(dist: float, v: float, a: float) -> Tuple[float, float, float, float, float]: - """Rest-to-rest 梯形:返回 (T, v_peak, t_acc, t_cruise, a)。巡航速度保持 v。""" - dist = max(float(dist), 1e-6) - v = max(float(v), 1e-4) - a = max(float(a), 1e-3) - d_acc = v * v / (2.0 * a) - if 2.0 * d_acc >= dist: - v_peak = math.sqrt(max(a * dist, 1e-12)) - t_acc = v_peak / a - return 2.0 * t_acc, v_peak, t_acc, 0.0, a - t_acc = v / a - t_cruise = (dist - 2.0 * d_acc) / v - return 2.0 * t_acc + t_cruise, v, t_acc, t_cruise, a - - -def _trap_sv( - t: float, dist: float, v_peak: float, t_acc: float, t_cruise: float, a: float, -) -> Tuple[float, float]: - """时刻 t 的路径弧长 s 与切向速度。""" - t = max(0.0, float(t)) - t_dec0 = t_acc + t_cruise - t_end = t_dec0 + t_acc - if t >= t_end: - return dist, 0.0 - if t < t_acc: - return 0.5 * a * t * t, a * t - if t < t_dec0: - return 0.5 * a * t_acc * t_acc + v_peak * (t - t_acc), v_peak - td = t - t_dec0 - s0 = 0.5 * a * t_acc * t_acc + v_peak * t_cruise - return s0 + v_peak * td - 0.5 * a * td * td, max(0.0, v_peak - a * td) - - -def _path_interp(waypoints: List[np.ndarray], u: float) -> Tuple[np.ndarray, np.ndarray]: - """沿关节路点折线插值。u∈[0,1] → (q, dq/du)。""" - nseg = len(waypoints) - 1 - if nseg <= 0: - z = np.zeros_like(waypoints[0]) - return waypoints[0].copy(), z - u = float(min(1.0, max(0.0, u))) - x = u * nseg - i = min(nseg - 1, int(math.floor(x))) - a = x - i - q = (1.0 - a) * waypoints[i] + a * waypoints[i + 1] - dq_du = (waypoints[i + 1] - waypoints[i]) * nseg - return q, dq_du - - def _sanitize_traj(traj: JointTrajectory, min_dt: float = 0.02) -> JointTrajectory: """JTC 要求 time_from_start 严格递增;短 hop 保持点曾经倒退导致拒轨。""" last = -min_dt @@ -246,6 +204,15 @@ class Stage2Locate(TolerantNode): self.coarse_border_px = int(stage2.get("coarse_border_px") or 2) # 最后轻降到孔面上方 final_gap_mm(不接触) self.final_gap_m = float(stage2.get("final_gap_mm") or 1.5) / 1000.0 + # 观测下降:对准后降到这个间隙就停,方便看规尖是不是对着孔。 + # 比 final_gap(贴面 2mm)高一截,对偏了也不会把规压上孔沿。 + self.observe_gap_m = float(stage2.get("observe_gap_mm") or 15.0) / 1000.0 + if self.observe_gap_m < self.final_gap_m: + self.get_logger().warn( + f"observe_gap_mm={self.observe_gap_m*1000:.1f} 比贴面目标 " + f"{self.final_gap_m*1000:.1f}mm 还近 —— 收到贴面高度,避免观测下降钻进孔" + ) + self.observe_gap_m = self.final_gap_m # 悬停高度**必须**让工具尖端留在工件面上方(2026-09-24 的教训,完整说明见 # gauge_fsm.hover_z_guard):悬停高是按相机视距定的,而工具(夹爪+通止规) # 比相机长 —— 实测悬停时尖端已经钻进工件 25mm,于是 S05 的横向对准把工件 @@ -511,18 +478,10 @@ class Stage2Locate(TolerantNode): return geometric_jacobian(link_R, link_p, p_ee, RIZON_LINK_AXES) def _w_down(self, R: np.ndarray) -> Tuple[np.ndarray, float]: - """让法兰 +Z 指向世界 -Z(垂直朝下)的角速度向量 + 当前倾角。""" - z_now = R[:, 2] - z_des = np.array([0.0, 0.0, -1.0]) - c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0)) - ang = float(np.arccos(c)) - if ang < 0.006: # 0.34° 以下不再修,避免原地抖动 - return np.zeros(3), ang - axis = np.cross(z_now, z_des) - n = float(np.linalg.norm(axis)) - axis = axis / n if n > 1e-9 else np.array([1.0, 0.0, 0.0]) - mag = float(np.clip(2.0 * ang, -0.5, 0.5)) - return axis * mag, ang + """让法兰 +Z 指向世界 -Z。公式只在 ``path_profile.flange_down_w``。""" + from arm_application.path_profile import flange_down_w + + return flange_down_w(R) def _wait_state(self, timeout_s: float) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: t0 = time.monotonic() @@ -595,7 +554,7 @@ class Stage2Locate(TolerantNode): target: np.ndarray, verticalize: bool, tag: str, - max_seg: float = 0.08, + max_seg: float = FROZEN_J_HOP_MAX_M, v: float = 0.05, min_dur: float = 1.6, ) -> bool: @@ -982,9 +941,12 @@ class Stage2Locate(TolerantNode): except Exception: return None # 两孔统一用工件表面深度 z_wp(中值,稳健)。 - # 孔是垂直孔,XY 位置与深度无关;而 _ring_depth 在通止规逼近目标孔时会采到 - # 更近的通止规(~88mm vs 面 ~115mm),把孔深拉偏 → 孔基座坐标随手臂移动漂移、 - # 精对准追不动目标而振荡。倾斜仅 ~0.4°,左右孔 camera-Z 差 <0.5mm,对 XY 影响 <0.2mm。 + # 不用每孔的环形深度:通止规逼近时环上会采到更近的规(~88mm vs 面 ~115mm), + # 孔的基座坐标就跟着臂飘、精对准追不动。 + # 法兰相对竖直大约 0.4°,但相机光轴相对法兰大约 30°。深度误差会进 XY: + # 2026-10-09 观测高度实测 dXY/dz ≈ 0.46 mm/mm。z_wp 必须是工件面, + # 不能是规。同一孔在悬停和观测高度上反投影,基座 XY 可以差约 2mm, + # 所以下降之后还要在当前高度再对一次(见 _trim_xy_after_descent)。 z_left = z_right = z_wp try: left_b = self._cam_to_base(self._px_to_cam(pts[0][0], pts[0][1], z_left, intr)) @@ -1360,65 +1322,108 @@ class Stage2Locate(TolerantNode): return np.array([p[0] + off[0], p[1] + off[1]]) def _tip_offset_from_depth( - self, depth_m: np.ndarray, intr: Dict[str, float], Tee: np.ndarray + self, + depth_m: np.ndarray, + intr: Dict[str, float], + Tee: np.ndarray, + z_wp_cam: Optional[float] = None, ) -> Tuple[Optional[np.ndarray], Optional[float], str]: """从一帧深度里找**规尖**,返回 (规尖相对法兰的基座 XY 偏移, 工具长, 说明)。 找不到/不可信时返回 (None, None, 原因)。判据(每条都能真的失败): 1. 只看法兰轴线附近的**中央竖带**——规就夹在爪里,不会跑到画面边上; - 2. 近端连通域要够大(规杆),且连通域内最深 3% 就是**规尖**; - 3. 反投出的"法兰 z − 规尖 z"必须落在 [tip_len_min, tip_len_max] —— - 这一条同时挡住"深度读到别的东西"(如台面/工件),是主干守卫。 + 2. 多个连通域里取画面**更靠下**(v 更大)的那一个,不按面积、也不按 + 「最接近配置工具长」——按面积会选爪子;贴配置长会选法兰轴上的假深点 + (2026-10-09 真机工具长 225mm、偏移约 0,残差 0.14mm 但孔偏了); + 3. 有同一帧工件面深度时,只留**比工件面至少近 8mm** 的像素。相机前倾 + ~30°,规尖沿光轴只比面近十来毫米(2026-10-09 观测高度:面 127mm、 + 尖 115mm)。再往前砍 15mm 会把尖切掉,只剩更近的夹爪/杆身(~80mm), + 反投影偏移 ~2mm,残差 0.2mm 看着达标,针却偏在孔沿上。 + 没给工件面深度时,退回固定近端(默认 150mm); + 4. 反投出的"法兰 z − 规尖 z"必须落在 [tip_len_min, tip_len_max], + 并且基座 z 要比工件面高出一截 —— 挡住"深度读到台面/工件"。 ⚠️ 工具长这一项**只在单帧内**可信;实测它会跳(192~223mm)——规尖细、反光, 掠射角下深度容易丢,丢了以后"最深有效像素"就往上跑、工具长只会**变短**。 - 所以: - · **XY 偏移**对这件事不敏感(规杆细且接近竖直,杆上任一像素反投回基座 XY - 都在杆轴 ~3mm 内)⇒ 逐帧拿来用是稳的; - · **工具长**必须挑"端头看得全"的拍(最长那一簇),见 _refresh_tip_offset。 + XY 偏移对这件事不敏感(杆细且接近竖直),见 _refresh_tip_offset。 """ import cv2 h, w = depth_m.shape + # 8mm:3mm 挡不住深度噪声(工件漏进来,工具长测成 296–315mm); + # 15mm 又把真规尖切掉(面 127mm、尖 115mm,只剩夹爪)。两条都在真机上红过。 + wp_gap = 0.008 + # z_wp 只在像相机深度时才当上限(真机孔 ~0.13–0.18m)。基座 z~0.03 会把近端切空。 + _z_wp = float(z_wp_cam) if z_wp_cam is not None else 0.0 + if 0.08 <= _z_wp <= 0.45: + ceiling = min(_z_wp - wp_gap, self.tip_max_m) + ceil_s = f"比工件面{_z_wp*1000:.0f}近{wp_gap*1000:.0f}" + else: + ceiling = self.tip_near_m + ceil_s = f"固定近端{self.tip_near_m*1000:.0f}" valid = (depth_m > self.tip_min_m) & (depth_m < self.tip_max_m) - near = (valid & (depth_m < self.tip_near_m)).astype(np.uint8) + near = (valid & (depth_m < ceiling)).astype(np.uint8) band = np.zeros_like(near) half = max(1, int(self.tip_band_frac * w)) c0, c1 = int(intr["cx"]) - half, int(intr["cx"]) + half band[:, max(0, c0):min(w, c1)] = 1 near = near & band if int(near.sum()) < self.tip_min_px: - return None, None, f"近端(<{self.tip_near_m*1000:.0f}mm)中央竖带内像素 {int(near.sum())} < {self.tip_min_px}" + return None, None, ( + f"近端(<{ceiling*1000:.0f}mm,{ceil_s})中央竖带内像素 " + f"{int(near.sum())} < {self.tip_min_px}" + ) n, lab, stats, _ = cv2.connectedComponentsWithStats(near, 8) if n <= 1: return None, None, "近端无连通域(规不见了吗)" - big = max(range(1, n), key=lambda i: stats[i, 4]) - ys, xs = np.where(lab == big) - dv = depth_m[ys, xs] - k = np.percentile(dv, 97) # 连通域内最深 3% ≈ 规尖 - sel = dv >= k - 1e-6 - u = float(np.median(xs[sel])) - v = float(np.median(ys[sel])) - y0, y1 = max(0, int(v) - 2), min(h, int(v) + 3) - x0, x1 = max(0, int(u) - 2), min(w, int(u) + 3) - patch = depth_m[y0:y1, x0:x1] - pv = patch[(patch > self.tip_min_m) & (patch < self.tip_max_m)] - if pv.size == 0: - return None, None, "规尖邻域深度无效" - z_tip = float(np.median(pv)) - try: - p_tip = self._cam_to_base(self._px_to_cam(u, v, z_tip, intr)) - except Exception as exc: # noqa: BLE001 - return None, None, f"规尖反投影失败(手眼 TF 缺?): {exc}" - tool_len = float(Tee[2, 3]) - float(p_tip[2]) - if not (self.tip_len_min <= tool_len <= self.tip_len_max): + + # 掩膜已经丢掉贴着工件面的像素。基座 z 再挡一层:反投影落在面上的 + # 仍不是规尖。裕量 8mm 而不是 20mm——观测高度规尖离面只有约 15mm, + # 20mm 会把真尖一起丢掉(2026-10-09)。悬停时尖离面约 75mm,8mm 仍够。 + surf = float(getattr(self, "surface_z_m", 0.0) or 0.0) + above_surface_m = 0.008 + cands = [] + stuck = 0 + for i in range(1, n): + if int(stats[i, 4]) < self.tip_min_px: + continue + ys, xs = np.where(lab == i) + dv = depth_m[ys, xs] + k = float(np.percentile(dv, 97)) + sel = dv >= k - 1e-6 + u = float(np.median(xs[sel])) + v = float(np.median(ys[sel])) + # 用连通域自己的近端深度,不用周围 5×5:规尖只有几像素宽, + # 邻域中值会落到工件面上,尖又被当成面丢掉。 + z_tip = float(np.median(dv[sel])) + try: + p_tip = self._cam_to_base(self._px_to_cam(u, v, z_tip, intr)) + except Exception as exc: # noqa: BLE001 + return None, None, f"规尖反投影失败(手眼 TF 缺?): {exc}" + tool_len = float(Tee[2, 3]) - float(p_tip[2]) + if not (self.tip_len_min <= tool_len <= self.tip_len_max): + continue + if surf > 0.0 and float(p_tip[2]) < surf + above_surface_m: + stuck += 1 + continue + off = np.array( + [float(p_tip[0]) - float(Tee[0, 3]), float(p_tip[1]) - float(Tee[1, 3])] + ) + cands.append((off, tool_len, u, v)) + if not cands: + if stuck: + return None, None, ( + f"近端点贴着工件面(高出不足 {above_surface_m*1000:.0f}mm),不是规尖" + ) return None, None, ( - f"反投影出的工具长 {tool_len*1000:.0f}mm 不在 " + f"反投影出的工具长不在 " f"[{self.tip_len_min*1000:.0f},{self.tip_len_max*1000:.0f}]mm —— " f"近端那个物体不是规" ) - off = np.array([float(p_tip[0]) - float(Tee[0, 3]), float(p_tip[1]) - float(Tee[1, 3])]) - return off, tool_len, f"px=({u:.0f},{v:.0f}) 工具长 {tool_len*1000:.0f}mm" + # v 优先:画面更靠下的才是伸向工件的那一端。工具长只作同分时的次序, + # 不拿去贴近 site.yaml —— 贴配置长会把法兰轴上的点当成规尖。 + off, tool_len, u, v = max(cands, key=lambda c: (c[3], c[1])) + return off, tool_len, f"px=({u:.0f},{v:.0f}) 工具长 {tool_len*1000:.0f}mm {ceil_s}" def _refresh_tip_offset(self) -> None: """攒够**一致**样本后,定下并**冻结**「规尖相对法兰」的偏移。 @@ -1440,6 +1445,10 @@ class Stage2Locate(TolerantNode): med = np.median(arr, axis=0) inl = np.linalg.norm(arr - med, axis=1) <= self.tip_off_tol if int(inl.sum()) < self.tip_calib_n: + self.get_logger().warn( + f" 规尖样本 {len(self._tip_samp)} 拍、一致的只有 {int(inl.sum())}/" + f"{self.tip_calib_n}(tol={self.tip_off_tol*1000:.0f}mm)—— 还冻不住,继续采" + ) return idx = np.where(inl)[0] self._tip_off = arr[idx].mean(0) @@ -1540,8 +1549,11 @@ class Stage2Locate(TolerantNode): 2. 腕部 FT 接触检测:``force.z`` 相对悬空基线突变 ⇒ 立即停 + 回退 1mm; 3. 任一步 ``_goto`` 没走到位(多半是碰到东西/被挡)⇒ 停止,保持当前高度。 """ - if not self.final_descent_enable: - return True, q, Tee + # final_descent_enable:降到贴面 final_gap(约 2mm)。 + # 关掉时(--no-descent)仍做**观测下降**,停在 observe_gap(约 15mm), + # 让人能看出规尖偏没偏,又不把 φ6 规压到 φ7 孔沿上。 + target_gap = self.final_gap_m if self.final_descent_enable else self.observe_gap_m + kind = "贴面轻降" if self.final_descent_enable else "观测下降" self._state("descend_above_hole") tip_len = float(self.tool_tip_below_flange_m) surf = float(self.surface_z_m) @@ -1557,7 +1569,7 @@ class Stage2Locate(TolerantNode): return False, q, Tee gap0 = float(Tee[2, 3]) - tip_len - surf # 几何间隙(唯一决定下降量的量) - descent = gap0 - self.final_gap_m + descent = gap0 - target_gap if descent > self.descent_budget_m + 5e-4: self.get_logger().warn( f"轻降超过配置几何预算:按 FK 算需降 {descent * 1000:.1f}mm > " @@ -1585,9 +1597,9 @@ class Stage2Locate(TolerantNode): ) self.get_logger().info( - f"轻降准备: 几何间隙 {gap0 * 1000:.1f}mm → 目标 {self.final_gap_m * 1000:.1f}mm," + f"{kind}准备: 几何间隙 {gap0 * 1000:.1f}mm → 目标 {target_gap * 1000:.1f}mm," f"需下降 {descent * 1000:.1f}mm(法兰 {Tee[2, 3]:.4f} − 工具长 {tip_len * 1000:.0f}mm" - f" − 工件面 {surf:.4f};很慢很柔)" + f" − 工件面 {surf:.4f};很慢,XY 保持)" ) if descent <= 0.001: self.get_logger().info("规尖已在目标间隙附近,无需下降") @@ -1632,7 +1644,7 @@ class Stage2Locate(TolerantNode): self.descent_final_gap_m = float(Tee[2, 3]) - tip_len - surf return True, q, Tee # 用 FK 现算剩余下降量(不是累加指令)—— 单步没走到也不会积累误差。 - remaining = (float(Tee[2, 3]) - tip_len - surf) - self.final_gap_m + remaining = (float(Tee[2, 3]) - tip_len - surf) - target_gap if remaining <= 1e-4: break step = min(remaining, 0.004) # 每步 ≤4mm,很慢很柔 @@ -1653,12 +1665,128 @@ class Stage2Locate(TolerantNode): self.descent_final_gap_m = gap_f lvl = self.get_logger().info if gap_f >= 0.0 else self.get_logger().warn lvl( - f"=== 轻降完成: 规尖悬在孔面上方 ≈{gap_f * 1000:.1f}mm(几何:法兰 " + f"=== {kind}完成: 规尖悬在孔面上方 ≈{gap_f * 1000:.1f}mm(几何:法兰 " f"{Tee[2, 3]:.4f} − 工具长 {tip_len * 1000:.0f}mm − 工件面 {surf:.4f};" - f"目标 {self.final_gap_m * 1000:.1f}mm,不接触)===" + f"目标 {target_gap * 1000:.1f}mm,不接触)===" ) return True, q, Tee + def _trim_xy_after_descent(self, q, Tee, final: Dict[str, Any]): + """下降之后、保持当前高度,用这一高度的规尖和孔再对一次 XY。 + + 悬停残差不能拿来当下降后的孔位。相机光轴相对法兰约 30°,孔的基座 XY + 对深度大约 0.5 mm/mm。2026-10-09:同一孔在悬停(相机深度 180mm,残差 + 0.22mm)和观测高度(127mm)上反投影,基座 XY 差约 2mm;法兰 XY 下降时 + 只动了 0.2mm。画面里针在孔的上方一截,主要是 15mm 间隙的透视,横着的 + 那 2mm 才是要补的。 + + 只用本高度新量的规尖,不沿用悬停时冻住的偏移。总修正超过 8mm 就不动 + —— 那一档是夹爪/杆身,不是针。量不到规尖也不动,悬停对准的结果留着。 + """ + if not self.tip_vision: + return q, Tee, final + final_in = final + z_hold = float(Tee[2, 3]) + saved = self._tip_off + budget = 0.008 + moved = 0.0 + prev: Optional[np.ndarray] = None + for it in range(4): + if not rclpy.ok(): + break + try: + _bgr, depth_m, intr, res = self._snap_holes(skip_center_gate=True) + except Exception as exc: # noqa: BLE001 + self.get_logger().warn(f"下降后复拍失败,保持悬停对准的位置:{exc}") + self._tip_off = saved + return q, Tee, final + if not res.get("status"): + self.get_logger().warn(f"下降后丢孔({res.get('msg')}),不再改 XY") + self._tip_off = saved + return q, Tee, final + hb = res.get("hole_base") + z_wp = float(res.get("z_wp") or 0.0) + if hb is None or len(hb) != 2 or z_wp <= 0.0: + self.get_logger().warn("下降后没有孔的基座坐标,不再改 XY") + self._tip_off = saved + return q, Tee, final + left_b = np.asarray(hb[0], dtype=float) + right_b = np.asarray(hb[1], dtype=float) + k1_b, k2_b = (left_b, right_b) if self.k1_label == "left" else (right_b, left_b) + tgt = k1_b if self.target_hole == "k1" else k2_b + Tee_now = self._lookup(self.base_frame, self.ee_frame) + if Tee_now is not None: + Tee = Tee_now + off, tlen, why = self._tip_offset_from_depth( + depth_m, intr, Tee, z_wp_cam=z_wp + ) + if off is None or tlen is None: + self.get_logger().warn(f"下降后没量到规尖({why}),不再改 XY") + self._tip_off = saved + return q, Tee, final + if prev is not None and float(np.linalg.norm(off - prev)) > 0.003: + self.get_logger().warn( + f"下降后规尖两拍差 {float(np.linalg.norm(off - prev))*1000:.1f}mm,这一拍不动" + ) + prev = np.array(off, dtype=float) + continue + prev = np.array(off, dtype=float) + if it == 0: + continue + gxy = np.array([ + float(Tee[0, 3]) + float(off[0]), float(Tee[1, 3]) + float(off[1]) + ]) + raw = np.array([float(tgt[0]) - gxy[0], float(tgt[1]) - gxy[1]]) + err = float(np.linalg.norm(raw)) + final = dict(final) + final["residual_mm"] = round(err * 1000.0, 2) + final["tip_offset_m"] = [round(float(off[0]), 5), round(float(off[1]), 5)] + final["tip_len_m"] = round(float(tlen), 5) + final["gauge_xy_m"] = [round(float(gxy[0]), 5), round(float(gxy[1]), 5)] + final["gauge_source"] = "视觉规尖(下降后重测)" + self._tip_off = np.array(off, dtype=float) + self.get_logger().info( + f"[下降后 {it}] 工具=({gxy[0]:.4f},{gxy[1]:.4f}) " + f"残差={err*1000:.2f}mm {why}" + ) + if err <= self.align_tol: + self.get_logger().info( + f"下降后 XY 已在容差内({err*1000:.2f}mm),高度保持 {z_hold:.4f}" + ) + return q, Tee, final + if moved + err > budget: + self.get_logger().error( + f"下降后要对 {err*1000:.1f}mm(已走 {moved*1000:.1f}mm)," + f"超过 {budget*1000:.0f}mm —— 不像针,停在原地" + ) + self._tip_off = saved + return q, Tee, final_in + + step = raw + if err > 0.004: + step = raw * (0.004 / err) + move = np.array([ + float(Tee[0, 3]) + float(step[0]), + float(Tee[1, 3]) + float(step[1]), + z_hold, + ]) + ok, q, Tee = self._goto( + q, Tee, move, verticalize=True, + tol_m=0.0004, tol_tilt=self.vert_tol_rad * 2.0, + tag="trim_xy", v=min(float(self.align_speed), 0.010), + max_hops=4, settle_s=0.30, strict_xy=True, + ) + self._pin(q) + moved += float(np.linalg.norm(step)) + if not ok: + self.get_logger().warn("下降后 XY 修正没走到,停") + return q, Tee, final + if abs(float(Tee[2, 3]) - z_hold) > 0.002: + self.get_logger().error( + f"下降后修正把高度从 {z_hold:.4f} 带到了 {float(Tee[2, 3]):.4f},停下" + ) + return q, Tee, final + return q, Tee, final def _snap_holes(self, *, skip_center_gate: bool) -> Tuple[np.ndarray, np.ndarray, Dict[str, float], dict]: """多帧拍孔:先对 N 帧做时间中值得到干净图,再在其上检测螺纹嵌件对。 @@ -2059,7 +2187,9 @@ class Stage2Locate(TolerantNode): # 把法兰对到孔上等于把规偏着对过去。偏移量准一次后冻结(刚体量)。 tip_src = "法兰+偏置" if self.tip_vision and self._tip_off is None: - off, tlen, why = self._tip_offset_from_depth(depth_m, intr, Tee) + off, tlen, why = self._tip_offset_from_depth( + depth_m, intr, Tee, z_wp_cam=float(z_wp) + ) if off is not None and tlen is not None: self._tip_samp.append((off, tlen)) self._refresh_tip_offset() @@ -2263,6 +2393,10 @@ class Stage2Locate(TolerantNode): okd, q, Tee = self._descend_above_hole(q, Tee) if not okd: self.get_logger().warn("轻降未完全到位,保持当前高度(安全优先)") + else: + # 悬停残差在下降 60mm 之后会变大约 2mm(相机 30°,同一孔两个高度 + # 反投影对不齐)。在观测高度重测规尖,只补 XY。 + q, Tee, final = self._trim_xy_after_descent(q, Tee, final) # 如实汇报**实际**间隙(几何量)——不是把目标值念一遍。停在离面 50mm 时 # 也显示 ~50mm,现场才看得出"轻降没做到"(R1.1:不许把没做到说成做到)。 gap_txt = ( diff --git a/src/arm_application/launch/pipeline.launch.py b/src/arm_application/launch/pipeline.launch.py index 92e085f..1b47cf2 100644 --- a/src/arm_application/launch/pipeline.launch.py +++ b/src/arm_application/launch/pipeline.launch.py @@ -81,6 +81,7 @@ def _launch_setup(context, *args, **kwargs): # 对到孔心"时先别让规往下走 —— 对不准就下压会把规顶在孔沿上(2026-10-08 真机 # 顶到 63N)。默认关;验证对准时用 --no-descent 打开。 no_descent = _as_bool(LaunchConfiguration("no_descent").perform(context)) + d435_only = _as_bool(LaunchConfiguration("d435_move_only").perform(context)) target_hole = LaunchConfiguration("target_hole").perform(context).strip() depth = LaunchConfiguration("insert_depth_mm").perform(context).strip() pitch = LaunchConfiguration("screw_pitch_mm").perform(context).strip() @@ -117,8 +118,9 @@ def _launch_setup(context, *args, **kwargs): "insert_depth_mm": float(depth) if depth else -1.0, "screw_pitch_mm": float(pitch) if pitch else -999.0, "skip_stage1": skip1, - "skip_stage2": skip2, - "skip_stage3": skip3, + "skip_stage2": skip2 or d435_only, + "skip_stage3": skip3 or d435_only, + "d435_move_only": d435_only, # -1=按 site 的 stage2.final_descent_enable;0=强制不降(调试) "final_descent_enable": 0 if no_descent else -1, } @@ -131,6 +133,7 @@ def _launch_setup(context, *args, **kwargs): LogInfo( msg=f"[pipeline] hole={target_hole or 'site'} depth={depth or 'site'} " f"pitch={pitch or 'site'} skip1={skip1} skip2={skip2} skip3={skip3}" + + (" [d435_only:只测 D435 位移]" if d435_only else "") + (" [no_descent:只对准,不轻降]" if no_descent else "") ), *nodes, @@ -156,6 +159,7 @@ def generate_launch_description() -> LaunchDescription: DeclareLaunchArgument("resnap", default_value="false"), # 只验证精对准、不轻降(见 _launch_setup 里的说明) DeclareLaunchArgument("no_descent", default_value="false"), + DeclareLaunchArgument("d435_move_only", default_value="false"), DeclareLaunchArgument("preview", default_value="true"), OpaqueFunction(function=_launch_setup), ] diff --git a/src/arm_application/launch/visual_servo.launch.py b/src/arm_application/launch/visual_servo.launch.py index 2dd937c..00b0958 100644 --- a/src/arm_application/launch/visual_servo.launch.py +++ b/src/arm_application/launch/visual_servo.launch.py @@ -60,6 +60,10 @@ def _load_site(): return merged, path, perception_overrides(data), servo_overrides(data), robot +def _as_bool(s: str) -> bool: + return s.strip().lower() in ("1", "true", "yes") + + def _handeye_xyz_xyzw(path: str): """读手眼文件 → ``(tx, ty, tz, qx, qy, qz, qw)``。 @@ -171,6 +175,9 @@ def _launch_setup(context, *args, **kwargs): servo_params["lock_hold_sec"] = float(lock_hold_arg) except ValueError: pass + d435_only = _as_bool(LaunchConfiguration("d435_move_only").perform(context)) + perc_params["d435_move_only"] = d435_only + servo_params["d435_move_only"] = d435_only # ------------------------------------------------------------------ # 感知来源:谁来产生"视觉测量"(site.yaml 的 perception.source) @@ -240,6 +247,10 @@ def _launch_setup(context, *args, **kwargs): ), LogInfo(msg=f"[site] 感知来源 perception.source = {perc_src}"), ] + if d435_only: + nodes.append( + LogInfo(msg="[d435_only] 只测 D435 单次位移:不交 D405、不进二阶段") + ) if perc_src == "yolo": nodes.append(LogInfo(msg=f"[site] YOLO weights: {weights}")) nodes.append( @@ -345,6 +356,10 @@ def generate_launch_description() -> LaunchDescription: "lock_hold_sec", default_value="", ), + DeclareLaunchArgument( + "d435_move_only", + default_value="false", + ), OpaqueFunction(function=_launch_setup), ] ) diff --git a/src/arm_application/package.xml b/src/arm_application/package.xml index 8015b8d..a0fb83a 100644 --- a/src/arm_application/package.xml +++ b/src/arm_application/package.xml @@ -16,6 +16,7 @@ std_msgs std_srvs trajectory_msgs + control_msgs tf2_ros python3-yaml python3-serial diff --git a/src/arm_application/test/test_feature_guard.py b/src/arm_application/test/test_feature_guard.py index b3f7efa..9322b5b 100644 --- a/src/arm_application/test/test_feature_guard.py +++ b/src/arm_application/test/test_feature_guard.py @@ -287,6 +287,9 @@ def test_perception_wires_slew_and_reacquire_guards(): src = _perception_src() assert "TrackSlewGuard(" in src, "位移上限没接" assert "FarReacquireConfirm(" in src, "远框重捕获确认没接" + assert "self.reacquire_far_px =" in src, ( + "参数读了没存成属性 —— 远框确认会 AttributeError,看门狗跟着急停" + ) def _export_site_env(): diff --git a/src/arm_application/test/test_gauge_exit_lift.py b/src/arm_application/test/test_gauge_exit_lift.py index bb276c7..e895eb6 100644 --- a/src/arm_application/test/test_gauge_exit_lift.py +++ b/src/arm_application/test/test_gauge_exit_lift.py @@ -12,8 +12,8 @@ 所以这里有两条断言,缺一不可: * **结构**:``_step_unscrew`` 在"成功返回"之前必须调用提出动作 —— 顺序错了 (比如把提出挪到 S10 里)就等于没做,因为横向动作已经发生了; -* **行为**:提出目标是**跟踪高**(``_track_z()`` = 悬停高 + 50mm),不是写死的 - 毫米数;已经在跟踪高上时不动(否则每次换孔白等 1 秒多)。 +* **行为**:提出目标是**跟踪高**(``_track_z()``:悬停时尖端离面不够才再抬 + 50mm),不是写死的毫米数;已经在跟踪高上时不动(否则每次换孔白等 1 秒多)。 """ from __future__ import annotations @@ -79,7 +79,7 @@ def test_unscrew_lifts_out_of_the_hole_before_succeeding(): def test_lift_helper_exists_and_uses_the_track_height_idiom(): - """提出高度用 ``_track_z()``(悬停高 + 50mm),不写死毫米数。""" + """提出高度用 ``_track_z()``,不写死毫米数。""" fn = _find_method("_lift_clear_of_hole") assert _call_lines(fn, "_track_z"), ( "提出高度必须用 _track_z():这是流程里『工具已离开工件』的既有定义," diff --git a/src/arm_application/test/test_handeye_lib.py b/src/arm_application/test/test_handeye_lib.py index 162b7ee..fcb1a5f 100644 --- a/src/arm_application/test/test_handeye_lib.py +++ b/src/arm_application/test/test_handeye_lib.py @@ -18,8 +18,10 @@ from arm_application.handeye_lib import ( CameraIntrinsics, ChessboardStabilizer, Rt_to_T, + T_from_flexiv_tcp, board_object_points, calibrate_hand_eye, + cam_to_flange_via_tool, format_transform_yaml, invert_Rt, is_pose_filled, @@ -420,3 +422,41 @@ def test_is_pose_filled_rejects_wrong_length(): def test_is_pose_filled_accepts_real_pose(): assert is_pose_filled([-1.107, -0.744, 0.982, 1.856, -0.735, 0.947, -0.118]) + + +def test_flexiv_tcp_identity_is_pure_translation(): + """Studio tool10120938:姿态单位四元数,只沿法兰 Z 偏 224.5 mm。""" + T = T_from_flexiv_tcp([-0.0005, 0.0067, 0.2245], [1.0, 0.0, 0.0, 0.0]) + assert np.allclose(T[:3, :3], np.eye(3), atol=1e-12) + assert T[2, 3] == pytest.approx(0.2245) + + +def test_cam_to_flange_via_tool_must_not_leave_t_unchanged(): + """忘了乘时 T_4x4 停在 TCP;乘反了相机会到法兰后方。""" + T_cam_tool = np.eye(4) + T_cam_tool[:3, 3] = [-0.05961995, -0.01523625, -0.09761517] + T_flange_tool = T_from_flexiv_tcp([-0.0005, 0.0067, 0.2245], [1.0, 0.0, 0.0, 0.0]) + T_cam_flange = cam_to_flange_via_tool(T_cam_tool, T_flange_tool) + delta = float(np.linalg.norm(T_cam_flange[:3, 3] - T_cam_tool[:3, 3])) + tool_len = float(np.linalg.norm(T_flange_tool[:3, 3])) + assert delta == pytest.approx(tool_len, abs=1e-9) + assert tool_len > 0.20 + # 相机在法兰前方(+Z ≈ 126 mm),与 2026-09-04 旧结果同量级。 + assert T_cam_flange[2, 3] == pytest.approx(0.12688483, abs=1e-8) + assert T_cam_flange[0, 3] == pytest.approx(-0.06011995, abs=1e-8) + # 负向:乘反了会到法兰后方约 −296 mm。 + wrong = T_cam_tool @ np.linalg.inv(T_flange_tool) + assert wrong[2, 3] < 0.0 + # 负向:直接把 cam→工具当成 cam→法兰,平移几乎不动。 + assert float(np.linalg.norm(T_cam_tool[:3, 3] - T_cam_tool[:3, 3])) < 1e-12 + # 折回去必须还原:T_ct = inv(T_ft) · T_cf + T_back = np.linalg.inv(T_flange_tool) @ T_cam_flange + assert np.allclose(T_back, T_cam_tool, atol=1e-12) + + +def test_cam_at_tcp_is_the_tool_pose_in_flange(): + """相机与 TCP 重合时,cam→法兰必须等于法兰→工具,不能是它的逆。""" + T_ft = T_from_flexiv_tcp([-0.0005, 0.0067, 0.2245], [1.0, 0.0, 0.0, 0.0]) + T_cf = cam_to_flange_via_tool(np.eye(4), T_ft) + assert np.allclose(T_cf, T_ft, atol=1e-12) + assert T_cf[2, 3] == pytest.approx(0.2245) diff --git a/src/arm_application/test/test_handeye_mode.py b/src/arm_application/test/test_handeye_mode.py index 1f52cb8..c1d2e8f 100644 --- a/src/arm_application/test/test_handeye_mode.py +++ b/src/arm_application/test/test_handeye_mode.py @@ -339,6 +339,7 @@ class TestRunHandeyeScriptMode: assert "camera=d435" in out assert "11x8 inner, square=0.02 m" in out assert "89_d435_eye_to_hand.yaml" in out + assert "tool10120938" in out # 负向:不能再去开腕部相机。 assert "camera=d405" not in out @@ -346,7 +347,8 @@ class TestRunHandeyeScriptMode: out = _show("eye_in_hand", _real_site) assert "mode=eye_in_hand" in out assert "d405" in out - assert "8x11 inner, square=0.015 m" in out + assert "11x8 inner, square=0.015 m" in out + assert "tool10120938" in out assert "89_eye_in_hand.yaml" in out def test_missing_board_blocks_calibrate(self, tmp_path: Path): @@ -392,6 +394,58 @@ class TestRunHandeyeScriptMode: assert proc.returncode != 0 assert "没有配置 [eye_to_hand] 用的标定板尺寸" in (proc.stdout + proc.stderr) + def test_left_arm_calibrate_wires_tool10120938(self): + """89 必须按 tool10120938 求解;脚本和节点都要接到这份 TCP。""" + cal = ( + ROOT / "src" / "arm_application" / "arm_application" / "handeye_calibrate.py" + ).read_text(encoding="utf-8") + sh = SCRIPT.read_text(encoding="utf-8") + assert "cam_to_flange_via_tool(" in cal + assert "gripper_tool" in cal + assert "gripper_tool:=" in sh + assert "T_4x4 仍是 cam→基座,不折" in sh + backup = ROOT / "handeye" / "results" / "backup" / "89_eye_in_hand_flange_tool1_20260904.yaml" + assert backup.is_file() + e2h_bak = ROOT / "handeye" / "results" / "backup" / "89_d435_eye_to_hand_20261009.yaml" + assert e2h_bak.is_file() + e2h = (ROOT / "handeye" / "results" / "89_d435_eye_to_hand.yaml").read_text( + encoding="utf-8" + ) + assert "gripper_tool: tool10120938" in e2h + assert "cam→基座" in e2h + import re + + m_e2h = re.search(r"translation_m:\s*\[([^]]+)\]", e2h) + assert m_e2h, e2h + xyz_e2h = [float(x) for x in m_e2h.group(1).split(",")] + # 顶置相机在基座上方约 0.7 m;折错或写到法兰会差几十厘米。 + assert 0.60 < xyz_e2h[2] < 0.90, xyz_e2h + # 眼在手外不得走 cam_to_flange_via_tool(相机和基座都在世界里)。 + e2h_block = cal.split("if eye_to_hand:")[1].split("elif self.gripper_tool:")[0] + assert "cam_to_flange_via_tool" not in e2h_block + live = ROOT / "handeye" / "results" / "89_eye_in_hand.yaml" + text = live.read_text(encoding="utf-8") + assert "BACKUP" in text or "backup/" in text + # 乘反了 tz 会是负的(法兰后方);现行必须在法兰前方。 + import re + + m = re.search(r"translation_m:\s*\[([^]]+)\]", text) + assert m, text + xyz = [float(x) for x in m.group(1).split(",")] + assert xyz[2] > 0.10, xyz + assert "inv(T_flange_to_tool)" not in text + + def test_teach_and_merge_share_the_same_taught_file(self): + """示教写 A、merge 读 B → 采了点也报 got 0。两边必须用 TAUGHT。""" + sh = SCRIPT.read_text(encoding="utf-8") + teach = ( + ROOT / "src" / "arm_application" / "arm_application" / "handeye_teach.py" + ).read_text(encoding="utf-8") + assert 'poses_file:="${TAUGHT}"' in sh + assert "taught_poses_${HANDEYE_MODE}" in sh + assert "declare_parameter('poses_file'" in teach + assert "get_parameter('poses_file')" in teach + def test_illegal_mode_exits_immediately(self): proc = subprocess.run( ["bash", str(SCRIPT), "--arm", "89", "show", "--mode", "eye_on_foot"], diff --git a/src/arm_application/test/test_merge_handeye_poses.py b/src/arm_application/test/test_merge_handeye_poses.py new file mode 100644 index 0000000..5a57415 --- /dev/null +++ b/src/arm_application/test/test_merge_handeye_poses.py @@ -0,0 +1,69 @@ +"""merge 必须能失败:空文件、连按重复点都不够 5 个独立姿态。""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +_SPEC = importlib.util.spec_from_file_location( + "merge_handeye_poses", ROOT / "scripts" / "merge_handeye_poses.py" +) +assert _SPEC and _SPEC.loader +_mod = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = _mod +_SPEC.loader.exec_module(_mod) + + +def _pose(i: int) -> list[float]: + return [0.1 * i, -1.0, 1.0, 1.4, -0.9, 0.2, 0.4] + + +def test_parse_taught_txt_skips_comments(tmp_path: Path): + text = "\n".join( + [ + "# pose 0 20261012_030533", + "-0.730276, -1.078914, 1.182093, 1.378457, -0.914310, 0.247653, 0.409611,", + "# image /tmp/a.png", + ] + ) + p = tmp_path / "taught_parse_once.txt" + p.write_text(text, encoding="utf-8") + poses = _mod.parse_poses(p) + assert len(poses) == 1 + assert abs(poses[0][0] + 0.730276) < 1e-9 + + +def test_unique_drops_enter_repeat(): + same = _pose(0) + poses = [same, list(same), list(same), _pose(1)] + uniq = _mod.unique_poses(poses) + assert len(uniq) == 2 + + +def test_merge_refuses_repeat_only_file(tmp_path: Path): + src = tmp_path / "taught.txt" + dst = tmp_path / "wp.yaml" + same = ", ".join(f"{v:.6f}" for v in _pose(0)) + "," + src.write_text("\n".join(f"# pose {i}\n{same}" for i in range(12)) + "\n") + dst.write_text(" num_poses: 0\n waypoint_positions: []\n", encoding="utf-8") + rc = _mod.main(["merge_handeye_poses.py", str(src), str(dst)]) + assert rc == 4 + assert "waypoint_positions: []" in dst.read_text(encoding="utf-8") + + +def test_merge_keeps_five_distinct(tmp_path: Path): + src = tmp_path / "taught.txt" + dst = tmp_path / "wp.yaml" + lines = [] + for i in range(5): + lines.append(f"# pose {i}") + lines.append(", ".join(f"{v:.6f}" for v in _pose(i)) + ",") + src.write_text("\n".join(lines) + "\n", encoding="utf-8") + dst.write_text(" num_poses: 0\n waypoint_positions: []\n", encoding="utf-8") + rc = _mod.main(["merge_handeye_poses.py", str(src), str(dst)]) + assert rc == 0 + text = dst.read_text(encoding="utf-8") + assert "num_poses: 5" in text + assert text.count("# pose ") == 5 diff --git a/src/arm_application/test/test_overhead_oneshot.py b/src/arm_application/test/test_overhead_oneshot.py index 2f56d8b..37ea5fe 100644 --- a/src/arm_application/test/test_overhead_oneshot.py +++ b/src/arm_application/test/test_overhead_oneshot.py @@ -11,14 +11,49 @@ from types import SimpleNamespace import numpy as np import pytest +from arm_application.path_profile import ( + D435_FULL_ACC_M, + D435_FULL_CRUISE_MPS, + D435_FULL_DEC_M, + cosine_peak_acc, + smooth_joint_schedule, + smooth_path_params, + smooth_path_sv, + trap_joint_schedule, +) from arm_application.overhead_oneshot import ( StillLock, + at_lock_height, best_det_conf, depth_raw_to_m, k0_uv_from_det, + lock_z_velocity, map_uv_to_depth, + D405_MISS_BEFORE_D435_S, + D435_MOVE_SPEED_SCALE, + D435_NEAR_CRUISE_MPS, + D435_NEAR_HOP_MAX_M, + D435_NEAR_LEFTOVER_M, + D435_SETTLE_S, + FROZEN_J_HOP_MAX_M, + build_arc_xy_move, + d405_commands_arm, + flange_down_w, + hold_translation_vz, + hybrid_cruise_vxy, + oneshot_cartesian_dp, + oneshot_concat_hold_ori, + oneshot_full_cruise_mps, + oneshot_full_segments, + oneshot_hop_limits, oneshot_k0_base, + oneshot_may_send_another, + oneshot_needs_verticalize, + oneshot_shot_timed_out, + oneshot_next_hop_m, + oneshot_should_submit, select_oneshot_det, + trap_acc_for_hop, wrist_needs_handoff, ) @@ -38,6 +73,399 @@ class TestHandoff: assert wrist_needs_handoff(0.5, 0.5) is False assert wrist_needs_handoff(0.87, 0.5) is False + def test_below_lock_height_is_not_ready_and_must_lift(self): + """0.26 m 是观测下降停的高度,不是锁高。必须判没到位,并且速度朝上。""" + assert at_lock_height(0.257, 0.320, 0.040) is False + assert at_lock_height(0.327, 0.320, 0.040) is True + assert at_lock_height(0.250, 0.320, 0.040) is False + vz = lock_z_velocity(0.257, 0.320, kp=1.0, v_lim=0.08) + assert vz > 0.0, "低于锁高必须上抬,写成 0 会和 D435 拒拍卡死" + assert lock_z_velocity(0.360, 0.320, kp=1.0, v_lim=0.08) < 0.0 + assert lock_z_velocity(0.321, 0.320, kp=1.0, v_lim=0.08) == 0.0 + + def test_z_locked_qos_is_latched(self): + """晚订阅必须能拿到已经发出的锁高状态,否则 D435 会一直拒拍。""" + from rclpy.qos import DurabilityPolicy + + from arm_application.overhead_oneshot import z_locked_qos + + qos = z_locked_qos() + assert qos.durability == DurabilityPolicy.TRANSIENT_LOCAL + assert qos.depth == 1 + + def test_oneshot_waits_half_second_then_fires_once(self): + """连续 0.5 s 没有高置信度才拍。差一帧不算。拍成之后不许连拍。 + + 不再另等「已到锁高」——那一闸和回锁高互相卡住。 + """ + wait = D405_MISS_BEFORE_D435_S + assert wait == 0.5 + assert oneshot_should_submit( + handoff=True, low_for_s=wait - 0.01, already_shot=False + ) is False + assert oneshot_should_submit( + handoff=True, low_for_s=wait, already_shot=False + ) is True + assert oneshot_should_submit( + handoff=True, low_for_s=wait + 1.0, already_shot=True + ) is False + assert oneshot_should_submit( + handoff=False, low_for_s=wait + 1.0, already_shot=False + ) is False + # 拍了伺服没接住(工作盒拒)必须能再拍;已经在赶路不许清。 + assert oneshot_shot_timed_out( + shot=True, commanding=False, age_s=0.79 + ) is False + assert oneshot_shot_timed_out( + shot=True, commanding=False, age_s=0.80 + ) is True + assert oneshot_shot_timed_out( + shot=True, commanding=True, age_s=2.0 + ) is False + perc = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "perception_node.py" + ).read_text(encoding="utf-8") + assert "oneshot_shot_timed_out(" in perc + assert "伺服没接住" in perc + + def test_d405_points_stay_off_the_arm_until_arrived_and_settled(self): + """用着 D435 那个点时,到位并且停稳之前,D405 的点不下发。推理不在这里关。""" + assert d405_commands_arm(using_d435=False, arrived=False, settled=False) is True + assert d405_commands_arm(using_d435=True, arrived=False, settled=False) is False + assert d405_commands_arm(using_d435=True, arrived=True, settled=False) is False + assert d405_commands_arm(using_d435=True, arrived=False, settled=True) is False + assert d405_commands_arm(using_d435=True, arrived=True, settled=True) is True + src = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "servo_node.py" + ).read_text(encoding="utf-8") + assert "d405_commands_arm(" in src + assert "_arrive" in src + perc = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "perception_node.py" + ).read_text(encoding="utf-8") + assert "D405_MISS_BEFORE_D435_S" in perc + assert "d435_commanding" in perc + + def test_d435_move_only_never_hands_back_to_d405(self): + """只测位移:D405 点一律不下发,到位停稳也不交。默认路径仍会交回。""" + assert d405_commands_arm( + using_d435=False, arrived=True, settled=True, move_only=True + ) is False + assert d405_commands_arm( + using_d435=True, arrived=True, settled=True, move_only=True + ) is False + assert d405_commands_arm( + using_d435=True, arrived=True, settled=True, move_only=False + ) is True + root = __import__("pathlib").Path(__file__).resolve().parents[1] + servo = (root / "arm_application" / "servo_node.py").read_text(encoding="utf-8") + perc = (root / "arm_application" / "perception_node.py").read_text( + encoding="utf-8" + ) + pipe = (root / "arm_application" / "pipeline_node.py").read_text( + encoding="utf-8" + ) + launch = (root / "launch" / "pipeline.launch.py").read_text(encoding="utf-8") + vs = (root / "launch" / "visual_servo.launch.py").read_text(encoding="utf-8") + ws = root + while ws != ws.parent and not (ws / "scripts" / "run_all.sh").is_file(): + ws = ws.parent + run_all = (ws / "scripts" / "run_all.sh").read_text(encoding="utf-8") + assert "move_only=self.d435_move_only" in servo + assert "不交 D405、不进二阶段" in servo + assert "改回下发 D405" in servo + assert "self.d435_move_only" in perc + assert "or wrist_needs_handoff(" in perc + assert "d435_move_only:={str(self.d435_move_only).lower()}" in pipe + assert "skip2 = True" in pipe + assert '"d435_move_only": d435_only' in launch + assert 'LaunchConfiguration("d435_move_only")' in vs + assert '"d435_move_only"' in vs + assert "--d435-only" in run_all + + def test_whole_path_uses_one_cruise_and_an_arc(self): + """远处仍 80 mm/s。近处必须另降一档。路径是圆弧,不是对着终点做 P。""" + assert D435_MOVE_SPEED_SCALE == 1.0 + assert hybrid_cruise_vxy(0.08) == pytest.approx(0.08) + hop_far, v_far = oneshot_hop_limits(0.20, 0.08) + hop_mid, v_mid = oneshot_hop_limits(0.12, 0.08) + hop_near, v_near = oneshot_hop_limits(0.074, 0.08) + assert hop_far == pytest.approx(FROZEN_J_HOP_MAX_M) + assert v_far == pytest.approx(0.08) + assert hop_mid == pytest.approx(0.040) + assert v_mid == pytest.approx(0.08) + assert hop_near == pytest.approx(D435_NEAR_HOP_MAX_M) + assert v_near == pytest.approx(D435_NEAR_CRUISE_MPS) + assert hop_near == pytest.approx(0.015) + assert hop_near < 0.030 + assert v_near < 0.08 + assert D435_NEAR_LEFTOVER_M == pytest.approx(0.08) + assert D435_SETTLE_S == pytest.approx(0.25) + p0 = np.array([0.687, -0.113]) + p1 = np.array([0.583, -0.502]) + move = build_arc_xy_move(p0, p1, v=0.08, a=0.40, now=0.0) + assert move.linear is False + assert move.length > float(np.linalg.norm(p1 - p0)) + assert move.v_cruise == pytest.approx(0.08) + xy0, v0, done0 = move.sample(0.0) + np.testing.assert_allclose(xy0, p0, atol=1e-6) + assert float(np.hypot(v0[0], v0[1])) == pytest.approx(0.0, abs=1e-6) + assert done0 is False + t_mid = 0.5 * (move.t_acc + (move.t_acc + move.t_cruise)) + xy_m, v_m, _ = move.sample(t_mid) + chord = p1 - p0 + # 中点不在弦上,否则还是直线冲。 + rel = xy_m - p0 + off = abs(float(chord[0] * rel[1] - chord[1] * rel[0])) / float( + np.linalg.norm(chord) + ) + assert off > 0.005 + assert float(np.hypot(v_m[0], v_m[1])) == pytest.approx(0.08, abs=0.008) + xy1, v1, done1 = move.sample(move.T + 0.01) + np.testing.assert_allclose(xy1, p1, atol=2e-3) + assert float(np.hypot(v1[0], v1[1])) == pytest.approx(0.0, abs=1e-4) + assert done1 is True + src = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "servo_node.py" + ).read_text(encoding="utf-8") + assert "hybrid_cruise_vxy(" in src + assert "_d435_oneshot_holds_the_arm" in src + assert "smooth_joint_schedule(" in src + assert "oneshot_full_segments(" in src + assert "oneshot_concat_hold_ori(" in src + assert "oneshot_full_cruise_mps(" in src + assert "oneshot_may_send_another(" in src + assert "oneshot_hop_limits(" in src + assert "oneshot_needs_verticalize(" in src + assert "verticalize=near" not in src + oneshot = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "overhead_oneshot.py" + ).read_text(encoding="utf-8") + assert "verticalize=False" in oneshot + assert "oneshot_concat_hold_ori" in oneshot + assert '_d435_send_verticalize' in src + assert 'tag="vert"' in src or '"vert"' in src + assert 'tag = "fix"' in src + assert "_d435_full_done" in src + assert "_d435_fix_done" in src + assert "flange_tilt_rad(" in src + assert "D435 重测 leftover=" in src + assert "dxy=(" in src + assert "_d435_remeasure_log" in src + assert "到位成功不许再往话题插 hold" in src + assert "FollowJointTrajectory" in src + assert "send_goal_async(" in src + assert "结果回来之前不再发下一条" in src + assert "钉住不再补刀" in src + hold = src[src.index("def _d435_oneshot_holds_the_arm") : src.index("def _tick_d435_arrive")] + assert 'self._track_source == "overhead"' in hold + assert "not self._d435_settled" in hold + assert "_publish_joint_schedule(" not in hold + assert "oneshot_cartesian_dp(" not in hold + assert "trap_joint_schedule(" not in hold + assert "_hybrid_goal_xy" not in src + assert "def _execute_twist(" in src + stage2 = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "stage2_locate_node.py" + ).read_text(encoding="utf-8") + assert "from arm_application.path_profile import" in stage2 + assert "FROZEN_J_HOP_MAX_M" in stage2 + assert "def _trap_params(" not in stage2 + assert "def _path_interp(" not in stage2 + + def test_frozen_j_hop_is_capped_and_same_leftover_must_not_repeat(self): + """400 mm 一次规划会少走;28 mm 同剩差再发就是真机那趟死循环。""" + assert FROZEN_J_HOP_MAX_M == pytest.approx(0.08) + assert oneshot_next_hop_m(0.402) == pytest.approx(0.08) + assert oneshot_next_hop_m(0.028) == pytest.approx(0.028) + arrive = 0.015 + assert oneshot_may_send_another(0.402, None, arrive_m=arrive) is True + assert oneshot_may_send_another(0.322, 0.402, arrive_m=arrive) is True + assert oneshot_may_send_another(0.028, 0.028, arrive_m=arrive) is False + assert oneshot_may_send_another(0.010, 0.028, arrive_m=arrive) is False + + def test_oneshot_dp_lifts_z_first_and_caps_xy(self): + """没到锁高只收 Z;400 mm 的 XY 必须被砍到 80 mm,并且带着锁高。""" + gate = 0.040 + lift = oneshot_cartesian_dp( + [0.65, -0.32, 0.26], [0.58, -0.50], 0.320, gate_m=gate + ) + np.testing.assert_allclose(lift[:2], [0.0, 0.0]) + assert lift[2] > 0.0 + assert float(np.linalg.norm(lift)) == pytest.approx(0.060, abs=1e-9) + xy = oneshot_cartesian_dp( + [0.65, -0.11, 0.320], [0.58, -0.50], 0.320, gate_m=gate + ) + assert float(np.linalg.norm(xy)) == pytest.approx(FROZEN_J_HOP_MAX_M) + assert abs(float(xy[2])) < 1e-9 + acc = trap_acc_for_hop(0.08, 0.08) + assert acc == pytest.approx(0.08 * 0.08 / (2.0 * 0.0256), abs=1e-6) + + def test_near_leftover_is_short_slow_xy_only(self): + """真机最后一跳 37–75 mm @ 80 mm/s 会晃。近处必须短、慢、不混 Z。""" + gate = 0.040 + dp = oneshot_cartesian_dp( + [0.583, -0.465, 0.327], [0.583, -0.502], 0.320, gate_m=gate + ) + leftover = 0.037 + hop_max, cruise = oneshot_hop_limits(leftover, 0.08) + assert leftover <= D435_NEAR_LEFTOVER_M + assert hop_max == pytest.approx(0.015) + assert cruise == pytest.approx(0.030) + assert float(np.linalg.norm(dp)) == pytest.approx(0.015, abs=1e-9) + assert abs(float(dp[2])) < 1e-9 + assert oneshot_needs_verticalize(0.03, 0.010, 0.015) is True + assert oneshot_needs_verticalize(0.03, 0.020, 0.015) is False + assert oneshot_needs_verticalize(0.03, 0.010, 0.015, already_done=True) is False + assert oneshot_needs_verticalize(0.005, 0.010, 0.015) is False + R = np.eye(3) + R[2, 2] = -1.0 + w, ang = flange_down_w(R) + np.testing.assert_allclose(w, [0.0, 0.0, 0.0]) + assert ang < 0.006 + stage2 = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "stage2_locate_node.py" + ).read_text(encoding="utf-8") + assert "return flange_down_w(R)" in stage2 + + def test_full_segments_do_not_chop_and_keep_z_then_xy(self): + """整段一次走完:200 mm 不许被砍成 80 mm;没到锁高先整段收 Z。""" + gate = 0.040 + segs = oneshot_full_segments( + [0.65, -0.11, 0.26], [0.58, -0.50], 0.320, gate_m=gate + ) + assert segs[0][1] == "lift_z" + np.testing.assert_allclose(segs[0][0][:2], [0.0, 0.0]) + assert segs[0][0][2] == pytest.approx(0.060) + assert segs[1][1] == "xy" + xy_len = float(np.linalg.norm(segs[1][0][:2])) + assert xy_len > 0.20 + assert xy_len > FROZEN_J_HOP_MAX_M + near = oneshot_full_segments( + [0.583, -0.465, 0.327], [0.583, -0.502], 0.320, gate_m=gate + ) + assert len(near) == 1 + assert near[0][1] == "xy" + assert float(np.linalg.norm(near[0][0])) == pytest.approx(0.037, abs=1e-9) + assert abs(float(near[0][0][2])) < 1e-9 + assert oneshot_full_cruise_mps(0.20, 0.08) == pytest.approx(D435_FULL_CRUISE_MPS) + assert oneshot_full_cruise_mps(0.20, 0.08) < 0.08 + assert oneshot_full_cruise_mps(0.05, 0.08) == pytest.approx(D435_NEAR_CRUISE_MPS) + chopped = oneshot_cartesian_dp( + [0.65, -0.11, 0.320], [0.58, -0.50], 0.320, gate_m=gate + ) + assert float(np.linalg.norm(chopped)) == pytest.approx(FROZEN_J_HOP_MAX_M) + + def test_smooth_path_is_rest_to_rest_and_slows_at_the_end(self): + """300 mm @ 60 mm/s:头尾速度 0,起步 ≥3 s,峰值加速度必须低于旧的弹射档。""" + assert D435_FULL_ACC_M == pytest.approx(0.100) + assert D435_FULL_DEC_M == pytest.approx(0.080) + assert D435_FULL_CRUISE_MPS == pytest.approx(0.060) + a_now = cosine_peak_acc(D435_FULL_CRUISE_MPS, D435_FULL_ACC_M) + a_old = cosine_peak_acc(0.08, 0.040) + assert a_old > 0.12 + assert a_now < 0.035 + assert a_now < 0.30 * a_old + T, v_peak, t_acc, t_cruise, d_acc, d_dec = smooth_path_params(0.30, 0.06) + assert v_peak == pytest.approx(0.06) + assert d_acc == pytest.approx(0.100) + assert d_dec == pytest.approx(0.080) + assert t_acc > 3.0 + assert t_cruise > 0.5 + s0, v0 = smooth_path_sv(0.0, 0.30, v_peak, t_acc, t_cruise, d_acc, d_dec) + assert s0 == pytest.approx(0.0, abs=1e-9) + assert v0 == pytest.approx(0.0, abs=1e-9) + s_mid, v_mid = smooth_path_sv( + t_acc + 0.5 * t_cruise, 0.30, v_peak, t_acc, t_cruise, d_acc, d_dec + ) + assert v_mid == pytest.approx(0.06, abs=1e-6) + assert 0.10 < s_mid < 0.24 + s1, v1 = smooth_path_sv(T, 0.30, v_peak, t_acc, t_cruise, d_acc, d_dec) + assert s1 == pytest.approx(0.30, abs=1e-6) + assert v1 == pytest.approx(0.0, abs=1e-6) + s_dec, v_dec = smooth_path_sv( + t_acc + t_cruise + 0.5 * (T - t_acc - t_cruise), + 0.30, + v_peak, + t_acc, + t_cruise, + d_acc, + d_dec, + ) + assert v_dec < 0.045 + assert s_dec > 0.20 + q0 = np.zeros(7) + q1 = np.zeros(7) + q1[0] = 0.3 + sched, T_move, vp = smooth_joint_schedule([q0, q1], 0.30, 0.06) + assert T_move == pytest.approx(T, abs=1e-6) + assert vp == pytest.approx(0.06) + assert float(np.linalg.norm(sched[0][1])) == pytest.approx(0.0, abs=1e-9) + assert float(np.linalg.norm(sched[-1][1])) == pytest.approx(0.0, abs=1e-9) + times = [t for _, _, t in sched] + assert all(times[i] < times[i + 1] for i in range(len(times) - 1)) + + def test_concat_hold_ori_refuses_to_mix_verticalize(self): + """串段必须 verticalize=False。混进收垂直会把腕部漏成 Y。""" + J = np.zeros((6, 7)) + J[0, 0] = 1.0 + J[1, 1] = 1.0 + J[2, 2] = 1.0 + segs = [ + (np.array([0.0, 0.0, 0.02]), "lift_z"), + (np.array([0.04, -0.03, 0.0]), "xy"), + ] + wps, planned, asked, tag = oneshot_concat_hold_ori( + np.zeros(7), J, segs, R=np.eye(3) + ) + assert tag == "full" + assert asked == pytest.approx(0.02 + float(np.hypot(0.04, 0.03))) + assert len(wps) >= 3 + src = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "overhead_oneshot.py" + ).read_text(encoding="utf-8") + assert "verticalize=False" in src + assert "oneshot_concat_hold_ori" in src + + def test_oneshot_schedule_is_rest_to_rest_and_times_increase(self): + """一次下发的轨迹:头尾速度 0,时间递增。中途再插点就会把这条掐掉。""" + q0 = np.zeros(7) + q1 = np.zeros(7) + q1[0] = 0.3 + sched, T_move, v_peak = trap_joint_schedule( + [q0, q1], 0.40, 0.08, 0.40 + ) + assert T_move > 4.0 + assert v_peak == pytest.approx(0.08) + assert len(sched) > 8 + assert float(np.linalg.norm(sched[0][1])) == pytest.approx(0.0, abs=1e-9) + assert float(np.linalg.norm(sched[-1][1])) == pytest.approx(0.0, abs=1e-9) + times = [t for _, _, t in sched] + assert times == sorted(times) + assert all(times[i] < times[i + 1] for i in range(len(times) - 1)) + mid = sched[len(sched) // 3] + assert float(np.linalg.norm(mid[1])) > 0.01 + + def test_translation_holds_the_height_it_started_at(self): + """平移钉住开始时的高度。沉下去要抬回来,已经在 4mm 内就不要再收。""" + assert hold_translation_vz(0.327, 0.327, kp=1.0, v_lim=0.05) == 0.0 + assert hold_translation_vz(0.325, 0.327, kp=1.0, v_lim=0.05) == 0.0 + assert hold_translation_vz(0.307, 0.327, kp=1.0, v_lim=0.05) > 0.0 + assert hold_translation_vz(0.347, 0.327, kp=1.0, v_lim=0.05) < 0.0 + src = ( + __import__("pathlib").Path(__file__).resolve().parents[1] + / "arm_application" / "servo_node.py" + ).read_text(encoding="utf-8") + assert "hold_translation_vz(" in src + def test_threshold_is_the_caller_value_not_hardcoded(self): # 负向:如果函数内部写死 0.5,改门槛 0.7 时 0.6 会判错 assert wrist_needs_handoff(0.6, 0.7) is True diff --git a/src/arm_application/test/test_pipeline_stage1_diag.py b/src/arm_application/test/test_pipeline_stage1_diag.py index 56c565c..05c272c 100644 --- a/src/arm_application/test/test_pipeline_stage1_diag.py +++ b/src/arm_application/test/test_pipeline_stage1_diag.py @@ -13,7 +13,7 @@ from __future__ import annotations import pytest -from arm_application.pipeline_node import stage1_timeout_reason +from arm_application.pipeline_node import stage1_timeout_reason, track_lock_z def test_zero_points_blames_perception_not_calibration(): @@ -40,3 +40,21 @@ def test_timeout_seconds_and_log_path_are_in_the_message(): msg = stage1_timeout_reason(0, 42.0, "/tmp/x.log") assert "42s" in msg assert "/tmp/x.log" in msg + + +def test_real_arm_locks_track_height_at_hover(): + """真机 89:悬停时规尖已经离面约 75mm,跟踪锁在悬停高,对孔前不再抬。""" + z = track_lock_z(0.320, 0.223, 0.022) + assert z == pytest.approx(0.320) + + +def test_sim_still_lifts_when_hover_barely_clears_the_part(): + """仿真悬停只留约 2mm,不抬就会在跟踪时刮到工件。""" + z = track_lock_z(0.657, 0.243, 0.412) + assert z == pytest.approx(0.707) + + +def test_missing_geometry_does_not_pretend_the_hover_is_clear(): + """工具长或工件面缺失时必须抬升。缺数还锁在悬停高,等于没检查。""" + assert track_lock_z(0.320, 0.0, 0.022) == pytest.approx(0.370) + assert track_lock_z(0.320, 0.223, 0.0) == pytest.approx(0.370) diff --git a/src/arm_application/test/test_stage2_descent_clamp.py b/src/arm_application/test/test_stage2_descent_clamp.py index 39718f5..3337e80 100644 --- a/src/arm_application/test/test_stage2_descent_clamp.py +++ b/src/arm_application/test/test_stage2_descent_clamp.py @@ -314,6 +314,24 @@ def test_goto_failure_stops_the_descent(): # --------------------------------------------------------------------------- # # 不许回退到"把目标值当结果汇报" # --------------------------------------------------------------------------- # +def test_no_descent_still_drops_to_the_observe_gap(): + """--no-descent 不是停在悬停高:要降到观测间隙(15mm),方便看规尖偏没偏。 + + 不许降到贴面 2mm —— 对偏了再贴面会把规顶在孔沿上。 + """ + st = _StubStage2() + st.final_descent_enable = False + st.observe_gap_m = 0.015 + ok, Tee, st = _run(st) + + assert ok is True + gap_mm = (Tee[2, 3] - _TOOL_LEN - _SURFACE_Z) * 1000 + assert abs(gap_mm - 15.0) < 1.0, f"观测下降应停在离面 15mm,实际 {gap_mm:.1f}mm" + total_mm = sum(st.steps_mm) + assert total_mm < _BUDGET * 1000 - 5.0, "观测下降走到了贴面预算,等于把 --no-descent 吃掉了" + assert any("观测下降完成" in m for m in st.logs), "日志要写明这是观测下降,不是贴面轻降" + + def test_wired_final_gap_is_reported_from_geometry_not_target(): """收尾要汇报**几何实算**的间隙,不能把目标值(2.0mm)念一遍。 diff --git a/src/arm_application/test/test_stage2_tip_align.py b/src/arm_application/test/test_stage2_tip_align.py index 00afd71..49f12eb 100644 --- a/src/arm_application/test/test_stage2_tip_align.py +++ b/src/arm_application/test/test_stage2_tip_align.py @@ -27,6 +27,7 @@ from __future__ import annotations from typing import Any, Dict, Optional, Tuple import numpy as np +import pytest from arm_application.stage2_locate_node import Stage2Locate @@ -101,9 +102,9 @@ class _Stub: return _R_CB @ np.asarray(p_cam, float) + self._t_cb -def _call(st: _Stub, depth: np.ndarray): +def _call(st: _Stub, depth: np.ndarray, z_wp_cam: Optional[float] = None): return Stage2Locate._tip_offset_from_depth( # type: ignore[arg-type] - st, depth, _INTR, _TEE + st, depth, _INTR, _TEE, z_wp_cam=z_wp_cam ) @@ -143,6 +144,129 @@ def test_offset_is_stable_frame_to_frame(): # --------------------------------------------------------------------------- # # 判据各自都要能真的失败(负向用例) # --------------------------------------------------------------------------- # +def _gripper_and_rod_frame() -> np.ndarray: + """爪子块头大且更近 + 规杆更细更深:复现 2026-10-09 真机『按面积选爪子』。""" + depth = np.full((480, 800), 0.50, np.float32) + depth[20:90, 330:470] = 0.07 # 爪子:宽、浅、画面上方 + for v in range(90, 280): + f = min(1.0, (v - 90) / 200.0) + depth[v, 392:408] = 0.08 + f * 0.05 # 规杆:细、伸向工件 + return depth + + +def test_picks_the_lower_tip_not_the_config_length_blob(): + """画面上方、工具长刚好贴着配置的那一块不是规尖。 + + 2026-10-09 真机:最接近配置 223mm 的点工具长 225mm、偏移约 0,冻住的是 + 法兰轴线。残差 0.14mm 达标,下降后规尖仍偏出约 10mm。规尖在画面更靠下。 + """ + depth = np.full((480, 800), 0.50, np.float32) + depth[40:100, 370:430] = 0.080 # 靠上、居中,工具长贴着配置 + for v in range(200, 301): + f = min(1.0, (v - 200) / 200.0) + depth[v, 442:458] = 0.10 + f * 0.02 # 靠下、偏出中心 + st = _Stub() + st.tool_tip_below_flange_m = 0.185 + off, tlen, why = _call(st, depth) + assert off is not None and tlen is not None, f"有规尖却没量到:{why}" + v = float(why.split("px=(")[1].split(")")[0].split(",")[1]) + assert v > 180, f"采到的是贴着配置工具长的上方点,不是规尖:{why}" + assert abs(float(off[0])) > 0.005, ( + f"规尖偏出中心,X 偏移不该≈0(那是法兰轴):{why} off={off}" + ) + + +def test_picks_rod_not_larger_gripper(): + """中央竖带里爪子面积更大时,必须仍选**更深的规杆**,不能按连通域面积。""" + depth = _gripper_and_rod_frame() + off, tlen, why = _call(_Stub(), depth) + assert off is not None and tlen is not None, f"有规杆却没量到:{why}" + assert "px=" in why + # 规尖在杆下端 v≈270,爪子在 v≈20–90;采到爪子 v 会 <100 + v = float(why.split("px=(")[1].split(")")[0].split(",")[1]) + assert v > 150, f"采到的是爪子不是规尖:{why}" + + +def test_workpiece_ceiling_keeps_the_real_tip(): + """同一帧工件面深度必须抬高近端上限,否则 150mm 会切掉真规尖。 + + 2026-10-09 真机:孔 camera-Z ≈168mm,固定 tip_near=150mm 把尖切掉, + 像素在 v=80(杆上半)和 v=188(尖偶尔漏进来)两档跳,6 拍冻不住。 + 上限要留在工件面前方(8mm):贴着面的像素是工件,不是规。 + 再往前砍 15mm 会把只比面近十来毫米的真尖切掉。 + """ + depth = np.full((480, 800), 0.50, np.float32) + for v in range(80, 200): + depth[v, 392:408] = 0.08 + (v - 80) / 120.0 * 0.06 # →0.14,在 150mm 内 + for v in range(200, 280): + depth[v, 392:408] = 0.14 + (v - 200) / 80.0 * 0.024 # →0.164,会被 150mm 切掉 + depth[300:400, 200:600] = 0.172 # 工件面;近端上限 = 面 − 15mm + cut, _t0, why0 = _call(_Stub(), depth) # 无 z_wp → 固定 150mm + full, tlen, why = _call(_Stub(), depth, z_wp_cam=0.172) + assert full is not None and tlen is not None, f"给了工件面仍没量到尖:{why}" + v_full = float(why.split("px=(")[1].split(")")[0].split(",")[1]) + assert v_full > 200, f"有工件面上限仍没采到杆下端:{why}" + if cut is not None: + v_cut = float(why0.split("px=(")[1].split(")")[0].split(",")[1]) + assert v_full > v_cut + 20, ( + f"工件面上限没有比固定 150mm 采到更靠下的尖:cut={why0} full={why}" + ) + + +def test_keeps_the_tip_when_it_is_only_slightly_in_front_of_the_surface(): + """规尖只比工件面近十来毫米时,必须采尖,不能退回更近的夹爪。 + + 2026-10-09 观测高度实测:工件面相机深度 127mm,规尖 115mm,夹爪/杆身 80mm。 + 近端砍到「面 − 15mm」会把尖切掉,冻住的是杆身(偏移约 2mm、工具长贴着 + 配置 223mm)。残差 0.2mm 达标,针却偏在孔沿上。 + """ + depth = np.full((480, 800), 0.50, np.float32) + depth[140:190, 360:430] = 0.080 # 夹爪:更近、更靠上 + depth[210:236, 430:470] = 0.118 # 规尖:只比面近 12mm,画面更靠下 + depth[250:420, 250:600] = 0.130 # 工件面 + off, tlen, why = _call(_Stub(), depth, z_wp_cam=0.130) + assert off is not None and tlen is not None, f"只比面近 12mm 的尖被切掉了:{why}" + v = float(why.split("px=(")[1].split(")")[0].split(",")[1]) + assert v > 200, f"采到的是夹爪不是规尖:{why}" + assert abs(float(off[0])) > 0.005, ( + f"尖偏出中心,不该量成法兰轴:{why} off={off}" + ) + + +def test_rejects_pixels_stuck_to_the_workpiece(): + """贴着工件面的近端块必须拒绝 —— 那是面,不是规尖。 + + 2026-10-09 重跑:上限只让 3mm,噪声把工件漏进来,工具长测成 296–315mm + (规实际约 190mm),样本互斥,随后两孔中点一跳就被当成工件被推走。 + """ + depth = np.full((480, 800), 0.50, np.float32) + depth[200:420, 360:440] = 0.170 # 大块、深度贴着 0.172 的工件面 + off, tlen, why = _call(_Stub(), depth, z_wp_cam=0.172) + assert off is None and tlen is None, f"贴着工件面的块被当成规尖:{why}" + assert "工件" in why + + +def test_rejects_tip_that_lands_on_the_workpiece_surface(): + """反投影落在工件面上的点必须丢掉,哪怕相机深度还在近端上限之内。 + + 2026-10-09:相机深度留了 15mm,工具长仍测成 298mm(配置 223mm), + 基座 z 贴在工件面上。偏移冻不住 → exit=8 → 观测下降根本不会执行。 + """ + st = _Stub() + st.surface_z_m = 1.0 # 高于任何反投影点 + off, tlen, why = _call(st, _rod_frame()) + assert off is None and tlen is None, f"贴在工件面上的点被当成规尖:{why}" + assert "工件" in why + + +def test_keeps_tip_that_clears_the_workpiece_surface(): + """规尖明显高于工件面时,表面守卫不许把真规尖一起丢掉。""" + st = _Stub() + st.surface_z_m = 0.05 + off, tlen, why = _call(st, _rod_frame()) + assert off is not None and tlen is not None, f"离面足够却被拒:{why}" + + def test_rejects_when_nothing_is_near(): """近端什么都没有(规不在视野)⇒ 必须判失败,不能返回一个假偏移。""" depth = np.full((480, 800), 0.50, np.float32) # 全在远端 @@ -301,8 +425,113 @@ def test_default_is_tip_vision_on_in_site(): (Path(__file__).resolve().parents[3] / "config" / "site.yaml").read_text(encoding="utf-8") ) st2 = ((site.get("hole_detect") or {}).get("stage2") or {}) + robots = ((site.get("robot") or {}).get("robots") or []) + arm89 = next(r for r in robots if str(r.get("sn")) == "Rizon4-063989") + servo = arm89.get("servo") or {} + assert float(servo.get("ee_z_m")) == pytest.approx( + float(servo.get("hole_hover_z_m")) + ), "左臂跟踪锁高必须等于孔悬停高:对孔完成前不许改高度" assert st2.get("align_tip_vision") is True, "默认必须用视觉规尖" assert float(st2.get("align_tol_m")) <= 0.00025 + 1e-9, ( f"align_tol_m={st2.get('align_tol_m')} 太松:会放过系统性的对孔偏差" ) assert int(st2.get("align_max_iters")) >= 12, "收紧容差后迭代上限要跟上" + + +class _TrimStub: + """下降后 XY 修正用到的成员。孔在基座里不动,规尖偏移是常数。""" + + tip_vision = True + k1_label = "left" + target_hole = "k1" + align_tol = 0.00025 + align_speed = 0.02 + vert_tol_rad = 0.02 + base_frame = "world" + ee_frame = "flange" + _tip_off = np.array([0.01, 0.0]) + _tip_samp: list = [] + _tip_len_m = 0.22 + + def __init__(self, err_xy=(0.0026, -0.0004), tip=None) -> None: + self.hole = np.array([0.50 + err_xy[0], -0.10 + err_xy[1], 0.022]) + self.tip = None if tip is False else np.array([0.0015, -0.0012]) + self.moves: list = [] + self._logger = _StubLogger() + + def get_logger(self): + return self._logger + + def _lookup(self, *_a): + return None + + def _pin(self, _q) -> None: + return None + + def _snap_holes(self, *, skip_center_gate: bool): + res = { + "status": True, + "hole_base": [self.hole.tolist(), (self.hole + np.array([0.0, -0.060, 0.0])).tolist()], + "z_wp": 0.127, + } + return None, None, {}, res + + def _tip_offset_from_depth(self, *_a, **_kw): + if self.tip is None: + return None, None, "没看到规" + return self.tip.copy(), 0.222, "px=(447,226)" + + def _goto(self, _q, Tee, target, **_kw): + Tee = np.array(Tee, float) + self.moves.append(np.array(target, float)) + Tee[0, 3] = float(target[0]) + Tee[1, 3] = float(target[1]) + Tee[2, 3] = float(target[2]) + return True, _q, Tee + + +def _trim(st: _TrimStub, z: float = 0.260): + import rclpy + + if not rclpy.ok(): + rclpy.init() + Tee = np.eye(4) + Tee[0, 3] = 0.50 + Tee[1, 3] = -0.10 + Tee[2, 3] = z + final = {"ok": True, "residual_mm": 0.22, "gauge_source": "视觉规尖(已冻结)"} + return Stage2Locate._trim_xy_after_descent(st, np.zeros(7), Tee, final) # type: ignore[arg-type] + + +def test_trim_after_descent_corrects_xy_and_holds_z(): + """下降后孔位漂了 2mm 级,要在当前高度补上,不许改高度。 + + 2026-10-09:悬停残差 0.22mm,降到观测高度后同一孔的基座 XY 差约 2.6mm。 + """ + st = _TrimStub() + _q, Tee, final = _trim(st) + assert abs(float(Tee[2, 3]) - 0.260) < 1e-9, "下降后修正不许改高度" + tip = np.array([Tee[0, 3] + st.tip[0], Tee[1, 3] + st.tip[1]]) + err = float(np.hypot(tip[0] - st.hole[0], tip[1] - st.hole[1])) + assert err <= 0.00025 + 1e-6, f"补完针还偏 {err*1000:.2f}mm" + assert final["residual_mm"] <= 0.25 + assert "下降后" in final["gauge_source"] + + +def test_trim_refuses_a_centimeter_scale_correction(): + """要补的量到了厘米,就是采到夹爪了,必须停在原地。""" + st = _TrimStub(err_xy=(0.014, 0.0)) + _q, Tee, final = _trim(st) + assert not st.moves, "厘米级修正被执行了" + assert abs(float(Tee[0, 3]) - 0.50) < 1e-9 + assert final["residual_mm"] == 0.22, "拒绝修正时不能把悬停结果改写成没补上的残差" + assert any("不像针" in m for m in st._logger.msgs) + + +def test_trim_does_not_move_when_the_tip_is_missing(): + """下降后没量到规尖,保持悬停对准的位置。""" + st = _TrimStub(tip=False) + _q, Tee, final = _trim(st) + assert not st.moves + assert abs(float(Tee[0, 3]) - 0.50) < 1e-9 + assert final["gauge_source"] == "视觉规尖(已冻结)" diff --git a/src/arm_driver_flexiv/resource/arm_driver_flexiv b/src/arm_driver_flexiv/resource/arm_driver_flexiv index e69de29..8b13789 100644 --- a/src/arm_driver_flexiv/resource/arm_driver_flexiv +++ b/src/arm_driver_flexiv/resource/arm_driver_flexiv @@ -0,0 +1 @@ + diff --git a/src/arm_kinematics/resource/arm_kinematics b/src/arm_kinematics/resource/arm_kinematics index e69de29..8b13789 100644 --- a/src/arm_kinematics/resource/arm_kinematics +++ b/src/arm_kinematics/resource/arm_kinematics @@ -0,0 +1 @@ + diff --git a/src/arm_sim_gazebo/resource/arm_sim_gazebo b/src/arm_sim_gazebo/resource/arm_sim_gazebo index e69de29..8b13789 100644 --- a/src/arm_sim_gazebo/resource/arm_sim_gazebo +++ b/src/arm_sim_gazebo/resource/arm_sim_gazebo @@ -0,0 +1 @@ +