diff --git a/.gitignore b/.gitignore index 88691e7..f6bcdff 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ Thumbs.db # Runtime and calibration scratch files /logs/ +/MvSdkLog/ *.tmp *.log *.bak @@ -66,8 +67,9 @@ Thumbs.db *.verification.json *_mapping_quality.json -# Device-specific robot descriptions derived from local CMC pitch calibration +# Device-specific robot descriptions derived from local calibration runs /src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf +/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_zero_calibrated_*.urdf # ROS bag / MCAP recordings and CAN captures rosbag2_*/ diff --git a/src/g20_thumb_apriltag_calibration/README.md b/src/g20_thumb_apriltag_calibration/README.md index 46d1c32..9d49964 100644 --- a/src/g20_thumb_apriltag_calibration/README.md +++ b/src/g20_thumb_apriltag_calibration/README.md @@ -1,6 +1,175 @@ -# G20 左手拇指正面 AprilTag 标定 +# G20 左手 AprilTag 标定 -该包启动 RealSense、彩色图像校正、`apriltag_ros`、Linker Hand SDK 和标定状态机, +## 三机位全手一键标定 + +正式全手入口同时使用三台海康 `MV-CS020-10U/10UM` 黑白全局快门相机, +但只有 `/g20_calibration` 一个节点拥有机械手命令发布权。默认机位绑定为: + +```text +front = DB2163742,Tag 0/1/2/3/10 +side = DB2163749,Tag 4/5/6/7 +top = DB2163739,Tag 8/9 +``` + +11 张 `tag36h11` 的程序角色必须与贴纸所在刚性件一致: + +| ID | 机位 | 固定位置/运动件 | +|---:|---|---| +| 0 | 正面 | 正面掌壳固定基准 | +| 1 | 正面 | 拇指 CMC 后连杆 | +| 2 | 正面 | 拇指 MCP 后连杆 | +| 3 | 正面 | 拇指 IP 后末节 | +| 4 | 侧面 | 掌壳侧面固定基准(最底下) | +| 5 | 侧面 | 食指 MCP 后连杆 | +| 6 | 侧面 | 食指 PIP 后连杆 | +| 7 | 侧面 | 食指 DIP 后末节 | +| 8 | 上面 | 上面相机可见的掌壳/底座固定基准 | +| 9 | 上面 | 拇指 CMC yaw 运动件 | +| 10 | 正面 | 食指根部侧摆运动件(index_mcp_roll) | + +ID 4 不贴在侧面相机看不到的掌心正面;ID 8 必须始终固定且可见, +ID 9 必须在拇指横摆的完整行程中持续可见。 +贴纸不能跨关节、贴在软胶上或在扫描过程中翘起。 + +每台相机必须有独立的内参文件: + +```text +~/.ros/camera_info/hikrobot_DB2163742.yaml +~/.ros/camera_info/hikrobot_DB2163749.yaml +~/.ros/camera_info/hikrobot_DB2163739.yaml +``` + +先使用禁止运动模式检查三个机位、内参和标签: + +```bash +ros2 launch g20_thumb_apriltag_calibration \ + three_camera_calibration.launch.py \ + serial_number:=G20_LEFT_001 \ + commands_enabled:=false +``` + +分别查看三个相机画面: + +```bash +ros2 run image_view image_view --ros-args \ + --remap image:=/g20_calibration/front/camera/image_rect +ros2 run image_view image_view --ros-args \ + --remap image:=/g20_calibration/side/camera/image_rect +ros2 run image_view image_view --ros-args \ + --remap image:=/g20_calibration/top/camera/image_rect +``` + +安装相机时可以按机位启动红/蓝线对准辅助节点。红线是画面理想水平线, +蓝线是在画面下部检测到的桌边、底座边或临时刚性直尺;两线夹角不超过 +`±0.5°` 且上下构图偏差不超过 `±12 px` 时显示 `ALIGNED`。Tag只画绿色 +识别框,其角点方向完全不参与红/蓝线角度计算,因此Tag无需为了相机对准而贴正。 + +下面以正面机位为例,先启动辅助节点: + +```bash +ros2 run g20_thumb_apriltag_calibration camera_alignment_view \ + --ros-args -p view:=front +``` + +再打开它发布的叠加画面: + +```bash +ros2 run image_view image_view --ros-args \ + --remap image:=/g20_camera_alignment_view/image +``` + +侧面和上面分别把 `view:=front` 改为 `view:=side`、`view:=top`。建议一次只开 +一个机位完成调整;侧面或上面没有合适长边时,临时放置与目标机械轴平行的刚性 +直尺。调整完成后退出辅助节点和 +`image_view`,再进行正式标定,以免额外的200万像素图像订阅影响采集帧率。 +这组红/蓝线只检查图像平面滚转角,不检查相机距离、俯仰、偏航,也不会阻止 +`/g20_calibration/start`。 + +确认所有目标关节的 `0~255` 行程安全、MVS 客户端已关闭且没有其他命令发布者后, +重新启动正式流程: + +```bash +ros2 launch g20_thumb_apriltag_calibration \ + three_camera_calibration.launch.py \ + serial_number:=G20_LEFT_001 \ + can_interface:=can0 +``` + +状态显示三个机位均“就绪”后只调用一次: + +```bash +ros2 topic echo /g20_calibration/status_text +ros2 service call /g20_calibration/start std_srvs/srv/Trigger {} +``` + +收到 `start` 后,程序先下发并确认以下20通道基准姿态,稳定保持0.5秒后才开始 +第一条轨迹扫描: + +```text +[255, 255, 255, 255, 255, 255, 127, 127, 127, 127, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255] +``` + +程序依次完成正面电机 `0/5/15/6`、侧面电机 `1/16`、上面电机 `10` 的三轮 +往返扫描。中指、无名指、小指复制食指模板。四指侧摆先以命令255 +为原始0角测出总行程,再减去总行程的一半;最终满足命令0为正、命令255为负, +零位命令是实测曲线上最接近角度中点的整数命令。 + +每个直接测量任务完成 `3轮×2方向=6个扫描方向` 后,程序立即试拟合 +该电机对应的所有主动/被动关节。`thumb_cmc_pitch`、`thumb_cmc_roll`、 +`thumb_mcp`、`thumb_ip`、`index_mcp_roll`、`index_mcp_pitch` 和 `index_pip` 使用图像平面的 +连杆相对中心圆相位,避免小尺寸平面 Tag 的PnP深度双解把稳定的二维圆轨迹扭曲成 +错误三维轨迹。上面斜视的 `thumb_cmc_yaw` 和侧面的被动 `index_dip` 继续使用 +父Tag坐标系下的三维相对圆。`thumb_ip` 理论上也适合相对三维,但当前正面小Tag的 +PnP深度在三轮间不稳定,实测会让三维行程漂移,因此继续采用可重复的二维投影轨迹。 +程序分别检查二维圆残差/半径或三维平面RMS/圆残差/半径,并统一检查最小圆弧、 +单调修正量、正反程回差、三轮行程一致性和直接零位拟合。主动关节三轮行程最大差 +默认不超过3°;被动耦合关节允许不超过10°,但仍必须通过其余质量门限。 +任一指标失败时会立即暂停,中文状态显示关节名、实测值和阈值,不再等到42个方向 +全部结束。修正现场问题后调用 `resume`, +程序只清除该电机任务的内存样本并重扫它的6个方向;前面已通过的关节保留。 +失败样本不从 `raw_samples.jsonl` 删除,而是使用 `attempt` 和 `retry` 记录区分, +便于调试;最终拟合只使用当前通过尝试的内存数据。 + +标定食指 `index_mcp_roll`(电机6)及其随机复测时,为避免中指遮挡ID 10, +程序将中指、无名指和小指的侧摆电机7/8/9固定为0;开始采样前会同时确认 +电机6到达扫描起点且电机7/8/9均已到达0。离开该标定项后恢复统一基准姿态。 +该项目还会通过SDK设置接口把五指速度临时设为 `[15,5,15,15,15]`,即只把 +食指速度从15降为5;离开该项目后恢复 `[15,15,15,15,15]`。 +侧面标定 `index_mcp_pitch`(电机1)和 `index_pip/index_dip`(电机16)时, +五指速度设为 `[15,10,15,15,15]`,即食指屈伸使用第三档速度10;其余直接 +测量关节保持普通速度15。 + +标定 `thumb_cmc_yaw`(电机10)及其随机复测时,程序将 +`thumb_cmc_roll`(电机5)固定为145,并在它到位后才开始采样,以保持运动Tag +ID 9的可见性和PnP稳定性。离开该标定项后,电机5恢复基准值255; +最终JSON的 `baseline_command_u8` 不变。 + +三机位流程默认设置 `validation_enabled:=false`,因此拟合完成后会直接 +恢复基准姿态并生成JSON,不再进入 `VALIDATION_MOVE/VALIDATION_CAPTURE`。 +此时 `quality.passed` 只由轨迹与零位拟合质量决定,`validation_mae_rad` 和 +`validation_p95_rad` 为 `null`。需要恢复随机复测时,启动参数加 +`validation_enabled:=true`。 + +上面机位在Tag二维质量合格但PnP连续无效达到1秒时,会自动重置该机位的单Tag +和双Tag连续性跟踪器,并从下一帧重新建链,早于3秒采集超时。暂停恢复时只要求 +当前活动机位就绪;首次调用 `start` 仍要求三个机位全部通过预检。 + +对外只生成一个精简运行时结果: + +```text +calibration_output/G20_LEFT_001/<时间戳>/ + g20_left_G20_LEFT_001_calibration.json +``` + +文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`、 +5个被动标记、模板来源和总体质量。相机、Tag、正反程及每轮质量只进入状态、日志和 +`raw_samples.jsonl`,不写入最终运行时 JSON。 + +下面保留原有正面拇指独立标定说明和兼容入口。 + +该包启动海康机器人 MVS USB3 Vision 黑白相机、图像校正、`apriltag_ros`、 +Linker Hand SDK 和标定状态机, 只扫描 G20 左手命令下标 `0`、`15`。默认使用单终点连续模式:每个方向只发送一次 终点命令,速度保持在固件能稳定响应的 `15`。SDK 以独立时间戳反馈实际 20 维位置, 程序把每帧 AprilTag 三维中心与同一时刻的实际电机位置插值配对并按整数位置分箱。 @@ -50,11 +219,11 @@ PnP 双分支跟踪和整段刚体复核仍保留,用于选出稳定的三维 ```bash sudo apt-get update sudo apt-get install -y \ - ros-jazzy-realsense2-camera \ - ros-jazzy-realsense2-description \ ros-jazzy-image-pipeline \ ros-jazzy-apriltag-ros \ - ros-jazzy-apriltag-msgs + ros-jazzy-apriltag-msgs \ + ros-jazzy-camera-calibration \ + python3-yaml cd /home/lxp/projects/linkerhand_retarget_ros2 source /opt/ros/jazzy/setup.bash @@ -63,6 +232,58 @@ colcon build --symlink-install \ source install/setup.bash ``` +相机节点直接使用海康 MVS SDK。当前机器的默认安装位置是 `/opt/MVS`,需要存在: + +```text +/opt/MVS/lib/64/libMvCameraControl.so +/opt/MVS/Samples/64/Python/MvImport/MvCameraControl_class.py +``` + +正面相机默认按序列号 `DB2163742` 绑定(MVS 显示的 GUID 是 +`2BDFB2163742`),型号校验为 `MV-CS020-10UM`。三台相机同时连接时程序不会按枚举 +顺序猜测机位。启动 ROS 节点前必须关闭 MVS 客户端中的相机连接,否则设备可能被占用。 + +`1624x1240 mono8` 每帧约 2.0 MB,超过 Fast DDS 2.14 默认约 512 KB 的共享内存段。 +相机节点和三个标定 launch 会自动加载 `config/fastdds_large_images.xml`,使用 64 MB +共享内存段;否则相机内部虽为 30 Hz,大图订阅端通常只能收到约 1~4 Hz。修改配置后 +必须重启相关 ROS 进程才能生效。 + +首次使用必须先标定该相机和当前镜头的内参。主 launch 默认从 +`~/.ros/camera_info/hikrobot_DB2163742.yaml` 加载标准 ROS CameraInfo YAML;文件缺失时 +仍可预览 `mono8` 原图,但发布的内参无效,轨迹标定预检不会解锁运动。 + +先单独启动相机(不会连接机械手,也不会发送关节命令): + +```bash +ros2 run g20_thumb_apriltag_calibration hikrobot_camera_node --ros-args \ + --remap __ns:=/camera/camera/color \ + -p serial_number:=DB2163742 \ + -p camera_info_url:=$HOME/.ros/camera_info/hikrobot_DB2163742.yaml +``` + +测速时优先检查同帧发布的小消息和原图;两者正常值都应接近 30 Hz: + +```bash +ros2 topic hz /camera/camera/color/camera_info +ros2 topic hz /camera/camera/color/image_raw +``` + +使用标定板采集内参。下面的 `8x6` 是内角点数量、`0.020` 是单格边长 20 mm,必须按 +实际标定板修改: + +```bash +ros2 run camera_calibration cameracalibrator \ + --size 8x6 --square 0.020 \ + --camera_name hikrobot_front_DB2163742 \ + --ros-args \ + --remap image:=/camera/camera/color/image_raw \ + --remap camera/set_camera_info:=/camera/camera/color/set_camera_info +``` + +在标定界面完成采样后点击 `CALIBRATE`,确认重投影误差,再点击 `COMMIT`。相机节点会 +原子写入上述 YAML,并立即开始发布有效内参。内参只适用于标定时的镜头焦距、对焦、 +分辨率和 ROI;改变任何一项都要重新标定。 + 连接 CAN 后先确认 `can0` 已启动。不要同时运行其他会发布 `/g20/cb_left_hand_control_cmd` 的程序。 @@ -74,6 +295,7 @@ source install/setup.bash ```bash ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \ serial_number:=G20_LEFT_001 \ + camera_serial_number:=DB2163742 \ commands_enabled:=false ``` @@ -84,6 +306,7 @@ ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \ ```bash ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \ serial_number:=G20_LEFT_001 \ + camera_serial_number:=DB2163742 \ can_interface:=can0 \ calibration_speed:=15 \ continuous_motion_mode:=endpoint \ @@ -92,7 +315,7 @@ ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \ use_roi:=false ``` -默认关闭 ROI,AprilTag 使用完整的 1280×720 校正画面。查看实际送入 AprilTag +默认关闭 ROI,AprilTag 使用完整的 1624×1240 校正画面。查看实际送入 AprilTag 的完整画面: ```bash @@ -241,16 +464,16 @@ for name, joint in data["joints"].items(): ``` 如果相机或 SDK 已由外部进程启动,可传 -`start_camera:=false` 或 `start_sdk:=false`。用 `camera_serial_number:=<序列号>` -可绑定指定 RealSense。 +`start_camera:=false` 或 `start_sdk:=false`。`camera_serial_number` 同时接受 MVS +序列号和 GUID,但推荐使用稳定且简短的序列号 `DB2163742`。 -D405 的彩色流来自 `depth_module`,启动文件会同时设置 -`depth_module.color_profile` 和 `rgb_camera.color_profile`,默认均为 -`1280x720x30`。连续模式默认关闭深度和 rosbag,以减少 USB、CPU 和磁盘负担; -它们都不参与角度计算。需要完整诊断留档时可增加 -`enable_depth:=true record_bag:=true`。 +海康相机默认输出 `1624x1240@30Hz mono8`,全局快门,曝光时间 `5000us`、增益 +`0dB`,并使用“只取最新帧”策略避免视觉延迟。现场亮度不足时优先增加照明;必要时可用 +`exposure_time_us`、`gain_db` 调整,或临时传 `auto_exposure:=true`。正式轨迹采集建议固定 +曝光,避免自动曝光在运动过程中改变角点质量。rosbag 默认关闭;需要诊断留档时增加 +`record_bag:=true`。 -默认对完整 1280×720 原图进行畸变校正和 AprilTag 检测。三维中心 PnP 必须使用 +默认对完整 1624×1240 原图进行畸变校正和 AprilTag 检测。三维中心 PnP 必须使用 `image_rect` 的角点及同一条处理链对应的 `CameraInfo`,启动文件已自动保证二者配对。 校正和 AprilTag 组件运行 在同一个多线程容器内并启用进程内传输,避免在处理链路中重复序列化、复制大图像。 diff --git a/src/g20_thumb_apriltag_calibration/config/fastdds_large_images.xml b/src/g20_thumb_apriltag_calibration/config/fastdds_large_images.xml new file mode 100644 index 0000000..4a866ea --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/config/fastdds_large_images.xml @@ -0,0 +1,32 @@ + + + + + + g20_udp_transport + UDPv4 + 10485760 + 10485760 + + + g20_shm_transport + SHM + 67108864 + 512 + 1000 + + + + + + + g20_udp_transport + g20_shm_transport + + false + + + + diff --git a/src/g20_thumb_apriltag_calibration/config/realsense_color_qos.yaml b/src/g20_thumb_apriltag_calibration/config/realsense_color_qos.yaml deleted file mode 100644 index 1261ee8..0000000 --- a/src/g20_thumb_apriltag_calibration/config/realsense_color_qos.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# This is a flat parameter mapping consumed by realsense2_camera/rs_launch.py. -# Keep image and CameraInfo durability identical so image_transport can -# synchronize them for image_proc and apriltag_ros. -color_qos: DEFAULT -color_info_qos: DEFAULT diff --git a/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml b/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml new file mode 100644 index 0000000..ea727e4 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml @@ -0,0 +1,76 @@ +g20_calibration: + ros__parameters: + command_topic: /g20/cb_left_hand_control_cmd + state_topic: /g20/cb_left_hand_state + info_topic: /g20/cb_left_hand_info + setting_topic: /g20/cb_hand_setting_cmd + front_camera_info_topic: /g20_calibration/front/camera/camera_info + front_detections_topic: /g20_calibration/front/apriltag/detections + side_camera_info_topic: /g20_calibration/side/camera/camera_info + side_detections_topic: /g20_calibration/side/apriltag/detections + top_camera_info_topic: /g20_calibration/top/camera/camera_info + top_detections_topic: /g20_calibration/top/apriltag/detections + + # /start先下发并确认这个20通道基准姿态,稳定后才进入第一条扫描。 + baseline_command_u8: [255, 255, 255, 255, 255, 255, 127, 127, 127, 127, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255] + normal_calibration_speed: 15 + index_roll_calibration_speed: 5 + index_flex_calibration_speed: 10 + speed_setting_settle_seconds: 0.25 + + tag_size_m: 0.010 + repetitions: 3 + preflight_frames: 60 + minimum_detection_rate: 0.95 + minimum_detection_hz: 15.0 + maximum_hamming: 0 + minimum_decision_margin: 30.0 + minimum_edge_pixels: 30.0 + + pnp_maximum_reprojection_error_px: 1.5 + pnp_reprojection_tie_px: 1.5 + pnp_maximum_pose_jump_deg: 35.0 + pnp_maximum_translation_jump_m: 0.04 + pnp_maximum_tag_tilt_deg: 75.0 + pnp_tracker_reset_seconds: 5.0 + top_pnp_invalid_reset_seconds: 1.0 + maximum_state_image_skew_ms: 150.0 + + endpoint_tolerance_u8: 2.0 + endpoint_hold_seconds: 0.5 + baseline_hold_seconds: 0.5 + position_timeout_seconds: 30.0 + sweep_timeout_seconds: 90.0 + invalid_timeout_seconds: 3.0 + minimum_sweep_frames: 40 + minimum_state_span_u8: 240.0 + minimum_sweep_bins: 32 + maximum_bin_gap: 16 + + trajectory_maximum_plane_rms_m: 0.004 + trajectory_maximum_radial_rms_m: 0.004 + trajectory_minimum_radius_m: 0.003 + trajectory_minimum_arc_deg: 15.0 + # 正面拇指pitch/MCP/IP使用二维圆相位,避免平面Tag的PnP深度歧义。 + image_trajectory_maximum_radial_rms_px: 2.0 + image_trajectory_maximum_radial_p95_px: 3.5 + image_trajectory_minimum_radius_px: 20.0 + trajectory_maximum_cycle_travel_difference_deg: 3.0 + passive_maximum_cycle_travel_difference_deg: 10.0 + zero_minimum_radius_px: 20.0 + zero_maximum_radial_rms_px: 2.0 + zero_maximum_radial_p95_px: 3.5 + zero_maximum_round_difference_deg: 1.0 + maximum_monotonic_correction_deg: 2.0 + maximum_hysteresis_deg: 5.0 + passive_maximum_monotonic_correction_deg: 3.0 + passive_maximum_hysteresis_deg: 7.5 + + # 默认跳过耗时的随机复测;需要验收精度时可在launch中设为true。 + validation_enabled: false + validation_command_count: 3 + validation_frames: 10 + validation_seed: 20260804 + validation_timeout_seconds: 20.0 + maximum_validation_mae_deg: 2.0 + maximum_validation_p95_deg: 3.0 diff --git a/src/g20_thumb_apriltag_calibration/config/three_camera_tags.yaml b/src/g20_thumb_apriltag_calibration/config/three_camera_tags.yaml new file mode 100644 index 0000000..5f24344 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/config/three_camera_tags.yaml @@ -0,0 +1,62 @@ +/g20_calibration/front/apriltag/apriltag: + ros__parameters: + image_transport: raw + qos_profile: sensor_data + family: 36h11 + size: 0.010 + profile: false + max_hamming: 0 + detector: + threads: 4 + decimate: 1.5 + blur: 0.0 + refine: true + sharpening: 0.25 + debug: false + pose_estimation_method: pnp + tag: + ids: [0, 1, 2, 3, 10] + frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, index_roll] + sizes: [0.010, 0.010, 0.010, 0.010, 0.010] + +/g20_calibration/side/apriltag/apriltag: + ros__parameters: + image_transport: raw + qos_profile: sensor_data + family: 36h11 + size: 0.010 + profile: false + max_hamming: 0 + detector: + threads: 4 + decimate: 1.5 + blur: 0.0 + refine: true + sharpening: 0.25 + debug: false + pose_estimation_method: pnp + tag: + ids: [4, 5, 6, 7] + frames: [side_base, index_mcp, index_pip, index_dip] + sizes: [0.010, 0.010, 0.010, 0.010] + +/g20_calibration/top/apriltag/apriltag: + ros__parameters: + image_transport: raw + qos_profile: sensor_data + family: 36h11 + size: 0.010 + profile: false + max_hamming: 0 + detector: + threads: 4 + decimate: 1.5 + blur: 0.0 + refine: true + sharpening: 0.25 + debug: false + pose_estimation_method: pnp + tag: + ids: [8, 9] + frames: [top_base, thumb_yaw] + sizes: [0.010, 0.010] diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/acquisition.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/acquisition.py index d3eadeb..d2e6b57 100644 --- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/acquisition.py +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/acquisition.py @@ -51,6 +51,39 @@ def tag_quality_is_valid( ) +def update_pnp_reset_watchdog( + *, + detection_good: bool, + pnp_valid: bool, + now: float, + invalid_since: float | None, + reset_after_seconds: float, +) -> tuple[float | None, bool]: + """Track continuous PnP-only failures and request a throttled reset.""" + reset_after = float(reset_after_seconds) + if reset_after <= 0.0: + raise ValueError("reset_after_seconds must be positive") + if not detection_good or pnp_valid: + return None, False + since = float(now) if invalid_since is None else float(invalid_since) + if float(now) - since >= reset_after: + # Start a new interval so a permanently bad view is not reset on every + # frame. The next valid frame clears the interval. + return float(now), True + return since, False + + +def required_resume_views(active_view: str | None) -> tuple[str, ...]: + """Require only the active view on resume; start still checks all views.""" + all_views = ("front", "side", "top") + if active_view is None: + return all_views + view = str(active_view) + if view not in all_views: + raise ValueError(f"unknown calibration view: {view}") + return (view,) + + @dataclass(frozen=True) class Observation: stamp_ns: int diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/alignment_view.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/alignment_view.py new file mode 100644 index 0000000..be019dd --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/alignment_view.py @@ -0,0 +1,358 @@ +"""Visual roll-alignment aid for one G20 calibration camera.""" + +from __future__ import annotations + +from collections import deque +import math +import time +from typing import Any, Mapping, Sequence + +from apriltag_msgs.msg import AprilTagDetectionArray +import cv2 +from cv_bridge import CvBridge +import numpy as np +import rclpy +from rclpy.node import Node +from rclpy.qos import qos_profile_sensor_data +from sensor_msgs.msg import Image + +from .full_hand import VIEW_TAGS +from .hikrobot_camera import configure_fastdds_large_image_transport +from .zero_calibration import detect_reference_alignment_line + + +VIEWS = ("front", "side", "top") + + +def summarize_alignment_measurements( + measurements: Sequence[Mapping[str, Any] | None], +) -> dict[str, Any] | None: + """Return a median-smoothed physical reference-line measurement.""" + valid = [measurement for measurement in measurements if measurement] + if not valid: + return None + return { + "line_xyxy_px": np.median( + np.asarray( + [measurement["line_xyxy_px"] for measurement in valid], + dtype=float, + ), + axis=0, + ).tolist(), + "angle_rad": float( + np.median( + [float(measurement["angle_rad"]) for measurement in valid] + ) + ), + "vertical_offset_px": float( + np.median( + [ + float(measurement["vertical_offset_px"]) + for measurement in valid + ] + ) + ), + "detected_frames": len(valid), + "window_frames": len(measurements), + } + + +class G20CameraAlignmentView(Node): + """Publish a red/blue roll aid based on a physical scene edge.""" + + def __init__(self) -> None: + """Configure one view without taking ownership of hand commands.""" + super().__init__("g20_camera_alignment_view") + self.declare_parameter("view", "front") + view = str(self.get_parameter("view").value).strip().lower() + if view not in VIEWS: + raise ValueError(f"view must be one of {VIEWS}") + self.view = view + + namespace = f"/g20_calibration/{view}" + self.required_tag_ids = { + int(value) for value in VIEW_TAGS[view].values() + } + self.declare_parameter("image_topic", f"{namespace}/camera/image_rect") + self.declare_parameter( + "detections_topic", f"{namespace}/apriltag/detections" + ) + self.declare_parameter("reference_y_ratio", 0.90) + self.declare_parameter("roi_y_min_ratio", 0.55) + self.declare_parameter("roi_y_max_ratio", 0.98) + self.declare_parameter("minimum_line_length_ratio", 0.30) + self.declare_parameter("maximum_candidate_angle_deg", 15.0) + self.declare_parameter("maximum_alignment_error_deg", 0.5) + self.declare_parameter("maximum_vertical_offset_px", 12.0) + self.declare_parameter("maximum_hamming", 0) + self.declare_parameter("minimum_decision_margin", 20.0) + self.declare_parameter("minimum_edge_pixels", 20.0) + self.declare_parameter("smoothing_frames", 10) + self.declare_parameter("maximum_line_age_seconds", 1.0) + self.declare_parameter("maximum_tag_age_seconds", 1.0) + self.declare_parameter("maximum_publish_rate_hz", 10.0) + self.declare_parameter("output_scale", 0.75) + + def value(name: str) -> Any: + return self.get_parameter(name).value + + self.image_topic = str(value("image_topic")) + self.detections_topic = str(value("detections_topic")) + self.reference_y_ratio = float(value("reference_y_ratio")) + self.roi_y_min_ratio = float(value("roi_y_min_ratio")) + self.roi_y_max_ratio = float(value("roi_y_max_ratio")) + self.minimum_line_length_ratio = float( + value("minimum_line_length_ratio") + ) + self.maximum_candidate_angle_rad = math.radians( + float(value("maximum_candidate_angle_deg")) + ) + self.maximum_alignment_error_rad = math.radians( + float(value("maximum_alignment_error_deg")) + ) + self.maximum_vertical_offset_px = float( + value("maximum_vertical_offset_px") + ) + self.maximum_hamming = int(value("maximum_hamming")) + self.minimum_decision_margin = float(value("minimum_decision_margin")) + self.minimum_edge_pixels = float(value("minimum_edge_pixels")) + self.maximum_line_age_seconds = float( + value("maximum_line_age_seconds") + ) + self.maximum_tag_age_seconds = float(value("maximum_tag_age_seconds")) + self.maximum_publish_rate_hz = float(value("maximum_publish_rate_hz")) + self.output_scale = float(value("output_scale")) + smoothing_frames = int(value("smoothing_frames")) + + if not ( + 0.0 + <= self.roi_y_min_ratio + < self.reference_y_ratio + < self.roi_y_max_ratio + <= 1.0 + ): + raise ValueError( + "ratios must satisfy 0 <= roi_min < reference < roi_max <= 1" + ) + if not 0.0 < self.minimum_line_length_ratio <= 1.0: + raise ValueError("minimum_line_length_ratio must be in (0, 1]") + if not ( + 0.0 + < self.maximum_alignment_error_rad + < self.maximum_candidate_angle_rad + < math.pi / 2.0 + ): + raise ValueError( + "angle limits must satisfy 0 < alignment < candidate < 90" + ) + if self.maximum_vertical_offset_px <= 0.0: + raise ValueError("maximum_vertical_offset_px must be positive") + if smoothing_frames < 1: + raise ValueError("smoothing_frames must be positive") + if self.maximum_line_age_seconds <= 0.0: + raise ValueError("maximum_line_age_seconds must be positive") + if self.maximum_tag_age_seconds <= 0.0: + raise ValueError("maximum_tag_age_seconds must be positive") + if self.maximum_publish_rate_hz <= 0.0: + raise ValueError("maximum_publish_rate_hz must be positive") + if not 0.1 <= self.output_scale <= 1.0: + raise ValueError("output_scale must be in [0.1, 1.0]") + + self.bridge = CvBridge() + self.line_history: deque[dict[str, Any] | None] = deque( + maxlen=smoothing_frames + ) + self.last_line_at = 0.0 + self.latest_tag_corners: dict[int, np.ndarray] = {} + self.latest_tag_at: dict[int, float] = {} + self.last_publish_at = 0.0 + self.publisher = self.create_publisher( + Image, "~/image", qos_profile_sensor_data + ) + self.create_subscription( + AprilTagDetectionArray, + self.detections_topic, + self._detections_callback, + qos_profile_sensor_data, + ) + self.create_subscription( + Image, + self.image_topic, + self._image_callback, + qos_profile_sensor_data, + ) + self.get_logger().info( + f"{view} alignment view uses physical long-edge detection; " + f"Tag orientation is ignored; input={self.image_topic}; " + f"output={self.get_name()}/image" + ) + + def _detections_callback(self, message: AprilTagDetectionArray) -> None: + now = time.monotonic() + for detection in message.detections: + tag_id = int(detection.id) + if tag_id not in self.required_tag_ids: + continue + corners = np.asarray( + [ + [float(point.x), float(point.y)] + for point in detection.corners + ], + dtype=float, + ) + if corners.shape != (4, 2) or not np.all(np.isfinite(corners)): + continue + edges = np.linalg.norm( + corners - np.roll(corners, -1, axis=0), axis=1 + ) + if ( + int(detection.hamming) > self.maximum_hamming + or float(detection.decision_margin) + < self.minimum_decision_margin + or float(np.mean(edges)) < self.minimum_edge_pixels + ): + continue + self.latest_tag_corners[tag_id] = corners + self.latest_tag_at[tag_id] = now + + def _draw_tags(self, image: np.ndarray, now: float) -> None: + for tag_id in sorted(self.required_tag_ids): + corners = self.latest_tag_corners.get(tag_id) + detected_at = self.latest_tag_at.get(tag_id, 0.0) + if ( + corners is None + or now - detected_at > self.maximum_tag_age_seconds + ): + continue + points = np.rint(corners * self.output_scale).astype(np.int32) + cv2.polylines(image, [points], True, (0, 220, 0), 2) + centre = np.rint(np.mean(points, axis=0)).astype(int) + cv2.putText( + image, + f"ID {tag_id}", + (int(centre[0]) + 5, int(centre[1]) - 7), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + (0, 220, 0), + 2, + ) + + def _image_callback(self, message: Image) -> None: + # Avoid conversion and Hough work until an image viewer subscribes. + if self.publisher.get_subscription_count() < 1: + return + now = time.monotonic() + if now - self.last_publish_at < 1.0 / self.maximum_publish_rate_hz: + return + self.last_publish_at = now + try: + image = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8") + except Exception as error: + self.get_logger().warning( + f"alignment image conversion failed: {error}" + ) + return + if self.output_scale != 1.0: + image = cv2.resize( + image, + None, + fx=self.output_scale, + fy=self.output_scale, + interpolation=cv2.INTER_AREA, + ) + + height, width = image.shape[:2] + reference_y = self.reference_y_ratio * float(height - 1) + detected = detect_reference_alignment_line( + image, + reference_y_px=reference_y, + roi_y_min_ratio=self.roi_y_min_ratio, + roi_y_max_ratio=self.roi_y_max_ratio, + minimum_length_ratio=self.minimum_line_length_ratio, + maximum_candidate_angle_rad=self.maximum_candidate_angle_rad, + ) + self.line_history.append(detected) + if detected is not None: + self.last_line_at = now + measurement = summarize_alignment_measurements(self.line_history) + if now - self.last_line_at > self.maximum_line_age_seconds: + measurement = None + + red_y = int(round(reference_y)) + cv2.line( + image, + (15, red_y), + (max(15, width - 15), red_y), + (0, 0, 255), + 4, + ) + if measurement is not None: + line = np.rint(measurement["line_xyxy_px"]).astype(int) + blue_ok, blue_start, blue_end = cv2.clipLine( + (0, 0, width, height), + (int(line[0]), int(line[1])), + (int(line[2]), int(line[3])), + ) + if blue_ok: + cv2.line(image, blue_start, blue_end, (255, 0, 0), 3) + angle_rad = float(measurement["angle_rad"]) + offset_px = float(measurement["vertical_offset_px"]) + aligned = bool( + abs(angle_rad) <= self.maximum_alignment_error_rad + and abs(offset_px) <= self.maximum_vertical_offset_px + ) + status = "ALIGNED" if aligned else "ADJUST CAMERA" + status_text = ( + f"{self.view.upper()} red-blue " + f"{math.degrees(angle_rad):+.2f} deg " + f"dy {offset_px:+.1f}px {status}" + ) + status_color = (0, 220, 0) if aligned else (0, 165, 255) + else: + status_text = ( + f"{self.view.upper()} PHYSICAL REFERENCE LINE NOT DETECTED" + ) + status_color = (0, 165, 255) + cv2.putText( + image, + status_text, + (20, 34), + cv2.FONT_HERSHEY_SIMPLEX, + 0.72, + status_color, + 2, + ) + self._draw_tags(image, now) + cv2.putText( + image, + "RED=target BLUE=physical edge GREEN=Tags (angle ignored)", + (20, max(64, height - 24)), + cv2.FONT_HERSHEY_SIMPLEX, + 0.60, + (255, 255, 255), + 2, + ) + output = self.bridge.cv2_to_imgmsg(image, encoding="bgr8") + output.header = message.header + self.publisher.publish(output) + + +def main(args: list[str] | None = None) -> None: + """Run the single-view alignment helper.""" + configure_fastdds_large_image_transport() + rclpy.init(args=args) + node: G20CameraAlignmentView | None = None + try: + node = G20CameraAlignmentView() + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + if node is not None: + node.destroy_node() + if rclpy.ok(): + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py new file mode 100644 index 0000000..b21d431 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/full_hand.py @@ -0,0 +1,756 @@ +"""Pure three-camera G20 calibration model and compact runtime schema. + +The hardware node records one parent/child AprilTag trajectory for each +directly observable joint. This module deliberately contains no ROS imports: +curve fitting, four-finger splay centring, inheritance, and schema validation +remain deterministic and unit-testable without connected cameras or a hand. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import math +from typing import Any, Mapping, Sequence + +import numpy as np + +from .core import BASELINE_COMMAND +from .trajectory import ( + _angle_for_circle, + _fit_circle_with_axis, + _fit_joint_curve, + _fit_plane_axis, + _orient_circle_positive, +) +from .zero_calibration import ( + _fit_circle, + _trajectory_arc_rad, + circular_median_rad, + maximum_pairwise_angle_difference_rad, + wrap_angle_rad, +) + + +@dataclass(frozen=True) +class JointSpec: + name: str + motor_index: int + active: bool + view: str | None + parent_role: str | None + child_role: str | None + source_joint: str | None = None + zero_kind: str | None = None + + @property + def measured(self) -> bool: + return self.source_joint is None + + +@dataclass(frozen=True) +class SweepSpec: + view: str + motor_index: int + joints: tuple[str, ...] + + +@dataclass(frozen=True) +class JointCurveFit: + angle_rad: tuple[float, ...] + decreasing_rad: tuple[float, ...] + increasing_rad: tuple[float, ...] + circle: Mapping[str, Any] + maximum_monotonic_correction_rad: float + maximum_hysteresis_rad: float + quality: Mapping[str, float] + zero_offset_rad: float = 0.0 + + +VIEW_TAGS: dict[str, dict[str, int]] = { + "front": { + "front_base": 0, + "thumb_cmc": 1, + "thumb_mcp": 2, + "thumb_ip": 3, + "index_roll": 10, + }, + "side": { + "side_base": 4, + "index_mcp": 5, + "index_pip": 6, + "index_dip": 7, + }, + "top": { + "top_base": 8, + "thumb_yaw": 9, + }, +} + + +JOINT_SPECS: dict[str, JointSpec] = { + "thumb_cmc_pitch": JointSpec( + "thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_cmc", + zero_kind="projected", + ), + "index_mcp_pitch": JointSpec( + "index_mcp_pitch", 1, True, "side", "side_base", "index_mcp", + zero_kind="projected", + ), + "middle_mcp_pitch": JointSpec( + "middle_mcp_pitch", 2, True, None, None, None, + source_joint="index_mcp_pitch", zero_kind="inherited", + ), + "ring_mcp_pitch": JointSpec( + "ring_mcp_pitch", 3, True, None, None, None, + source_joint="index_mcp_pitch", zero_kind="inherited", + ), + "pinky_mcp_pitch": JointSpec( + "pinky_mcp_pitch", 4, True, None, None, None, + source_joint="index_mcp_pitch", zero_kind="inherited", + ), + "thumb_cmc_roll": JointSpec( + "thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc", + zero_kind="projected", + ), + "index_mcp_roll": JointSpec( + "index_mcp_roll", 6, True, "front", "front_base", "index_roll", + zero_kind="travel_midpoint", + ), + "middle_mcp_roll": JointSpec( + "middle_mcp_roll", 7, True, None, None, None, + source_joint="index_mcp_roll", zero_kind="inherited", + ), + "ring_mcp_roll": JointSpec( + "ring_mcp_roll", 8, True, None, None, None, + source_joint="index_mcp_roll", zero_kind="inherited", + ), + "pinky_mcp_roll": JointSpec( + "pinky_mcp_roll", 9, True, None, None, None, + source_joint="index_mcp_roll", zero_kind="inherited", + ), + "thumb_cmc_yaw": JointSpec( + "thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw", + zero_kind="projected", + ), + "thumb_mcp": JointSpec( + "thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp", + zero_kind="projected", + ), + "index_pip": JointSpec( + "index_pip", 16, True, "side", "index_mcp", "index_pip", + zero_kind="projected", + ), + "middle_pip": JointSpec( + "middle_pip", 17, True, None, None, None, + source_joint="index_pip", zero_kind="inherited", + ), + "ring_pip": JointSpec( + "ring_pip", 18, True, None, None, None, + source_joint="index_pip", zero_kind="inherited", + ), + "pinky_pip": JointSpec( + "pinky_pip", 19, True, None, None, None, + source_joint="index_pip", zero_kind="inherited", + ), + "thumb_ip": JointSpec( + "thumb_ip", 15, False, "front", "thumb_mcp", "thumb_ip", + ), + "index_dip": JointSpec( + "index_dip", 16, False, "side", "index_pip", "index_dip", + ), + "middle_dip": JointSpec( + "middle_dip", 17, False, None, None, None, + source_joint="index_dip", + ), + "ring_dip": JointSpec( + "ring_dip", 18, False, None, None, None, + source_joint="index_dip", + ), + "pinky_dip": JointSpec( + "pinky_dip", 19, False, None, None, None, + source_joint="index_dip", + ), +} + + +SWEEP_SPECS: tuple[SweepSpec, ...] = ( + SweepSpec("front", 0, ("thumb_cmc_pitch",)), + SweepSpec("front", 5, ("thumb_cmc_roll",)), + SweepSpec("front", 15, ("thumb_mcp", "thumb_ip")), + SweepSpec("front", 6, ("index_mcp_roll",)), + SweepSpec("side", 1, ("index_mcp_pitch",)), + SweepSpec("side", 16, ("index_pip", "index_dip")), + SweepSpec("top", 10, ("thumb_cmc_yaw",)), +) + +MEASURED_JOINTS: tuple[str, ...] = tuple( + name for name, spec in JOINT_SPECS.items() if spec.measured +) +ACTIVE_JOINTS: tuple[str, ...] = tuple( + name for name, spec in JOINT_SPECS.items() if spec.active +) +PASSIVE_JOINTS: tuple[str, ...] = tuple( + name for name, spec in JOINT_SPECS.items() if not spec.active +) +SPLAY_JOINTS: tuple[str, ...] = ( + "index_mcp_roll", + "middle_mcp_roll", + "ring_mcp_roll", + "pinky_mcp_roll", +) + +# These directly actuated motions have a fixed parent link during their sweep +# and a motion plane that is close to the active camera's image plane. Their +# projected circles are substantially more repeatable than the difference of +# two independently estimated planar-Tag PnP depths. thumb_ip remains projected +# because its front-view PnP depth is not repeatable enough for a 3-D fit; the +# side-view index_dip and oblique top-view yaw remain parent-relative 3-D. +IMAGE_TRAJECTORY_JOINTS: frozenset[str] = frozenset( + { + "thumb_cmc_pitch", + "thumb_cmc_roll", + "thumb_mcp", + "thumb_ip", + "index_mcp_roll", + "index_mcp_pitch", + "index_pip", + } +) + +# Keep the three unmeasured finger-roll motors away from the front camera's +# line of sight while index_mcp_roll is measured. +INDEX_ROLL_CLEARANCE_COMMANDS: dict[int, int] = { + 7: 0, + 8: 0, + 9: 0, +} + +# Hold thumb CMC roll at a camera-friendly pose while thumb CMC yaw is +# measured. This keeps the moving top-view tag sufficiently front-facing. +THUMB_YAW_CLEARANCE_COMMANDS: dict[int, int] = { + 5: 145, +} + + +def calibration_auxiliary_commands(spec: SweepSpec) -> dict[int, int]: + """Return motors that must remain fixed throughout one calibration task.""" + if spec.motor_index == 6: + return dict(INDEX_ROLL_CLEARANCE_COMMANDS) + if spec.motor_index == 10: + return dict(THUMB_YAW_CLEARANCE_COMMANDS) + return {} + + +def build_full_hand_command( + motor_index: int, + command_u8: int, + baseline: Sequence[int] = BASELINE_COMMAND, +) -> list[int]: + if len(baseline) != 20: + raise ValueError("baseline must contain exactly 20 values") + motor = int(motor_index) + if motor not in {spec.motor_index for spec in JOINT_SPECS.values()}: + raise ValueError("motor_index is not a controlled G20 calibration channel") + command = int(command_u8) + if not 0 <= command <= 255: + raise ValueError("command_u8 must be in [0, 255]") + result = [int(value) for value in baseline] + if any(not 0 <= value <= 255 for value in result): + raise ValueError("baseline values must be in [0, 255]") + result[motor] = command + return result + + +def build_calibration_motion_command( + spec: SweepSpec, + command_u8: int, + baseline: Sequence[int] = BASELINE_COMMAND, +) -> list[int]: + """Build a sweep command, including any required clearance pose.""" + result = build_full_hand_command( + spec.motor_index, + command_u8, + baseline=baseline, + ) + for motor_index, auxiliary_command in calibration_auxiliary_commands( + spec + ).items(): + result[motor_index] = auxiliary_command + return result + + +def build_calibration_speed_profile( + spec: SweepSpec, + *, + normal_speed: int, + index_roll_speed: int, + index_flex_speed: int, +) -> list[int]: + """Return G20's five per-finger speeds for one calibration task.""" + normal = int(normal_speed) + index_roll = int(index_roll_speed) + index_flex = int(index_flex_speed) + if not all( + 0 <= speed <= 255 for speed in (normal, index_roll, index_flex) + ): + raise ValueError("calibration speeds must be in [0, 255]") + speeds = [normal] * 5 + if spec.motor_index == 6: + speeds[1] = index_roll + elif spec.motor_index in {1, 16}: + speeds[1] = index_flex + return speeds + + +def _record_vector(record: Mapping[str, Any]) -> np.ndarray: + value = np.asarray(record.get("relative_translation_xyz_m"), dtype=float) + if value.shape != (3,) or not np.all(np.isfinite(value)): + raise ValueError("record relative_translation_xyz_m must contain 3 values") + return value + + +def _record_image_vector(record: Mapping[str, Any]) -> np.ndarray: + value = np.asarray(record.get("image_relative_xy_px"), dtype=float) + if value.shape != (2,) or not np.all(np.isfinite(value)): + raise ValueError("record image_relative_xy_px must contain 2 values") + return value + + +def fit_joint_center_curve( + records: Sequence[Mapping[str, Any]], + *, + maximum_plane_rms_m: float = 0.004, + maximum_radial_rms_m: float = 0.004, + minimum_radius_m: float = 0.003, + minimum_arc_rad: float = math.radians(15.0), +) -> JointCurveFit: + """Fit one command-indexed curve from a parent-frame centre trajectory.""" + samples = [dict(record) for record in records] + if len(samples) < 12: + raise ValueError("joint trajectory requires at least 12 samples") + points = np.asarray([_record_vector(record) for record in samples]) + axis, common_plane_rms = _fit_plane_axis([points]) + circle = _fit_circle_with_axis(points, axis) + circle = _orient_circle_positive(circle, samples, list(points)) + values = [_angle_for_circle(point, circle) for point in points] + curves, correction, hysteresis = _fit_joint_curve(samples, values) + quality = { + "plane_rms_m": max( + float(common_plane_rms), float(circle["plane_rms_m"]) + ), + "radial_rms_m": float(circle["radial_rms_m"]), + "radius_m": float(circle["radius_m"]), + "arc_rad": float(circle["observed_arc_rad"]), + } + failures: list[str] = [] + if quality["plane_rms_m"] > float(maximum_plane_rms_m): + failures.append("plane_rms") + if quality["radial_rms_m"] > float(maximum_radial_rms_m): + failures.append("radial_rms") + if quality["radius_m"] < float(minimum_radius_m): + failures.append("radius") + if quality["arc_rad"] < float(minimum_arc_rad): + failures.append("arc") + if failures: + raise ValueError("joint_trajectory_quality_failed:" + ",".join(failures)) + return JointCurveFit( + angle_rad=tuple(float(value) for value in curves["angle_rad"]), + decreasing_rad=tuple( + float(value) for value in curves["decreasing_rad"] + ), + increasing_rad=tuple( + float(value) for value in curves["increasing_rad"] + ), + circle=dict(circle), + maximum_monotonic_correction_rad=float(correction), + maximum_hysteresis_rad=float(hysteresis), + quality=quality, + ) + + +def _signed_image_circle_angle( + point_xy_px: Sequence[float], circle: Mapping[str, Any] +) -> float: + point = np.asarray(point_xy_px, dtype=float) + centre = np.asarray(circle["center_xy_px"], dtype=float) + reference = np.asarray(circle["reference_xy_px"], dtype=float) + vector = point - centre + if point.shape != (2,) or not np.all(np.isfinite(point)): + raise ValueError("image point must contain 2 finite values") + if float(np.linalg.norm(vector)) < 1.0e-9: + raise ValueError("image point lies at the fitted circle centre") + return float(circle["orientation_sign"]) * math.atan2( + float(reference[0] * vector[1] - reference[1] * vector[0]), + float(np.dot(reference, vector)), + ) + + +def fit_joint_image_curve( + records: Sequence[Mapping[str, Any]], + *, + maximum_radial_rms_px: float = 2.0, + maximum_radial_p95_px: float = 3.5, + minimum_radius_px: float = 20.0, + minimum_arc_rad: float = math.radians(15.0), +) -> JointCurveFit: + """Fit angular phase from a stable projected parent/child circle.""" + samples = [dict(record) for record in records] + if len(samples) < 12: + raise ValueError("joint image trajectory requires at least 12 samples") + points = np.asarray([_record_image_vector(record) for record in samples]) + centre, radius = _fit_circle(points) + radial_error = np.abs(np.linalg.norm(points - centre, axis=1) - radius) + quality = { + "radial_rms_px": float(np.sqrt(np.mean(np.square(radial_error)))), + "radial_p95_px": float(np.percentile(radial_error, 95.0)), + "radius_px": float(radius), + "arc_rad": float(_trajectory_arc_rad(points, centre)), + } + failures: list[str] = [] + if quality["radial_rms_px"] > float(maximum_radial_rms_px): + failures.append("radial_rms") + if quality["radial_p95_px"] > float(maximum_radial_p95_px): + failures.append("radial_p95") + if quality["radius_px"] < float(minimum_radius_px): + failures.append("radius") + if quality["arc_rad"] < float(minimum_arc_rad): + failures.append("arc") + if failures: + raise ValueError( + "joint_image_trajectory_quality_failed:" + ",".join(failures) + ) + + endpoint_points = np.asarray( + [ + point + for record, point in zip(samples, points) + if int(record["command_u8"]) == 255 + ] + ) + if endpoint_points.size == 0: + raise ValueError("joint image trajectory is missing command 255") + reference = np.median(endpoint_points, axis=0) - centre + reference_norm = float(np.linalg.norm(reference)) + if reference_norm < 1.0e-9: + raise ValueError("joint image trajectory endpoint is degenerate") + reference /= reference_norm + circle: dict[str, Any] = { + "space": "image_2d", + "center_xy_px": [float(value) for value in centre], + "reference_xy_px": [float(value) for value in reference], + "orientation_sign": 1.0, + "radius_px": float(radius), + } + values = [_signed_image_circle_angle(point, circle) for point in points] + command_zero_values = [ + value + for record, value in zip(samples, values) + if int(record["command_u8"]) == 0 + ] + if not command_zero_values: + raise ValueError("joint image trajectory is missing command 0") + if float(np.median(command_zero_values)) < 0.0: + circle["orientation_sign"] = -1.0 + values = [-value for value in values] + curves, correction, hysteresis = _fit_joint_curve(samples, values) + return JointCurveFit( + angle_rad=tuple(float(value) for value in curves["angle_rad"]), + decreasing_rad=tuple( + float(value) for value in curves["decreasing_rad"] + ), + increasing_rad=tuple( + float(value) for value in curves["increasing_rad"] + ), + circle=circle, + maximum_monotonic_correction_rad=float(correction), + maximum_hysteresis_rad=float(hysteresis), + quality=quality, + ) + + +def fit_measured_joint_curve( + joint_name: str, + records: Sequence[Mapping[str, Any]], + *, + maximum_plane_rms_m: float = 0.004, + maximum_radial_rms_m: float = 0.004, + minimum_radius_m: float = 0.003, + minimum_arc_rad: float = math.radians(15.0), + image_maximum_radial_rms_px: float = 2.0, + image_maximum_radial_p95_px: float = 3.5, + image_minimum_radius_px: float = 20.0, +) -> JointCurveFit: + """Select the stable trajectory representation for one measured joint.""" + if str(joint_name) in IMAGE_TRAJECTORY_JOINTS: + return fit_joint_image_curve( + records, + maximum_radial_rms_px=image_maximum_radial_rms_px, + maximum_radial_p95_px=image_maximum_radial_p95_px, + minimum_radius_px=image_minimum_radius_px, + minimum_arc_rad=minimum_arc_rad, + ) + return fit_joint_center_curve( + records, + maximum_plane_rms_m=maximum_plane_rms_m, + maximum_radial_rms_m=maximum_radial_rms_m, + minimum_radius_m=minimum_radius_m, + minimum_arc_rad=minimum_arc_rad, + ) + + +def center_splay_curve(fit: JointCurveFit) -> tuple[JointCurveFit, int, float]: + """Centre a measured 255->0 splay travel on its angular midpoint.""" + raw = np.asarray(fit.angle_rad, dtype=float) + if raw.shape != (256,) or not np.all(np.isfinite(raw)): + raise ValueError("splay curve must contain 256 finite values") + if np.any(np.diff(raw) > 1.0e-7): + raise ValueError("raw splay curve must be non-increasing") + travel_midpoint = 0.5 * float(raw[0] + raw[255]) + if travel_midpoint <= 0.0: + raise ValueError("splay travel must be positive") + centred = raw - travel_midpoint + # Make the public endpoint symmetry exact after decimal rounding. + endpoint = round(0.5 * float(raw[0] - raw[255]), 8) + centred[0] = endpoint + centred[255] = -endpoint + centred = np.asarray([round(float(value), 8) for value in centred]) + zero_command = int(np.argmin(np.abs(centred))) + return ( + replace( + fit, + angle_rad=tuple(float(value) for value in centred), + zero_offset_rad=round(float(travel_midpoint), 8), + ), + zero_command, + round(float(travel_midpoint), 8), + ) + + +def measure_joint_vector(fit: JointCurveFit, vector_xyz_m: Sequence[float]) -> float: + """Measure one vector with a fitted model, including splay centring.""" + return float( + _angle_for_circle(vector_xyz_m, fit.circle) - fit.zero_offset_rad + ) + + +def measure_joint_observation( + fit: JointCurveFit, + *, + vector_xyz_m: Sequence[float], + image_vector_xy_px: Sequence[float], +) -> float: + """Measure an observation in the same space used to fit its curve.""" + if fit.circle.get("space") == "image_2d": + return float( + _signed_image_circle_angle(image_vector_xy_px, fit.circle) + - fit.zero_offset_rad + ) + return measure_joint_vector(fit, vector_xyz_m) + + +def fit_projected_zero( + records: Sequence[Mapping[str, Any]], + *, + minimum_radius_px: float = 20.0, + minimum_arc_rad: float = math.radians(15.0), + maximum_radial_rms_px: float = 2.0, + maximum_radial_p95_px: float = 3.5, + maximum_round_difference_rad: float = math.radians(1.0), +) -> float: + """Return the image/table-projected zero from three sweep rounds.""" + samples = [dict(record) for record in records] + points = np.asarray( + [record.get("image_relative_xy_px") for record in samples], dtype=float + ) + if points.ndim != 2 or points.shape[1] != 2 or len(points) < 12: + raise ValueError("projected zero requires 2-D trajectory samples") + if not np.all(np.isfinite(points)): + raise ValueError("projected zero samples must be finite") + centre, radius = _fit_circle(points) + radial_error = np.abs(np.linalg.norm(points - centre, axis=1) - radius) + radial_rms = float(np.sqrt(np.mean(np.square(radial_error)))) + radial_p95 = float(np.percentile(radial_error, 95.0)) + arc = _trajectory_arc_rad(points, centre) + if radius < minimum_radius_px: + raise ValueError("projected_zero_radius_too_small") + if arc < minimum_arc_rad: + raise ValueError("projected_zero_arc_too_small") + if radial_rms > maximum_radial_rms_px: + raise ValueError("projected_zero_radial_rms_too_large") + if radial_p95 > maximum_radial_p95_px: + raise ValueError("projected_zero_radial_p95_too_large") + + cycles = sorted({int(record["cycle"]) for record in samples}) + if len(cycles) < 3: + raise ValueError("projected zero requires three sweep rounds") + angles: list[float] = [] + for cycle in cycles: + zero_points = np.asarray( + [ + record["image_relative_xy_px"] + for record in samples + if int(record["cycle"]) == cycle + and int(record["command_u8"]) == 255 + ], + dtype=float, + ) + if zero_points.size == 0: + raise ValueError("projected zero is missing command 255") + point = np.median(zero_points, axis=0) + inward = centre - point + if float(np.linalg.norm(inward)) < 1.0e-9: + raise ValueError("projected zero point is degenerate") + angles.append( + wrap_angle_rad(math.atan2(-float(inward[1]), float(inward[0]))) + ) + if maximum_pairwise_angle_difference_rad(angles) > maximum_round_difference_rad: + raise ValueError("projected_zero_round_difference_too_large") + return round(float(circular_median_rad(angles)), 8) + + +def build_compact_payload( + *, + serial_number: str, + measured_fits: Mapping[str, JointCurveFit], + projected_zeros_rad: Mapping[str, float], + splay_zero_command_u8: int, + splay_midpoint_rad: float, + validation_errors_rad: Sequence[float], + passed: bool, + baseline: Sequence[int] = BASELINE_COMMAND, +) -> dict[str, Any]: + if set(measured_fits) != set(MEASURED_JOINTS): + raise ValueError("measured_fits must contain all directly measured joints") + expected_projected = { + name for name, spec in JOINT_SPECS.items() + if spec.zero_kind == "projected" + } + if set(projected_zeros_rad) != expected_projected: + raise ValueError("projected_zeros_rad has the wrong joint set") + if not 0 <= int(splay_zero_command_u8) <= 255: + raise ValueError("splay zero command must be in [0, 255]") + + joints: dict[str, dict[str, Any]] = {} + for name, spec in JOINT_SPECS.items(): + source_name = spec.source_joint or name + fit = measured_fits[source_name] + joint: dict[str, Any] = { + "motor_index": int(spec.motor_index), + "angle_rad": [round(float(value), 8) for value in fit.angle_rad], + } + if spec.active: + joint["zero_command_u8"] = ( + int(splay_zero_command_u8) + if name in SPLAY_JOINTS + else 255 + ) + else: + joint["passive"] = True + if spec.source_joint is not None: + joint["source_joint"] = spec.source_joint + elif spec.zero_kind == "projected": + joint["zero_angles"] = { + "table_projected_zero_rad": round( + float(projected_zeros_rad[name]), 8 + ) + } + elif spec.zero_kind == "travel_midpoint": + joint["zero_angles"] = { + "travel_midpoint_rad": round(float(splay_midpoint_rad), 8) + } + joints[name] = joint + + errors = np.abs(np.asarray(validation_errors_rad, dtype=float)) + mae = float(np.mean(errors)) if errors.size else float("nan") + p95 = float(np.percentile(errors, 95.0)) if errors.size else float("nan") + payload = { + "schema_version": 3, + "model": "G20", + "side": "left", + "serial_number": str(serial_number), + "angle_unit": "rad", + "command_range": [0, 255], + "baseline_command_u8": [int(value) for value in baseline], + "joints": joints, + "quality": { + "passed": bool(passed), + "validation_mae_rad": ( + None if not math.isfinite(mae) else round(mae, 8) + ), + "validation_p95_rad": ( + None if not math.isfinite(p95) else round(p95, 8) + ), + }, + } + validate_compact_payload(payload) + return payload + + +def validate_compact_payload(payload: Mapping[str, Any]) -> None: + expected_top = { + "schema_version", "model", "side", "serial_number", "angle_unit", + "command_range", "baseline_command_u8", "joints", "quality", + } + if set(payload) != expected_top: + raise ValueError("compact calibration has unexpected top-level fields") + if payload["schema_version"] != 3: + raise ValueError("schema_version must be 3") + if payload["model"] != "G20" or payload["side"] != "left": + raise ValueError("payload must describe a left G20") + if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]: + raise ValueError("payload angle or command units are invalid") + baseline = payload["baseline_command_u8"] + if not isinstance(baseline, list) or len(baseline) != 20: + raise ValueError("baseline_command_u8 must contain 20 values") + joints = payload["joints"] + if not isinstance(joints, Mapping) or set(joints) != set(JOINT_SPECS): + raise ValueError("payload must contain exactly 21 G20 joints") + + for name, spec in JOINT_SPECS.items(): + joint = joints[name] + allowed = {"motor_index", "angle_rad"} + allowed.add("zero_command_u8" if spec.active else "passive") + if spec.source_joint is not None: + allowed.add("source_joint") + elif spec.zero_kind in {"projected", "travel_midpoint"}: + allowed.add("zero_angles") + if set(joint) != allowed: + raise ValueError(f"{name} has unexpected fields") + if int(joint["motor_index"]) != spec.motor_index: + raise ValueError(f"{name} has the wrong motor_index") + curve = np.asarray(joint["angle_rad"], dtype=float) + if curve.shape != (256,) or not np.all(np.isfinite(curve)): + raise ValueError(f"{name}.angle_rad must contain 256 finite values") + if np.any(np.diff(curve) > 1.0e-7): + raise ValueError(f"{name}.angle_rad must be non-increasing") + if spec.active: + zero = joint["zero_command_u8"] + if not isinstance(zero, int) or not 0 <= zero <= 255: + raise ValueError(f"{name}.zero_command_u8 is invalid") + elif joint.get("passive") is not True: + raise ValueError(f"{name} must be marked passive") + if spec.source_joint is not None: + if joint.get("source_joint") != spec.source_joint: + raise ValueError(f"{name} has the wrong source_joint") + source = joints[spec.source_joint] + if joint["angle_rad"] != source["angle_rad"]: + raise ValueError(f"{name} must copy its source curve exactly") + if spec.active and joint["zero_command_u8"] != source["zero_command_u8"]: + raise ValueError(f"{name} must copy its source zero command") + + index_roll = np.asarray(joints["index_mcp_roll"]["angle_rad"], dtype=float) + if not index_roll[0] > 0.0 or not index_roll[255] < 0.0: + raise ValueError("index_mcp_roll endpoints must be positive then negative") + if abs(float(index_roll[0] + index_roll[255])) > 1.0e-7: + raise ValueError("index_mcp_roll endpoints must be symmetric") + for name, spec in JOINT_SPECS.items(): + if name not in SPLAY_JOINTS and spec.source_joint != "index_mcp_roll": + curve = np.asarray(joints[name]["angle_rad"], dtype=float) + if abs(float(curve[255])) > 1.0e-6: + raise ValueError(f"{name}.angle_rad[255] must be zero") + + quality = payload["quality"] + if set(quality) != { + "passed", "validation_mae_rad", "validation_p95_rad" + }: + raise ValueError("quality must contain only compact summary fields") diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/hikrobot_camera.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/hikrobot_camera.py new file mode 100644 index 0000000..f2a6694 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/hikrobot_camera.py @@ -0,0 +1,644 @@ +"""ROS 2 image publisher for Hikrobot MVS USB3 Vision cameras.""" + +from __future__ import annotations + +from ctypes import byref, cast, memset, POINTER, sizeof, string_at +from dataclasses import dataclass +import importlib +import os +from pathlib import Path +import sys +from threading import Event, Lock, Thread +from typing import Any, Sequence +from urllib.parse import unquote, urlparse + +import rclpy +from rclpy.node import Node +from rclpy.qos import qos_profile_sensor_data +from sensor_msgs.msg import CameraInfo, Image +from sensor_msgs.srv import SetCameraInfo +import yaml + + +DEFAULT_MVS_PYTHON_PATH = "/opt/MVS/Samples/64/Python/MvImport" +DEFAULT_MVS_RUNTIME_PATH = "/opt/MVS/lib" + + +@dataclass(frozen=True) +class DeviceDescriptor: + """Stable identity fields for one enumerated USB3 Vision camera.""" + + index: int + model: str + serial: str + guid: str + + +@dataclass(frozen=True) +class CameraCalibration: + """Validated subset of a standard ROS camera calibration YAML file.""" + + camera_name: str + width: int + height: int + distortion_model: str + d: tuple[float, ...] + k: tuple[float, ...] + r: tuple[float, ...] + p: tuple[float, ...] + + +def decode_c_string(value: Any) -> str: + """Decode a null-terminated MVS SDK character array.""" + raw = bytes(value).split(b"\0", 1)[0] + for encoding in ("utf-8", "gbk", "latin-1"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + return raw.decode("latin-1", errors="replace") + + +def select_device( + devices: Sequence[DeviceDescriptor], + selector: str, + expected_model: str, +) -> DeviceDescriptor: + """Select exactly one camera by serial or GUID and check its model.""" + if not devices: + raise RuntimeError("MVS did not enumerate any USB3 Vision cameras") + selector = selector.strip() + if selector: + matches = [ + device + for device in devices + if selector in {device.serial, device.guid} + ] + if not matches: + available = ", ".join( + f"{device.model}:{device.serial}:{device.guid}" + for device in devices + ) + raise RuntimeError( + f"camera selector {selector!r} was not found; " + f"available cameras: {available}" + ) + elif len(devices) == 1: + matches = [devices[0]] + else: + available = ", ".join(device.serial for device in devices) + raise RuntimeError( + "camera selector is required when multiple cameras are present; " + f"available serials: {available}" + ) + if len(matches) != 1: + raise RuntimeError(f"camera selector {selector!r} is not unique") + selected = matches[0] + if expected_model and expected_model not in selected.model: + raise RuntimeError( + f"camera {selected.serial} is {selected.model!r}, expected a model " + f"containing {expected_model!r}" + ) + return selected + + +def resolve_camera_info_path(url_or_path: str) -> Path | None: + """Resolve a plain path or file:// URL used for CameraInfo persistence.""" + value = url_or_path.strip() + if not value: + return None + parsed = urlparse(value) + if parsed.scheme not in {"", "file"}: + raise ValueError( + "camera_info_url must be a filesystem path or file:// URL" + ) + if parsed.scheme == "file": + if parsed.netloc not in {"", "localhost"}: + raise ValueError("remote file:// camera_info_url is not supported") + value = unquote(parsed.path) + return Path(value).expanduser().resolve() + + +def _matrix_data(mapping: dict[str, Any], name: str, length: int) -> tuple[float, ...]: + section = mapping.get(name) + if not isinstance(section, dict): + raise ValueError(f"camera calibration is missing {name}") + data = section.get("data") + if not isinstance(data, list) or len(data) != length: + raise ValueError(f"{name}.data must contain exactly {length} values") + result = tuple(float(value) for value in data) + if not all(value == value and abs(value) != float("inf") for value in result): + raise ValueError(f"{name}.data contains a non-finite value") + return result + + +def load_camera_calibration(path: Path) -> CameraCalibration: + """Load and validate a standard ROS camera calibration YAML file.""" + mapping = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(mapping, dict): + raise ValueError("camera calibration YAML root must be a mapping") + width = int(mapping.get("image_width", 0)) + height = int(mapping.get("image_height", 0)) + if width <= 0 or height <= 0: + raise ValueError("camera calibration image dimensions must be positive") + distortion = mapping.get("distortion_coefficients") + if not isinstance(distortion, dict) or not isinstance( + distortion.get("data"), list + ): + raise ValueError( + "camera calibration is missing distortion_coefficients.data" + ) + d = tuple(float(value) for value in distortion["data"]) + calibration = CameraCalibration( + camera_name=str(mapping.get("camera_name", "hikrobot_camera")), + width=width, + height=height, + distortion_model=str(mapping.get("distortion_model", "plumb_bob")), + d=d, + k=_matrix_data(mapping, "camera_matrix", 9), + r=_matrix_data(mapping, "rectification_matrix", 9), + p=_matrix_data(mapping, "projection_matrix", 12), + ) + if calibration.k[0] <= 0.0 or calibration.k[4] <= 0.0: + raise ValueError("camera calibration focal lengths must be positive") + if calibration.p[0] <= 0.0 or calibration.p[5] <= 0.0: + raise ValueError("camera calibration projection focal lengths must be positive") + return calibration + + +def calibration_to_mapping( + camera_name: str, camera_info: CameraInfo +) -> dict[str, Any]: + """Convert CameraInfo into the standard ROS YAML representation.""" + return { + "image_width": int(camera_info.width), + "image_height": int(camera_info.height), + "camera_name": camera_name, + "camera_matrix": { + "rows": 3, + "cols": 3, + "data": [float(value) for value in camera_info.k], + }, + "distortion_model": camera_info.distortion_model, + "distortion_coefficients": { + "rows": 1, + "cols": len(camera_info.d), + "data": [float(value) for value in camera_info.d], + }, + "rectification_matrix": { + "rows": 3, + "cols": 3, + "data": [float(value) for value in camera_info.r], + }, + "projection_matrix": { + "rows": 3, + "cols": 4, + "data": [float(value) for value in camera_info.p], + }, + } + + +def configure_fastdds_large_image_transport() -> Path: + """Select the package's large-image SHM profile before creating ROS nodes.""" + from ament_index_python.packages import get_package_share_directory + + profile = ( + Path(get_package_share_directory("g20_thumb_apriltag_calibration")) + / "config" + / "fastdds_large_images.xml" + ) + if not profile.is_file(): + raise RuntimeError(f"Fast DDS large-image profile not found: {profile}") + os.environ.setdefault("FASTRTPS_DEFAULT_PROFILES_FILE", str(profile)) + return profile + + +class HikrobotCameraNode(Node): + """Publish synchronized Mono8 Image and CameraInfo messages from MVS.""" + + def __init__(self) -> None: + super().__init__("hikrobot_camera") + self._declare_parameters() + self._stop_event = Event() + self._camera_info_lock = Lock() + self._grab_thread: Thread | None = None + self._camera: Any | None = None + self._mvs: Any | None = None + self._sdk_initialized = False + self._handle_created = False + self._camera_opened = False + self._grabbing = False + self._closed = False + self._frame_count = 0 + self._timeout_count = 0 + + self.selector = str(self.get_parameter("serial_number").value) + self.expected_model = str(self.get_parameter("expected_model").value) + self.frame_id = str(self.get_parameter("frame_id").value) + self.camera_name = str(self.get_parameter("camera_name").value) + self.image_width = int(self.get_parameter("image_width").value) + self.image_height = int(self.get_parameter("image_height").value) + if self.image_width <= 0 or self.image_height <= 0: + raise ValueError("image_width and image_height must be positive") + if float(self.get_parameter("frame_rate").value) <= 0.0: + raise ValueError("frame_rate must be positive") + if int(self.get_parameter("grab_timeout_ms").value) <= 0: + raise ValueError("grab_timeout_ms must be positive") + self.camera_info_path = resolve_camera_info_path( + str(self.get_parameter("camera_info_url").value) + ) + self._calibration = self._load_calibration() + + self.image_publisher = self.create_publisher( + Image, "image_raw", qos_profile_sensor_data + ) + self.camera_info_publisher = self.create_publisher( + CameraInfo, "camera_info", qos_profile_sensor_data + ) + self.create_service( + SetCameraInfo, "set_camera_info", self._set_camera_info_callback + ) + + try: + self._open_camera() + self._grab_thread = Thread( + target=self._grab_loop, + name="hikrobot_mvs_grab", + daemon=True, + ) + self._grab_thread.start() + except Exception: + self.close() + raise + + def _declare_parameters(self) -> None: + self.declare_parameter("serial_number", "") + self.declare_parameter("expected_model", "MV-CS020-10UM") + self.declare_parameter("camera_name", "hikrobot_front") + self.declare_parameter("frame_id", "camera_color_optical_frame") + self.declare_parameter("image_width", 1624) + self.declare_parameter("image_height", 1240) + self.declare_parameter("frame_rate", 30.0) + self.declare_parameter("exposure_time_us", 5000.0) + self.declare_parameter("gain_db", 0.0) + self.declare_parameter("auto_exposure", False) + self.declare_parameter("camera_info_url", "") + self.declare_parameter("mvs_python_path", DEFAULT_MVS_PYTHON_PATH) + self.declare_parameter("mvs_runtime_path", DEFAULT_MVS_RUNTIME_PATH) + self.declare_parameter("grab_timeout_ms", 1000) + + def _load_calibration(self) -> CameraCalibration | None: + if self.camera_info_path is None or not self.camera_info_path.exists(): + target = self.camera_info_path or "an unset camera_info_url" + self.get_logger().warning( + f"No camera calibration found at {target}; publishing " + "uncalibrated CameraInfo. Trajectory calibration will remain " + "locked until valid intrinsics are installed." + ) + return None + calibration = load_camera_calibration(self.camera_info_path) + self._validate_calibration_dimensions(calibration) + self.get_logger().info( + f"Loaded camera intrinsics from {self.camera_info_path}" + ) + return calibration + + def _validate_calibration_dimensions( + self, calibration: CameraCalibration + ) -> None: + if ( + calibration.width != self.image_width + or calibration.height != self.image_height + ): + raise ValueError( + "camera calibration dimensions " + f"{calibration.width}x{calibration.height} do not match " + f"configured stream {self.image_width}x{self.image_height}" + ) + + def _import_mvs(self) -> Any: + python_path = Path( + str(self.get_parameter("mvs_python_path").value) + ).expanduser() + runtime_path = Path( + str(self.get_parameter("mvs_runtime_path").value) + ).expanduser() + if not python_path.is_dir(): + raise RuntimeError(f"MVS Python bindings not found: {python_path}") + if not runtime_path.is_dir(): + raise RuntimeError(f"MVS runtime not found: {runtime_path}") + os.environ.setdefault("MVCAM_COMMON_RUNENV", str(runtime_path)) + if str(python_path) not in sys.path: + sys.path.insert(0, str(python_path)) + return importlib.import_module("MvCameraControl_class") + + def _enumerate_usb_cameras( + self, mvs: Any + ) -> tuple[Any, list[DeviceDescriptor]]: + device_list = mvs.MV_CC_DEVICE_INFO_LIST() + result = mvs.MvCamera.MV_CC_EnumDevices( + mvs.MV_USB_DEVICE, device_list + ) + self._require_ok("enumerate USB3 Vision devices", result) + descriptors: list[DeviceDescriptor] = [] + for index in range(device_list.nDeviceNum): + device_info = cast( + device_list.pDeviceInfo[index], POINTER(mvs.MV_CC_DEVICE_INFO) + ).contents + usb_info = device_info.SpecialInfo.stUsb3VInfo + descriptors.append( + DeviceDescriptor( + index=index, + model=decode_c_string(usb_info.chModelName), + serial=decode_c_string(usb_info.chSerialNumber), + guid=decode_c_string(usb_info.chDeviceGUID), + ) + ) + return device_list, descriptors + + def _open_camera(self) -> None: + mvs = self._import_mvs() + self._mvs = mvs + self._require_ok("initialize MVS SDK", mvs.MvCamera.MV_CC_Initialize()) + self._sdk_initialized = True + device_list, descriptors = self._enumerate_usb_cameras(mvs) + selected = select_device(descriptors, self.selector, self.expected_model) + device_info = cast( + device_list.pDeviceInfo[selected.index], + POINTER(mvs.MV_CC_DEVICE_INFO), + ).contents + + camera = mvs.MvCamera() + self._require_ok("create camera handle", camera.MV_CC_CreateHandle(device_info)) + self._camera = camera + self._handle_created = True + self._require_ok( + "open camera", + camera.MV_CC_OpenDevice(mvs.MV_ACCESS_Exclusive, 0), + ) + self._camera_opened = True + + self._set_required_enum("AcquisitionMode", "Continuous") + self._set_required_enum("TriggerMode", "Off") + self._set_required_enum("PixelFormat", "Mono8") + self._set_required_integer("OffsetX", 0) + self._set_required_integer("OffsetY", 0) + self._set_required_integer("Width", self.image_width) + self._set_required_integer("Height", self.image_height) + self._set_optional_bool("AcquisitionFrameRateEnable", True) + self._set_optional_float( + "AcquisitionFrameRate", + float(self.get_parameter("frame_rate").value), + ) + if bool(self.get_parameter("auto_exposure").value): + self._set_required_enum("ExposureAuto", "Continuous") + else: + self._set_required_enum("ExposureAuto", "Off") + self._set_optional_float( + "ExposureTime", + float(self.get_parameter("exposure_time_us").value), + ) + self._set_required_enum("GainAuto", "Off") + self._set_optional_float( + "Gain", float(self.get_parameter("gain_db").value) + ) + + self._require_ok( + "set latest-frame grab strategy", + camera.MV_CC_SetGrabStrategy(mvs.MV_GrabStrategy_LatestImagesOnly), + ) + self._require_ok("start image acquisition", camera.MV_CC_StartGrabbing()) + self._grabbing = True + self.get_logger().info( + "Opened Hikrobot camera " + f"model={selected.model} serial={selected.serial} " + f"guid={selected.guid} stream={self.image_width}x{self.image_height} " + "encoding=mono8" + ) + + def _set_required_enum(self, name: str, value: str) -> None: + assert self._camera is not None + self._require_ok( + f"set {name}={value}", + self._camera.MV_CC_SetEnumValueByString(name, value), + ) + + def _set_required_integer(self, name: str, value: int) -> None: + assert self._camera is not None + self._require_ok( + f"set {name}={value}", + self._camera.MV_CC_SetIntValueEx(name, value), + ) + + def _set_optional_bool(self, name: str, value: bool) -> None: + assert self._camera is not None + result = self._camera.MV_CC_SetBoolValue(name, value) + if result != 0: + self.get_logger().warning( + f"MVS could not set {name}={value}: 0x{result:08x}" + ) + + def _set_optional_float(self, name: str, value: float) -> None: + assert self._camera is not None + result = self._camera.MV_CC_SetFloatValue(name, value) + if result != 0: + self.get_logger().warning( + f"MVS could not set {name}={value}: 0x{result:08x}" + ) + + @staticmethod + def _require_ok(operation: str, result: int) -> None: + if result != 0: + hint = "" + if result == 0x80000203: + hint = ( + "; access denied: stop acquisition and close the MVS " + "viewer or any other process using this camera" + ) + raise RuntimeError( + f"MVS failed to {operation}: 0x{result:08x}{hint}" + ) + + def _grab_loop(self) -> None: + assert self._camera is not None + assert self._mvs is not None + timeout_ms = int(self.get_parameter("grab_timeout_ms").value) + while not self._stop_event.is_set(): + frame = self._mvs.MV_FRAME_OUT() + memset(byref(frame), 0, sizeof(frame)) + result = self._camera.MV_CC_GetImageBuffer(frame, timeout_ms) + if result != 0: + self._timeout_count += 1 + if self._timeout_count == 1 or self._timeout_count % 10 == 0: + self.get_logger().warning( + "MVS image acquisition timed out or failed: " + f"0x{result:08x}; consecutive={self._timeout_count}" + ) + continue + try: + self._timeout_count = 0 + self._publish_frame(frame) + except Exception as error: + self.get_logger().error(f"Failed to publish MVS frame: {error}") + finally: + free_result = self._camera.MV_CC_FreeImageBuffer(frame) + if free_result != 0: + self.get_logger().error( + f"MVS failed to release frame: 0x{free_result:08x}" + ) + + def _publish_frame(self, frame: Any) -> None: + assert self._mvs is not None + info = frame.stFrameInfo + width = int(info.nWidth) + height = int(info.nHeight) + if width != self.image_width or height != self.image_height: + raise RuntimeError( + f"received {width}x{height}, expected " + f"{self.image_width}x{self.image_height}" + ) + if int(info.enPixelType) != int(self._mvs.PixelType_Gvsp_Mono8): + raise RuntimeError( + f"received pixel type 0x{int(info.enPixelType):x}, expected Mono8" + ) + expected_size = width * height + if int(info.nFrameLen) < expected_size: + raise RuntimeError( + f"frame contains {info.nFrameLen} bytes, expected {expected_size}" + ) + stamp = self.get_clock().now().to_msg() + image = Image() + image.header.stamp = stamp + image.header.frame_id = self.frame_id + image.height = height + image.width = width + image.encoding = "mono8" + image.is_bigendian = 0 + image.step = width + image.data = string_at(frame.pBufAddr, expected_size) + + camera_info = self._make_camera_info(width, height) + camera_info.header.stamp = stamp + camera_info.header.frame_id = self.frame_id + self.image_publisher.publish(image) + self.camera_info_publisher.publish(camera_info) + self._frame_count += 1 + + def _make_camera_info(self, width: int, height: int) -> CameraInfo: + message = CameraInfo() + message.width = width + message.height = height + with self._camera_info_lock: + calibration = self._calibration + if calibration is None: + message.distortion_model = "plumb_bob" + message.d = [] + message.k = [0.0] * 9 + message.r = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + message.p = [0.0] * 12 + return message + message.distortion_model = calibration.distortion_model + message.d = list(calibration.d) + message.k = list(calibration.k) + message.r = list(calibration.r) + message.p = list(calibration.p) + return message + + def _set_camera_info_callback( + self, + request: SetCameraInfo.Request, + response: SetCameraInfo.Response, + ) -> SetCameraInfo.Response: + try: + if self.camera_info_path is None: + raise ValueError( + "camera_info_url is empty; set it before saving calibration" + ) + message = request.camera_info + if ( + int(message.width) != self.image_width + or int(message.height) != self.image_height + ): + raise ValueError( + f"calibration is {message.width}x{message.height}, expected " + f"{self.image_width}x{self.image_height}" + ) + mapping = calibration_to_mapping(self.camera_name, message) + self.camera_info_path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = self.camera_info_path.with_suffix( + self.camera_info_path.suffix + ".tmp" + ) + temporary_path.write_text( + yaml.safe_dump(mapping, sort_keys=False), encoding="utf-8" + ) + temporary_path.replace(self.camera_info_path) + calibration = load_camera_calibration(self.camera_info_path) + self._validate_calibration_dimensions(calibration) + with self._camera_info_lock: + self._calibration = calibration + response.success = True + response.status_message = ( + f"saved camera calibration to {self.camera_info_path}" + ) + self.get_logger().info(response.status_message) + except Exception as error: + response.success = False + response.status_message = str(error) + self.get_logger().error( + f"Rejected camera calibration: {response.status_message}" + ) + return response + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._stop_event.set() + if self._grab_thread is not None: + self._grab_thread.join(timeout=2.0) + if self._camera is not None and self._camera_opened: + if self._grabbing: + result = self._camera.MV_CC_StopGrabbing() + if result != 0: + self.get_logger().warning( + f"MVS failed to stop grabbing: 0x{result:08x}" + ) + self._grabbing = False + result = self._camera.MV_CC_CloseDevice() + if result != 0: + self.get_logger().warning( + f"MVS failed to close camera: 0x{result:08x}" + ) + self._camera.MV_CC_DestroyHandle() + elif self._camera is not None and self._handle_created: + self._camera.MV_CC_DestroyHandle() + self._camera = None + self._handle_created = False + self._camera_opened = False + if self._sdk_initialized and self._mvs is not None: + self._mvs.MvCamera.MV_CC_Finalize() + self._sdk_initialized = False + + def destroy_node(self) -> None: + self.close() + super().destroy_node() + + +def main(args: list[str] | None = None) -> None: + configure_fastdds_large_image_transport() + rclpy.init(args=args) + node: HikrobotCameraNode | None = None + try: + node = HikrobotCameraNode() + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + if node is not None: + node.destroy_node() + if rclpy.ok(): + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/node.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/node.py index cda9daa..5ecc605 100644 --- a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/node.py +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/node.py @@ -1,4 +1,4 @@ -"""ROS 2 hardware calibration node for a front-facing RealSense camera.""" +"""ROS 2 hardware calibration node for a front-facing industrial camera.""" from __future__ import annotations diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py new file mode 100644 index 0000000..2869978 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_diagnostics.py @@ -0,0 +1,306 @@ +"""Chinese, operator-facing diagnostics for three-camera calibration.""" + +from __future__ import annotations + +from typing import Any, Mapping + + +STATE_NAMES_ZH = { + "PREFLIGHT": "设备和标签预检", + "WAIT_START": "等待开始标定", + "RETURN_BASELINE": "正在返回基准姿态", + "PREPARE_SWEEP": "正在到达扫描起点", + "SWEEP": "正在采集轨迹", + "FITTING": "正在拟合轨迹和零位", + "VALIDATION_MOVE": "正在移动到随机复测位置", + "VALIDATION_CAPTURE": "正在采集随机复测数据", + "PAUSED": "标定已暂停", + "ABORTED": "标定已终止", + "COMPLETE": "标定已完成", +} + +VIEW_NAMES_ZH = { + "front": "正面", + "side": "侧面", + "top": "上面", +} + +JOINT_NAMES_ZH = { + "thumb_cmc_pitch": "拇指CMC俯仰", + "thumb_cmc_roll": "拇指CMC滚转", + "thumb_mcp": "拇指MCP", + "thumb_ip": "拇指IP(被动)", + "index_mcp_roll": "食指MCP侧摆", + "index_mcp_pitch": "食指MCP屈伸", + "index_pip": "食指PIP", + "index_dip": "食指DIP(被动)", + "thumb_cmc_yaw": "拇指CMC侧摆", +} + + +def _format_u8(value: Any) -> str: + if value is None: + return "尚无反馈" + return f"{float(value):.1f}" + + +def _task_text(active: Mapping[str, Any]) -> str: + if not active: + return "尚无活动任务" + view = VIEW_NAMES_ZH.get(str(active.get("view", "")), str(active.get("view", ""))) + if active.get("kind") == "fit_failure": + joints = active.get("joints", []) + joint_text = "/".join( + JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints + ) + return ( + f"{view}机位,{joint_text}拟合检查失败," + f"电机{active.get('motor_index')}," + f"第{active.get('attempt', 1)}次尝试" + ) + if active.get("kind") == "validation": + return ( + f"{view}机位,随机复测,电机{active.get('motor_index')}," + f"目标命令{active.get('command_u8')}" + ) + joints = active.get("joints", []) + joint_text = "/".join( + JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints + ) + start = active.get("start_u8") + target = active.get("target_u8") + cycle = active.get("cycle", "?") + repetitions = active.get("repetitions", "?") + return ( + f"{view}机位,{joint_text},电机{active.get('motor_index')}," + f"第{cycle}/{repetitions}轮,{start}→{target}" + ) + + +def three_camera_reason_zh( + state: str, + reason: str, + active: Mapping[str, Any], +) -> tuple[str, str]: + """Translate a reason code and provide one concrete operator action.""" + reason = str(reason) + sample = active.get("sample", {}) if active else {} + missing = [int(value) for value in sample.get("missing_endpoint_u8", [])] + sample_range = ( + f"{_format_u8(sample.get('minimum_u8'))}~" + f"{_format_u8(sample.get('maximum_u8'))}" + ) + tolerance = sample.get("endpoint_tolerance_u8", "?") + + if reason == "sweep_missing_endpoint_bin": + missing_text = "、".join(str(value) for value in missing) or "0或255" + return ( + f"本方向已有{active.get('valid_frames', 0)}帧同步有效数据,但缺少" + f"电机端点{missing_text}附近的有效分箱;采样到的实际电机范围为" + f"{sample_range},端点容差为±{tolerance}。这通常表示电机虽然运动到" + "端点,但该时刻没有同时取得有效Tag图像和电机状态。", + "确认当前机位所需Tag在整个行程(尤其缺失端点)均可见,然后调用" + "/g20_calibration/resume;程序会重新扫描当前方向,不要调用start。", + ) + if reason == "sweep_bins_too_few": + return ( + f"有效电机分箱只有{sample.get('bin_count', 0)}个,要求至少" + f"{sample.get('minimum_bin_count', '?')}个;当前采样范围{sample_range}。", + "检查Tag连续识别和电机状态频率,修正后调用resume重新扫描当前方向。", + ) + if reason == "sweep_bin_gap_too_large": + return ( + f"轨迹相邻有效电机分箱的最大空缺为{sample.get('maximum_bin_gap', '?')}," + f"允许值不超过{sample.get('allowed_maximum_bin_gap', '?')}。", + "检查运动中Tag是否间歇丢失;修正遮挡、反光或对焦后调用resume。", + ) + if reason == "synchronised_tag_state_timeout": + return ( + "运动过程中连续超过允许时间没有取得“所需Tag全部有效且能与电机状态" + "按时间戳配对”的图像帧。", + "查看下面活动机位的缺失Tag,确认状态话题仍在更新;修正后调用resume," + "程序会重扫当前方向。", + ) + if reason == "sweep_start_position_timeout": + return ( + f"电机{active.get('motor_index')}未在规定时间到达扫描起点" + f"{active.get('start_u8')},当前实际值{_format_u8(active.get('actual_u8'))}。", + "检查CAN、机械手使能和是否存在机械卡阻,确认安全后调用resume。", + ) + if reason == "sweep_timeout": + return ( + "当前方向在规定时间内未完成端点到达、有效帧数和行程覆盖要求。", + "检查电机实际值、Tag连续识别和标定速度,修正后调用resume。", + ) + if reason == "return_baseline_timeout": + return ( + "一个或多个标定电机未在规定时间返回基准命令。", + "检查机械手状态、CAN和机械卡阻,确认安全后调用resume。", + ) + if reason == "validation_move_timeout": + return ( + "随机复测时电机未在规定时间到达目标命令。", + "检查机械手状态和机械卡阻,确认安全后调用resume。", + ) + if reason == "validation_capture_timeout": + return ( + "随机复测位置没有采集到足够的同步有效Tag帧。", + "检查当前机位Tag可见性后调用resume。", + ) + if reason == "joint_fit_check_failed": + metric_names = { + "plane_rms_mm": "平面拟合RMS", + "radial_rms_mm": "圆半径拟合RMS", + "radius_mm": "拟合半径", + "image_radial_rms_px": "二维圆半径拟合RMS", + "image_radial_p95_px": "二维圆半径误差P95", + "image_radius_px": "二维拟合半径", + "arc_deg": "实测圆弧", + "monotonic_correction_deg": "最大单调修正", + "hysteresis_deg": "最大正反程差", + "cycle_travel_range_deg": "三轮行程差", + } + metric_units = { + "plane_rms_mm": "mm", + "radial_rms_mm": "mm", + "radius_mm": "mm", + "image_radial_rms_px": "px", + "image_radial_p95_px": "px", + "image_radius_px": "px", + "arc_deg": "°", + "monotonic_correction_deg": "°", + "hysteresis_deg": "°", + "cycle_travel_range_deg": "°", + } + details: list[str] = [] + for failure in active.get("failures", []): + joint = JOINT_NAMES_ZH.get( + str(failure.get("joint")), str(failure.get("joint")) + ) + metric = str(failure.get("metric", "")) + if metric in metric_names: + comparison = str(failure.get("comparison", "")) + requirement = "不超过" if comparison == "maximum" else "至少" + unit = metric_units[metric] + detail = ( + f"{joint}的{metric_names[metric]}为" + f"{float(failure.get('actual', 0.0)):.2f}{unit}," + f"要求{requirement}{float(failure.get('limit', 0.0)):.2f}{unit}" + ) + cycle_travel = failure.get("cycle_travel_deg", []) + if cycle_travel: + detail += "(三轮=" + "/".join( + f"{float(value):.2f}°" for value in cycle_travel + ) + ")" + details.append(detail) + else: + cycle = failure.get("cycle") + cycle_text = "" if cycle is None else f"第{cycle}轮" + details.append( + f"{joint}的{cycle_text}{metric or '轨迹'}拟合失败:" + f"{failure.get('reason', '未知原因')}" + ) + detail_text = ";".join(details) or "当前关节的轨迹拟合未通过" + return ( + detail_text + "。程序已在当前关节结束后立即停止后续步骤。", + "修正Tag位置、遮挡或机械行程后调用" + "/g20_calibration/resume;程序只清除当前失败关节的数据" + f"并重扫{active.get('directions_to_rescan', 6)}个方向,不要调用start。", + ) + if reason in {"waiting_for_three_cameras_tags_and_sdk", "preflight_lost"}: + return ( + "正在等待三台相机内参、帧率、全部必需Tag以及机械手SDK同时就绪。", + "根据下面每个机位的缺失Tag和有效率排查;全部就绪后程序会进入等待开始状态。", + ) + if reason == "call_start": + return ( + "三机位预检已经通过,等待操作员确认开始。", + "清空机械手运动范围后调用/g20_calibration/start。", + ) + if reason == "operator_pause": + return "操作员主动暂停了标定。", "确认安全后调用/g20_calibration/resume。" + if reason == "operator_abort": + return "操作员终止了本次标定,程序保持终止时的当前姿态。", "需要重新启动一次新标定。" + if reason == "collecting_timestamp_synchronised_tag_centres": + return "正在按时间戳配对Tag图像和电机状态并采集当前轨迹。", "无需操作,保持相机、标签和底座不动。" + if reason == "capturing_random_validation_pose": + return "正在当前随机命令位置采集复测数据。", "无需操作,保持设备不动。" + if reason in {"calibration_passed", "calibration_complete"}: + return "轨迹、零位和随机复测已经完成。", "检查结果路径和quality.passed。" + if reason == "quality_failed": + return "标定流程完成,但拟合或随机复测质量没有达到验收阈值。", "检查最终JSON的quality以及启动终端中的拟合日志。" + if reason.startswith("prepare_") or state == "PREPARE_SWEEP": + return "正在把当前电机移动到本方向的扫描起点并等待稳定。", "无需操作。" + if state == "RETURN_BASELINE": + return "正在把已使用的标定电机恢复到统一基准命令。", "无需操作。" + if state == "FITTING": + return "所有扫描已经完成,正在拟合21个关节的轨迹和零位。", "无需操作。" + return f"未分类原因码:{reason}", "保留该原因码和启动终端日志用于进一步定位。" + + +def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str: + """Render the complete operator status; the JSON topic remains unchanged.""" + state = str(payload.get("state", "")) + active = payload.get("active", {}) + reason_zh, action_zh = three_camera_reason_zh( + state, str(payload.get("reason", "")), active + ) + progress = float(payload.get("progress", 0.0)) + completed = payload.get("completed_sweeps", 0) + total = payload.get("total_sweeps", 0) + lines = [ + f"状态:{STATE_NAMES_ZH.get(state, state)}({state})", + f"原因:{reason_zh}", + f"建议:{action_zh}", + f"进度:{progress:.1%}(已完成{completed}/{total}个扫描方向)", + f"当前任务:{_task_text(active)}", + ] + if state == "RETURN_BASELINE": + lines.append( + f"正在确认基准姿态:{payload.get('baseline_command_u8', [])}" + ) + if active and active.get("kind") != "fit_failure": + sample = active.get("sample", {}) + motion_progress = active.get("motion_progress") + motion_text = ( + "未知" if motion_progress is None else f"{float(motion_progress):.1%}" + ) + lines.append( + "运动采样:" + f"目标{active.get('target_u8', active.get('command_u8', '?'))}," + f"实际{_format_u8(active.get('actual_u8'))}," + f"本方向{motion_text},有效帧{active.get('valid_frames', 0)}," + f"实际采样范围{_format_u8(sample.get('minimum_u8'))}~" + f"{_format_u8(sample.get('maximum_u8'))}" + ) + auxiliary = active.get("auxiliary_motors", []) + if auxiliary: + lines.append( + "避挡姿态:" + + ",".join( + f"电机{item.get('motor_index')}目标" + f"{item.get('command_u8')}、实际" + f"{_format_u8(item.get('actual_u8'))}" + for item in auxiliary + ) + ) + speed = active.get("speed", {}) + if speed: + lines.append( + "阶段速度:五指目标" + f"{speed.get('commanded_finger_speed')},SDK报告" + f"{speed.get('reported_finger_speed')}" + ) + lines.append("机位:") + for name, view in payload.get("views", {}).items(): + missing = view.get("missing_tag_ids", []) + missing_text = "无" if not missing else ",".join(map(str, missing)) + lines.append( + f"- {VIEW_NAMES_ZH.get(str(name), str(name))}:" + f"{'就绪' if view.get('ready') else '等待'}," + f"{float(view.get('detection_hz', 0.0)):.1f}Hz," + f"全部必需Tag同时有效率{float(view.get('valid_rate', 0.0)):.1%}," + f"当前缺失Tag={missing_text}" + ) + lines.append(f"结果:{payload.get('result_path') or '尚未生成'}") + return "\n".join(lines) diff --git a/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py new file mode 100644 index 0000000..6e8acee --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/g20_thumb_apriltag_calibration/three_camera_node.py @@ -0,0 +1,1944 @@ +"""One-command three-camera calibration for the complete left G20 hand.""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +import json +import math +from pathlib import Path +import random +import re +import time +from typing import Any, Mapping + +import numpy as np +import rclpy +from apriltag_msgs.msg import AprilTagDetectionArray +from rclpy.node import Node +from rclpy.qos import qos_profile_sensor_data +from scipy.spatial.transform import Rotation +from sensor_msgs.msg import CameraInfo, JointState +from std_msgs.msg import String +from std_srvs.srv import Trigger + +from .acquisition import ( + StateSample, + TagQuality, + interpolate_state_u8, + required_resume_views, + tag_quality_is_valid, + update_pnp_reset_watchdog, +) +from .core import ( + BASELINE_COMMAND, + COMMAND_NAMES, + DIRECTION_DECREASING, + DIRECTION_INCREASING, +) +from .full_hand import ( + IMAGE_TRAJECTORY_JOINTS, + JOINT_SPECS, + MEASURED_JOINTS, + SWEEP_SPECS, + VIEW_TAGS, + JointCurveFit, + SweepSpec, + build_calibration_motion_command, + build_calibration_speed_profile, + build_compact_payload, + calibration_auxiliary_commands, + center_splay_curve, + fit_measured_joint_curve, + fit_projected_zero, + measure_joint_observation, +) +from .hikrobot_camera import configure_fastdds_large_image_transport +from .pnp import ( + SquareTagGroupPoseTracker, + SquareTagPose, + SquareTagPoseTracker, +) +from .storage import append_jsonl, atomic_write_json +from .three_camera_diagnostics import render_three_camera_status_text_zh + + +STATE_PREFLIGHT = "PREFLIGHT" +STATE_WAIT_START = "WAIT_START" +STATE_RETURN_BASELINE = "RETURN_BASELINE" +STATE_PREPARE_SWEEP = "PREPARE_SWEEP" +STATE_SWEEP = "SWEEP" +STATE_FITTING = "FITTING" +STATE_VALIDATION_MOVE = "VALIDATION_MOVE" +STATE_VALIDATION_CAPTURE = "VALIDATION_CAPTURE" +STATE_PAUSED = "PAUSED" +STATE_ABORTED = "ABORTED" +STATE_COMPLETE = "COMPLETE" + + +def _stamp_ns(stamp: Any) -> int: + return int(stamp.sec) * 1_000_000_000 + int(stamp.nanosec) + + +def _safe_name(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value).strip()) + return safe or "UNSET" + + +def _view_pairs(view: str) -> tuple[tuple[str, str], ...]: + if view == "front": + return ( + ("front_base", "thumb_cmc"), + ("thumb_cmc", "thumb_mcp"), + ("thumb_mcp", "thumb_ip"), + ("front_base", "index_roll"), + ) + if view == "side": + return ( + ("side_base", "index_mcp"), + ("index_mcp", "index_pip"), + ("index_pip", "index_dip"), + ) + if view == "top": + return (("top_base", "thumb_yaw"),) + raise ValueError(f"unknown view {view}") + + +@dataclass(frozen=True) +class FrameObservation: + stamp_ns: int + received_at: float + view: str + state_u8: tuple[float, ...] + state_sync_error_ns: int + joint_vectors_xyz_m: Mapping[str, tuple[float, float, float]] + image_vectors_xy_px: Mapping[str, tuple[float, float]] + + +@dataclass(frozen=True) +class SweepItem: + spec: SweepSpec + cycle: int + direction: str + + @property + def start_u8(self) -> int: + return 255 if self.direction == DIRECTION_DECREASING else 0 + + @property + def target_u8(self) -> int: + return 0 if self.direction == DIRECTION_DECREASING else 255 + + +@dataclass(frozen=True) +class ValidationItem: + spec: SweepSpec + command_u8: int + + +@dataclass +class ViewRuntime: + name: str + tag_size_m: float + preflight_frames: int + tracker: SquareTagPoseTracker + group_tracker: SquareTagGroupPoseTracker + role_by_id: dict[int, str] = field(default_factory=dict) + camera_matrix: np.ndarray | None = None + camera_info_valid: bool = False + camera_frame: str = "" + valid_flags: deque[bool] = field(default_factory=deque) + detection_times: deque[float] = field(default_factory=deque) + latest_tag_quality: dict[str, TagQuality] = field(default_factory=dict) + latest_pnp_rejections: dict[str, str] = field(default_factory=dict) + latest_group_pnp_reason: str = "" + pnp_invalid_since: float | None = None + pnp_reset_count: int = 0 + last_message_at: float = 0.0 + last_valid_at: float = 0.0 + + def __post_init__(self) -> None: + self.role_by_id = { + tag_id: role for role, tag_id in VIEW_TAGS[self.name].items() + } + self.valid_flags = deque(maxlen=self.preflight_frames) + self.detection_times = deque(maxlen=self.preflight_frames) + + @property + def roles(self) -> tuple[str, ...]: + return tuple(VIEW_TAGS[self.name]) + + @property + def valid_rate(self) -> float: + if not self.valid_flags: + return 0.0 + return float(sum(self.valid_flags) / len(self.valid_flags)) + + @property + def detection_hz(self) -> float: + if len(self.detection_times) < 2: + return 0.0 + elapsed = self.detection_times[-1] - self.detection_times[0] + return 0.0 if elapsed <= 0.0 else (len(self.detection_times) - 1) / elapsed + + +class G20ThreeCameraCalibrationNode(Node): + """Own all hand commands while observing three fixed Hikrobot views.""" + + def __init__(self) -> None: + super().__init__("g20_calibration") + self._declare_parameters() + self._load_parameters() + + self.session_dir.mkdir(parents=True, exist_ok=True) + self.raw_path = self.session_dir / "raw_samples.jsonl" + self.final_path = self.session_dir / ( + f"g20_left_{_safe_name(self.serial_number)}_calibration.json" + ) + + self.views = { + name: self._make_view_runtime(name) + for name in ("front", "side", "top") + } + self.latest_state_u8: tuple[float, ...] = () + self.state_history: deque[StateSample] = deque(maxlen=1200) + self.latest_hand_info: dict[str, Any] = {} + self.commanded_speed_profile: tuple[int, ...] = () + self.speed_commanded_at = 0.0 + + self.state = STATE_PREFLIGHT + self.reason = "waiting_for_three_cameras_tags_and_sdk" + self.paused_reason = "" + self.started = False + self.baseline_after = "" + self.position_hold_since: float | None = None + self.motion_stage_started_at = 0.0 + + self.sweep_items: list[SweepItem] = [] + self.sweep_index = 0 + self.active_sweep: SweepItem | None = None + self.sweep_frames: list[FrameObservation] = [] + self.sweep_started_at = 0.0 + self.sweep_last_valid_at = 0.0 + self.sweep_endpoint_since: float | None = None + self.retry_sweep_spec: SweepSpec | None = None + self.fit_failure: dict[str, Any] = {} + self.sweep_attempts: dict[int, int] = { + spec.motor_index: 1 for spec in SWEEP_SPECS + } + self.records_by_joint: dict[str, list[dict[str, Any]]] = { + name: [] for name in MEASURED_JOINTS + } + + self.measured_fits: dict[str, JointCurveFit] = {} + self.projected_zeros_rad: dict[str, float] = {} + self.splay_zero_command_u8 = 0 + self.splay_midpoint_rad = 0.0 + self.fit_quality_passed = False + + self.validation_items: list[ValidationItem] = [] + self.validation_index = 0 + self.active_validation: ValidationItem | None = None + self.validation_frames_buffer: list[FrameObservation] = [] + self.validation_errors_rad: list[float] = [] + self.validation_stage_started_at = 0.0 + self.completed_payload: dict[str, Any] | None = None + + self.command_publisher = self.create_publisher( + JointState, self.command_topic, 1 + ) + self.setting_publisher = self.create_publisher( + String, self.setting_topic, 10 + ) + self.status_publisher = self.create_publisher(String, "~/status", 10) + self.status_text_publisher = self.create_publisher( + String, "~/status_text", 10 + ) + self.create_subscription( + JointState, self.state_topic, self._state_callback, 10 + ) + self.create_subscription( + String, self.info_topic, self._info_callback, 10 + ) + for name, runtime in self.views.items(): + del runtime + self.create_subscription( + CameraInfo, + self.camera_info_topics[name], + lambda message, view=name: self._camera_info_callback( + view, message + ), + qos_profile_sensor_data, + ) + self.create_subscription( + AprilTagDetectionArray, + self.detections_topics[name], + lambda message, view=name: self._detections_callback( + view, message + ), + qos_profile_sensor_data, + ) + + self.create_service(Trigger, "~/start", self._start_callback) + self.create_service(Trigger, "~/pause", self._pause_callback) + self.create_service(Trigger, "~/resume", self._resume_callback) + self.create_service(Trigger, "~/abort", self._abort_callback) + self.last_status_publish = 0.0 + self.timer = self.create_timer(0.05, self._timer_callback) + self.get_logger().info( + f"Three-camera calibration session: {self.session_dir}" + ) + + def _declare_parameters(self) -> None: + self.declare_parameter("serial_number", "UNSET") + self.declare_parameter("session_dir", "calibration_output/session") + self.declare_parameter("commands_enabled", True) + self.declare_parameter("command_topic", "/g20/cb_left_hand_control_cmd") + self.declare_parameter("state_topic", "/g20/cb_left_hand_state") + self.declare_parameter("info_topic", "/g20/cb_left_hand_info") + self.declare_parameter( + "setting_topic", "/g20/cb_hand_setting_cmd" + ) + for view in ("front", "side", "top"): + self.declare_parameter( + f"{view}_camera_info_topic", + f"/g20_calibration/{view}/camera/camera_info", + ) + self.declare_parameter( + f"{view}_detections_topic", + f"/g20_calibration/{view}/apriltag/detections", + ) + self.declare_parameter("tag_size_m", 0.010) + self.declare_parameter("baseline_command_u8", list(BASELINE_COMMAND)) + self.declare_parameter("normal_calibration_speed", 15) + self.declare_parameter("index_roll_calibration_speed", 5) + self.declare_parameter("index_flex_calibration_speed", 10) + self.declare_parameter("speed_setting_settle_seconds", 0.25) + self.declare_parameter("repetitions", 3) + self.declare_parameter("preflight_frames", 60) + self.declare_parameter("minimum_detection_rate", 0.95) + self.declare_parameter("minimum_detection_hz", 15.0) + self.declare_parameter("maximum_hamming", 0) + self.declare_parameter("minimum_decision_margin", 30.0) + self.declare_parameter("minimum_edge_pixels", 30.0) + self.declare_parameter("pnp_maximum_reprojection_error_px", 1.5) + self.declare_parameter("pnp_reprojection_tie_px", 1.5) + self.declare_parameter("pnp_maximum_pose_jump_deg", 35.0) + self.declare_parameter("pnp_maximum_translation_jump_m", 0.04) + self.declare_parameter("pnp_maximum_tag_tilt_deg", 75.0) + self.declare_parameter("pnp_tracker_reset_seconds", 5.0) + self.declare_parameter("top_pnp_invalid_reset_seconds", 1.0) + self.declare_parameter("maximum_state_image_skew_ms", 150.0) + self.declare_parameter("endpoint_tolerance_u8", 2.0) + self.declare_parameter("endpoint_hold_seconds", 0.5) + self.declare_parameter("baseline_hold_seconds", 0.5) + self.declare_parameter("position_timeout_seconds", 30.0) + self.declare_parameter("sweep_timeout_seconds", 90.0) + self.declare_parameter("invalid_timeout_seconds", 3.0) + self.declare_parameter("minimum_sweep_frames", 40) + self.declare_parameter("minimum_state_span_u8", 240.0) + self.declare_parameter("minimum_sweep_bins", 32) + self.declare_parameter("maximum_bin_gap", 16) + self.declare_parameter("trajectory_maximum_plane_rms_m", 0.004) + self.declare_parameter("trajectory_maximum_radial_rms_m", 0.004) + self.declare_parameter("trajectory_minimum_radius_m", 0.003) + self.declare_parameter("trajectory_minimum_arc_deg", 15.0) + self.declare_parameter("image_trajectory_maximum_radial_rms_px", 2.0) + self.declare_parameter("image_trajectory_maximum_radial_p95_px", 3.5) + self.declare_parameter("image_trajectory_minimum_radius_px", 20.0) + self.declare_parameter( + "trajectory_maximum_cycle_travel_difference_deg", 3.0 + ) + self.declare_parameter( + "passive_maximum_cycle_travel_difference_deg", 10.0 + ) + self.declare_parameter("zero_minimum_radius_px", 20.0) + self.declare_parameter("zero_maximum_radial_rms_px", 2.0) + self.declare_parameter("zero_maximum_radial_p95_px", 3.5) + self.declare_parameter("zero_maximum_round_difference_deg", 1.0) + self.declare_parameter("maximum_monotonic_correction_deg", 2.0) + self.declare_parameter("maximum_hysteresis_deg", 5.0) + self.declare_parameter("passive_maximum_monotonic_correction_deg", 3.0) + self.declare_parameter("passive_maximum_hysteresis_deg", 7.5) + self.declare_parameter("validation_enabled", False) + self.declare_parameter("validation_command_count", 3) + self.declare_parameter("validation_frames", 10) + self.declare_parameter("validation_seed", 20260804) + self.declare_parameter("validation_timeout_seconds", 20.0) + self.declare_parameter("maximum_validation_mae_deg", 2.0) + self.declare_parameter("maximum_validation_p95_deg", 3.0) + + def _load_parameters(self) -> None: + def value(name: str) -> Any: + return self.get_parameter(name).value + + self.serial_number = str(value("serial_number")) + if self.serial_number == "UNSET": + raise ValueError("serial_number is required") + self.session_dir = Path(str(value("session_dir"))).expanduser().resolve() + self.commands_enabled = bool(value("commands_enabled")) + self.command_topic = str(value("command_topic")) + self.state_topic = str(value("state_topic")) + self.info_topic = str(value("info_topic")) + self.setting_topic = str(value("setting_topic")) + self.camera_info_topics = { + view: str(value(f"{view}_camera_info_topic")) + for view in ("front", "side", "top") + } + self.detections_topics = { + view: str(value(f"{view}_detections_topic")) + for view in ("front", "side", "top") + } + self.tag_size_m = float(value("tag_size_m")) + self.baseline_command = tuple( + int(item) for item in value("baseline_command_u8") + ) + self.normal_calibration_speed = int(value("normal_calibration_speed")) + self.index_roll_calibration_speed = int( + value("index_roll_calibration_speed") + ) + self.index_flex_calibration_speed = int( + value("index_flex_calibration_speed") + ) + self.speed_setting_settle_seconds = float( + value("speed_setting_settle_seconds") + ) + self.repetitions = int(value("repetitions")) + self.preflight_frames = int(value("preflight_frames")) + self.minimum_detection_rate = float(value("minimum_detection_rate")) + self.minimum_detection_hz = float(value("minimum_detection_hz")) + self.maximum_hamming = int(value("maximum_hamming")) + self.minimum_decision_margin = float(value("minimum_decision_margin")) + self.minimum_edge_pixels = float(value("minimum_edge_pixels")) + self.pnp_maximum_reprojection_error_px = float( + value("pnp_maximum_reprojection_error_px") + ) + self.pnp_reprojection_tie_px = float(value("pnp_reprojection_tie_px")) + self.pnp_maximum_pose_jump_rad = math.radians( + float(value("pnp_maximum_pose_jump_deg")) + ) + self.pnp_maximum_translation_jump_m = float( + value("pnp_maximum_translation_jump_m") + ) + self.pnp_maximum_tag_tilt_rad = math.radians( + float(value("pnp_maximum_tag_tilt_deg")) + ) + self.pnp_tracker_reset_seconds = float(value("pnp_tracker_reset_seconds")) + self.top_pnp_invalid_reset_seconds = float( + value("top_pnp_invalid_reset_seconds") + ) + self.maximum_state_image_skew_ns = int( + float(value("maximum_state_image_skew_ms")) * 1_000_000.0 + ) + self.endpoint_tolerance_u8 = float(value("endpoint_tolerance_u8")) + self.endpoint_hold_seconds = float(value("endpoint_hold_seconds")) + self.baseline_hold_seconds = float(value("baseline_hold_seconds")) + self.position_timeout_seconds = float(value("position_timeout_seconds")) + self.sweep_timeout_seconds = float(value("sweep_timeout_seconds")) + self.invalid_timeout_seconds = float(value("invalid_timeout_seconds")) + self.minimum_sweep_frames = int(value("minimum_sweep_frames")) + self.minimum_state_span_u8 = float(value("minimum_state_span_u8")) + self.minimum_sweep_bins = int(value("minimum_sweep_bins")) + self.maximum_bin_gap = int(value("maximum_bin_gap")) + self.trajectory_maximum_plane_rms_m = float( + value("trajectory_maximum_plane_rms_m") + ) + self.trajectory_maximum_radial_rms_m = float( + value("trajectory_maximum_radial_rms_m") + ) + self.trajectory_minimum_radius_m = float( + value("trajectory_minimum_radius_m") + ) + self.trajectory_minimum_arc_rad = math.radians( + float(value("trajectory_minimum_arc_deg")) + ) + self.image_trajectory_maximum_radial_rms_px = float( + value("image_trajectory_maximum_radial_rms_px") + ) + self.image_trajectory_maximum_radial_p95_px = float( + value("image_trajectory_maximum_radial_p95_px") + ) + self.image_trajectory_minimum_radius_px = float( + value("image_trajectory_minimum_radius_px") + ) + self.trajectory_maximum_cycle_travel_difference_rad = math.radians( + float(value("trajectory_maximum_cycle_travel_difference_deg")) + ) + self.passive_maximum_cycle_travel_difference_rad = math.radians( + float(value("passive_maximum_cycle_travel_difference_deg")) + ) + self.zero_minimum_radius_px = float(value("zero_minimum_radius_px")) + self.zero_maximum_radial_rms_px = float( + value("zero_maximum_radial_rms_px") + ) + self.zero_maximum_radial_p95_px = float( + value("zero_maximum_radial_p95_px") + ) + self.zero_maximum_round_difference_rad = math.radians( + float(value("zero_maximum_round_difference_deg")) + ) + self.maximum_monotonic_correction_rad = math.radians( + float(value("maximum_monotonic_correction_deg")) + ) + self.maximum_hysteresis_rad = math.radians( + float(value("maximum_hysteresis_deg")) + ) + self.passive_maximum_monotonic_correction_rad = math.radians( + float(value("passive_maximum_monotonic_correction_deg")) + ) + self.passive_maximum_hysteresis_rad = math.radians( + float(value("passive_maximum_hysteresis_deg")) + ) + self.validation_enabled = bool(value("validation_enabled")) + self.validation_command_count = int(value("validation_command_count")) + self.validation_frames = int(value("validation_frames")) + self.validation_seed = int(value("validation_seed")) + self.validation_timeout_seconds = float( + value("validation_timeout_seconds") + ) + self.maximum_validation_mae_rad = math.radians( + float(value("maximum_validation_mae_deg")) + ) + self.maximum_validation_p95_rad = math.radians( + float(value("maximum_validation_p95_deg")) + ) + if len(self.baseline_command) != 20: + raise ValueError("baseline_command_u8 must contain exactly 20 values") + if any(value < 0 or value > 255 for value in self.baseline_command): + raise ValueError("baseline_command_u8 values must be in [0, 255]") + if not 0 <= self.normal_calibration_speed <= 255: + raise ValueError("normal_calibration_speed must be in [0, 255]") + if not 0 <= self.index_roll_calibration_speed <= 255: + raise ValueError("index_roll_calibration_speed must be in [0, 255]") + if not 0 <= self.index_flex_calibration_speed <= 255: + raise ValueError("index_flex_calibration_speed must be in [0, 255]") + if self.speed_setting_settle_seconds < 0.0: + raise ValueError("speed_setting_settle_seconds must be non-negative") + if self.top_pnp_invalid_reset_seconds <= 0.0: + raise ValueError("top_pnp_invalid_reset_seconds must be positive") + if not all( + value > 0.0 + for value in ( + self.image_trajectory_maximum_radial_rms_px, + self.image_trajectory_maximum_radial_p95_px, + self.image_trajectory_minimum_radius_px, + self.trajectory_maximum_cycle_travel_difference_rad, + self.passive_maximum_cycle_travel_difference_rad, + self.passive_maximum_monotonic_correction_rad, + self.passive_maximum_hysteresis_rad, + ) + ): + raise ValueError("trajectory quality thresholds must be positive") + if self.repetitions != 3: + raise ValueError("three-camera calibration requires exactly 3 repetitions") + if self.preflight_frames < 10 or self.minimum_sweep_bins < 3: + raise ValueError("preflight_frames or minimum_sweep_bins is too small") + if not 1 <= self.validation_command_count <= 10: + raise ValueError("validation_command_count must be in [1, 10]") + if self.validation_frames < 3: + raise ValueError("validation_frames must be at least 3") + + def _make_view_runtime(self, name: str) -> ViewRuntime: + tracker = SquareTagPoseTracker( + maximum_reprojection_error_px=self.pnp_maximum_reprojection_error_px, + reprojection_tie_px=self.pnp_reprojection_tie_px, + maximum_pose_jump_rad=self.pnp_maximum_pose_jump_rad, + maximum_translation_jump_m=self.pnp_maximum_translation_jump_m, + maximum_tag_tilt_rad=self.pnp_maximum_tag_tilt_rad, + reset_after_seconds=self.pnp_tracker_reset_seconds, + ) + group = SquareTagGroupPoseTracker( + roles=tuple(VIEW_TAGS[name]), + adjacent_pairs=_view_pairs(name), + maximum_pose_jump_rad=self.pnp_maximum_pose_jump_rad, + maximum_translation_jump_m=self.pnp_maximum_translation_jump_m, + relative_rotation_scale_rad=math.radians(5.0), + relative_translation_scale_m=0.01, + reprojection_scale_px=0.1, + reprojection_weight=0.05, + reset_after_seconds=self.pnp_tracker_reset_seconds, + ) + return ViewRuntime( + name=name, + tag_size_m=self.tag_size_m, + preflight_frames=self.preflight_frames, + tracker=tracker, + group_tracker=group, + ) + + def _camera_info_callback(self, view: str, message: CameraInfo) -> None: + runtime = self.views[view] + valid = ( + int(message.width) > 0 + and int(message.height) > 0 + and len(message.p) == 12 + and float(message.p[0]) > 0.0 + and float(message.p[5]) > 0.0 + ) + matrix = None + if valid: + matrix = np.asarray(message.p, dtype=float).reshape(3, 4)[:, :3] + valid = bool( + np.all(np.isfinite(matrix)) + and matrix[0, 0] > 0.0 + and matrix[1, 1] > 0.0 + ) + if ( + matrix is not None + and runtime.camera_matrix is not None + and not np.allclose(matrix, runtime.camera_matrix) + ): + runtime.tracker.reset() + runtime.group_tracker.reset() + runtime.camera_info_valid = valid + runtime.camera_matrix = matrix if valid else None + runtime.camera_frame = str(message.header.frame_id) + + def _state_callback(self, message: JointState) -> None: + if len(message.position) != 20: + return + if len(message.name) == 20 and set(message.name) == set(COMMAND_NAMES): + lookup = dict(zip(message.name, message.position)) + state = tuple(float(lookup[name]) for name in COMMAND_NAMES) + else: + state = tuple(float(value) for value in message.position) + stamp = _stamp_ns(message.header.stamp) + if stamp <= 0: + stamp = int(self.get_clock().now().nanoseconds) + self.latest_state_u8 = state + if not self.state_history or stamp > self.state_history[-1].stamp_ns: + self.state_history.append(StateSample(stamp, state)) + + def _info_callback(self, message: String) -> None: + try: + value = json.loads(message.data) + except json.JSONDecodeError: + return + if isinstance(value, dict): + self.latest_hand_info = value + + def _quality_valid(self, quality: TagQuality, *, include_pnp: bool) -> bool: + return tag_quality_is_valid( + quality, + maximum_hamming=self.maximum_hamming, + minimum_decision_margin=self.minimum_decision_margin, + minimum_edge_pixels=self.minimum_edge_pixels, + maximum_reprojection_error_px=( + self.pnp_maximum_reprojection_error_px if include_pnp else None + ), + ) + + def _detections_callback( + self, view: str, message: AprilTagDetectionArray + ) -> None: + runtime = self.views[view] + now = time.monotonic() + stamp = _stamp_ns(message.header.stamp) + qualities: dict[str, TagQuality] = {} + corners_by_role: dict[str, np.ndarray] = {} + centres_by_role: dict[str, np.ndarray] = {} + for detection in message.detections: + role = runtime.role_by_id.get(int(detection.id)) + if role is None: + continue + corners = np.asarray( + [[float(point.x), float(point.y)] for point in detection.corners], + dtype=float, + ) + if corners.shape != (4, 2): + continue + edges = np.linalg.norm(corners - np.roll(corners, -1, axis=0), axis=1) + qualities[role] = TagQuality( + hamming=int(detection.hamming), + decision_margin=float(detection.decision_margin), + edge_pixels=float(np.mean(edges)), + ) + corners_by_role[role] = corners + centres_by_role[role] = np.mean(corners, axis=0) + + detection_good = set(qualities) == set(runtime.roles) and all( + self._quality_valid(quality, include_pnp=False) + for quality in qualities.values() + ) + selected: dict[str, SquareTagPose] | None = None + pnp_rejections: dict[str, str] = {} + group_pnp_reason = "" + if detection_good and runtime.camera_matrix is not None: + candidates: dict[str, tuple[SquareTagPose, ...]] = {} + for role in runtime.roles: + _, rejection = runtime.tracker.estimate( + role, + corners_by_role[role], + tag_size_m=runtime.tag_size_m, + camera_matrix=runtime.camera_matrix, + stamp_ns=stamp, + ) + if rejection: + pnp_rejections[role] = rejection + candidates[role] = runtime.tracker.last_candidates_by_role.get( + role, () + ) + selected, group_pnp_reason = runtime.group_tracker.select( + candidates, stamp_ns=stamp + ) + if selected is not None: + for role, pose in selected.items(): + qualities[role] = TagQuality( + hamming=qualities[role].hamming, + decision_margin=qualities[role].decision_margin, + edge_pixels=qualities[role].edge_pixels, + reprojection_error_px=pose.reprojection_error_px, + ) + valid = bool( + selected is not None + and all(self._quality_valid(q, include_pnp=True) for q in qualities.values()) + ) + runtime.latest_pnp_rejections = pnp_rejections + runtime.latest_group_pnp_reason = group_pnp_reason + if view == "top": + runtime.pnp_invalid_since, reset_due = update_pnp_reset_watchdog( + detection_good=bool( + detection_good and runtime.camera_matrix is not None + ), + pnp_valid=valid, + now=now, + invalid_since=runtime.pnp_invalid_since, + reset_after_seconds=self.top_pnp_invalid_reset_seconds, + ) + if reset_due: + runtime.tracker.reset() + runtime.group_tracker.reset() + runtime.pnp_reset_count += 1 + self.get_logger().warning( + "Top-view PnP was continuously invalid for " + f"{self.top_pnp_invalid_reset_seconds:.1f}s; reset trackers " + f"(count={runtime.pnp_reset_count}, " + f"group_reason={group_pnp_reason or 'none'}, " + f"tag_rejections={pnp_rejections})" + ) + runtime.latest_tag_quality = qualities + runtime.last_message_at = now + runtime.detection_times.append(now) + runtime.valid_flags.append(valid) + if not valid or selected is None: + return + runtime.last_valid_at = now + + matched = interpolate_state_u8( + list(self.state_history), + stamp, + maximum_skew_ns=self.maximum_state_image_skew_ns, + ) + if matched is None: + return + state_u8, sync_error_ns = matched + joint_vectors: dict[str, tuple[float, float, float]] = {} + image_vectors: dict[str, tuple[float, float]] = {} + for name, spec in JOINT_SPECS.items(): + if not spec.measured or spec.view != view: + continue + assert spec.parent_role is not None and spec.child_role is not None + parent = selected[spec.parent_role] + child = selected[spec.child_role] + parent_rotation = Rotation.from_quat(parent.quaternion_xyzw) + relative = parent_rotation.inv().apply( + np.asarray(child.translation_xyz_m, dtype=float) + - np.asarray(parent.translation_xyz_m, dtype=float) + ) + image_relative = ( + centres_by_role[spec.child_role] + - centres_by_role[spec.parent_role] + ) + joint_vectors[name] = tuple(float(value) for value in relative) + image_vectors[name] = tuple(float(value) for value in image_relative) + observation = FrameObservation( + stamp_ns=stamp, + received_at=now, + view=view, + state_u8=state_u8, + state_sync_error_ns=int(sync_error_ns), + joint_vectors_xyz_m=joint_vectors, + image_vectors_xy_px=image_vectors, + ) + self._accept_frame(observation) + + def _accept_frame(self, observation: FrameObservation) -> None: + if self.state == STATE_SWEEP and self.active_sweep is not None: + if observation.view != self.active_sweep.spec.view: + return + motor = self.active_sweep.spec.motor_index + value = float(observation.state_u8[motor]) + if not -3.0 <= value <= 258.0: + return + if not self._motion_command_reached( + self.active_sweep.spec, + int(np.clip(np.rint(value), 0, 255)), + observation.state_u8, + ): + return + self.sweep_frames.append(observation) + self.sweep_last_valid_at = observation.received_at + return + if ( + self.state == STATE_VALIDATION_CAPTURE + and self.active_validation is not None + and observation.view == self.active_validation.spec.view + ): + if self._motion_command_reached( + self.active_validation.spec, + self.active_validation.command_u8, + observation.state_u8, + ): + self.validation_frames_buffer.append(observation) + + def _view_ready(self, runtime: ViewRuntime, now: float) -> bool: + minimum_frames = min(30, self.preflight_frames) + return bool( + runtime.camera_info_valid + and len(runtime.valid_flags) >= minimum_frames + and runtime.valid_rate >= self.minimum_detection_rate + and runtime.detection_hz >= self.minimum_detection_hz + and now - runtime.last_message_at <= 1.0 + ) + + def _all_preflight_ready(self, now: float) -> bool: + return bool( + len(self.latest_state_u8) == 20 + and all(self._view_ready(runtime, now) for runtime in self.views.values()) + ) + + def _active_view_for_resume(self) -> str | None: + if self.active_sweep is not None: + return self.active_sweep.spec.view + if self.active_validation is not None: + return self.active_validation.spec.view + if self.retry_sweep_spec is not None: + return self.retry_sweep_spec.view + return None + + def _resume_preflight_ready(self, now: float) -> bool: + if len(self.latest_state_u8) != 20: + return False + active_view = self._active_view_for_resume() + return all( + self._view_ready(self.views[view], now) + for view in required_resume_views(active_view) + ) + + def _prepare_failed_sweep_retry(self) -> SweepSpec: + """Discard only a failed spec's in-memory samples before its retry.""" + if self.retry_sweep_spec is None: + raise RuntimeError("no failed sweep is pending retry") + spec = self.retry_sweep_spec + for joint_name in spec.joints: + self.records_by_joint[joint_name].clear() + self.sweep_attempts[spec.motor_index] = ( + self.sweep_attempts.get(spec.motor_index, 1) + 1 + ) + append_jsonl( + self.raw_path, + { + "kind": "retry", + "view": spec.view, + "motor_index": spec.motor_index, + "joints": list(spec.joints), + "attempt": self.sweep_attempts[spec.motor_index], + "reason": self.paused_reason, + }, + ) + self.retry_sweep_spec = None + self.fit_failure = {} + return spec + + def _start_callback( + self, request: Trigger.Request, response: Trigger.Response + ) -> Trigger.Response: + del request + if self.state != STATE_WAIT_START: + response.success = False + response.message = f"not ready: state={self.state} reason={self.reason}" + return response + if not self.commands_enabled: + response.success = False + response.message = "commands_enabled=false; preview cannot move the hand" + return response + if self.command_publisher.get_subscription_count() < 1: + response.success = False + response.message = "hand SDK is not subscribed to the command topic" + return response + if self.setting_publisher.get_subscription_count() < 1: + response.success = False + response.message = "hand SDK is not subscribed to the setting topic" + return response + if len(self.get_publishers_info_by_topic(self.command_topic)) > 1: + response.success = False + response.message = "another node is publishing hand commands" + return response + self.started = True + self.sweep_items = [ + SweepItem(spec, cycle, direction) + for spec in SWEEP_SPECS + for cycle in range(self.repetitions) + for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING) + ] + self.sweep_index = 0 + self.retry_sweep_spec = None + self.fit_failure = {} + self.sweep_attempts = { + spec.motor_index: 1 for spec in SWEEP_SPECS + } + self.records_by_joint = {name: [] for name in MEASURED_JOINTS} + self._begin_return_baseline("next_sweep") + response.success = True + response.message = "three-camera calibration started" + return response + + def _pause_callback( + self, request: Trigger.Request, response: Trigger.Response + ) -> Trigger.Response: + del request + if self.state in {STATE_COMPLETE, STATE_ABORTED, STATE_PREFLIGHT, STATE_WAIT_START}: + response.success = False + response.message = f"cannot pause from {self.state}" + return response + self._pause("operator_pause") + response.success = True + response.message = "calibration paused and current pose held" + return response + + def _resume_callback( + self, request: Trigger.Request, response: Trigger.Response + ) -> Trigger.Response: + del request + if self.state != STATE_PAUSED: + response.success = False + response.message = "calibration is not paused" + return response + now = time.monotonic() + active_view = self._active_view_for_resume() + if not self._resume_preflight_ready(now): + response.success = False + required = ",".join(required_resume_views(active_view)) + response.message = ( + f"resume preflight is not ready; required_views={required}" + ) + return response + for view in required_resume_views(active_view): + runtime = self.views[view] + runtime.tracker.reset() + runtime.group_tracker.reset() + runtime.pnp_invalid_since = None + self.sweep_frames.clear() + self.validation_frames_buffer.clear() + if self.retry_sweep_spec is not None: + self._prepare_failed_sweep_retry() + self._begin_return_baseline("resume_sweep") + response.success = True + response.message = ( + "calibration resumed; only the failed six-direction joint " + "sweep will restart" + ) + return response + if self.active_sweep is not None and self.sweep_index < len(self.sweep_items): + self._begin_return_baseline("resume_sweep") + elif self.active_validation is not None: + self._begin_return_baseline("resume_validation") + else: + self._begin_return_baseline("next_sweep") + response.success = True + response.message = "calibration resumed; active step will restart" + return response + + def _abort_callback( + self, request: Trigger.Request, response: Trigger.Response + ) -> Trigger.Response: + del request + self._publish_speed_profile(self._normal_speed_profile()) + self._publish_hold_current() + self.state = STATE_ABORTED + self.reason = "operator_abort" + response.success = True + response.message = "calibration aborted and current pose held" + return response + + def _publish_command(self, values: list[int]) -> None: + if not self.commands_enabled: + return + message = JointState() + message.header.stamp = self.get_clock().now().to_msg() + message.name = list(COMMAND_NAMES) + message.position = [float(value) for value in values] + self.command_publisher.publish(message) + + def _publish_speed_profile(self, speeds: list[int]) -> None: + if not self.commands_enabled: + return + profile = tuple(int(value) for value in speeds) + if profile == self.commanded_speed_profile: + return + message = String() + message.data = json.dumps( + { + "setting_cmd": "set_speed", + "params": { + "hand_type": "left", + "speed": list(profile), + }, + } + ) + self.setting_publisher.publish(message) + self.commanded_speed_profile = profile + self.speed_commanded_at = time.monotonic() + + def _normal_speed_profile(self) -> list[int]: + return [self.normal_calibration_speed] * 5 + + def _speed_profile_for_spec(self, spec: SweepSpec) -> list[int]: + return build_calibration_speed_profile( + spec, + normal_speed=self.normal_calibration_speed, + index_roll_speed=self.index_roll_calibration_speed, + index_flex_speed=self.index_flex_calibration_speed, + ) + + def _publish_hold_current(self) -> None: + if len(self.latest_state_u8) != 20: + return + values = [ + int(np.clip(np.rint(value), 0, 255)) + for value in self.latest_state_u8 + ] + self._publish_command(values) + + def _begin_return_baseline(self, after: str) -> None: + self.baseline_after = str(after) + self.position_hold_since = None + self.motion_stage_started_at = time.monotonic() + self.state = STATE_RETURN_BASELINE + self.reason = f"return_baseline_before_{after}" + self._publish_speed_profile(self._normal_speed_profile()) + self._publish_command(list(self.baseline_command)) + + def _baseline_reached(self) -> bool: + if len(self.latest_state_u8) != 20: + return False + indices = sorted({spec.motor_index for spec in JOINT_SPECS.values()}) + return all( + abs(float(self.latest_state_u8[index]) - self.baseline_command[index]) + <= self.endpoint_tolerance_u8 + for index in indices + ) + + def _motion_command_reached( + self, + spec: SweepSpec, + command_u8: int, + state_u8: tuple[float, ...] | None = None, + ) -> bool: + """Check the swept motor and all clearance motors together.""" + state = self.latest_state_u8 if state_u8 is None else state_u8 + if len(state) != 20: + return False + expected = build_calibration_motion_command( + spec, + command_u8, + baseline=self.baseline_command, + ) + indices = {spec.motor_index} + indices.update(calibration_auxiliary_commands(spec)) + return all( + abs(float(state[index]) - expected[index]) + <= self.endpoint_tolerance_u8 + for index in indices + ) + + def _start_next_sweep(self) -> None: + if self.sweep_index >= len(self.sweep_items): + self.active_sweep = None + self._begin_return_baseline("fit") + return + self.active_sweep = self.sweep_items[self.sweep_index] + item = self.active_sweep + self.position_hold_since = None + self.motion_stage_started_at = time.monotonic() + self.state = STATE_PREPARE_SWEEP + self.reason = ( + f"prepare_{item.spec.view}_motor_{item.spec.motor_index}_" + f"cycle_{item.cycle}_{item.direction}" + ) + self._publish_speed_profile(self._speed_profile_for_spec(item.spec)) + self._publish_command( + build_calibration_motion_command( + item.spec, + item.start_u8, + baseline=self.baseline_command, + ) + ) + + def _begin_active_sweep(self, now: float) -> None: + assert self.active_sweep is not None + self.sweep_frames.clear() + self.sweep_started_at = now + self.sweep_last_valid_at = now + self.sweep_endpoint_since = None + self.state = STATE_SWEEP + self.reason = "collecting_timestamp_synchronised_tag_centres" + self._publish_command( + build_calibration_motion_command( + self.active_sweep.spec, + self.active_sweep.target_u8, + baseline=self.baseline_command, + ) + ) + + def _sweep_spec_start_index(self, spec: SweepSpec) -> int: + return next( + index + for index, item in enumerate(self.sweep_items) + if item.spec == spec + ) + + def _fit_joint_records( + self, + joint_name: str, + records: list[dict[str, Any]], + *, + relaxed: bool = False, + ) -> JointCurveFit: + return fit_measured_joint_curve( + joint_name, + records, + maximum_plane_rms_m=( + float("inf") + if relaxed + else self.trajectory_maximum_plane_rms_m + ), + maximum_radial_rms_m=( + float("inf") + if relaxed + else self.trajectory_maximum_radial_rms_m + ), + minimum_radius_m=( + 0.0 if relaxed else self.trajectory_minimum_radius_m + ), + minimum_arc_rad=( + 0.0 if relaxed else self.trajectory_minimum_arc_rad + ), + image_maximum_radial_rms_px=( + float("inf") + if relaxed + else self.image_trajectory_maximum_radial_rms_px + ), + image_maximum_radial_p95_px=( + float("inf") + if relaxed + else self.image_trajectory_maximum_radial_p95_px + ), + image_minimum_radius_px=( + 0.0 if relaxed else self.image_trajectory_minimum_radius_px + ), + ) + + def _provisional_fit_failures( + self, spec: SweepSpec + ) -> list[dict[str, Any]]: + """Check geometry, repeatability and zero after one six-way task.""" + failures: list[dict[str, Any]] = [] + fits: dict[str, JointCurveFit] = {} + for joint_name in spec.joints: + try: + fit = self._fit_joint_records( + joint_name, + self.records_by_joint[joint_name], + relaxed=True, + ) + except Exception as error: + failures.append( + { + "joint": joint_name, + "metric": "trajectory_fit", + "reason": str(error), + } + ) + continue + fits[joint_name] = fit + quality = fit.quality + if joint_name in IMAGE_TRAJECTORY_JOINTS: + checks = ( + ( + "image_radial_rms_px", + float(quality["radial_rms_px"]), + self.image_trajectory_maximum_radial_rms_px, + "maximum", + ), + ( + "image_radial_p95_px", + float(quality["radial_p95_px"]), + self.image_trajectory_maximum_radial_p95_px, + "maximum", + ), + ( + "image_radius_px", + float(quality["radius_px"]), + self.image_trajectory_minimum_radius_px, + "minimum", + ), + ( + "arc_deg", + math.degrees(float(quality["arc_rad"])), + math.degrees(self.trajectory_minimum_arc_rad), + "minimum", + ), + ) + else: + checks = ( + ( + "plane_rms_mm", + 1000.0 * float(quality["plane_rms_m"]), + 1000.0 * self.trajectory_maximum_plane_rms_m, + "maximum", + ), + ( + "radial_rms_mm", + 1000.0 * float(quality["radial_rms_m"]), + 1000.0 * self.trajectory_maximum_radial_rms_m, + "maximum", + ), + ( + "radius_mm", + 1000.0 * float(quality["radius_m"]), + 1000.0 * self.trajectory_minimum_radius_m, + "minimum", + ), + ( + "arc_deg", + math.degrees(float(quality["arc_rad"])), + math.degrees(self.trajectory_minimum_arc_rad), + "minimum", + ), + ) + joint_spec = JOINT_SPECS[joint_name] + monotonic_limit = ( + self.maximum_monotonic_correction_rad + if joint_spec.active + else self.passive_maximum_monotonic_correction_rad + ) + hysteresis_limit = ( + self.maximum_hysteresis_rad + if joint_spec.active + else self.passive_maximum_hysteresis_rad + ) + checks += ( + ( + "monotonic_correction_deg", + math.degrees(fit.maximum_monotonic_correction_rad), + math.degrees(monotonic_limit), + "maximum", + ), + ( + "hysteresis_deg", + math.degrees(fit.maximum_hysteresis_rad), + math.degrees(hysteresis_limit), + "maximum", + ), + ) + for metric, actual, limit, comparison in checks: + failed = ( + actual > limit + if comparison == "maximum" + else actual < limit + ) + if failed: + failures.append( + { + "joint": joint_name, + "metric": metric, + "actual": round(float(actual), 6), + "limit": round(float(limit), 6), + "comparison": comparison, + } + ) + + cycle_travels: list[float] = [] + for cycle in range(self.repetitions): + cycle_records = [ + record + for record in self.records_by_joint[joint_name] + if int(record["cycle"]) == cycle + ] + try: + cycle_fit = self._fit_joint_records( + joint_name, cycle_records + ) + except Exception as error: + failures.append( + { + "joint": joint_name, + "metric": "cycle_fit", + "cycle": cycle + 1, + "reason": str(error), + } + ) + continue + cycle_travels.append( + abs( + float(cycle_fit.angle_rad[0]) + - float(cycle_fit.angle_rad[255]) + ) + ) + if len(cycle_travels) == self.repetitions: + travel_range = max(cycle_travels) - min(cycle_travels) + cycle_limit = ( + self.trajectory_maximum_cycle_travel_difference_rad + if joint_spec.active + else self.passive_maximum_cycle_travel_difference_rad + ) + if travel_range > cycle_limit: + failures.append( + { + "joint": joint_name, + "metric": "cycle_travel_range_deg", + "actual": round(math.degrees(travel_range), 6), + "limit": round(math.degrees(cycle_limit), 6), + "comparison": "maximum", + "cycle_travel_deg": [ + round(math.degrees(value), 6) + for value in cycle_travels + ], + } + ) + + failed_joints = {str(item["joint"]) for item in failures} + for joint_name in spec.joints: + if joint_name in failed_joints or joint_name not in fits: + continue + joint_spec = JOINT_SPECS[joint_name] + if joint_spec.zero_kind == "projected": + try: + fit_projected_zero( + self.records_by_joint[joint_name], + minimum_radius_px=self.zero_minimum_radius_px, + minimum_arc_rad=self.trajectory_minimum_arc_rad, + maximum_radial_rms_px=self.zero_maximum_radial_rms_px, + maximum_radial_p95_px=self.zero_maximum_radial_p95_px, + maximum_round_difference_rad=( + self.zero_maximum_round_difference_rad + ), + ) + except Exception as error: + failures.append( + { + "joint": joint_name, + "metric": "projected_zero", + "reason": str(error), + } + ) + elif joint_spec.zero_kind == "travel_midpoint": + try: + center_splay_curve(fits[joint_name]) + except Exception as error: + failures.append( + { + "joint": joint_name, + "metric": "travel_midpoint", + "reason": str(error), + } + ) + return failures + + def _pause_for_provisional_fit_failure( + self, spec: SweepSpec, failures: list[dict[str, Any]] + ) -> None: + start_index = self._sweep_spec_start_index(spec) + attempt = self.sweep_attempts.get(spec.motor_index, 1) + self.retry_sweep_spec = spec + self.sweep_index = start_index + self.fit_failure = { + "kind": "fit_failure", + "view": spec.view, + "motor_index": spec.motor_index, + "joints": list(spec.joints), + "attempt": attempt, + "directions_to_rescan": 2 * self.repetitions, + "failures": failures, + } + append_jsonl( + self.raw_path, + { + "kind": "fit_failure", + **self.fit_failure, + }, + ) + self._pause("joint_fit_check_failed") + + def _finish_active_sweep(self) -> None: + assert self.active_sweep is not None + item = self.active_sweep + motor = item.spec.motor_index + states = np.asarray( + [float(frame.state_u8[motor]) for frame in self.sweep_frames] + ) + bins: dict[int, list[FrameObservation]] = {} + for frame, state in zip(self.sweep_frames, states): + if abs(state - item.start_u8) <= self.endpoint_tolerance_u8: + command = item.start_u8 + elif abs(state - item.target_u8) <= self.endpoint_tolerance_u8: + command = item.target_u8 + else: + command = int(np.clip(np.rint(state), 0, 255)) + bins.setdefault(command, []).append(frame) + commands = sorted(bins) + if len(commands) < self.minimum_sweep_bins: + self._pause("sweep_bins_too_few") + return + if commands[0] != 0 or commands[-1] != 255: + self._pause("sweep_missing_endpoint_bin") + return + if max(np.diff(commands), default=0) > self.maximum_bin_gap: + self._pause("sweep_bin_gap_too_large") + return + + for joint_name in item.spec.joints: + for command in commands: + frames = bins[command] + vector = np.median( + np.asarray( + [frame.joint_vectors_xyz_m[joint_name] for frame in frames] + ), + axis=0, + ) + image_vector = np.median( + np.asarray( + [frame.image_vectors_xy_px[joint_name] for frame in frames] + ), + axis=0, + ) + record = { + "kind": "sample", + "attempt": self.sweep_attempts.get(motor, 1), + "view": item.spec.view, + "joint": joint_name, + "motor_index": motor, + "cycle": item.cycle, + "direction": item.direction, + "command_u8": int(command), + "relative_translation_xyz_m": [ + float(value) for value in vector + ], + "image_relative_xy_px": [ + float(value) for value in image_vector + ], + "valid_frames": len(frames), + } + self.records_by_joint[joint_name].append(record) + append_jsonl(self.raw_path, record) + + previous_spec = item.spec + self.sweep_index += 1 + self.active_sweep = None + spec_complete = bool( + self.sweep_index >= len(self.sweep_items) + or self.sweep_items[self.sweep_index].spec != previous_spec + ) + if spec_complete: + failures = self._provisional_fit_failures(previous_spec) + if failures: + self._pause_for_provisional_fit_failure( + previous_spec, failures + ) + return + if self.retry_sweep_spec == previous_spec: + self.retry_sweep_spec = None + self.fit_failure = {} + if self.sweep_index >= len(self.sweep_items): + self._begin_return_baseline("fit") + elif self.sweep_items[self.sweep_index].spec != previous_spec: + self._begin_return_baseline("next_sweep") + else: + self._start_next_sweep() + + def _fit_all_curves(self) -> None: + self.state = STATE_FITTING + self.reason = "fitting_21_joint_compact_calibration" + measured: dict[str, JointCurveFit] = {} + for name in MEASURED_JOINTS: + measured[name] = self._fit_joint_records( + name, self.records_by_joint[name] + ) + centred, zero_command, midpoint = center_splay_curve( + measured["index_mcp_roll"] + ) + measured["index_mcp_roll"] = centred + projected: dict[str, float] = {} + for name, spec in JOINT_SPECS.items(): + if spec.zero_kind != "projected": + continue + projected[name] = fit_projected_zero( + self.records_by_joint[name], + minimum_radius_px=self.zero_minimum_radius_px, + minimum_arc_rad=self.trajectory_minimum_arc_rad, + maximum_radial_rms_px=self.zero_maximum_radial_rms_px, + maximum_radial_p95_px=self.zero_maximum_radial_p95_px, + maximum_round_difference_rad=( + self.zero_maximum_round_difference_rad + ), + ) + self.measured_fits = measured + self.projected_zeros_rad = projected + self.splay_zero_command_u8 = zero_command + self.splay_midpoint_rad = midpoint + self.fit_quality_passed = all( + fit.maximum_monotonic_correction_rad + <= ( + self.maximum_monotonic_correction_rad + if JOINT_SPECS[name].active + else self.passive_maximum_monotonic_correction_rad + ) + and fit.maximum_hysteresis_rad + <= ( + self.maximum_hysteresis_rad + if JOINT_SPECS[name].active + else self.passive_maximum_hysteresis_rad + ) + for name, fit in measured.items() + ) + if self.validation_enabled: + self._build_validation_items() + self._start_next_validation() + else: + self.validation_items.clear() + self.validation_index = 0 + self.validation_errors_rad.clear() + self._begin_return_baseline("finalize") + + def _build_validation_items(self) -> None: + generator = random.Random(self.validation_seed) + self.validation_items = [] + for spec in SWEEP_SPECS: + commands = generator.sample( + list(range(16, 240)), self.validation_command_count + ) + self.validation_items.extend( + ValidationItem(spec, command) for command in commands + ) + self.validation_index = 0 + self.validation_errors_rad.clear() + + def _start_next_validation(self) -> None: + if self.validation_index >= len(self.validation_items): + self.active_validation = None + self._begin_return_baseline("finalize") + return + self.active_validation = self.validation_items[self.validation_index] + self.validation_frames_buffer.clear() + self.position_hold_since = None + self.validation_stage_started_at = time.monotonic() + self.state = STATE_VALIDATION_MOVE + self.reason = ( + f"validation_{self.active_validation.spec.view}_motor_" + f"{self.active_validation.spec.motor_index}_" + f"command_{self.active_validation.command_u8}" + ) + self._publish_speed_profile( + self._speed_profile_for_spec(self.active_validation.spec) + ) + self._publish_command( + build_calibration_motion_command( + self.active_validation.spec, + self.active_validation.command_u8, + baseline=self.baseline_command, + ) + ) + + def _finish_validation_capture(self) -> None: + assert self.active_validation is not None + item = self.active_validation + command = item.command_u8 + for name in item.spec.joints: + vectors = np.asarray( + [frame.joint_vectors_xyz_m[name] for frame in self.validation_frames_buffer] + ) + image_vectors = np.asarray( + [ + frame.image_vectors_xy_px[name] + for frame in self.validation_frames_buffer + ] + ) + observed = measure_joint_observation( + self.measured_fits[name], + vector_xyz_m=np.median(vectors, axis=0), + image_vector_xy_px=np.median(image_vectors, axis=0), + ) + expected = float(self.measured_fits[name].angle_rad[command]) + self.validation_errors_rad.append(observed - expected) + previous_spec = item.spec + self.validation_index += 1 + self.active_validation = None + if self.validation_index >= len(self.validation_items): + self._begin_return_baseline("finalize") + elif self.validation_items[self.validation_index].spec != previous_spec: + self._begin_return_baseline("validation_next") + else: + self._start_next_validation() + + def _finalize(self) -> None: + errors = np.abs(np.asarray(self.validation_errors_rad, dtype=float)) + validation_passed = bool( + not self.validation_enabled + or ( + errors.size > 0 + and float(np.mean(errors)) <= self.maximum_validation_mae_rad + and float(np.percentile(errors, 95.0)) + <= self.maximum_validation_p95_rad + ) + ) + passed = bool(self.fit_quality_passed and validation_passed) + payload = build_compact_payload( + serial_number=self.serial_number, + measured_fits=self.measured_fits, + projected_zeros_rad=self.projected_zeros_rad, + splay_zero_command_u8=self.splay_zero_command_u8, + splay_midpoint_rad=self.splay_midpoint_rad, + validation_errors_rad=self.validation_errors_rad, + passed=passed, + baseline=self.baseline_command, + ) + atomic_write_json(self.final_path, payload) + self.completed_payload = payload + self.state = STATE_COMPLETE + self.reason = "calibration_passed" if passed else "quality_failed" + self.get_logger().info( + f"Three-camera calibration complete: passed={passed}; {self.final_path}" + ) + + def _pause(self, reason: str) -> None: + self._publish_hold_current() + self.paused_reason = str(reason) + self.state = STATE_PAUSED + self.reason = str(reason) + self.position_hold_since = None + + def _timer_callback(self) -> None: + now = time.monotonic() + try: + self._advance(now) + except Exception as error: + self.get_logger().error(f"Calibration paused: {error}") + self._pause(str(error)) + if now - self.last_status_publish >= 0.5: + self.last_status_publish = now + self._publish_status(now) + + def _advance(self, now: float) -> None: + if self.state == STATE_PREFLIGHT: + if self._all_preflight_ready(now): + self.state = STATE_WAIT_START + self.reason = "call_start" + return + if self.state == STATE_WAIT_START: + if not self._all_preflight_ready(now): + self.state = STATE_PREFLIGHT + self.reason = "preflight_lost" + return + if self.state == STATE_RETURN_BASELINE: + if now - self.motion_stage_started_at > self.position_timeout_seconds: + self._pause("return_baseline_timeout") + return + if self._baseline_reached(): + if self.position_hold_since is None: + self.position_hold_since = now + elif now - self.position_hold_since >= self.baseline_hold_seconds: + after = self.baseline_after + self.position_hold_since = None + if after in {"next_sweep", "resume_sweep"}: + self._start_next_sweep() + elif after == "fit": + self._fit_all_curves() + elif after in {"validation_next", "resume_validation"}: + self._start_next_validation() + elif after == "finalize": + self._finalize() + else: + self.position_hold_since = None + return + if self.state == STATE_PREPARE_SWEEP: + assert self.active_sweep is not None + if now - self.motion_stage_started_at > self.position_timeout_seconds: + self._pause("sweep_start_position_timeout") + return + reached = self._motion_command_reached( + self.active_sweep.spec, + self.active_sweep.start_u8, + ) + speed_ready = bool( + now - self.speed_commanded_at + >= self.speed_setting_settle_seconds + ) + if reached and speed_ready: + if self.position_hold_since is None: + self.position_hold_since = now + elif now - self.position_hold_since >= self.endpoint_hold_seconds: + self.position_hold_since = None + self._begin_active_sweep(now) + else: + self.position_hold_since = None + return + if self.state == STATE_SWEEP: + assert self.active_sweep is not None + motor = self.active_sweep.spec.motor_index + if now - self.sweep_started_at > self.sweep_timeout_seconds: + self._pause("sweep_timeout") + return + if now - self.sweep_last_valid_at > self.invalid_timeout_seconds: + self._pause("synchronised_tag_state_timeout") + return + values = [ + float(frame.state_u8[motor]) for frame in self.sweep_frames + ] + span = 0.0 if not values else max(values) - min(values) + target_reached = self._motion_command_reached( + self.active_sweep.spec, + self.active_sweep.target_u8, + ) + if target_reached: + if self.sweep_endpoint_since is None: + self.sweep_endpoint_since = now + else: + self.sweep_endpoint_since = None + if ( + self.sweep_endpoint_since is not None + and now - self.sweep_endpoint_since >= self.endpoint_hold_seconds + and len(self.sweep_frames) >= self.minimum_sweep_frames + and span >= self.minimum_state_span_u8 + ): + self._finish_active_sweep() + return + if self.state == STATE_VALIDATION_MOVE: + assert self.active_validation is not None + reached = self._motion_command_reached( + self.active_validation.spec, + self.active_validation.command_u8, + ) + speed_ready = bool( + now - self.speed_commanded_at + >= self.speed_setting_settle_seconds + ) + if reached and speed_ready: + if self.position_hold_since is None: + self.position_hold_since = now + elif now - self.position_hold_since >= self.endpoint_hold_seconds: + self.position_hold_since = None + self.validation_frames_buffer.clear() + self.validation_stage_started_at = now + self.state = STATE_VALIDATION_CAPTURE + self.reason = "capturing_random_validation_pose" + else: + self.position_hold_since = None + if now - self.validation_stage_started_at > self.validation_timeout_seconds: + self._pause("validation_move_timeout") + return + if self.state == STATE_VALIDATION_CAPTURE: + if len(self.validation_frames_buffer) >= self.validation_frames: + self._finish_validation_capture() + elif now - self.validation_stage_started_at > self.validation_timeout_seconds: + self._pause("validation_capture_timeout") + + def _publish_status(self, now: float) -> None: + views = { + name: { + "ready": self._view_ready(runtime, now), + "camera_info_valid": runtime.camera_info_valid, + "detection_hz": round(runtime.detection_hz, 2), + "valid_rate": round(runtime.valid_rate, 4), + "required_tag_ids": sorted(runtime.role_by_id), + "detected_tag_ids": sorted( + tag_id + for tag_id, role in runtime.role_by_id.items() + if role in runtime.latest_tag_quality + ), + "missing_tag_ids": sorted( + tag_id + for tag_id, role in runtime.role_by_id.items() + if role not in runtime.latest_tag_quality + ), + "pnp_rejections": dict(runtime.latest_pnp_rejections), + "group_pnp_reason": runtime.latest_group_pnp_reason, + "pnp_reset_count": runtime.pnp_reset_count, + "pnp_invalid_seconds": ( + 0.0 + if runtime.pnp_invalid_since is None + else round(max(0.0, now - runtime.pnp_invalid_since), 3) + ), + } + for name, runtime in self.views.items() + } + active: dict[str, Any] = {} + if self.active_sweep is not None: + motor = self.active_sweep.spec.motor_index + values = [ + float(frame.state_u8[motor]) for frame in self.sweep_frames + ] + bins = sorted( + { + ( + self.active_sweep.start_u8 + if abs(value - self.active_sweep.start_u8) + <= self.endpoint_tolerance_u8 + else self.active_sweep.target_u8 + if abs(value - self.active_sweep.target_u8) + <= self.endpoint_tolerance_u8 + else int(np.clip(np.rint(value), 0, 255)) + ) + for value in values + } + ) + missing_endpoints = [ + endpoint for endpoint in (0, 255) if endpoint not in bins + ] + actual = ( + None + if len(self.latest_state_u8) != 20 + else float(self.latest_state_u8[motor]) + ) + motion_progress = None + if actual is not None: + motion_progress = float( + np.clip( + abs(actual - self.active_sweep.start_u8) / 255.0, + 0.0, + 1.0, + ) + ) + active = { + "kind": "sweep", + "view": self.active_sweep.spec.view, + "motor_index": motor, + "joints": list(self.active_sweep.spec.joints), + "cycle": self.active_sweep.cycle + 1, + "repetitions": self.repetitions, + "direction": self.active_sweep.direction, + "start_u8": self.active_sweep.start_u8, + "target_u8": self.active_sweep.target_u8, + "actual_u8": actual, + "motion_progress": motion_progress, + "valid_frames": len(self.sweep_frames), + "sample": { + "minimum_u8": None if not values else min(values), + "maximum_u8": None if not values else max(values), + "span_u8": 0.0 if not values else max(values) - min(values), + "bin_count": len(bins), + "minimum_bin_count": self.minimum_sweep_bins, + "maximum_bin_gap": int(max(np.diff(bins), default=0)), + "allowed_maximum_bin_gap": self.maximum_bin_gap, + "missing_endpoint_u8": missing_endpoints, + "endpoint_tolerance_u8": self.endpoint_tolerance_u8, + }, + "auxiliary_motors": [ + { + "motor_index": index, + "command_u8": command, + "actual_u8": ( + None + if len(self.latest_state_u8) != 20 + else float(self.latest_state_u8[index]) + ), + } + for index, command in calibration_auxiliary_commands( + self.active_sweep.spec + ).items() + ], + "speed": { + "commanded_finger_speed": self._speed_profile_for_spec( + self.active_sweep.spec + ), + "reported_finger_speed": self.latest_hand_info.get("speed"), + "normal_speed": self.normal_calibration_speed, + "index_roll_speed": self.index_roll_calibration_speed, + "index_flex_speed": self.index_flex_calibration_speed, + }, + } + elif self.active_validation is not None: + motor = self.active_validation.spec.motor_index + active = { + "kind": "validation", + "view": self.active_validation.spec.view, + "motor_index": motor, + "command_u8": self.active_validation.command_u8, + "actual_u8": ( + None + if len(self.latest_state_u8) != 20 + else float(self.latest_state_u8[motor]) + ), + "valid_frames": len(self.validation_frames_buffer), + "auxiliary_motors": [ + { + "motor_index": index, + "command_u8": command, + "actual_u8": ( + None + if len(self.latest_state_u8) != 20 + else float(self.latest_state_u8[index]) + ), + } + for index, command in calibration_auxiliary_commands( + self.active_validation.spec + ).items() + ], + "speed": { + "commanded_finger_speed": self._speed_profile_for_spec( + self.active_validation.spec + ), + "reported_finger_speed": self.latest_hand_info.get("speed"), + "normal_speed": self.normal_calibration_speed, + "index_roll_speed": self.index_roll_calibration_speed, + "index_flex_speed": self.index_flex_calibration_speed, + }, + } + elif self.fit_failure: + active = dict(self.fit_failure) + payload = { + "state": self.state, + "reason": self.reason, + "progress": ( + 0.0 + if not self.sweep_items + else round(self.sweep_index / len(self.sweep_items), 4) + ), + "completed_sweeps": self.sweep_index, + "total_sweeps": len(self.sweep_items), + "baseline_command_u8": list(self.baseline_command), + "views": views, + "active": active, + "result_path": ( + str(self.final_path) if self.completed_payload is not None else "" + ), + "quality": ( + {} if self.completed_payload is None else self.completed_payload["quality"] + ), + } + status = String() + status.data = json.dumps(payload, ensure_ascii=False) + self.status_publisher.publish(status) + text = String() + text.data = render_three_camera_status_text_zh(payload) + self.status_text_publisher.publish(text) + + +def main(args: list[str] | None = None) -> None: + configure_fastdds_large_image_transport() + rclpy.init(args=args) + node: G20ThreeCameraCalibrationNode | None = None + try: + node = G20ThreeCameraCalibrationNode() + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + if node is not None: + node.destroy_node() + if rclpy.ok(): + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/src/g20_thumb_apriltag_calibration/launch/front_cmc_pitch_zero.launch.py b/src/g20_thumb_apriltag_calibration/launch/front_cmc_pitch_zero.launch.py index 50c8aa8..4cb6d93 100644 --- a/src/g20_thumb_apriltag_calibration/launch/front_cmc_pitch_zero.launch.py +++ b/src/g20_thumb_apriltag_calibration/launch/front_cmc_pitch_zero.launch.py @@ -10,12 +10,11 @@ from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription from launch.actions import ( DeclareLaunchArgument, - IncludeLaunchDescription, LogInfo, OpaqueFunction, + SetEnvironmentVariable, ) from launch.conditions import IfCondition -from launch.launch_description_sources import PythonLaunchDescriptionSource from launch.substitutions import LaunchConfiguration from launch_ros.actions import ComposableNodeContainer, Node from launch_ros.descriptions import ComposableNode @@ -48,29 +47,41 @@ def _launch_stack(context): tag_config = LaunchConfiguration("tag_config").perform(context) zero_config = LaunchConfiguration("zero_config").perform(context) - realsense_config = LaunchConfiguration("realsense_config").perform(context) - realsense_launch = ( - Path(get_package_share_directory("realsense2_camera")) - / "launch" - / "rs_launch.py" - ) - - camera = IncludeLaunchDescription( - PythonLaunchDescriptionSource(str(realsense_launch)), + camera = Node( + package="g20_thumb_apriltag_calibration", + executable="hikrobot_camera_node", + name="hikrobot_camera", + namespace="/camera/camera/color", + output="screen", + emulate_tty=True, condition=IfCondition(LaunchConfiguration("start_camera")), - launch_arguments={ - "camera_name": "camera", - "camera_namespace": "camera", - "serial_no": LaunchConfiguration("camera_serial_number"), - "config_file": realsense_config, - "enable_color": "true", - "rgb_camera.color_profile": LaunchConfiguration("color_profile"), - "depth_module.color_profile": LaunchConfiguration("color_profile"), - "enable_depth": "false", - "enable_sync": "false", - "align_depth.enable": "false", - "diagnostics_period": "1.0", - }.items(), + parameters=[ + { + "serial_number": LaunchConfiguration("camera_serial_number"), + "expected_model": LaunchConfiguration("camera_model"), + "camera_name": LaunchConfiguration("camera_name"), + "frame_id": LaunchConfiguration("camera_frame_id"), + "image_width": ParameterValue( + LaunchConfiguration("image_width"), value_type=int + ), + "image_height": ParameterValue( + LaunchConfiguration("image_height"), value_type=int + ), + "frame_rate": ParameterValue( + LaunchConfiguration("camera_frame_rate"), value_type=float + ), + "exposure_time_us": ParameterValue( + LaunchConfiguration("exposure_time_us"), value_type=float + ), + "gain_db": ParameterValue( + LaunchConfiguration("gain_db"), value_type=float + ), + "auto_exposure": ParameterValue( + LaunchConfiguration("auto_exposure"), value_type=bool + ), + "camera_info_url": LaunchConfiguration("camera_info_url"), + } + ], ) raw_topic = "/camera/camera/color/image_raw" @@ -196,10 +207,39 @@ def generate_launch_description() -> LaunchDescription: ) return LaunchDescription( [ + SetEnvironmentVariable( + name="FASTRTPS_DEFAULT_PROFILES_FILE", + value=str( + package_share / "config" / "fastdds_large_images.xml" + ), + ), DeclareLaunchArgument("serial_number", default_value="UNSET"), - DeclareLaunchArgument("camera_serial_number", default_value=""), DeclareLaunchArgument( - "color_profile", default_value="1280x720x30" + "camera_serial_number", default_value="DB2163742" + ), + DeclareLaunchArgument( + "camera_model", default_value="MV-CS020-10UM" + ), + DeclareLaunchArgument( + "camera_name", default_value="hikrobot_front_DB2163742" + ), + DeclareLaunchArgument( + "camera_frame_id", default_value="camera_color_optical_frame" + ), + DeclareLaunchArgument("image_width", default_value="1624"), + DeclareLaunchArgument("image_height", default_value="1240"), + DeclareLaunchArgument("camera_frame_rate", default_value="30.0"), + DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), + DeclareLaunchArgument("gain_db", default_value="0.0"), + DeclareLaunchArgument("auto_exposure", default_value="false"), + DeclareLaunchArgument( + "camera_info_url", + default_value=str( + Path.home() + / ".ros" + / "camera_info" + / "hikrobot_DB2163742.yaml" + ), ), DeclareLaunchArgument("can_interface", default_value="can0"), DeclareLaunchArgument("calibration_speed", default_value="15"), @@ -221,12 +261,6 @@ def generate_launch_description() -> LaunchDescription: package_share / "config" / "cmc_pitch_zero.yaml" ), ), - DeclareLaunchArgument( - "realsense_config", - default_value=str( - package_share / "config" / "realsense_color_qos.yaml" - ), - ), DeclareLaunchArgument( "tag_config", default_value=str( diff --git a/src/g20_thumb_apriltag_calibration/launch/front_cmc_roll_calibration.launch.py b/src/g20_thumb_apriltag_calibration/launch/front_cmc_roll_calibration.launch.py index a396a4f..0ce1548 100644 --- a/src/g20_thumb_apriltag_calibration/launch/front_cmc_roll_calibration.launch.py +++ b/src/g20_thumb_apriltag_calibration/launch/front_cmc_roll_calibration.launch.py @@ -10,12 +10,11 @@ from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription from launch.actions import ( DeclareLaunchArgument, - IncludeLaunchDescription, LogInfo, OpaqueFunction, + SetEnvironmentVariable, ) from launch.conditions import IfCondition -from launch.launch_description_sources import PythonLaunchDescriptionSource from launch.substitutions import LaunchConfiguration from launch_ros.actions import ComposableNodeContainer, Node from launch_ros.descriptions import ComposableNode @@ -50,29 +49,41 @@ def _launch_stack(context): calibration_config = LaunchConfiguration( "calibration_config" ).perform(context) - realsense_config = LaunchConfiguration("realsense_config").perform(context) - realsense_launch = ( - Path(get_package_share_directory("realsense2_camera")) - / "launch" - / "rs_launch.py" - ) - - camera = IncludeLaunchDescription( - PythonLaunchDescriptionSource(str(realsense_launch)), + camera = Node( + package="g20_thumb_apriltag_calibration", + executable="hikrobot_camera_node", + name="hikrobot_camera", + namespace="/camera/camera/color", + output="screen", + emulate_tty=True, condition=IfCondition(LaunchConfiguration("start_camera")), - launch_arguments={ - "camera_name": "camera", - "camera_namespace": "camera", - "serial_no": LaunchConfiguration("camera_serial_number"), - "config_file": realsense_config, - "enable_color": "true", - "rgb_camera.color_profile": LaunchConfiguration("color_profile"), - "depth_module.color_profile": LaunchConfiguration("color_profile"), - "enable_depth": "false", - "enable_sync": "false", - "align_depth.enable": "false", - "diagnostics_period": "1.0", - }.items(), + parameters=[ + { + "serial_number": LaunchConfiguration("camera_serial_number"), + "expected_model": LaunchConfiguration("camera_model"), + "camera_name": LaunchConfiguration("camera_name"), + "frame_id": LaunchConfiguration("camera_frame_id"), + "image_width": ParameterValue( + LaunchConfiguration("image_width"), value_type=int + ), + "image_height": ParameterValue( + LaunchConfiguration("image_height"), value_type=int + ), + "frame_rate": ParameterValue( + LaunchConfiguration("camera_frame_rate"), value_type=float + ), + "exposure_time_us": ParameterValue( + LaunchConfiguration("exposure_time_us"), value_type=float + ), + "gain_db": ParameterValue( + LaunchConfiguration("gain_db"), value_type=float + ), + "auto_exposure": ParameterValue( + LaunchConfiguration("auto_exposure"), value_type=bool + ), + "camera_info_url": LaunchConfiguration("camera_info_url"), + } + ], ) raw_topic = "/camera/camera/color/image_raw" @@ -198,10 +209,39 @@ def generate_launch_description() -> LaunchDescription: ) return LaunchDescription( [ + SetEnvironmentVariable( + name="FASTRTPS_DEFAULT_PROFILES_FILE", + value=str( + package_share / "config" / "fastdds_large_images.xml" + ), + ), DeclareLaunchArgument("serial_number", default_value="UNSET"), - DeclareLaunchArgument("camera_serial_number", default_value=""), DeclareLaunchArgument( - "color_profile", default_value="1280x720x30" + "camera_serial_number", default_value="DB2163742" + ), + DeclareLaunchArgument( + "camera_model", default_value="MV-CS020-10UM" + ), + DeclareLaunchArgument( + "camera_name", default_value="hikrobot_front_DB2163742" + ), + DeclareLaunchArgument( + "camera_frame_id", default_value="camera_color_optical_frame" + ), + DeclareLaunchArgument("image_width", default_value="1624"), + DeclareLaunchArgument("image_height", default_value="1240"), + DeclareLaunchArgument("camera_frame_rate", default_value="30.0"), + DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), + DeclareLaunchArgument("gain_db", default_value="0.0"), + DeclareLaunchArgument("auto_exposure", default_value="false"), + DeclareLaunchArgument( + "camera_info_url", + default_value=str( + Path.home() + / ".ros" + / "camera_info" + / "hikrobot_DB2163742.yaml" + ), ), DeclareLaunchArgument("can_interface", default_value="can0"), DeclareLaunchArgument("calibration_speed", default_value="15"), @@ -225,12 +265,6 @@ def generate_launch_description() -> LaunchDescription: / "cmc_roll_zero_travel.yaml" ), ), - DeclareLaunchArgument( - "realsense_config", - default_value=str( - package_share / "config" / "realsense_color_qos.yaml" - ), - ), DeclareLaunchArgument( "tag_config", default_value=str( diff --git a/src/g20_thumb_apriltag_calibration/launch/front_thumb_calibration.launch.py b/src/g20_thumb_apriltag_calibration/launch/front_thumb_calibration.launch.py index df9c0c5..f8cd2d9 100644 --- a/src/g20_thumb_apriltag_calibration/launch/front_thumb_calibration.launch.py +++ b/src/g20_thumb_apriltag_calibration/launch/front_thumb_calibration.launch.py @@ -11,12 +11,11 @@ from launch import LaunchDescription from launch.actions import ( DeclareLaunchArgument, ExecuteProcess, - IncludeLaunchDescription, LogInfo, OpaqueFunction, + SetEnvironmentVariable, ) from launch.conditions import IfCondition -from launch.launch_description_sources import PythonLaunchDescriptionSource from launch.substitutions import LaunchConfiguration from launch_ros.actions import ComposableNodeContainer, Node from launch_ros.descriptions import ComposableNode @@ -48,7 +47,6 @@ def _launch_stack(context): calibration_config = LaunchConfiguration("calibration_config").perform(context) tag_config = LaunchConfiguration("tag_config").perform(context) - realsense_config = LaunchConfiguration("realsense_config").perform(context) use_roi_text = LaunchConfiguration("use_roi").perform(context).strip().lower() if use_roi_text not in {"true", "false"}: raise RuntimeError("use_roi must be true or false") @@ -66,47 +64,58 @@ def _launch_stack(context): if roi_values["roi_width"] <= 0 or roi_values["roi_height"] <= 0: raise RuntimeError("roi_width and roi_height must be positive") - color_profile = LaunchConfiguration("color_profile").perform(context) - profile_match = re.fullmatch(r"(\d+)x(\d+)x(\d+)", color_profile) - if use_roi and profile_match is not None: - image_width = int(profile_match.group(1)) - image_height = int(profile_match.group(2)) + try: + image_width = int(LaunchConfiguration("image_width").perform(context)) + image_height = int(LaunchConfiguration("image_height").perform(context)) + except ValueError as error: + raise RuntimeError("image_width and image_height must be integers") from error + if image_width <= 0 or image_height <= 0: + raise RuntimeError("image_width and image_height must be positive") + if use_roi: if ( roi_values["roi_x"] + roi_values["roi_width"] > image_width or roi_values["roi_y"] + roi_values["roi_height"] > image_height ): raise RuntimeError( - "ROI lies outside color_profile " + "ROI lies outside camera image " f"{image_width}x{image_height}" ) - realsense_launch = Path( - get_package_share_directory("realsense2_camera") - ) / "launch" / "rs_launch.py" - - camera = IncludeLaunchDescription( - PythonLaunchDescriptionSource(str(realsense_launch)), + camera = Node( + package="g20_thumb_apriltag_calibration", + executable="hikrobot_camera_node", + name="hikrobot_camera", + namespace="/camera/camera/color", + output="screen", + emulate_tty=True, condition=IfCondition(LaunchConfiguration("start_camera")), - launch_arguments={ - "camera_name": "camera", - "camera_namespace": "camera", - "serial_no": LaunchConfiguration("camera_serial_number"), - "config_file": realsense_config, - "enable_color": "true", - # D405 exposes color from the stereo/depth module. Keep the RGB - # camera argument as well so the same launch file also works with - # D435/D455-class devices. - "rgb_camera.color_profile": LaunchConfiguration("color_profile"), - "depth_module.color_profile": LaunchConfiguration("color_profile"), - "enable_depth": LaunchConfiguration("enable_depth"), - "depth_module.depth_profile": LaunchConfiguration("depth_profile"), - # Depth is archival/diagnostic only. Synchronising and aligning it - # adds significant D405 processing latency without affecting the - # relative AprilTag rotation calculation. - "enable_sync": "false", - "align_depth.enable": "false", - "diagnostics_period": "1.0", - }.items(), + parameters=[ + { + "serial_number": LaunchConfiguration("camera_serial_number"), + "expected_model": LaunchConfiguration("camera_model"), + "camera_name": LaunchConfiguration("camera_name"), + "frame_id": LaunchConfiguration("camera_frame_id"), + "image_width": ParameterValue( + LaunchConfiguration("image_width"), value_type=int + ), + "image_height": ParameterValue( + LaunchConfiguration("image_height"), value_type=int + ), + "frame_rate": ParameterValue( + LaunchConfiguration("camera_frame_rate"), value_type=float + ), + "exposure_time_us": ParameterValue( + LaunchConfiguration("exposure_time_us"), value_type=float + ), + "gain_db": ParameterValue( + LaunchConfiguration("gain_db"), value_type=float + ), + "auto_exposure": ParameterValue( + LaunchConfiguration("auto_exposure"), value_type=bool + ), + "camera_info_url": LaunchConfiguration("camera_info_url"), + } + ], ) vision_components = [] @@ -275,7 +284,6 @@ def _launch_stack(context): str(bag_path), processed_image_raw_topic, processed_camera_info_topic, - "/camera/camera/depth/image_rect_raw", "/apriltag/detections", "/tf", "/g20/cb_left_hand_control_cmd", @@ -312,15 +320,40 @@ def generate_launch_description() -> LaunchDescription: default_output = str(Path.cwd() / "calibration_output") return LaunchDescription( [ + SetEnvironmentVariable( + name="FASTRTPS_DEFAULT_PROFILES_FILE", + value=str( + package_share / "config" / "fastdds_large_images.xml" + ), + ), DeclareLaunchArgument("serial_number", default_value="UNSET"), - DeclareLaunchArgument("camera_serial_number", default_value=""), DeclareLaunchArgument( - "color_profile", default_value="1280x720x30" + "camera_serial_number", default_value="DB2163742" ), DeclareLaunchArgument( - "depth_profile", default_value="640x480x30" + "camera_model", default_value="MV-CS020-10UM" + ), + DeclareLaunchArgument( + "camera_name", default_value="hikrobot_front_DB2163742" + ), + DeclareLaunchArgument( + "camera_frame_id", default_value="camera_color_optical_frame" + ), + DeclareLaunchArgument("image_width", default_value="1624"), + DeclareLaunchArgument("image_height", default_value="1240"), + DeclareLaunchArgument("camera_frame_rate", default_value="30.0"), + DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), + DeclareLaunchArgument("gain_db", default_value="0.0"), + DeclareLaunchArgument("auto_exposure", default_value="false"), + DeclareLaunchArgument( + "camera_info_url", + default_value=str( + Path.home() + / ".ros" + / "camera_info" + / "hikrobot_DB2163742.yaml" + ), ), - DeclareLaunchArgument("enable_depth", default_value="false"), DeclareLaunchArgument( "publish_debug_image", default_value="false" ), @@ -349,12 +382,6 @@ def generate_launch_description() -> LaunchDescription: "calibration_config", default_value=str(package_share / "config" / "calibration.yaml"), ), - DeclareLaunchArgument( - "realsense_config", - default_value=str( - package_share / "config" / "realsense_color_qos.yaml" - ), - ), DeclareLaunchArgument( "tag_config", default_value=str(package_share / "config" / "front_tags.yaml"), diff --git a/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py b/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py new file mode 100644 index 0000000..d976c67 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/launch/three_camera_calibration.launch.py @@ -0,0 +1,341 @@ +"""Launch three Hikrobot views and one complete-G20 calibration owner.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import re + +from ament_index_python.packages import get_package_share_directory +from launch import LaunchDescription +from launch.actions import ( + DeclareLaunchArgument, + ExecuteProcess, + LogInfo, + OpaqueFunction, + SetEnvironmentVariable, +) +from launch.conditions import IfCondition +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import ComposableNodeContainer, Node +from launch_ros.descriptions import ComposableNode +from launch_ros.parameter_descriptions import ParameterValue + + +VIEWS = ("front", "side", "top") + + +def _launch_stack(context): + hand_serial = LaunchConfiguration("serial_number").perform(context) + if ( + not hand_serial + or hand_serial == "UNSET" + or re.fullmatch(r"[A-Za-z0-9_.-]+", hand_serial) is None + or hand_serial in {".", ".."} + ): + raise RuntimeError("serial_number must be a safe non-empty hand serial") + + requested_session = LaunchConfiguration("session_dir").perform(context) + output_root = Path( + LaunchConfiguration("output_root").perform(context) + ).expanduser().resolve() + if requested_session: + session_dir = Path(requested_session).expanduser().resolve() + else: + session_dir = ( + output_root + / hand_serial + / datetime.now().strftime("%Y%m%d_%H%M%S") + ) + session_dir.mkdir(parents=True, exist_ok=True) + + camera_serials = { + view: LaunchConfiguration(f"{view}_camera_serial").perform(context) + for view in VIEWS + } + if any(not serial for serial in camera_serials.values()): + raise RuntimeError("all three camera serial numbers are required") + if len(set(camera_serials.values())) != 3: + raise RuntimeError("front/side/top camera serial numbers must be unique") + + cameras = [] + components = [] + raw_topics = [] + info_topics = [] + detection_topics = [] + for view in VIEWS: + namespace = f"/g20_calibration/{view}/camera" + raw_topic = f"{namespace}/image_raw" + info_topic = f"{namespace}/camera_info" + rect_topic = f"{namespace}/image_rect" + detector_namespace = f"/g20_calibration/{view}/apriltag" + detection_topic = f"{detector_namespace}/detections" + raw_topics.append(raw_topic) + info_topics.append(info_topic) + detection_topics.append(detection_topic) + cameras.append( + Node( + package="g20_thumb_apriltag_calibration", + executable="hikrobot_camera_node", + name="hikrobot_camera", + namespace=namespace, + output="screen", + emulate_tty=True, + condition=IfCondition(LaunchConfiguration("start_cameras")), + parameters=[ + { + "serial_number": LaunchConfiguration( + f"{view}_camera_serial" + ), + "expected_model": LaunchConfiguration("camera_model"), + "camera_name": LaunchConfiguration( + f"{view}_camera_name" + ), + "frame_id": f"g20_calibration_{view}_optical_frame", + "image_width": 1624, + "image_height": 1240, + "frame_rate": ParameterValue( + LaunchConfiguration("camera_frame_rate"), + value_type=float, + ), + "exposure_time_us": ParameterValue( + LaunchConfiguration("exposure_time_us"), + value_type=float, + ), + "gain_db": ParameterValue( + LaunchConfiguration("gain_db"), value_type=float + ), + "auto_exposure": ParameterValue( + LaunchConfiguration("auto_exposure"), value_type=bool + ), + "camera_info_url": LaunchConfiguration( + f"{view}_camera_info_url" + ), + } + ], + ) + ) + components.extend( + [ + ComposableNode( + package="image_proc", + plugin="image_proc::RectifyNode", + name=f"rectify_{view}", + namespace=namespace, + remappings=[ + ("image", raw_topic), + ("camera_info", info_topic), + ("image_rect", rect_topic), + ], + parameters=[{"queue_size": 1}], + extra_arguments=[{"use_intra_process_comms": True}], + ), + ComposableNode( + package="apriltag_ros", + plugin="AprilTagNode", + name="apriltag", + namespace=detector_namespace, + parameters=[ + LaunchConfiguration("tag_config"), + { + "detector.decimate": ParameterValue( + LaunchConfiguration("apriltag_decimate"), + value_type=float, + ) + }, + ], + remappings=[ + ("image_rect", rect_topic), + ("camera_info", info_topic), + ], + extra_arguments=[{"use_intra_process_comms": True}], + ), + ] + ) + + vision = ComposableNodeContainer( + name="g20_three_camera_vision", + namespace="/", + package="rclcpp_components", + executable="component_container_mt", + composable_node_descriptions=components, + output="screen", + emulate_tty=True, + ) + sdk = Node( + package="linker_hand_ros2_sdk", + executable="linker_hand_sdk", + name="linker_hand_sdk", + output="screen", + condition=IfCondition(LaunchConfiguration("start_sdk")), + parameters=[ + { + "hand_type": "left", + "hand_joint": "G20", + "can": LaunchConfiguration("can_interface"), + "modbus": "None", + "topic_prefix": "/g20", + "move_on_startup": False, + "startup_speed": ParameterValue( + LaunchConfiguration("calibration_speed"), value_type=int + ), + "startup_torque": 80, + "state_poll_rate": 10.0, + "repeat_position_commands": False, + "is_touch": False, + } + ], + ) + calibration = Node( + package="g20_thumb_apriltag_calibration", + executable="three_camera_calibration_node", + name="g20_calibration", + output="screen", + emulate_tty=True, + parameters=[ + LaunchConfiguration("calibration_config"), + { + "serial_number": hand_serial, + "session_dir": str(session_dir), + "commands_enabled": ParameterValue( + LaunchConfiguration("commands_enabled"), value_type=bool + ), + "normal_calibration_speed": ParameterValue( + LaunchConfiguration("calibration_speed"), value_type=int + ), + "index_roll_calibration_speed": ParameterValue( + LaunchConfiguration("index_roll_calibration_speed"), + value_type=int, + ), + "index_flex_calibration_speed": ParameterValue( + LaunchConfiguration("index_flex_calibration_speed"), + value_type=int, + ), + "validation_enabled": ParameterValue( + LaunchConfiguration("validation_enabled"), value_type=bool + ), + }, + ], + ) + bag = ExecuteProcess( + condition=IfCondition(LaunchConfiguration("record_bag")), + cmd=[ + "ros2", + "bag", + "record", + "--storage", + "mcap", + "--storage-preset-profile", + "zstd_fast", + "--max-bag-size", + "10737418240", + "--output", + str(session_dir / "rosbag"), + *raw_topics, + *info_topics, + *detection_topics, + "/g20/cb_left_hand_control_cmd", + "/g20/cb_left_hand_state", + "/g20/cb_left_hand_info", + "/g20_calibration/status", + ], + output="screen", + ) + return [ + LogInfo(msg=f"G20 three-camera session: {session_dir}"), + LogInfo( + msg=( + "Camera mapping: front=" + f"{camera_serials['front']} side={camera_serials['side']} " + f"top={camera_serials['top']}" + ) + ), + *cameras, + vision, + sdk, + calibration, + bag, + ] + + +def generate_launch_description() -> LaunchDescription: + package_share = Path( + get_package_share_directory("g20_thumb_apriltag_calibration") + ) + info_root = Path.home() / ".ros" / "camera_info" + return LaunchDescription( + [ + SetEnvironmentVariable( + name="FASTRTPS_DEFAULT_PROFILES_FILE", + value=str(package_share / "config" / "fastdds_large_images.xml"), + ), + DeclareLaunchArgument("serial_number", default_value="UNSET"), + DeclareLaunchArgument( + "front_camera_serial", default_value="DB2163742" + ), + DeclareLaunchArgument( + "side_camera_serial", default_value="DB2163749" + ), + DeclareLaunchArgument( + "top_camera_serial", default_value="DB2163739" + ), + DeclareLaunchArgument("camera_model", default_value="MV-CS020-10U"), + DeclareLaunchArgument( + "front_camera_name", default_value="hikrobot_front_DB2163742" + ), + DeclareLaunchArgument( + "side_camera_name", default_value="hikrobot_side_DB2163749" + ), + DeclareLaunchArgument( + "top_camera_name", default_value="hikrobot_top_DB2163739" + ), + DeclareLaunchArgument( + "front_camera_info_url", + default_value=str(info_root / "hikrobot_DB2163742.yaml"), + ), + DeclareLaunchArgument( + "side_camera_info_url", + default_value=str(info_root / "hikrobot_DB2163749.yaml"), + ), + DeclareLaunchArgument( + "top_camera_info_url", + default_value=str(info_root / "hikrobot_DB2163739.yaml"), + ), + DeclareLaunchArgument("camera_frame_rate", default_value="30.0"), + DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), + DeclareLaunchArgument("gain_db", default_value="0.0"), + DeclareLaunchArgument("auto_exposure", default_value="false"), + DeclareLaunchArgument("apriltag_decimate", default_value="1.5"), + DeclareLaunchArgument("can_interface", default_value="can0"), + DeclareLaunchArgument("calibration_speed", default_value="15"), + DeclareLaunchArgument( + "index_roll_calibration_speed", default_value="5" + ), + DeclareLaunchArgument( + "index_flex_calibration_speed", default_value="10" + ), + DeclareLaunchArgument("validation_enabled", default_value="false"), + DeclareLaunchArgument("commands_enabled", default_value="true"), + DeclareLaunchArgument("start_cameras", default_value="true"), + DeclareLaunchArgument("start_sdk", default_value="true"), + DeclareLaunchArgument("record_bag", default_value="false"), + DeclareLaunchArgument( + "output_root", + default_value=str(Path.cwd() / "calibration_output"), + ), + DeclareLaunchArgument("session_dir", default_value=""), + DeclareLaunchArgument( + "calibration_config", + default_value=str( + package_share / "config" / "three_camera_calibration.yaml" + ), + ), + DeclareLaunchArgument( + "tag_config", + default_value=str( + package_share / "config" / "three_camera_tags.yaml" + ), + ), + OpaqueFunction(function=_launch_stack), + ] + ) diff --git a/src/g20_thumb_apriltag_calibration/package.xml b/src/g20_thumb_apriltag_calibration/package.xml index 08eb9fd..ed71c5b 100644 --- a/src/g20_thumb_apriltag_calibration/package.xml +++ b/src/g20_thumb_apriltag_calibration/package.xml @@ -3,7 +3,7 @@ g20_thumb_apriltag_calibration 0.1.0 - Front RealSense AprilTag calibration for the left G20 thumb. + Three-view Hikrobot AprilTag calibration for the complete left G20 hand. lxp MIT @@ -17,7 +17,6 @@ linker_hand_ros2_sdk rclcpp_components rclpy - realsense2_camera rosbag2 sensor_msgs std_msgs @@ -26,6 +25,7 @@ python3-numpy python3-opencv python3-scipy + python3-yaml python3-pytest diff --git a/src/g20_thumb_apriltag_calibration/setup.py b/src/g20_thumb_apriltag_calibration/setup.py index a615a18..ab423d8 100644 --- a/src/g20_thumb_apriltag_calibration/setup.py +++ b/src/g20_thumb_apriltag_calibration/setup.py @@ -15,18 +15,25 @@ setup( ["resource/" + package_name], ), ("share/" + package_name, ["package.xml", "README.md"]), - ("share/" + package_name + "/config", glob("config/*.yaml")), + ( + "share/" + package_name + "/config", + glob("config/*.yaml") + glob("config/*.xml"), + ), ("share/" + package_name + "/launch", glob("launch/*.launch.py")), ], - install_requires=["setuptools", "numpy", "scipy"], + install_requires=["setuptools", "numpy", "scipy", "PyYAML"], tests_require=["pytest"], zip_safe=True, maintainer="lxp", maintainer_email="support@linker-robotics.com", - description="Front RealSense AprilTag calibration for the left G20 thumb", + description="Three-view Hikrobot AprilTag calibration for the complete left G20 hand", license="MIT", entry_points={ "console_scripts": [ + ( + "hikrobot_camera_node = " + "g20_thumb_apriltag_calibration.hikrobot_camera:main" + ), "calibration_node = g20_thumb_apriltag_calibration.node:main", ( "cmc_pitch_zero_node = " @@ -36,6 +43,14 @@ setup( "cmc_roll_calibration_node = " "g20_thumb_apriltag_calibration.zero_node:main" ), + ( + "three_camera_calibration_node = " + "g20_thumb_apriltag_calibration.three_camera_node:main" + ), + ( + "camera_alignment_view = " + "g20_thumb_apriltag_calibration.alignment_view:main" + ), ], }, ) diff --git a/src/g20_thumb_apriltag_calibration/test/test_acquisition.py b/src/g20_thumb_apriltag_calibration/test/test_acquisition.py index ae0d484..c32738f 100644 --- a/src/g20_thumb_apriltag_calibration/test/test_acquisition.py +++ b/src/g20_thumb_apriltag_calibration/test/test_acquisition.py @@ -15,11 +15,60 @@ from g20_thumb_apriltag_calibration.acquisition import ( aggregate_observations, aggregate_sweep_observations, interpolate_state_u8, + required_resume_views, tag_quality_is_valid, + update_pnp_reset_watchdog, ) from g20_thumb_apriltag_calibration.core import PAIR_NAMES +def test_pnp_watchdog_resets_after_one_continuous_invalid_second() -> None: + since, reset = update_pnp_reset_watchdog( + detection_good=True, + pnp_valid=False, + now=10.0, + invalid_since=None, + reset_after_seconds=1.0, + ) + assert since == 10.0 + assert reset is False + + since, reset = update_pnp_reset_watchdog( + detection_good=True, + pnp_valid=False, + now=11.01, + invalid_since=since, + reset_after_seconds=1.0, + ) + assert since == 11.01 + assert reset is True + + +def test_pnp_watchdog_clears_on_valid_pose_or_bad_detection() -> None: + assert update_pnp_reset_watchdog( + detection_good=True, + pnp_valid=True, + now=11.0, + invalid_since=10.0, + reset_after_seconds=1.0, + ) == (None, False) + assert update_pnp_reset_watchdog( + detection_good=False, + pnp_valid=False, + now=11.0, + invalid_since=10.0, + reset_after_seconds=1.0, + ) == (None, False) + + +def test_resume_requires_only_the_active_view() -> None: + assert required_resume_views("top") == ("top",) + assert required_resume_views("front") == ("front",) + assert required_resume_views(None) == ("front", "side", "top") + with pytest.raises(ValueError, match="unknown"): + required_resume_views("rear") + + def _observation(index: int, angle_rad: float = 0.0) -> Observation: quaternion = tuple( float(value) diff --git a/src/g20_thumb_apriltag_calibration/test/test_alignment_view.py b/src/g20_thumb_apriltag_calibration/test/test_alignment_view.py new file mode 100644 index 0000000..095fecf --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/test/test_alignment_view.py @@ -0,0 +1,39 @@ +"""Tests for the independent physical-line alignment overlay.""" + +import pytest + +from g20_thumb_apriltag_calibration.alignment_view import ( + summarize_alignment_measurements, +) + + +def _measurement(angle: float, offset: float, y: float) -> dict: + return { + "line_xyxy_px": [0.0, y, 100.0, y - angle * 100.0], + "angle_rad": angle, + "vertical_offset_px": offset, + } + + +def test_line_summary_smooths_only_physical_line_measurements() -> None: + """The overlay smooths scene lines without any Tag orientation input.""" + result = summarize_alignment_measurements( + [ + _measurement(-0.02, -4.0, 80.0), + None, + _measurement(0.00, 0.0, 82.0), + _measurement(0.02, 4.0, 84.0), + ] + ) + + assert result is not None + assert result["angle_rad"] == pytest.approx(0.0) + assert result["vertical_offset_px"] == pytest.approx(0.0) + assert result["line_xyxy_px"] == pytest.approx([0.0, 82.0, 100.0, 82.0]) + assert result["detected_frames"] == 3 + assert result["window_frames"] == 4 + + +def test_line_summary_returns_none_without_scene_line() -> None: + """No blue line is fabricated when the scene has no valid long edge.""" + assert summarize_alignment_measurements([None, None]) is None diff --git a/src/g20_thumb_apriltag_calibration/test/test_config.py b/src/g20_thumb_apriltag_calibration/test/test_config.py index 01a4477..4b9797c 100644 --- a/src/g20_thumb_apriltag_calibration/test/test_config.py +++ b/src/g20_thumb_apriltag_calibration/test/test_config.py @@ -1,4 +1,5 @@ from pathlib import Path +from xml.etree import ElementTree import yaml @@ -6,6 +7,24 @@ import yaml PACKAGE_ROOT = Path(__file__).resolve().parents[1] +def test_fastdds_profile_has_capacity_for_full_resolution_images() -> None: + root = ElementTree.parse( + PACKAGE_ROOT / "config" / "fastdds_large_images.xml" + ).getroot() + namespace = {"dds": "http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles"} + profiles = root.find("dds:profiles", namespace) + assert profiles is not None + segment = profiles.find( + ".//dds:transport_descriptor[dds:type='SHM']/dds:segment_size", + namespace, + ) + assert segment is not None + assert int(segment.text) >= 64 * 1024 * 1024 + participant = profiles.find("dds:participant", namespace) + assert participant is not None + assert participant.attrib["is_default_profile"] == "true" + + def test_front_tag_parameters_match_namespaced_detector() -> None: config = yaml.safe_load( (PACKAGE_ROOT / "config" / "front_tags.yaml").read_text() @@ -24,6 +43,85 @@ def test_front_tag_parameters_match_namespaced_detector() -> None: assert detector["detector"]["debug"] is False +def test_three_camera_tag_ids_and_topics_are_disjoint() -> None: + tags = yaml.safe_load( + (PACKAGE_ROOT / "config" / "three_camera_tags.yaml").read_text() + ) + expected = { + "front": [0, 1, 2, 3, 10], + "side": [4, 5, 6, 7], + "top": [8, 9], + } + all_ids = set() + for view, ids in expected.items(): + key = f"/g20_calibration/{view}/apriltag/apriltag" + parameters = tags[key]["ros__parameters"] + assert parameters["tag"]["ids"] == ids + assert parameters["qos_profile"] == "sensor_data" + assert parameters["detector"]["decimate"] == 1.5 + all_ids.update(ids) + assert all_ids == set(range(11)) + + +def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None: + config = yaml.safe_load( + (PACKAGE_ROOT / "config" / "three_camera_calibration.yaml").read_text() + ) + parameters = config["g20_calibration"]["ros__parameters"] + + assert parameters["baseline_command_u8"] == [ + 255, + 255, + 255, + 255, + 255, + 255, + 127, + 127, + 127, + 127, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + ] + assert parameters["setting_topic"] == "/g20/cb_hand_setting_cmd" + assert parameters["normal_calibration_speed"] == 15 + assert parameters["index_roll_calibration_speed"] == 5 + assert parameters["index_flex_calibration_speed"] == 10 + assert parameters["speed_setting_settle_seconds"] >= 0.2 + assert parameters["top_pnp_invalid_reset_seconds"] == 1.0 + assert parameters["repetitions"] == 3 + assert parameters["validation_enabled"] is False + assert parameters["minimum_detection_rate"] == 0.95 + assert parameters["minimum_detection_hz"] == 15.0 + assert parameters["minimum_state_span_u8"] >= 240.0 + assert parameters["minimum_sweep_bins"] >= 32 + assert parameters["maximum_bin_gap"] <= 16 + assert parameters["position_timeout_seconds"] >= 20.0 + assert parameters["zero_maximum_round_difference_deg"] <= 1.0 + assert parameters["image_trajectory_maximum_radial_rms_px"] <= 2.0 + assert parameters["image_trajectory_maximum_radial_p95_px"] <= 3.5 + assert parameters["image_trajectory_minimum_radius_px"] >= 20.0 + assert parameters["trajectory_maximum_cycle_travel_difference_deg"] <= 3.0 + assert parameters["passive_maximum_cycle_travel_difference_deg"] <= 10.0 + assert parameters["passive_maximum_monotonic_correction_deg"] <= 3.0 + assert parameters["passive_maximum_hysteresis_deg"] <= 7.5 + for view in ("front", "side", "top"): + assert parameters[f"{view}_camera_info_topic"].startswith( + f"/g20_calibration/{view}/" + ) + assert parameters[f"{view}_detections_topic"] == ( + f"/g20_calibration/{view}/apriltag/detections" + ) + + def test_trial_uses_centre_trajectory_and_thirty_pixel_tags() -> None: config = yaml.safe_load( (PACKAGE_ROOT / "config" / "calibration.yaml").read_text() @@ -87,15 +185,6 @@ def test_trial_uses_centre_trajectory_and_thirty_pixel_tags() -> None: assert parameters["continuous_prepare_timeout_seconds"] >= 30.0 -def test_realsense_color_and_camera_info_use_matching_qos() -> None: - config = yaml.safe_load( - (PACKAGE_ROOT / "config" / "realsense_color_qos.yaml").read_text() - ) - - assert config["color_qos"] == "DEFAULT" - assert config["color_info_qos"] == "DEFAULT" - - def test_cmc_pitch_zero_config_uses_three_trajectory_circle_rounds() -> None: config = yaml.safe_load( (PACKAGE_ROOT / "config" / "cmc_pitch_zero.yaml").read_text() diff --git a/src/g20_thumb_apriltag_calibration/test/test_full_hand.py b/src/g20_thumb_apriltag_calibration/test/test_full_hand.py new file mode 100644 index 0000000..0c78b75 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/test/test_full_hand.py @@ -0,0 +1,335 @@ +import math + +import numpy as np +import pytest + +from g20_thumb_apriltag_calibration.full_hand import ( + ACTIVE_JOINTS, + IMAGE_TRAJECTORY_JOINTS, + JOINT_SPECS, + MEASURED_JOINTS, + PASSIVE_JOINTS, + SPLAY_JOINTS, + SWEEP_SPECS, + VIEW_TAGS, + build_calibration_motion_command, + build_calibration_speed_profile, + build_compact_payload, + build_full_hand_command, + center_splay_curve, + fit_joint_center_curve, + fit_joint_image_curve, + fit_measured_joint_curve, + fit_projected_zero, + measure_joint_observation, + validate_compact_payload, +) + + +def _records() -> list[dict[str, object]]: + commands = list(range(0, 256, 16)) + if commands[-1] != 255: + commands.append(255) + records: list[dict[str, object]] = [] + centre = np.asarray([0.006, -0.004, 0.012]) + radius = 0.025 + image_centre = np.asarray([30.0, -12.0]) + image_radius = 100.0 + for cycle in range(3): + for direction, sequence in ( + ("decreasing", reversed(commands)), + ("increasing", commands), + ): + for command in sequence: + angle = 0.70 * (255.0 - command) / 255.0 + point = centre + np.asarray( + [radius * math.cos(angle), radius * math.sin(angle), 0.0] + ) + # At command 255 the inward vector points along image +x, so + # table_projected_zero_rad is exactly zero. + image_point = image_centre + np.asarray( + [ + -image_radius * math.cos(angle), + image_radius * math.sin(angle), + ] + ) + records.append( + { + "cycle": cycle, + "direction": direction, + "command_u8": command, + "relative_translation_xyz_m": point.tolist(), + "image_relative_xy_px": image_point.tolist(), + } + ) + return records + + +def test_joint_layout_covers_16_active_and_5_passive_joints() -> None: + assert len(JOINT_SPECS) == 21 + assert len(ACTIVE_JOINTS) == 16 + assert len(PASSIVE_JOINTS) == 5 + assert {tag for tags in VIEW_TAGS.values() for tag in tags.values()} == set( + range(11) + ) + assert VIEW_TAGS["front"]["index_roll"] == 10 + assert VIEW_TAGS["side"] == { + "side_base": 4, + "index_mcp": 5, + "index_pip": 6, + "index_dip": 7, + } + assert VIEW_TAGS["top"] == {"top_base": 8, "thumb_yaw": 9} + assert [spec.motor_index for spec in SWEEP_SPECS] == [0, 5, 15, 6, 1, 16, 10] + + +def test_joint_trajectory_spaces_match_observation_geometry() -> None: + assert IMAGE_TRAJECTORY_JOINTS == { + "thumb_cmc_pitch", + "thumb_cmc_roll", + "thumb_mcp", + "thumb_ip", + "index_mcp_roll", + "index_mcp_pitch", + "index_pip", + } + records = _records() + for name in IMAGE_TRAJECTORY_JOINTS: + fit = fit_measured_joint_curve(name, records) + assert fit.circle["space"] == "image_2d" + for name in ("index_dip", "thumb_cmc_yaw"): + assert "space" not in fit_measured_joint_curve(name, records).circle + + +def test_full_hand_command_changes_exactly_one_controlled_motor() -> None: + result = build_full_hand_command(6, 27) + assert result[6] == 27 + assert result[:6] == [255] * 6 + with pytest.raises(ValueError, match="controlled"): + build_full_hand_command(11, 27) + + +def test_index_roll_motion_moves_other_three_roll_motors_out_of_view() -> None: + baseline = [255] * 20 + baseline[6:10] = [127, 127, 127, 127] + index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6) + + result = build_calibration_motion_command(index_roll, 27, baseline) + + assert result[6:10] == [27, 0, 0, 0] + assert result[:6] == baseline[:6] + assert result[10:] == baseline[10:] + + +def test_non_index_roll_motion_keeps_clearance_motors_at_baseline() -> None: + baseline = [255] * 20 + baseline[6:10] = [127, 127, 127, 127] + thumb_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 0) + + result = build_calibration_motion_command(thumb_pitch, 17, baseline) + + assert result[0] == 17 + assert result[6:10] == [127, 127, 127, 127] + + +def test_thumb_yaw_motion_holds_thumb_roll_at_camera_clearance_pose() -> None: + baseline = [255] * 20 + baseline[6:10] = [127, 127, 127, 127] + thumb_yaw = next(spec for spec in SWEEP_SPECS if spec.motor_index == 10) + + result = build_calibration_motion_command(thumb_yaw, 27, baseline) + + assert result[5] == 145 + assert result[10] == 27 + assert result[:5] == baseline[:5] + assert result[6:10] == baseline[6:10] + assert result[11:] == baseline[11:] + + +def test_only_index_roll_uses_the_slow_index_finger_speed() -> None: + index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6) + index_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 1) + + assert build_calibration_speed_profile( + index_roll, + normal_speed=15, + index_roll_speed=5, + index_flex_speed=10, + ) == [15, 5, 15, 15, 15] + assert build_calibration_speed_profile( + index_pitch, + normal_speed=15, + index_roll_speed=5, + index_flex_speed=10, + ) == [15, 10, 15, 15, 15] + + index_pip = next(spec for spec in SWEEP_SPECS if spec.motor_index == 16) + assert build_calibration_speed_profile( + index_pip, + normal_speed=15, + index_roll_speed=5, + index_flex_speed=10, + ) == [15, 10, 15, 15, 15] + + thumb_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 0) + assert build_calibration_speed_profile( + thumb_pitch, + normal_speed=15, + index_roll_speed=5, + index_flex_speed=10, + ) == [15, 15, 15, 15, 15] + + +def test_calibration_speed_profile_rejects_out_of_range_values() -> None: + index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6) + with pytest.raises(ValueError, match="speeds"): + build_calibration_speed_profile( + index_roll, + normal_speed=15, + index_roll_speed=256, + index_flex_speed=10, + ) + + +def test_curve_fit_and_projected_zero_recover_synthetic_geometry() -> None: + records = _records() + fit = fit_joint_center_curve(records) + assert fit.angle_rad[255] == pytest.approx(0.0, abs=1.0e-8) + assert fit.angle_rad[0] == pytest.approx(0.70, abs=1.0e-4) + assert fit.maximum_hysteresis_rad == pytest.approx(0.0, abs=1.0e-8) + assert fit_projected_zero(records) == pytest.approx(0.0, abs=1.0e-6) + + +def test_thumb_image_curve_avoids_corrupted_pnp_depth() -> None: + records = _records() + for record in records: + command = int(record["command_u8"]) + record["relative_translation_xyz_m"] = [ + 0.001 * command, + 0.0, + 0.0, + ] + + fit = fit_measured_joint_curve("thumb_mcp", records) + + assert fit.circle["space"] == "image_2d" + assert fit.angle_rad[0] == pytest.approx(0.70, abs=0.02) + assert fit.angle_rad[255] == pytest.approx(0.0) + assert math.degrees(fit.maximum_monotonic_correction_rad) < 0.01 + assert math.degrees(fit.maximum_hysteresis_rad) < 0.01 + + command_zero = next( + record for record in records if int(record["command_u8"]) == 0 + ) + observed = measure_joint_observation( + fit, + vector_xyz_m=command_zero["relative_translation_xyz_m"], + image_vector_xy_px=command_zero["image_relative_xy_px"], + ) + assert observed == pytest.approx(0.70, abs=0.02) + + +def test_image_curve_rejects_insufficient_projected_arc() -> None: + records = _records() + for record in records: + command = float(record["command_u8"]) + angle = math.radians(2.0) * (255.0 - command) / 255.0 + record["image_relative_xy_px"] = [ + 100.0 * math.cos(angle), + 100.0 * math.sin(angle), + ] + + with pytest.raises( + ValueError, match="joint_image_trajectory_quality_failed:arc" + ): + fit_joint_image_curve(records) + + +def test_splay_uses_angular_midpoint_not_fixed_command_midpoint() -> None: + fit = fit_joint_center_curve(_records()) + centred, zero_command, midpoint = center_splay_curve(fit) + assert midpoint == pytest.approx(0.35, abs=1.0e-4) + assert centred.angle_rad[0] == pytest.approx(0.35, abs=1.0e-4) + assert centred.angle_rad[255] == pytest.approx(-0.35, abs=1.0e-4) + assert zero_command in {127, 128} + assert abs(centred.angle_rad[zero_command]) <= 0.002 + + +def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None: + base = fit_joint_center_curve(_records()) + splay, zero_command, midpoint = center_splay_curve(base) + measured = { + name: splay if name == "index_mcp_roll" else base + for name in MEASURED_JOINTS + } + projected = { + name: 0.01 * index + for index, (name, spec) in enumerate(JOINT_SPECS.items()) + if spec.zero_kind == "projected" + } + payload = build_compact_payload( + serial_number="G20_LEFT_001", + measured_fits=measured, + projected_zeros_rad=projected, + splay_zero_command_u8=zero_command, + splay_midpoint_rad=midpoint, + validation_errors_rad=[0.01, -0.02], + passed=True, + ) + validate_compact_payload(payload) + assert set(payload) == { + "schema_version", + "model", + "side", + "serial_number", + "angle_unit", + "command_range", + "baseline_command_u8", + "joints", + "quality", + } + assert len(payload["joints"]) == 21 + assert all(len(joint["angle_rad"]) == 256 for joint in payload["joints"].values()) + assert sum("zero_command_u8" in joint for joint in payload["joints"].values()) == 16 + assert sum(joint.get("passive") is True for joint in payload["joints"].values()) == 5 + assert payload["joints"]["middle_mcp_roll"]["source_joint"] == "index_mcp_roll" + assert ( + payload["joints"]["middle_mcp_roll"]["angle_rad"] + == payload["joints"]["index_mcp_roll"]["angle_rad"] + ) + assert payload["joints"]["index_mcp_roll"]["zero_angles"] == { + "travel_midpoint_rad": pytest.approx(0.35, abs=1.0e-4) + } + for name in SPLAY_JOINTS: + assert payload["joints"][name]["zero_command_u8"] == zero_command + + +def test_compact_payload_allows_skipped_random_validation() -> None: + base = fit_joint_center_curve(_records()) + splay, zero_command, midpoint = center_splay_curve(base) + measured = { + name: splay if name == "index_mcp_roll" else base + for name in MEASURED_JOINTS + } + projected = { + name: 0.0 + for name, spec in JOINT_SPECS.items() + if spec.zero_kind == "projected" + } + + payload = build_compact_payload( + serial_number="G20_LEFT_001", + measured_fits=measured, + projected_zeros_rad=projected, + splay_zero_command_u8=zero_command, + splay_midpoint_rad=midpoint, + validation_errors_rad=[], + passed=True, + ) + + validate_compact_payload(payload) + assert payload["quality"] == { + "passed": True, + "validation_mae_rad": None, + "validation_p95_rad": None, + } diff --git a/src/g20_thumb_apriltag_calibration/test/test_hikrobot_camera.py b/src/g20_thumb_apriltag_calibration/test/test_hikrobot_camera.py new file mode 100644 index 0000000..e7d91c9 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/test/test_hikrobot_camera.py @@ -0,0 +1,104 @@ +from pathlib import Path + +import pytest +import yaml + +from g20_thumb_apriltag_calibration.hikrobot_camera import ( + DeviceDescriptor, + decode_c_string, + load_camera_calibration, + resolve_camera_info_path, + select_device, +) + + +def test_decode_c_string_stops_at_first_null() -> None: + assert decode_c_string(b"DB2163742\0ignored") == "DB2163742" + + +def test_select_device_accepts_serial_or_guid() -> None: + devices = [ + DeviceDescriptor( + index=0, + model="MV-CS020-10UM", + serial="DB2163742", + guid="2BDFB2163742", + ), + DeviceDescriptor( + index=1, + model="MV-CS020-10UM", + serial="DB2163739", + guid="2BDFB2163739", + ), + ] + + assert select_device(devices, "DB2163742", "MV-CS020-10UM").index == 0 + assert select_device(devices, "2BDFB2163739", "MV-CS020-10UM").index == 1 + + +def test_select_device_never_guesses_when_multiple_cameras_exist() -> None: + devices = [ + DeviceDescriptor(0, "MV-CS020-10UM", "one", "guid-one"), + DeviceDescriptor(1, "MV-CS020-10UM", "two", "guid-two"), + ] + + with pytest.raises(RuntimeError, match="selector is required"): + select_device(devices, "", "MV-CS020-10UM") + + +def test_select_device_rejects_wrong_model() -> None: + devices = [DeviceDescriptor(0, "other", "DB2163742", "guid")] + + with pytest.raises(RuntimeError, match="expected a model"): + select_device(devices, "DB2163742", "MV-CS020-10UM") + + +def test_load_standard_camera_calibration(tmp_path: Path) -> None: + path = tmp_path / "camera.yaml" + path.write_text( + yaml.safe_dump( + { + "image_width": 1624, + "image_height": 1240, + "camera_name": "hikrobot_front_DB2163742", + "camera_matrix": { + "rows": 3, + "cols": 3, + "data": [1000.0, 0.0, 812.0, 0.0, 1001.0, 620.0, 0.0, 0.0, 1.0], + }, + "distortion_model": "plumb_bob", + "distortion_coefficients": { + "rows": 1, + "cols": 5, + "data": [0.1, -0.2, 0.0, 0.0, 0.1], + }, + "rectification_matrix": { + "rows": 3, + "cols": 3, + "data": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], + }, + "projection_matrix": { + "rows": 3, + "cols": 4, + "data": [1000.0, 0.0, 812.0, 0.0, 0.0, 1001.0, 620.0, 0.0, 0.0, 0.0, 1.0, 0.0], + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + calibration = load_camera_calibration(path) + + assert calibration.width == 1624 + assert calibration.height == 1240 + assert calibration.k[0] == 1000.0 + assert calibration.p[5] == 1001.0 + + +def test_camera_info_url_only_accepts_local_files(tmp_path: Path) -> None: + path = resolve_camera_info_path(str(tmp_path / "front.yaml")) + assert path == (tmp_path / "front.yaml").resolve() + assert resolve_camera_info_path("") is None + with pytest.raises(ValueError, match="filesystem path"): + resolve_camera_info_path("package://example/front.yaml") diff --git a/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py b/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py new file mode 100644 index 0000000..08caa10 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/test/test_three_camera_diagnostics.py @@ -0,0 +1,217 @@ +import json + +import numpy as np + +from g20_thumb_apriltag_calibration.three_camera_diagnostics import ( + render_three_camera_status_text_zh, +) + + +def test_missing_endpoint_has_specific_chinese_reason_and_recovery() -> None: + payload = { + "state": "PAUSED", + "reason": "sweep_missing_endpoint_bin", + "progress": 0.1429, + "completed_sweeps": 6, + "total_sweeps": 42, + "active": { + "kind": "sweep", + "view": "front", + "motor_index": 5, + "joints": ["thumb_cmc_roll"], + "cycle": 1, + "repetitions": 3, + "start_u8": 255, + "target_u8": 0, + "actual_u8": 0.4, + "motion_progress": 0.998, + "valid_frames": 239, + "sample": { + "minimum_u8": 0.4, + "maximum_u8": 248.2, + "bin_count": 180, + "minimum_bin_count": 32, + "maximum_bin_gap": 3, + "allowed_maximum_bin_gap": 16, + "missing_endpoint_u8": [255], + "endpoint_tolerance_u8": 2.0, + }, + }, + "views": { + "front": { + "ready": False, + "detection_hz": 30.04, + "valid_rate": 0.0, + "missing_tag_ids": [2], + "pnp_rejections": {}, + "group_pnp_reason": "group_pose_jump", + "pnp_invalid_seconds": 0.8, + "pnp_reset_count": 2, + }, + "side": { + "ready": True, + "detection_hz": 30.02, + "valid_rate": 1.0, + "missing_tag_ids": [], + }, + }, + "result_path": "", + } + + text = render_three_camera_status_text_zh(payload) + + assert "标定已暂停(PAUSED)" in text + assert "缺少电机端点255" in text + assert "实际电机范围为0.4~248.2" in text + assert "第1/3轮,255→0" in text + assert "已完成6/42个扫描方向" in text + assert "当前缺失Tag=2" in text + assert "group_pose_jump" not in text + assert "PnP拒绝" not in text + assert "自动重置" not in text + assert "/g20_calibration/resume" in text + + +def test_preflight_lists_missing_tags_in_chinese() -> None: + payload = { + "state": "PREFLIGHT", + "reason": "waiting_for_three_cameras_tags_and_sdk", + "progress": 0.0, + "completed_sweeps": 0, + "total_sweeps": 0, + "active": {}, + "views": { + "top": { + "ready": False, + "detection_hz": 30.0, + "valid_rate": 0.0, + "missing_tag_ids": [9], + } + }, + "result_path": "", + } + + text = render_three_camera_status_text_zh(payload) + + assert "设备和标签预检(PREFLIGHT)" in text + assert "当前缺失Tag=9" in text + assert "全部必需Tag同时有效率0.0%" in text + + +def test_joint_fit_failure_names_metric_and_selective_retry() -> None: + payload = { + "state": "PAUSED", + "reason": "joint_fit_check_failed", + "progress": 18 / 42, + "completed_sweeps": 18, + "total_sweeps": 42, + "active": { + "kind": "fit_failure", + "view": "front", + "motor_index": 6, + "joints": ["index_mcp_roll"], + "attempt": 1, + "directions_to_rescan": 6, + "failures": [ + { + "joint": "index_mcp_roll", + "metric": "arc_deg", + "actual": 3.98, + "limit": 15.0, + "comparison": "minimum", + } + ], + }, + "views": {}, + "result_path": "", + } + + text = render_three_camera_status_text_zh(payload) + + assert "食指MCP侧摆的实测圆弧为3.98°" in text + assert "要求至少15.00°" in text + assert "只清除当前失败关节的数据并重扫6个方向" in text + assert "第1次尝试" in text + assert "运动采样:" not in text + + +def test_return_baseline_prints_the_exact_command() -> None: + baseline = [255] * 20 + baseline[6:10] = [127, 127, 127, 127] + payload = { + "state": "RETURN_BASELINE", + "reason": "return_baseline_before_next_sweep", + "progress": 0.0, + "completed_sweeps": 0, + "total_sweeps": 42, + "baseline_command_u8": baseline, + "active": {}, + "views": {}, + "result_path": "", + } + + text = render_three_camera_status_text_zh(payload) + + assert "正在返回基准姿态(RETURN_BASELINE)" in text + assert f"正在确认基准姿态:{baseline}" in text + + +def test_status_numeric_diagnostics_are_json_serializable() -> None: + bins = [0, 16, 255] + payload = { + "state": "SWEEP", + "reason": "collecting_timestamp_synchronised_tag_centres", + "active": { + "sample": { + # np.diff返回NumPy标量;节点必须在放入状态前转成原生int。 + "maximum_bin_gap": int(max(np.diff(bins), default=0)), + } + }, + } + + encoded = json.dumps(payload, ensure_ascii=False) + + assert '"maximum_bin_gap": 239' in encoded + + +def test_index_roll_status_prints_clearance_motor_feedback() -> None: + payload = { + "state": "SWEEP", + "reason": "collecting_timestamp_synchronised_tag_centres", + "progress": 0.43, + "completed_sweeps": 18, + "total_sweeps": 42, + "active": { + "kind": "sweep", + "view": "front", + "motor_index": 6, + "joints": ["index_mcp_roll"], + "cycle": 1, + "repetitions": 3, + "start_u8": 255, + "target_u8": 0, + "actual_u8": 44.0, + "motion_progress": 0.827, + "valid_frames": 971, + "sample": {"minimum_u8": 40.0, "maximum_u8": 253.0}, + "auxiliary_motors": [ + {"motor_index": 7, "command_u8": 0, "actual_u8": 0.0}, + {"motor_index": 8, "command_u8": 0, "actual_u8": 1.0}, + {"motor_index": 9, "command_u8": 0, "actual_u8": 0.0}, + ], + "speed": { + "commanded_finger_speed": [15, 5, 15, 15, 15], + "reported_finger_speed": [15, 5, 15, 15, 15], + }, + }, + "views": {}, + "result_path": "", + } + + text = render_three_camera_status_text_zh(payload) + + assert "避挡姿态:电机7目标0、实际0.0" in text + assert "电机8目标0、实际1.0" in text + assert "电机9目标0、实际0.0" in text + assert "阶段速度:五指目标[15, 5, 15, 15, 15]" in text + assert "SDK报告[15, 5, 15, 15, 15]" in text diff --git a/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py b/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py new file mode 100644 index 0000000..ca933e7 --- /dev/null +++ b/src/g20_thumb_apriltag_calibration/test/test_three_camera_retry.py @@ -0,0 +1,170 @@ +import json +import math +from types import SimpleNamespace + +from g20_thumb_apriltag_calibration.core import ( + DIRECTION_DECREASING, + DIRECTION_INCREASING, +) +from g20_thumb_apriltag_calibration.full_hand import SWEEP_SPECS +from g20_thumb_apriltag_calibration.three_camera_node import ( + G20ThreeCameraCalibrationNode, + SweepItem, +) + + +def _sweep_items() -> list[SweepItem]: + return [ + SweepItem(spec, cycle, direction) + for spec in SWEEP_SPECS + for cycle in range(3) + for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING) + ] + + +def _image_cycle_records(travels_rad: list[float]) -> list[dict]: + commands = list(range(0, 256, 16)) + [255] + records = [] + for cycle, travel in enumerate(travels_rad): + for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING): + for command in commands: + angle = travel * (255.0 - command) / 255.0 + records.append( + { + "cycle": cycle, + "direction": direction, + "command_u8": command, + "relative_translation_xyz_m": [0.01, 0.0, 0.0], + "image_relative_xy_px": [ + 100.0 * math.cos(angle), + 100.0 * math.sin(angle), + ], + } + ) + return records + + +def _fit_check_node(records_by_joint: dict) -> SimpleNamespace: + node = SimpleNamespace( + records_by_joint=records_by_joint, + repetitions=3, + trajectory_maximum_plane_rms_m=0.004, + trajectory_maximum_radial_rms_m=0.004, + trajectory_minimum_radius_m=0.003, + trajectory_minimum_arc_rad=math.radians(15.0), + image_trajectory_maximum_radial_rms_px=2.0, + image_trajectory_maximum_radial_p95_px=3.5, + image_trajectory_minimum_radius_px=20.0, + trajectory_maximum_cycle_travel_difference_rad=math.radians(3.0), + passive_maximum_cycle_travel_difference_rad=math.radians(10.0), + maximum_monotonic_correction_rad=math.radians(2.0), + maximum_hysteresis_rad=math.radians(5.0), + passive_maximum_monotonic_correction_rad=math.radians(3.0), + passive_maximum_hysteresis_rad=math.radians(7.5), + zero_minimum_radius_px=20.0, + zero_maximum_radial_rms_px=2.0, + zero_maximum_radial_p95_px=3.5, + zero_maximum_round_difference_rad=math.radians(1.0), + ) + node._fit_joint_records = lambda name, records, relaxed=False: ( + G20ThreeCameraCalibrationNode._fit_joint_records( + node, name, records, relaxed=relaxed + ) + ) + return node + + +def test_fit_failure_rewinds_to_failed_specs_first_direction(tmp_path) -> None: + spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) + node = SimpleNamespace( + sweep_items=_sweep_items(), + sweep_attempts={spec.motor_index: 1}, + retry_sweep_spec=None, + fit_failure={}, + sweep_index=24, + raw_path=tmp_path / "raw_samples.jsonl", + repetitions=3, + ) + node._sweep_spec_start_index = lambda selected: ( + G20ThreeCameraCalibrationNode._sweep_spec_start_index(node, selected) + ) + node._pause = lambda reason: setattr(node, "paused_reason", reason) + + G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( + node, + spec, + [ + { + "joint": "index_mcp_roll", + "metric": "arc_deg", + "actual": 3.98, + "limit": 15.0, + "comparison": "minimum", + } + ], + ) + + assert node.sweep_index == 18 + assert node.retry_sweep_spec == spec + assert node.fit_failure["directions_to_rescan"] == 6 + assert node.paused_reason == "joint_fit_check_failed" + + +def test_resume_discards_only_failed_specs_samples(tmp_path) -> None: + spec = next(item for item in SWEEP_SPECS if item.motor_index == 15) + records = { + "thumb_mcp": [{"old": "mcp"}], + "thumb_ip": [{"old": "ip"}], + "thumb_cmc_pitch": [{"keep": True}], + } + node = SimpleNamespace( + retry_sweep_spec=spec, + records_by_joint=records, + sweep_attempts={spec.motor_index: 1}, + raw_path=tmp_path / "raw_samples.jsonl", + paused_reason="joint_fit_check_failed", + fit_failure={"failures": ["old"]}, + ) + + selected = G20ThreeCameraCalibrationNode._prepare_failed_sweep_retry(node) + + assert selected == spec + assert records["thumb_mcp"] == [] + assert records["thumb_ip"] == [] + assert records["thumb_cmc_pitch"] == [{"keep": True}] + assert node.sweep_attempts[15] == 2 + assert node.retry_sweep_spec is None + assert node.fit_failure == {} + event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) + assert event == { + "kind": "retry", + "view": "front", + "motor_index": 15, + "joints": ["thumb_mcp", "thumb_ip"], + "attempt": 2, + "reason": "joint_fit_check_failed", + } + + +def test_provisional_fit_rejects_inconsistent_cycle_travel() -> None: + spec = next(item for item in SWEEP_SPECS if item.motor_index == 0) + node = _fit_check_node( + { + "thumb_cmc_pitch": _image_cycle_records( + [math.radians(47.0), math.radians(47.2), math.radians(55.0)] + ) + } + ) + + failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( + node, spec + ) + + cycle_failure = next( + item + for item in failures + if item["metric"] == "cycle_travel_range_deg" + ) + assert cycle_failure["joint"] == "thumb_cmc_pitch" + assert cycle_failure["actual"] == 8.0 + assert cycle_failure["limit"] == 3.0