标定
This commit is contained in:
@@ -60,6 +60,7 @@ Thumbs.db
|
||||
# Reproducible seed profiles remain under
|
||||
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
|
||||
/profiles/
|
||||
/calibration_output/
|
||||
*.wear_check.json
|
||||
*.checkpoint.json
|
||||
*.verification.json
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# G20 左手拇指正面 AprilTag 标定
|
||||
|
||||
该包启动 RealSense、彩色图像校正、`apriltag_ros`、Linker Hand SDK 和标定状态机,
|
||||
只扫描 G20 左手命令下标 `0`、`15`。默认使用单终点连续模式:每个方向只发送一次
|
||||
终点命令,速度保持在固件能稳定响应的 `15`。SDK 以独立时间戳反馈实际 20 维位置,
|
||||
程序把每帧 AprilTag 角度与同一时刻的实际电机位置插值配对并按整数位置分箱。
|
||||
完成 `255→0→255` 后分别拟合正反方向并检查回差,最终运行时 JSON 将两条曲线逐点
|
||||
平均,只为每个关节保存一个 256 项 `angle_rad`。最后用 5 个随机静态命令复测精度。
|
||||
|
||||
当前正面单机位使用 `image_plane_2d` 模式:从四个有序角点计算 Tag 在校正图像平面内的
|
||||
方向,再求 T0→T3、T3→T4、T4→T5 的相对转角。该模式只用于正面屈伸角,不估计侧摆、
|
||||
横摆或出平面旋转;AprilTag 的 PnP/TF 仍保留作诊断和数据归档。
|
||||
|
||||
## 1. 标记和安全检查
|
||||
|
||||
- `T0` 固定在掌壳,`T3` 固定在拇指根部运动连杆,`T4` 固定在 MCP 后的连杆,
|
||||
`T5` 固定在最末节。四张 Tag 必须与所在刚性件完全固定,不能跨关节或贴在软胶上。
|
||||
- 当前实物使用 `tag36h11` 的 ID `0/1/2/3`,依次对应 T0/T3/T4/T5。如果实物 ID 改变,同时修改
|
||||
`config/front_tags.yaml` 里检测节点和标定节点的两组数组。
|
||||
- `tag.sizes`/`tag_sizes_m` 必须填写每张 Tag 的实测有效边长(米),当前配置为 `0.010`。
|
||||
测量检测角点所围成的正方形边长,不包含外围白色留边。
|
||||
- 当前试标定允许四张 Tag 的有效边长至少 30 px(实测静态约 32~38 px),最终仍由
|
||||
静止角度 RMS 和随机复测误差决定是否合格。四张 Tag 必须在全行程内均可见。需要短时检查标记时,
|
||||
启动参数增加 `publish_debug_image:=true`,再订阅
|
||||
`/g20_thumb_calibration/debug_image`;正式长时间扫描建议保持默认关闭。
|
||||
- 执行全行程前清空拇指周围空间并准备断开电机电源。确认这只手的下标 0 和 15
|
||||
均可安全走完整 `255→0→255`。标定节点发现命令话题上另有发布者时不会解锁扫描。
|
||||
|
||||
## 2. 安装与构建
|
||||
|
||||
```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
|
||||
|
||||
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||
source /opt/ros/jazzy/setup.bash
|
||||
colcon build --symlink-install \
|
||||
--packages-select linker_hand_ros2_sdk g20_thumb_apriltag_calibration
|
||||
source install/setup.bash
|
||||
```
|
||||
|
||||
连接 CAN 后先确认 `can0` 已启动。不要同时运行其他会发布
|
||||
`/g20/cb_left_hand_control_cmd` 的程序。
|
||||
|
||||
## 3. 启动和操作
|
||||
|
||||
首次使用时可先用 `commands_enabled:=false` 做预检;SDK 仍会设置速度/扭矩并读取状态,
|
||||
但标定节点不会发送位置运动命令,也不会允许解锁全行程扫描:
|
||||
|
||||
```bash
|
||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
||||
serial_number:=G20_LEFT_001 \
|
||||
commands_enabled:=false
|
||||
```
|
||||
|
||||
确认 T0、T3、T4、T5 在根部和尖部全行程中不会被遮挡,且拇指运动不会碰撞后,
|
||||
停止预检并启动一个新的正式会话。默认使用 AprilTag 内部 `decimate=1.5` 提升检测
|
||||
速度,并使用单终点连续运动:
|
||||
|
||||
```bash
|
||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
||||
serial_number:=G20_LEFT_001 \
|
||||
can_interface:=can0 \
|
||||
calibration_speed:=15 \
|
||||
continuous_motion_mode:=endpoint \
|
||||
apriltag_decimate:=1.5 \
|
||||
use_roi:=false
|
||||
```
|
||||
|
||||
默认关闭 ROI,AprilTag 使用完整的 1280×720 校正画面。查看实际送入 AprilTag
|
||||
的完整画面:
|
||||
|
||||
```bash
|
||||
ros2 run image_view image_view --ros-args \
|
||||
--remap image:=/camera/camera/color/image_rect
|
||||
```
|
||||
|
||||
若以后需要以帧率优先,可传入 `use_roi:=true`;默认 ROI 是原图中的
|
||||
`x=128, y=192, width=1024, height=528`,也可用 `roi_x`、`roi_y`、
|
||||
`roi_width`、`roi_height` 覆盖。
|
||||
|
||||
监控状态:
|
||||
|
||||
```bash
|
||||
ros2 topic echo /g20_thumb_calibration/status
|
||||
```
|
||||
|
||||
预检通过后状态为 `WAIT_ROOT_CONFIRM`,`reason` 为 `call_start`。只需调用一次:
|
||||
|
||||
```bash
|
||||
ros2 service call /g20_thumb_calibration/start std_srvs/srv/Trigger {}
|
||||
```
|
||||
|
||||
节点随后自动完成下标 0 的 `255→0→255`、下标 15 的 `255→0→255` 和 5 点随机复测,
|
||||
正常结束状态为 `COMPLETE`,无需在根部和尖部之间再次确认。为安全起见,调用 `start`
|
||||
前必须一次性确认两个关节的完整行程都已清空。原来的
|
||||
`confirm_root_full_range`、`confirm_tip_full_range` 服务仍保留用于兼容。
|
||||
|
||||
暂停、恢复和终止:
|
||||
|
||||
```bash
|
||||
ros2 service call /g20_thumb_calibration/pause std_srvs/srv/Trigger {}
|
||||
ros2 service call /g20_thumb_calibration/resume std_srvs/srv/Trigger {}
|
||||
ros2 service call /g20_thumb_calibration/abort std_srvs/srv/Trigger {}
|
||||
```
|
||||
|
||||
预检要求四 Tag 有效帧率至少 95%,且检测消息频率至少 15 Hz。连续扫描要求
|
||||
图像与状态的时间差不超过 150 ms、全行程至少得到 40 个有效帧、
|
||||
至少覆盖 32 个整数位置且相邻实测位置间隔不超过 16。Tag 或同步状态持续丢失 3 秒、
|
||||
90 秒内未到达终点,或覆盖不足时,节点保持当前命令并进入 `PAUSED`。恢复时会先回到
|
||||
该方向的起点,再完整重扫这个方向,避免把半程数据混入结果。`abort` 也只停止队列,
|
||||
不会主动移动机械手。正常扫描和随机复测最后一项均为命令 255。
|
||||
|
||||
单终点连续模式共有 4 个端到端命令:根部和尖部各一个往返。每个方向运动前会先用
|
||||
实际电机反馈确认已经到达起点,再做一次短暂静态确认;随机验证的“接近位置”只等待
|
||||
电机反馈到位,不再重复采图。若实际 AprilTag 检测仍低于 15 Hz,先优化检测链路,
|
||||
不要降低到固件低速区。必须临时回退时可启动
|
||||
`continuous_motion_mode:=paced`,该模式按步长 8 到位即发下一段。
|
||||
|
||||
连续扫描中的主要状态字段:
|
||||
|
||||
- `scan_progress`:4 个方向的完成比例,依次约为 0、0.25、0.5、0.75、1.0。
|
||||
- `sweep_valid_frames_seen`:当前连续方向已收到的同步有效帧数。
|
||||
- `sweep_state_span_u8`:当前方向实际覆盖的电机范围,接近 255 才算完整。
|
||||
- `active_phase`/`active_direction`:当前是根部或尖部、下降或上升方向。
|
||||
|
||||
## 4. 中断恢复和输出
|
||||
|
||||
默认会话目录是启动命令当前目录下:
|
||||
|
||||
```text
|
||||
calibration_output/<序列号>/<时间戳>/
|
||||
```
|
||||
|
||||
恢复时必须显式复用原目录,否则会创建新会话:
|
||||
|
||||
```bash
|
||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
||||
serial_number:=G20_LEFT_001 \
|
||||
session_dir:=/绝对路径/calibration_output/G20_LEFT_001/20260727_120000
|
||||
```
|
||||
|
||||
恢复会校验序列号、Tag 配置、基准命令、扫描模式、采集参数和代码哈希;
|
||||
任一项变化都会拒绝混用旧样本,
|
||||
此时应新建会话。
|
||||
|
||||
目录内文件:
|
||||
|
||||
- `raw_samples.jsonl`:连续帧按实际整数电机位置分箱后的鲁棒统计及复测点;每完成一个
|
||||
扫描方向后落盘。
|
||||
- `checkpoint.json`:当前状态和进度。
|
||||
- `session_manifest.json`:Tag、相机内参、SDK、代码哈希和会话信息。
|
||||
- `validation.json`:随机复测及全部质量判据。
|
||||
- `rosbag/`:仅在 `record_bag:=true` 时生成,用于保存相机、检测、命令和状态等诊断数据。
|
||||
- `g20_left_<序列号>_thumb_angle.json`:精简后的运行时标定文件。
|
||||
|
||||
最终文件使用 `schema_version: 2`。每个关节只包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"motor_index": 0,
|
||||
"angle_rad": ["按命令0~255索引的256个弧度值"]
|
||||
}
|
||||
```
|
||||
|
||||
`thumb_ip` 另外包含 `"passive": true`。正反方向原始曲线不进入最终 JSON,但仍保留
|
||||
在 `raw_samples.jsonl` 中,并用于最大回差和质量判定。
|
||||
|
||||
如果相机或 SDK 已由外部进程启动,可传
|
||||
`start_camera:=false` 或 `start_sdk:=false`。用 `camera_serial_number:=<序列号>`
|
||||
可绑定指定 RealSense。
|
||||
|
||||
D405 的彩色流来自 `depth_module`,启动文件会同时设置
|
||||
`depth_module.color_profile` 和 `rgb_camera.color_profile`,默认均为
|
||||
`1280x720x30`。连续模式默认关闭深度和 rosbag,以减少 USB、CPU 和磁盘负担;
|
||||
它们都不参与角度计算。需要完整诊断留档时可增加
|
||||
`enable_depth:=true record_bag:=true`。
|
||||
|
||||
默认对完整 1280×720 原图进行畸变校正和 AprilTag 检测。校正和 AprilTag 组件运行
|
||||
在同一个多线程容器内并启用进程内传输,避免在处理链路中重复序列化、复制大图像。
|
||||
可选 ROI 模式会额外在同一容器内加入裁剪组件并同步修正 `CameraInfo`。标定节点默认
|
||||
不订阅整幅图像,只订阅检测结果和 TF。
|
||||
若启用调试图,预览会缩放到 50%、限速 10 Hz 并使用最新帧优先的传输方式,
|
||||
不影响 AprilTag 的 ROI 输入。
|
||||
|
||||
静态预检会把偏离鲁棒姿态超过 5° 的平面 PnP 瞬时翻解视为异常帧,但要求姿态内点率
|
||||
至少 95%;内点自身仍必须满足 0.5° RMS,避免用异常过滤掩盖真实抖动。
|
||||
|
||||
启用 rosbag 后保存裁剪后的原始图像和配套 `CameraInfo`,避免新增一个全分辨率图像
|
||||
订阅者;同时使用 MCAP `zstd_fast` 压缩并按 10 GiB 分卷。快速标定通常不需要录制;
|
||||
若用于正式可追溯验收,再启用并检查磁盘空间。
|
||||
@@ -0,0 +1,69 @@
|
||||
g20_thumb_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
|
||||
camera_info_topic: /camera/camera/color/camera_info
|
||||
image_topic: /camera/camera/color/image_rect
|
||||
detections_topic: /apriltag/detections
|
||||
tf_topic: /tf
|
||||
angle_estimation_mode: image_plane_2d
|
||||
publish_debug_image: false
|
||||
debug_max_rate_hz: 10.0
|
||||
debug_scale: 0.5
|
||||
|
||||
# Default: send one end-to-end command per direction and pair every valid
|
||||
# AprilTag frame with the timestamp-interpolated actual G20 state.
|
||||
scan_mode: continuous
|
||||
continuous_motion_mode: endpoint
|
||||
repetitions: 1
|
||||
# Used only by point-mode fallback and validation approach offsets.
|
||||
command_step: 8
|
||||
auto_start_tip: true
|
||||
maximum_state_image_skew_ms: 150.0
|
||||
continuous_endpoint_tolerance_u8: 2.0
|
||||
continuous_endpoint_hold_seconds: 1.0
|
||||
continuous_timeout_seconds: 90.0
|
||||
continuous_invalid_timeout_seconds: 3.0
|
||||
continuous_minimum_valid_frames: 40
|
||||
continuous_minimum_state_span_u8: 240.0
|
||||
continuous_minimum_bins: 32
|
||||
continuous_maximum_bin_gap: 16
|
||||
# Keep the responsive firmware speed, but pace it through the same
|
||||
# 8-unit grid without waiting for static image captures at each point.
|
||||
continuous_segment_minimum_seconds: 0.1
|
||||
continuous_segment_timeout_seconds: 5.0
|
||||
continuous_prepare_timeout_seconds: 30.0
|
||||
preflight_frames: 150
|
||||
minimum_detection_rate: 0.95
|
||||
minimum_detection_hz: 15.0
|
||||
maximum_hamming: 0
|
||||
minimum_decision_margin: 30.0
|
||||
# Trial threshold for the current 10 mm tags (observed at 32-38 px).
|
||||
# Final acceptance is still guarded by static RMS and random validation.
|
||||
minimum_edge_pixels: 30.0
|
||||
maximum_static_std_deg: 0.5
|
||||
pose_outlier_threshold_deg: 5.0
|
||||
minimum_pose_inlier_rate: 0.95
|
||||
|
||||
# Static captures are now used only for sweep preparation and validation.
|
||||
stable_frames: 5
|
||||
capture_frames: 8
|
||||
minimum_settle_seconds: 0.4
|
||||
maximum_stable_spread_deg: 0.3
|
||||
settle_timeout_seconds: 10.0
|
||||
capture_timeout_seconds: 10.0
|
||||
|
||||
validation_command_count: 5
|
||||
# The backlash approach point only waits for feedback to reach the target;
|
||||
# it no longer performs an unnecessary image capture.
|
||||
validation_approach_minimum_seconds: 0.2
|
||||
validation_approach_timeout_seconds: 10.0
|
||||
validation_position_tolerance_u8: 2.0
|
||||
validation_seed: 20260727
|
||||
maximum_validation_mae_deg: 2.0
|
||||
maximum_validation_p95_deg: 3.0
|
||||
maximum_coupling_drift_deg: 2.0
|
||||
minimum_ip_coupling_r_squared: 0.98
|
||||
maximum_monotonic_correction_deg: 2.0
|
||||
maximum_hysteresis_deg: 5.0
|
||||
@@ -0,0 +1,26 @@
|
||||
/apriltag/apriltag:
|
||||
ros__parameters:
|
||||
image_transport: raw
|
||||
family: 36h11
|
||||
size: 0.01
|
||||
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]
|
||||
frames: [tag_t0, tag_t3, tag_t4, tag_t5]
|
||||
sizes: [0.010, 0.010, 0.010, 0.010]
|
||||
|
||||
g20_thumb_calibration:
|
||||
ros__parameters:
|
||||
tag_roles: [t0, t3, t4, t5]
|
||||
tag_ids: [0, 1, 2, 3]
|
||||
tag_frames: [tag_t0, tag_t3, tag_t4, tag_t5]
|
||||
tag_sizes_m: [0.010, 0.010, 0.010, 0.010]
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Front-camera AprilTag calibration for the left LinkerHand G20 thumb."""
|
||||
|
||||
from .core import BASELINE_COMMAND, COMMAND_NAMES
|
||||
|
||||
__all__ = ["BASELINE_COMMAND", "COMMAND_NAMES"]
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Hardware-independent point acquisition state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bisect import bisect_left
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .core import PAIR_NAMES, robust_rotation_summary
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TagQuality:
|
||||
hamming: int
|
||||
decision_margin: float
|
||||
edge_pixels: float
|
||||
|
||||
|
||||
def tag_quality_is_valid(
|
||||
quality: TagQuality,
|
||||
*,
|
||||
maximum_hamming: int,
|
||||
minimum_decision_margin: float,
|
||||
minimum_edge_pixels: float,
|
||||
) -> bool:
|
||||
return (
|
||||
quality.hamming <= maximum_hamming
|
||||
and quality.decision_margin >= minimum_decision_margin
|
||||
and quality.edge_pixels >= minimum_edge_pixels
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Observation:
|
||||
stamp_ns: int
|
||||
received_at: float
|
||||
relative_quaternion_xyzw: Mapping[str, tuple[float, float, float, float]]
|
||||
tag_quality: Mapping[str, TagQuality]
|
||||
state_u8: tuple[float, ...] = ()
|
||||
state_stamp_ns: int | None = None
|
||||
state_sync_error_ns: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateSample:
|
||||
stamp_ns: int
|
||||
position_u8: tuple[float, ...]
|
||||
|
||||
|
||||
def interpolate_state_u8(
|
||||
samples: Sequence[StateSample],
|
||||
stamp_ns: int,
|
||||
*,
|
||||
maximum_skew_ns: int,
|
||||
) -> tuple[tuple[float, ...], int] | None:
|
||||
"""Interpolate the 20-D hand state at an image timestamp.
|
||||
|
||||
The SDK publishes state independently from the camera. Continuous
|
||||
calibration must therefore use the image timestamp instead of whichever
|
||||
state happened to arrive most recently in the ROS callback thread.
|
||||
"""
|
||||
if maximum_skew_ns < 0:
|
||||
raise ValueError("maximum_skew_ns must be non-negative")
|
||||
if not samples:
|
||||
return None
|
||||
stamps = [int(sample.stamp_ns) for sample in samples]
|
||||
index = bisect_left(stamps, int(stamp_ns))
|
||||
|
||||
if index < len(samples) and stamps[index] == int(stamp_ns):
|
||||
state = samples[index].position_u8
|
||||
return (tuple(float(value) for value in state), 0)
|
||||
|
||||
before = samples[index - 1] if index > 0 else None
|
||||
after = samples[index] if index < len(samples) else None
|
||||
if before is not None and after is not None:
|
||||
before_gap = int(stamp_ns) - int(before.stamp_ns)
|
||||
after_gap = int(after.stamp_ns) - int(stamp_ns)
|
||||
nearest_gap = min(before_gap, after_gap)
|
||||
if nearest_gap > maximum_skew_ns:
|
||||
return None
|
||||
denominator = int(after.stamp_ns) - int(before.stamp_ns)
|
||||
if denominator <= 0:
|
||||
return (
|
||||
tuple(float(value) for value in before.position_u8),
|
||||
nearest_gap,
|
||||
)
|
||||
fraction = before_gap / denominator
|
||||
before_values = np.asarray(before.position_u8, dtype=float)
|
||||
after_values = np.asarray(after.position_u8, dtype=float)
|
||||
if before_values.shape != (20,) or after_values.shape != (20,):
|
||||
return None
|
||||
interpolated = before_values + fraction * (after_values - before_values)
|
||||
return (
|
||||
tuple(float(value) for value in interpolated),
|
||||
nearest_gap,
|
||||
)
|
||||
|
||||
nearest = before if before is not None else after
|
||||
if nearest is None:
|
||||
return None
|
||||
gap = abs(int(stamp_ns) - int(nearest.stamp_ns))
|
||||
if gap > maximum_skew_ns or len(nearest.position_u8) != 20:
|
||||
return None
|
||||
return (tuple(float(value) for value in nearest.position_u8), gap)
|
||||
|
||||
|
||||
class ContinuousSweepCollector:
|
||||
"""Collect timestamp-synchronised observations during one end-to-end move."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
endpoint_tolerance_u8: float = 2.0,
|
||||
endpoint_hold_seconds: float = 1.0,
|
||||
timeout_seconds: float = 90.0,
|
||||
invalid_timeout_seconds: float = 2.0,
|
||||
minimum_valid_frames: int = 40,
|
||||
minimum_state_span_u8: float = 240.0,
|
||||
) -> None:
|
||||
if endpoint_tolerance_u8 < 0.0:
|
||||
raise ValueError("endpoint_tolerance_u8 must be non-negative")
|
||||
if endpoint_hold_seconds <= 0.0:
|
||||
raise ValueError("endpoint_hold_seconds must be positive")
|
||||
if timeout_seconds <= 0.0 or invalid_timeout_seconds <= 0.0:
|
||||
raise ValueError("sweep timeouts must be positive")
|
||||
if minimum_valid_frames < 3:
|
||||
raise ValueError("minimum_valid_frames must be at least 3")
|
||||
if minimum_state_span_u8 <= 0.0:
|
||||
raise ValueError("minimum_state_span_u8 must be positive")
|
||||
self.endpoint_tolerance_u8 = float(endpoint_tolerance_u8)
|
||||
self.endpoint_hold_seconds = float(endpoint_hold_seconds)
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self.invalid_timeout_seconds = float(invalid_timeout_seconds)
|
||||
self.minimum_valid_frames = int(minimum_valid_frames)
|
||||
self.minimum_state_span_u8 = float(minimum_state_span_u8)
|
||||
self.observations: list[Observation] = []
|
||||
self.motor_index = 0
|
||||
self.start_u8 = 255.0
|
||||
self.target_u8 = 0.0
|
||||
self.started_at: float | None = None
|
||||
self.last_valid_at: float | None = None
|
||||
self.endpoint_since: float | None = None
|
||||
self.state = "idle"
|
||||
self.reason = ""
|
||||
|
||||
def start(
|
||||
self,
|
||||
now: float,
|
||||
*,
|
||||
motor_index: int,
|
||||
start_u8: int,
|
||||
target_u8: int,
|
||||
) -> None:
|
||||
if motor_index not in (0, 15):
|
||||
raise ValueError("continuous thumb sweep only permits motor 0 or 15")
|
||||
if {int(start_u8), int(target_u8)} != {0, 255}:
|
||||
raise ValueError("continuous sweep endpoints must be 0 and 255")
|
||||
self.observations.clear()
|
||||
self.motor_index = int(motor_index)
|
||||
self.start_u8 = float(start_u8)
|
||||
self.target_u8 = float(target_u8)
|
||||
self.started_at = float(now)
|
||||
self.last_valid_at = float(now)
|
||||
self.endpoint_since = None
|
||||
self.state = "collecting"
|
||||
self.reason = ""
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.state == "collecting"
|
||||
|
||||
@property
|
||||
def valid_frames_seen(self) -> int:
|
||||
return len(self.observations)
|
||||
|
||||
@property
|
||||
def state_span_u8(self) -> float:
|
||||
if not self.observations:
|
||||
return 0.0
|
||||
values = [
|
||||
float(observation.state_u8[self.motor_index])
|
||||
for observation in self.observations
|
||||
]
|
||||
return float(max(values) - min(values))
|
||||
|
||||
def add(
|
||||
self, observation: Observation, now: float
|
||||
) -> list[Observation] | None:
|
||||
if not self.active:
|
||||
return None
|
||||
if (
|
||||
len(observation.state_u8) != 20
|
||||
or observation.state_sync_error_ns is None
|
||||
):
|
||||
return None
|
||||
value = float(observation.state_u8[self.motor_index])
|
||||
if not np.isfinite(value) or not -3.0 <= value <= 258.0:
|
||||
return None
|
||||
now = float(now)
|
||||
self.observations.append(observation)
|
||||
self.last_valid_at = now
|
||||
|
||||
if abs(value - self.target_u8) <= self.endpoint_tolerance_u8:
|
||||
if self.endpoint_since is None:
|
||||
self.endpoint_since = now
|
||||
else:
|
||||
self.endpoint_since = None
|
||||
|
||||
enough_endpoint_hold = (
|
||||
self.endpoint_since is not None
|
||||
and now - self.endpoint_since >= self.endpoint_hold_seconds
|
||||
)
|
||||
if (
|
||||
enough_endpoint_hold
|
||||
and len(self.observations) >= self.minimum_valid_frames
|
||||
and self.state_span_u8 >= self.minimum_state_span_u8
|
||||
):
|
||||
self.state = "complete"
|
||||
return list(self.observations)
|
||||
return None
|
||||
|
||||
def poll(self, now: float) -> None:
|
||||
if not self.active:
|
||||
return
|
||||
now = float(now)
|
||||
if now - float(self.started_at) > self.timeout_seconds:
|
||||
self.state = "failed"
|
||||
self.reason = "sweep_timeout"
|
||||
elif now - float(self.last_valid_at) > self.invalid_timeout_seconds:
|
||||
self.state = "failed"
|
||||
self.reason = "synchronised_tag_state_timeout"
|
||||
|
||||
|
||||
def aggregate_sweep_observations(
|
||||
observations: Sequence[Observation],
|
||||
*,
|
||||
motor_index: int,
|
||||
start_u8: int,
|
||||
target_u8: int,
|
||||
endpoint_tolerance_u8: float,
|
||||
) -> dict[int, dict[str, Any]]:
|
||||
"""Robustly aggregate continuous observations into integer motor bins."""
|
||||
if not observations:
|
||||
raise ValueError("cannot aggregate an empty continuous sweep")
|
||||
bins: dict[int, list[Observation]] = {}
|
||||
for observation in observations:
|
||||
if len(observation.state_u8) != 20:
|
||||
continue
|
||||
value = float(observation.state_u8[motor_index])
|
||||
if abs(value - float(start_u8)) <= endpoint_tolerance_u8:
|
||||
command = int(start_u8)
|
||||
elif abs(value - float(target_u8)) <= endpoint_tolerance_u8:
|
||||
command = int(target_u8)
|
||||
else:
|
||||
command = int(np.clip(np.rint(value), 0, 255))
|
||||
bins.setdefault(command, []).append(observation)
|
||||
return {
|
||||
command: aggregate_observations(values)
|
||||
for command, values in sorted(bins.items())
|
||||
}
|
||||
|
||||
|
||||
class PointCollector:
|
||||
"""Wait for a stable pose, then aggregate a fixed number of frames."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
stable_frames: int = 15,
|
||||
capture_frames: int = 30,
|
||||
minimum_settle_seconds: float = 0.4,
|
||||
maximum_stable_spread_rad: float = np.deg2rad(0.3),
|
||||
settle_timeout_seconds: float = 5.0,
|
||||
capture_timeout_seconds: float = 5.0,
|
||||
) -> None:
|
||||
if stable_frames < 3 or capture_frames < 3:
|
||||
raise ValueError("stable_frames and capture_frames must be at least 3")
|
||||
self.stable_frames = int(stable_frames)
|
||||
self.capture_frames = int(capture_frames)
|
||||
self.minimum_settle_seconds = float(minimum_settle_seconds)
|
||||
self.maximum_stable_spread_rad = float(maximum_stable_spread_rad)
|
||||
self.settle_timeout_seconds = float(settle_timeout_seconds)
|
||||
self.capture_timeout_seconds = float(capture_timeout_seconds)
|
||||
self._stable: deque[Observation] = deque(maxlen=self.stable_frames)
|
||||
self._captured: list[Observation] = []
|
||||
self._consecutive_invalid_frames = 0
|
||||
self.started_at: float | None = None
|
||||
self.capture_started_at: float | None = None
|
||||
self.state = "idle"
|
||||
self.reason = ""
|
||||
|
||||
def start(self, now: float) -> None:
|
||||
self._stable.clear()
|
||||
self._captured.clear()
|
||||
self._consecutive_invalid_frames = 0
|
||||
self.started_at = float(now)
|
||||
self.capture_started_at = None
|
||||
self.state = "settling"
|
||||
self.reason = ""
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.state in {"settling", "capturing"}
|
||||
|
||||
@property
|
||||
def stable_frames_seen(self) -> int:
|
||||
return len(self._stable)
|
||||
|
||||
@property
|
||||
def capture_frames_seen(self) -> int:
|
||||
return len(self._captured)
|
||||
|
||||
def _window_is_stable(self) -> bool:
|
||||
if len(self._stable) < self.stable_frames:
|
||||
return False
|
||||
for pair in PAIR_NAMES:
|
||||
quaternions = [
|
||||
observation.relative_quaternion_xyzw[pair]
|
||||
for observation in self._stable
|
||||
]
|
||||
_, spread = robust_rotation_summary(quaternions)
|
||||
if spread > self.maximum_stable_spread_rad:
|
||||
self.reason = f"{pair}_not_stable"
|
||||
return False
|
||||
return True
|
||||
|
||||
def add(
|
||||
self, observation: Observation, now: float
|
||||
) -> dict[str, Any] | None:
|
||||
if not self.active:
|
||||
return None
|
||||
self._consecutive_invalid_frames = 0
|
||||
now = float(now)
|
||||
if self.state == "settling":
|
||||
self._stable.append(observation)
|
||||
elapsed = now - float(self.started_at)
|
||||
if elapsed >= self.minimum_settle_seconds and self._window_is_stable():
|
||||
self.state = "capturing"
|
||||
self.capture_started_at = now
|
||||
self._captured.clear()
|
||||
self.reason = ""
|
||||
return None
|
||||
|
||||
self._captured.append(observation)
|
||||
if len(self._captured) < self.capture_frames:
|
||||
return None
|
||||
self.state = "complete"
|
||||
return aggregate_observations(self._captured)
|
||||
|
||||
def poll(self, now: float) -> None:
|
||||
if not self.active:
|
||||
return
|
||||
now = float(now)
|
||||
if self.state == "settling":
|
||||
if now - float(self.started_at) > self.settle_timeout_seconds:
|
||||
self.state = "failed"
|
||||
self.reason = self.reason or "settle_timeout"
|
||||
elif self.state == "capturing":
|
||||
if now - float(self.capture_started_at) > self.capture_timeout_seconds:
|
||||
self.state = "failed"
|
||||
self.reason = "capture_timeout"
|
||||
|
||||
def mark_invalid_frame(self) -> None:
|
||||
"""Skip one invalid frame while retaining the recent valid window."""
|
||||
if self.state == "settling":
|
||||
self._consecutive_invalid_frames += 1
|
||||
if self._consecutive_invalid_frames >= 3:
|
||||
self._stable.clear()
|
||||
self.reason = "invalid_tag_frame"
|
||||
|
||||
|
||||
def aggregate_observations(
|
||||
observations: Sequence[Observation],
|
||||
) -> dict[str, Any]:
|
||||
if not observations:
|
||||
raise ValueError("cannot aggregate an empty observation sequence")
|
||||
relative: dict[str, list[float]] = {}
|
||||
spread: dict[str, float] = {}
|
||||
for pair in PAIR_NAMES:
|
||||
quaternion, maximum = robust_rotation_summary(
|
||||
[
|
||||
observation.relative_quaternion_xyzw[pair]
|
||||
for observation in observations
|
||||
]
|
||||
)
|
||||
relative[pair] = [float(value) for value in quaternion]
|
||||
spread[pair] = float(maximum)
|
||||
|
||||
quality: dict[str, dict[str, float]] = {}
|
||||
tag_names = sorted(observations[0].tag_quality)
|
||||
for tag_name in tag_names:
|
||||
values = [
|
||||
observation.tag_quality[tag_name] for observation in observations
|
||||
]
|
||||
quality[tag_name] = {
|
||||
"minimum_decision_margin": float(
|
||||
min(value.decision_margin for value in values)
|
||||
),
|
||||
"minimum_edge_pixels": float(min(value.edge_pixels for value in values)),
|
||||
"maximum_hamming": int(max(value.hamming for value in values)),
|
||||
}
|
||||
|
||||
states = [
|
||||
observation.state_u8
|
||||
for observation in observations
|
||||
if len(observation.state_u8) == 20
|
||||
]
|
||||
state_median: list[float] = []
|
||||
if states:
|
||||
state_median = [
|
||||
float(value)
|
||||
for value in np.median(np.asarray(states, dtype=float), axis=0)
|
||||
]
|
||||
sync_errors = [
|
||||
int(observation.state_sync_error_ns)
|
||||
for observation in observations
|
||||
if observation.state_sync_error_ns is not None
|
||||
]
|
||||
|
||||
return {
|
||||
"stamp_start_ns": int(observations[0].stamp_ns),
|
||||
"stamp_end_ns": int(observations[-1].stamp_ns),
|
||||
"valid_frames": len(observations),
|
||||
"relative_quaternion_xyzw": relative,
|
||||
"maximum_spread_rad": spread,
|
||||
"tag_quality": quality,
|
||||
"state_u8_median": state_median,
|
||||
"maximum_state_sync_error_ms": (
|
||||
None
|
||||
if not sync_errors
|
||||
else float(max(sync_errors)) / 1_000_000.0
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
"""Pure calibration math and command helpers.
|
||||
|
||||
This module deliberately has no ROS imports so the geometry, fitting, and
|
||||
output schema can be tested without a camera or a connected hand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
|
||||
COMMAND_NAMES: tuple[str, ...] = (
|
||||
"thumb_cmc_pitch",
|
||||
"index_mcp_pitch",
|
||||
"middle_mcp_pitch",
|
||||
"ring_mcp_pitch",
|
||||
"pinky_mcp_pitch",
|
||||
"thumb_cmc_roll",
|
||||
"index_mcp_roll",
|
||||
"middle_mcp_roll",
|
||||
"ring_mcp_roll",
|
||||
"pinky_mcp_roll",
|
||||
"thumb_cmc_yaw",
|
||||
"reserved_11",
|
||||
"reserved_12",
|
||||
"reserved_13",
|
||||
"reserved_14",
|
||||
"thumb_mcp",
|
||||
"index_pip",
|
||||
"middle_pip",
|
||||
"ring_pip",
|
||||
"pinky_pip",
|
||||
)
|
||||
|
||||
BASELINE_COMMAND: tuple[int, ...] = (
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
193,
|
||||
148,
|
||||
105,
|
||||
42,
|
||||
245,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
)
|
||||
|
||||
PAIR_ROOT = "t0_t3"
|
||||
PAIR_MCP = "t3_t4"
|
||||
PAIR_IP = "t4_t5"
|
||||
PAIR_NAMES: tuple[str, ...] = (PAIR_ROOT, PAIR_MCP, PAIR_IP)
|
||||
|
||||
DIRECTION_DECREASING = "decreasing"
|
||||
DIRECTION_INCREASING = "increasing"
|
||||
DIRECTIONS: tuple[str, ...] = (
|
||||
DIRECTION_DECREASING,
|
||||
DIRECTION_INCREASING,
|
||||
)
|
||||
|
||||
PHASE_ROOT = "root"
|
||||
PHASE_TIP = "tip"
|
||||
|
||||
JOINT_SPECS: dict[str, tuple[str, str, int]] = {
|
||||
"thumb_cmc_pitch": (PHASE_ROOT, PAIR_ROOT, 0),
|
||||
"thumb_mcp": (PHASE_TIP, PAIR_MCP, 15),
|
||||
"thumb_ip": (PHASE_TIP, PAIR_IP, 15),
|
||||
}
|
||||
|
||||
|
||||
def build_command(
|
||||
motor_index: int,
|
||||
command_u8: int,
|
||||
baseline: Sequence[int] = BASELINE_COMMAND,
|
||||
) -> list[int]:
|
||||
"""Return one full G20 command with exactly one replaced motor slot."""
|
||||
if len(baseline) != 20:
|
||||
raise ValueError("baseline must contain exactly 20 values")
|
||||
values = [int(value) for value in baseline]
|
||||
if any(value < 0 or value > 255 for value in values):
|
||||
raise ValueError("baseline values must be in [0, 255]")
|
||||
if motor_index not in (0, 15):
|
||||
raise ValueError("front thumb calibration only permits motor 0 or 15")
|
||||
command_u8 = int(command_u8)
|
||||
if command_u8 < 0 or command_u8 > 255:
|
||||
raise ValueError("command_u8 must be in [0, 255]")
|
||||
values[motor_index] = command_u8
|
||||
return values
|
||||
|
||||
|
||||
def scan_targets(
|
||||
repetitions: int = 3,
|
||||
command_step: int = 1,
|
||||
) -> list[tuple[int, str, int]]:
|
||||
"""Build repeated 255->0->255 scan targets on a bounded command grid."""
|
||||
if repetitions < 1:
|
||||
raise ValueError("repetitions must be positive")
|
||||
if command_step < 1 or command_step > 255:
|
||||
raise ValueError("command_step must be in [1, 255]")
|
||||
increasing = list(range(0, 256, command_step))
|
||||
if increasing[-1] != 255:
|
||||
increasing.append(255)
|
||||
decreasing = list(reversed(increasing))
|
||||
targets: list[tuple[int, str, int]] = []
|
||||
for cycle in range(repetitions):
|
||||
targets.extend(
|
||||
(cycle, DIRECTION_DECREASING, command)
|
||||
for command in decreasing
|
||||
)
|
||||
targets.extend(
|
||||
(cycle, DIRECTION_INCREASING, command)
|
||||
for command in increasing
|
||||
)
|
||||
return targets
|
||||
|
||||
|
||||
def normalize_quaternion_xyzw(values: Sequence[float]) -> np.ndarray:
|
||||
quaternion = np.asarray(values, dtype=float)
|
||||
if quaternion.shape != (4,) or not np.all(np.isfinite(quaternion)):
|
||||
raise ValueError("quaternion must contain four finite xyzw values")
|
||||
norm = float(np.linalg.norm(quaternion))
|
||||
if norm < 1e-12:
|
||||
raise ValueError("quaternion norm is zero")
|
||||
return quaternion / norm
|
||||
|
||||
|
||||
def relative_quaternion_xyzw(
|
||||
parent_camera_quaternion: Sequence[float],
|
||||
child_camera_quaternion: Sequence[float],
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Compute parent->child orientation from two camera->tag rotations."""
|
||||
parent = Rotation.from_quat(normalize_quaternion_xyzw(parent_camera_quaternion))
|
||||
child = Rotation.from_quat(normalize_quaternion_xyzw(child_camera_quaternion))
|
||||
quaternion = (parent.inv() * child).as_quat()
|
||||
return tuple(float(value) for value in quaternion)
|
||||
|
||||
|
||||
def image_plane_tag_quaternion_xyzw(
|
||||
corners_xy: Sequence[Sequence[float]],
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Estimate tag orientation about the optical axis from ordered corners."""
|
||||
corners = np.asarray(corners_xy, dtype=float)
|
||||
if corners.shape != (4, 2) or not np.all(np.isfinite(corners)):
|
||||
raise ValueError("corners_xy must contain four finite xy points")
|
||||
# AprilTag corners 0->1 and 3->2 both follow the tag-local x axis.
|
||||
# Average the two edges to reduce sub-pixel corner noise and perspective
|
||||
# asymmetry. Image y points down, hence the minus sign for a right-handed
|
||||
# camera-frame z rotation.
|
||||
x_axis = (corners[1] - corners[0]) + (corners[2] - corners[3])
|
||||
if float(np.linalg.norm(x_axis)) < 1e-9:
|
||||
raise ValueError("tag x-axis is degenerate")
|
||||
angle = -math.atan2(float(x_axis[1]), float(x_axis[0]))
|
||||
quaternion = Rotation.from_rotvec([0.0, 0.0, angle]).as_quat()
|
||||
return tuple(float(value) for value in quaternion)
|
||||
|
||||
|
||||
def robust_rotation_summary(
|
||||
quaternions_xyzw: Sequence[Sequence[float]],
|
||||
) -> tuple[tuple[float, float, float, float], float]:
|
||||
"""Return a robust orientation and maximum angular residual in radians."""
|
||||
if not quaternions_xyzw:
|
||||
raise ValueError("at least one quaternion is required")
|
||||
rotations = Rotation.from_quat(
|
||||
np.asarray(
|
||||
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
|
||||
dtype=float,
|
||||
)
|
||||
)
|
||||
reference = rotations[0]
|
||||
delta_vectors = (reference.inv() * rotations).as_rotvec()
|
||||
median_delta = np.median(delta_vectors, axis=0)
|
||||
robust = reference * Rotation.from_rotvec(median_delta)
|
||||
residuals = (robust.inv() * rotations).magnitude()
|
||||
maximum = float(np.max(residuals)) if residuals.size else 0.0
|
||||
return (
|
||||
tuple(float(value) for value in robust.as_quat()),
|
||||
maximum,
|
||||
)
|
||||
|
||||
|
||||
def rotation_spread_rad(
|
||||
quaternions_xyzw: Sequence[Sequence[float]],
|
||||
) -> float:
|
||||
"""Return the maximum geodesic residual around a robust orientation."""
|
||||
_, spread = robust_rotation_summary(quaternions_xyzw)
|
||||
return spread
|
||||
|
||||
|
||||
def rotation_rms_rad(
|
||||
quaternions_xyzw: Sequence[Sequence[float]],
|
||||
*,
|
||||
outlier_threshold_rad: float | None = None,
|
||||
) -> float:
|
||||
"""Return RMS geodesic noise around a robust orientation."""
|
||||
robust, _ = robust_rotation_summary(quaternions_xyzw)
|
||||
reference = Rotation.from_quat(robust)
|
||||
rotations = Rotation.from_quat(
|
||||
np.asarray(
|
||||
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
|
||||
dtype=float,
|
||||
)
|
||||
)
|
||||
residuals = (reference.inv() * rotations).magnitude()
|
||||
if outlier_threshold_rad is not None:
|
||||
threshold = float(outlier_threshold_rad)
|
||||
if threshold <= 0.0:
|
||||
raise ValueError("outlier_threshold_rad must be positive")
|
||||
residuals = residuals[residuals <= threshold]
|
||||
if residuals.size == 0:
|
||||
return float("inf")
|
||||
return float(np.sqrt(np.mean(np.square(residuals))))
|
||||
|
||||
|
||||
def rotation_inlier_fraction(
|
||||
quaternions_xyzw: Sequence[Sequence[float]],
|
||||
*,
|
||||
outlier_threshold_rad: float,
|
||||
) -> float:
|
||||
"""Return the fraction close to the robust orientation."""
|
||||
threshold = float(outlier_threshold_rad)
|
||||
if threshold <= 0.0:
|
||||
raise ValueError("outlier_threshold_rad must be positive")
|
||||
robust, _ = robust_rotation_summary(quaternions_xyzw)
|
||||
reference = Rotation.from_quat(robust)
|
||||
rotations = Rotation.from_quat(
|
||||
np.asarray(
|
||||
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
|
||||
dtype=float,
|
||||
)
|
||||
)
|
||||
residuals = (reference.inv() * rotations).magnitude()
|
||||
return float(np.mean(residuals <= threshold))
|
||||
|
||||
|
||||
def delta_rotation_vector(
|
||||
reference_xyzw: Sequence[float],
|
||||
observed_xyzw: Sequence[float],
|
||||
) -> np.ndarray:
|
||||
reference = Rotation.from_quat(normalize_quaternion_xyzw(reference_xyzw))
|
||||
observed = Rotation.from_quat(normalize_quaternion_xyzw(observed_xyzw))
|
||||
return (reference.inv() * observed).as_rotvec()
|
||||
|
||||
|
||||
def fit_rotation_axis(
|
||||
vectors: Sequence[Sequence[float]],
|
||||
commands: Sequence[int],
|
||||
) -> np.ndarray:
|
||||
"""Fit and orient the single rotational axis used by one motor sweep."""
|
||||
matrix = np.asarray(vectors, dtype=float)
|
||||
command_values = np.asarray(commands, dtype=int)
|
||||
if matrix.ndim != 2 or matrix.shape[1] != 3:
|
||||
raise ValueError("vectors must have shape (N, 3)")
|
||||
if command_values.shape != (matrix.shape[0],):
|
||||
raise ValueError("commands must match vectors")
|
||||
useful = np.linalg.norm(matrix, axis=1) > 1e-6
|
||||
if int(np.count_nonzero(useful)) < 3:
|
||||
raise ValueError("insufficient non-zero rotations to fit an axis")
|
||||
_, _, vh = np.linalg.svd(matrix[useful], full_matrices=False)
|
||||
axis = vh[0]
|
||||
projections = matrix @ axis
|
||||
low = projections[command_values <= 16]
|
||||
high = projections[command_values >= 239]
|
||||
if low.size and high.size and float(np.median(low)) < float(np.median(high)):
|
||||
axis = -axis
|
||||
return axis / np.linalg.norm(axis)
|
||||
|
||||
|
||||
def isotonic_nonincreasing(values: Sequence[float]) -> np.ndarray:
|
||||
"""Unweighted PAVA projection onto non-increasing values."""
|
||||
original = np.asarray(values, dtype=float)
|
||||
if original.ndim != 1 or not np.all(np.isfinite(original)):
|
||||
raise ValueError("values must be a finite vector")
|
||||
negated = -original
|
||||
levels: list[float] = []
|
||||
weights: list[int] = []
|
||||
starts: list[int] = []
|
||||
for index, value in enumerate(negated):
|
||||
levels.append(float(value))
|
||||
weights.append(1)
|
||||
starts.append(index)
|
||||
while len(levels) >= 2 and levels[-2] > levels[-1]:
|
||||
total_weight = weights[-2] + weights[-1]
|
||||
merged = (
|
||||
levels[-2] * weights[-2] + levels[-1] * weights[-1]
|
||||
) / total_weight
|
||||
levels[-2:] = [merged]
|
||||
weights[-2:] = [total_weight]
|
||||
starts.pop()
|
||||
projected = np.empty_like(original)
|
||||
for block_index, (level, start) in enumerate(zip(levels, starts)):
|
||||
end = starts[block_index + 1] if block_index + 1 < len(starts) else len(original)
|
||||
projected[start:end] = -level
|
||||
return projected
|
||||
|
||||
|
||||
def _record_rotation(record: Mapping[str, Any], pair: str) -> tuple[float, ...]:
|
||||
rotations = record.get("relative_quaternion_xyzw", {})
|
||||
value = rotations.get(pair)
|
||||
if value is None:
|
||||
raise ValueError(f"sample record is missing {pair}")
|
||||
return tuple(float(component) for component in value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FitResult:
|
||||
joints: dict[str, dict[str, Any]]
|
||||
axes: dict[str, tuple[float, float, float]]
|
||||
references: dict[str, tuple[float, float, float, float]]
|
||||
ip_coupling: dict[str, float]
|
||||
max_monotonic_correction_rad: float
|
||||
max_hysteresis_rad: float
|
||||
|
||||
def measure_from_reference(
|
||||
self,
|
||||
joint_name: str,
|
||||
observed_quaternion_xyzw: Sequence[float],
|
||||
reference_quaternion_xyzw: Sequence[float] | None = None,
|
||||
) -> float:
|
||||
reference = (
|
||||
reference_quaternion_xyzw
|
||||
if reference_quaternion_xyzw is not None
|
||||
else self.references[joint_name]
|
||||
)
|
||||
vector = delta_rotation_vector(reference, observed_quaternion_xyzw)
|
||||
axis = np.asarray(self.axes[joint_name], dtype=float)
|
||||
return float(vector @ axis)
|
||||
|
||||
|
||||
def fit_calibration_curves(records: Iterable[Mapping[str, Any]]) -> FitResult:
|
||||
"""Fit six complete 256-entry curves from dense or sparse scan records."""
|
||||
samples = [
|
||||
dict(record)
|
||||
for record in records
|
||||
if record.get("kind", "sample") == "sample"
|
||||
]
|
||||
if not samples:
|
||||
raise ValueError("no scan records were provided")
|
||||
|
||||
joint_results: dict[str, dict[str, Any]] = {}
|
||||
axes: dict[str, tuple[float, float, float]] = {}
|
||||
references: dict[str, tuple[float, float, float, float]] = {}
|
||||
maximum_correction = 0.0
|
||||
maximum_hysteresis = 0.0
|
||||
|
||||
for joint_name, (phase, pair, motor_index) in JOINT_SPECS.items():
|
||||
phase_records = [record for record in samples if record.get("phase") == phase]
|
||||
if not phase_records:
|
||||
raise ValueError(f"no records for phase {phase}")
|
||||
|
||||
cycle_references: dict[int, tuple[float, ...]] = {}
|
||||
for record in phase_records:
|
||||
if (
|
||||
record.get("direction") == DIRECTION_DECREASING
|
||||
and int(record.get("command_u8", -1)) == 255
|
||||
):
|
||||
cycle_references.setdefault(
|
||||
int(record["cycle"]),
|
||||
_record_rotation(record, pair),
|
||||
)
|
||||
cycles = sorted({int(record["cycle"]) for record in phase_records})
|
||||
if any(cycle not in cycle_references for cycle in cycles):
|
||||
raise ValueError(f"{joint_name} is missing a command-255 cycle reference")
|
||||
|
||||
vectors: list[np.ndarray] = []
|
||||
commands: list[int] = []
|
||||
indexed: list[tuple[Mapping[str, Any], np.ndarray]] = []
|
||||
for record in phase_records:
|
||||
cycle = int(record["cycle"])
|
||||
vector = delta_rotation_vector(
|
||||
cycle_references[cycle],
|
||||
_record_rotation(record, pair),
|
||||
)
|
||||
vectors.append(vector)
|
||||
commands.append(int(record["command_u8"]))
|
||||
indexed.append((record, vector))
|
||||
axis = fit_rotation_axis(vectors, commands)
|
||||
axes[joint_name] = tuple(float(value) for value in axis)
|
||||
references[joint_name] = robust_rotation_summary(
|
||||
list(cycle_references.values())
|
||||
)[0]
|
||||
|
||||
branch_values: dict[str, list[list[float]]] = {
|
||||
direction: [[] for _ in range(256)] for direction in DIRECTIONS
|
||||
}
|
||||
for record, vector in indexed:
|
||||
direction = str(record["direction"])
|
||||
command = int(record["command_u8"])
|
||||
branch_values[direction][command].append(float(vector @ axis))
|
||||
|
||||
fitted_branches: dict[str, list[float]] = {}
|
||||
for direction in DIRECTIONS:
|
||||
sample_commands = np.asarray(
|
||||
[
|
||||
command
|
||||
for command, values in enumerate(branch_values[direction])
|
||||
if values
|
||||
],
|
||||
dtype=int,
|
||||
)
|
||||
if (
|
||||
sample_commands.size < 3
|
||||
or int(sample_commands[0]) != 0
|
||||
or int(sample_commands[-1]) != 255
|
||||
):
|
||||
raise ValueError(
|
||||
f"{joint_name}.{direction} requires at least three samples "
|
||||
"including commands 0 and 255"
|
||||
)
|
||||
raw = np.asarray(
|
||||
[
|
||||
float(np.median(branch_values[direction][command]))
|
||||
for command in sample_commands
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
raw -= raw[-1]
|
||||
projected_samples = isotonic_nonincreasing(raw)
|
||||
projected_samples -= projected_samples[-1]
|
||||
correction = float(np.max(np.abs(projected_samples - raw)))
|
||||
maximum_correction = max(maximum_correction, correction)
|
||||
projected = np.interp(
|
||||
np.arange(256, dtype=float),
|
||||
sample_commands.astype(float),
|
||||
projected_samples,
|
||||
)
|
||||
projected -= projected[255]
|
||||
fitted_branches[direction] = [
|
||||
round(float(value), 8) for value in projected
|
||||
]
|
||||
|
||||
hysteresis = float(
|
||||
np.max(
|
||||
np.abs(
|
||||
np.asarray(fitted_branches[DIRECTION_DECREASING])
|
||||
- np.asarray(fitted_branches[DIRECTION_INCREASING])
|
||||
)
|
||||
)
|
||||
)
|
||||
maximum_hysteresis = max(maximum_hysteresis, hysteresis)
|
||||
combined_curve = 0.5 * (
|
||||
np.asarray(
|
||||
fitted_branches[DIRECTION_DECREASING], dtype=float
|
||||
)
|
||||
+ np.asarray(
|
||||
fitted_branches[DIRECTION_INCREASING], dtype=float
|
||||
)
|
||||
)
|
||||
combined_curve -= combined_curve[255]
|
||||
joint_result: dict[str, Any] = {
|
||||
"motor_index": motor_index,
|
||||
"angle_rad": [
|
||||
round(float(value), 8) for value in combined_curve
|
||||
],
|
||||
"decreasing_rad": fitted_branches[DIRECTION_DECREASING],
|
||||
"increasing_rad": fitted_branches[DIRECTION_INCREASING],
|
||||
}
|
||||
if joint_name == "thumb_ip":
|
||||
joint_result["passive"] = True
|
||||
joint_results[joint_name] = joint_result
|
||||
|
||||
mcp = joint_results["thumb_mcp"]
|
||||
ip = joint_results["thumb_ip"]
|
||||
x = np.asarray(mcp["angle_rad"], dtype=float)
|
||||
y = np.asarray(ip["angle_rad"], dtype=float)
|
||||
design = np.column_stack((x, np.ones_like(x)))
|
||||
multiplier, offset = np.linalg.lstsq(design, y, rcond=None)[0]
|
||||
predicted = multiplier * x + offset
|
||||
residual_sum = float(np.sum((y - predicted) ** 2))
|
||||
total_sum = float(np.sum((y - np.mean(y)) ** 2))
|
||||
r_squared = 1.0 if total_sum < 1e-12 else 1.0 - residual_sum / total_sum
|
||||
|
||||
return FitResult(
|
||||
joints=joint_results,
|
||||
axes=axes,
|
||||
references=references,
|
||||
ip_coupling={
|
||||
"multiplier": round(float(multiplier), 8),
|
||||
"offset_rad": round(float(offset), 8),
|
||||
"r_squared": round(float(r_squared), 8),
|
||||
},
|
||||
max_monotonic_correction_rad=maximum_correction,
|
||||
max_hysteresis_rad=maximum_hysteresis,
|
||||
)
|
||||
|
||||
|
||||
def create_final_payload(
|
||||
*,
|
||||
serial_number: str,
|
||||
fit: FitResult,
|
||||
validation_errors_rad: Sequence[float],
|
||||
passed: bool,
|
||||
baseline: Sequence[int] = BASELINE_COMMAND,
|
||||
) -> dict[str, Any]:
|
||||
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)) if errors.size else float("nan")
|
||||
runtime_joints: dict[str, dict[str, Any]] = {}
|
||||
for joint_name, joint in fit.joints.items():
|
||||
runtime_joint: dict[str, Any] = {
|
||||
"motor_index": int(joint["motor_index"]),
|
||||
"angle_rad": [
|
||||
round(float(value), 8) for value in joint["angle_rad"]
|
||||
],
|
||||
}
|
||||
if joint_name == "thumb_ip":
|
||||
runtime_joint["passive"] = True
|
||||
runtime_joints[joint_name] = runtime_joint
|
||||
|
||||
payload = {
|
||||
"schema_version": 2,
|
||||
"model": "G20",
|
||||
"side": "left",
|
||||
"serial_number": str(serial_number),
|
||||
"angle_unit": "rad",
|
||||
"command_range": [0, 255],
|
||||
"zero_command_u8": 255,
|
||||
"baseline_command_u8": [int(value) for value in baseline],
|
||||
"joints": runtime_joints,
|
||||
"ip_coupling": {
|
||||
"multiplier": fit.ip_coupling["multiplier"],
|
||||
"offset_rad": fit.ip_coupling["offset_rad"],
|
||||
},
|
||||
"quality": {
|
||||
"passed": bool(passed),
|
||||
"validation_mae_rad": None if not np.isfinite(mae) else round(mae, 8),
|
||||
"validation_p95_rad": None if not np.isfinite(p95) else round(p95, 8),
|
||||
},
|
||||
}
|
||||
validate_final_payload(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def maximum_non_target_drift_rad(
|
||||
records: Iterable[Mapping[str, Any]],
|
||||
fit: FitResult,
|
||||
) -> float:
|
||||
"""Measure unintended active-joint motion during the two isolated scans."""
|
||||
samples = [
|
||||
dict(record)
|
||||
for record in records
|
||||
if record.get("kind", "sample") == "sample"
|
||||
]
|
||||
maximum = 0.0
|
||||
checks = (
|
||||
(PHASE_ROOT, "thumb_mcp", PAIR_MCP),
|
||||
(PHASE_ROOT, "thumb_ip", PAIR_IP),
|
||||
(PHASE_TIP, "thumb_cmc_pitch", PAIR_ROOT),
|
||||
)
|
||||
for phase, joint_name, pair in checks:
|
||||
phase_records = [record for record in samples if record.get("phase") == phase]
|
||||
for cycle in sorted({int(record["cycle"]) for record in phase_records}):
|
||||
cycle_records = [
|
||||
record for record in phase_records if int(record["cycle"]) == cycle
|
||||
]
|
||||
reference_record = next(
|
||||
(
|
||||
record
|
||||
for record in cycle_records
|
||||
if record.get("direction") == DIRECTION_DECREASING
|
||||
and int(record.get("command_u8", -1)) == 255
|
||||
),
|
||||
None,
|
||||
)
|
||||
if reference_record is None:
|
||||
continue
|
||||
reference = _record_rotation(reference_record, pair)
|
||||
axis = np.asarray(fit.axes[joint_name], dtype=float)
|
||||
for record in cycle_records:
|
||||
drift = abs(
|
||||
float(
|
||||
delta_rotation_vector(
|
||||
reference,
|
||||
_record_rotation(record, pair),
|
||||
)
|
||||
@ axis
|
||||
)
|
||||
)
|
||||
maximum = max(maximum, drift)
|
||||
return maximum
|
||||
|
||||
|
||||
def validate_final_payload(payload: Mapping[str, Any]) -> None:
|
||||
"""Validate the deliberately small runtime JSON schema."""
|
||||
if payload.get("schema_version") != 2:
|
||||
raise ValueError("schema_version must be 2")
|
||||
if payload.get("model") != "G20" or payload.get("side") != "left":
|
||||
raise ValueError("payload must describe a left G20")
|
||||
if payload.get("angle_unit") != "rad":
|
||||
raise ValueError("angle_unit must be rad")
|
||||
baseline = payload.get("baseline_command_u8")
|
||||
if not isinstance(baseline, list) or len(baseline) != 20:
|
||||
raise ValueError("baseline_command_u8 must contain 20 values")
|
||||
joints = payload.get("joints")
|
||||
if not isinstance(joints, Mapping) or set(joints) != set(JOINT_SPECS):
|
||||
raise ValueError("payload must contain exactly the three thumb joints")
|
||||
for joint_name, joint in joints.items():
|
||||
expected_motor = JOINT_SPECS[joint_name][2]
|
||||
if int(joint.get("motor_index", -1)) != expected_motor:
|
||||
raise ValueError(f"{joint_name} has the wrong motor index")
|
||||
curve = joint.get("angle_rad")
|
||||
if not isinstance(curve, list) or len(curve) != 256:
|
||||
raise ValueError(
|
||||
f"{joint_name}.angle_rad must contain 256 values"
|
||||
)
|
||||
values = np.asarray(curve, dtype=float)
|
||||
if not np.all(np.isfinite(values)):
|
||||
raise ValueError(
|
||||
f"{joint_name}.angle_rad contains non-finite values"
|
||||
)
|
||||
if np.any(np.diff(values) > 1e-7):
|
||||
raise ValueError(
|
||||
f"{joint_name}.angle_rad must be non-increasing"
|
||||
)
|
||||
if abs(float(values[255])) > 1e-6:
|
||||
raise ValueError(
|
||||
f"{joint_name}.angle_rad[255] must be zero"
|
||||
)
|
||||
if joints["thumb_ip"].get("passive") is not True:
|
||||
raise ValueError("thumb_ip must be marked passive")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
"""Crash-safe session storage for hardware calibration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
|
||||
def atomic_write_json(path: str | Path, payload: Mapping[str, Any]) -> None:
|
||||
destination = Path(path)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
||||
with temporary.open("w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, ensure_ascii=False, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, destination)
|
||||
|
||||
|
||||
def append_jsonl(path: str | Path, payload: Mapping[str, Any]) -> None:
|
||||
destination = Path(path)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
with destination.open("a", encoding="utf-8") as stream:
|
||||
stream.write(line + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def load_jsonl(path: str | Path) -> list[dict[str, Any]]:
|
||||
source = Path(path)
|
||||
if not source.exists():
|
||||
return []
|
||||
records: list[dict[str, Any]] = []
|
||||
with source.open("r", encoding="utf-8") as stream:
|
||||
lines = stream.readlines()
|
||||
nonempty_lines = [
|
||||
index for index, line in enumerate(lines, 1) if line.strip()
|
||||
]
|
||||
last_nonempty_line = nonempty_lines[-1] if nonempty_lines else 0
|
||||
for line_number, line in enumerate(lines, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
if line_number == last_nonempty_line:
|
||||
break
|
||||
raise ValueError(
|
||||
f"{source}:{line_number}: invalid JSONL record"
|
||||
) from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{source}:{line_number}: record must be an object")
|
||||
records.append(value)
|
||||
return records
|
||||
|
||||
|
||||
def load_json(path: str | Path) -> dict[str, Any] | None:
|
||||
source = Path(path)
|
||||
if not source.exists():
|
||||
return None
|
||||
with source.open("r", encoding="utf-8") as stream:
|
||||
value = json.load(stream)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{source} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def completed_scan_keys(
|
||||
records: Iterable[Mapping[str, Any]],
|
||||
) -> set[tuple[str, int, str, int]]:
|
||||
keys: set[tuple[str, int, str, int]] = set()
|
||||
for record in records:
|
||||
if record.get("kind", "sample") != "sample":
|
||||
continue
|
||||
keys.add(
|
||||
(
|
||||
str(record["phase"]),
|
||||
int(record["cycle"]),
|
||||
str(record["direction"]),
|
||||
int(record["command_u8"]),
|
||||
)
|
||||
)
|
||||
return keys
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Launch the complete front-camera G20 thumb calibration stack."""
|
||||
|
||||
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,
|
||||
IncludeLaunchDescription,
|
||||
LogInfo,
|
||||
OpaqueFunction,
|
||||
)
|
||||
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
|
||||
from launch_ros.parameter_descriptions import ParameterValue
|
||||
|
||||
|
||||
def _launch_stack(context):
|
||||
serial_number = LaunchConfiguration("serial_number").perform(context)
|
||||
if not serial_number or serial_number == "UNSET":
|
||||
raise RuntimeError(
|
||||
"serial_number is required, for example serial_number:=G20_LEFT_001"
|
||||
)
|
||||
if (
|
||||
re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
|
||||
or serial_number in {".", ".."}
|
||||
):
|
||||
raise RuntimeError(
|
||||
"serial_number may contain only letters, digits, dot, underscore and dash"
|
||||
)
|
||||
requested_session = LaunchConfiguration("session_dir").perform(context)
|
||||
output_root = Path(LaunchConfiguration("output_root").perform(context)).resolve()
|
||||
if requested_session:
|
||||
session_dir = Path(requested_session).expanduser().resolve()
|
||||
else:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
session_dir = output_root / serial_number / timestamp
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
bag_path = session_dir / "rosbag"
|
||||
|
||||
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")
|
||||
use_roi = use_roi_text == "true"
|
||||
|
||||
roi_values = {}
|
||||
for name in ("roi_x", "roi_y", "roi_width", "roi_height"):
|
||||
text = LaunchConfiguration(name).perform(context)
|
||||
try:
|
||||
roi_values[name] = int(text)
|
||||
except ValueError as error:
|
||||
raise RuntimeError(f"{name} must be an integer") from error
|
||||
if roi_values["roi_x"] < 0 or roi_values["roi_y"] < 0:
|
||||
raise RuntimeError("roi_x and roi_y must be non-negative")
|
||||
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))
|
||||
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 "
|
||||
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)),
|
||||
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(),
|
||||
)
|
||||
|
||||
vision_components = []
|
||||
if use_roi:
|
||||
processed_image_raw_topic = "/g20_thumb_roi/image_raw"
|
||||
processed_camera_info_topic = "/g20_thumb_roi/camera_info"
|
||||
processed_image_rect_topic = "/g20_thumb_roi/image_rect"
|
||||
vision_components.append(
|
||||
ComposableNode(
|
||||
package="image_proc",
|
||||
plugin="image_proc::CropDecimateNode",
|
||||
name="crop_color_roi",
|
||||
namespace="/g20_thumb_roi",
|
||||
remappings=[
|
||||
("in/image_raw", "/camera/camera/color/image_raw"),
|
||||
("in/camera_info", "/camera/camera/color/camera_info"),
|
||||
("out/image_raw", processed_image_raw_topic),
|
||||
("out/camera_info", processed_camera_info_topic),
|
||||
],
|
||||
parameters=[
|
||||
{
|
||||
"queue_size": 5,
|
||||
"decimation_x": 1,
|
||||
"decimation_y": 1,
|
||||
"offset_x": roi_values["roi_x"],
|
||||
"offset_y": roi_values["roi_y"],
|
||||
"width": roi_values["roi_width"],
|
||||
"height": roi_values["roi_height"],
|
||||
}
|
||||
],
|
||||
extra_arguments=[{"use_intra_process_comms": True}],
|
||||
)
|
||||
)
|
||||
rectifier_namespace = "/g20_thumb_roi"
|
||||
rectifier_name = "rectify_color_roi"
|
||||
else:
|
||||
processed_image_raw_topic = "/camera/camera/color/image_raw"
|
||||
processed_camera_info_topic = "/camera/camera/color/camera_info"
|
||||
processed_image_rect_topic = "/camera/camera/color/image_rect"
|
||||
rectifier_namespace = "/camera/camera/color"
|
||||
rectifier_name = "rectify_color"
|
||||
|
||||
vision_components.append(
|
||||
ComposableNode(
|
||||
package="image_proc",
|
||||
plugin="image_proc::RectifyNode",
|
||||
name=rectifier_name,
|
||||
namespace=rectifier_namespace,
|
||||
remappings=[
|
||||
("image", processed_image_raw_topic),
|
||||
("camera_info", processed_camera_info_topic),
|
||||
("image_rect", processed_image_rect_topic),
|
||||
],
|
||||
parameters=[{"queue_size": 1}],
|
||||
extra_arguments=[{"use_intra_process_comms": True}],
|
||||
)
|
||||
)
|
||||
|
||||
vision_components.append(
|
||||
ComposableNode(
|
||||
package="apriltag_ros",
|
||||
plugin="AprilTagNode",
|
||||
name="apriltag",
|
||||
namespace="/apriltag",
|
||||
parameters=[
|
||||
tag_config,
|
||||
{
|
||||
"detector.decimate": ParameterValue(
|
||||
LaunchConfiguration("apriltag_decimate"),
|
||||
value_type=float,
|
||||
)
|
||||
},
|
||||
],
|
||||
remappings=[
|
||||
("image_rect", processed_image_rect_topic),
|
||||
("camera_info", processed_camera_info_topic),
|
||||
],
|
||||
extra_arguments=[{"use_intra_process_comms": True}],
|
||||
)
|
||||
)
|
||||
|
||||
vision_container = ComposableNodeContainer(
|
||||
name="g20_thumb_vision_container",
|
||||
namespace="/",
|
||||
package="rclcpp_components",
|
||||
executable="component_container_mt",
|
||||
composable_node_descriptions=vision_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="calibration_node",
|
||||
name="g20_thumb_calibration",
|
||||
output="screen",
|
||||
parameters=[
|
||||
calibration_config,
|
||||
tag_config,
|
||||
{
|
||||
"serial_number": serial_number,
|
||||
"session_dir": str(session_dir),
|
||||
"commands_enabled": LaunchConfiguration("commands_enabled"),
|
||||
"calibration_speed": ParameterValue(
|
||||
LaunchConfiguration("calibration_speed"),
|
||||
value_type=int,
|
||||
),
|
||||
"continuous_motion_mode": ParameterValue(
|
||||
LaunchConfiguration("continuous_motion_mode"),
|
||||
value_type=str,
|
||||
),
|
||||
"camera_serial_number": LaunchConfiguration("camera_serial_number"),
|
||||
"rosbag_path": str(bag_path),
|
||||
"camera_info_topic": processed_camera_info_topic,
|
||||
"image_topic": processed_image_rect_topic,
|
||||
"publish_debug_image": LaunchConfiguration(
|
||||
"publish_debug_image"
|
||||
),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
bag = ExecuteProcess(
|
||||
condition=IfCondition(LaunchConfiguration("record_bag")),
|
||||
cmd=[
|
||||
"ros2",
|
||||
"bag",
|
||||
"record",
|
||||
"--storage",
|
||||
"mcap",
|
||||
"--storage-preset-profile",
|
||||
"zstd_fast",
|
||||
"--max-bag-size",
|
||||
"10737418240",
|
||||
"--output",
|
||||
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",
|
||||
"/g20/cb_left_hand_state",
|
||||
"/g20/cb_left_hand_info",
|
||||
"/g20_thumb_calibration/status",
|
||||
],
|
||||
output="screen",
|
||||
)
|
||||
|
||||
actions = [
|
||||
LogInfo(msg=f"G20 thumb calibration session: {session_dir}"),
|
||||
LogInfo(
|
||||
msg=(
|
||||
"G20 thumb image ROI: "
|
||||
f"x={roi_values['roi_x']}, y={roi_values['roi_y']}, "
|
||||
f"width={roi_values['roi_width']}, "
|
||||
f"height={roi_values['roi_height']}"
|
||||
if use_roi
|
||||
else "G20 thumb image ROI: disabled"
|
||||
)
|
||||
),
|
||||
camera,
|
||||
vision_container,
|
||||
]
|
||||
actions.extend([sdk, calibration, bag])
|
||||
return actions
|
||||
|
||||
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
package_share = Path(
|
||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
||||
)
|
||||
default_output = str(Path.cwd() / "calibration_output")
|
||||
return LaunchDescription(
|
||||
[
|
||||
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
||||
DeclareLaunchArgument("camera_serial_number", default_value=""),
|
||||
DeclareLaunchArgument(
|
||||
"color_profile", default_value="1280x720x30"
|
||||
),
|
||||
DeclareLaunchArgument(
|
||||
"depth_profile", default_value="640x480x30"
|
||||
),
|
||||
DeclareLaunchArgument("enable_depth", default_value="false"),
|
||||
DeclareLaunchArgument(
|
||||
"publish_debug_image", default_value="false"
|
||||
),
|
||||
DeclareLaunchArgument("use_roi", default_value="false"),
|
||||
DeclareLaunchArgument("roi_x", default_value="128"),
|
||||
DeclareLaunchArgument("roi_y", default_value="192"),
|
||||
DeclareLaunchArgument("roi_width", default_value="1024"),
|
||||
DeclareLaunchArgument("roi_height", default_value="528"),
|
||||
DeclareLaunchArgument("can_interface", default_value="can0"),
|
||||
DeclareLaunchArgument("calibration_speed", default_value="15"),
|
||||
DeclareLaunchArgument(
|
||||
"continuous_motion_mode", default_value="endpoint"
|
||||
),
|
||||
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
|
||||
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
||||
DeclareLaunchArgument("start_camera", default_value="true"),
|
||||
DeclareLaunchArgument("start_sdk", default_value="true"),
|
||||
DeclareLaunchArgument("record_bag", default_value="false"),
|
||||
DeclareLaunchArgument("output_root", default_value=default_output),
|
||||
DeclareLaunchArgument("session_dir", default_value=""),
|
||||
DeclareLaunchArgument(
|
||||
"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"),
|
||||
),
|
||||
OpaqueFunction(function=_launch_stack),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>g20_thumb_apriltag_calibration</name>
|
||||
<version>0.1.0</version>
|
||||
<description>Front RealSense AprilTag calibration for the left G20 thumb.</description>
|
||||
<maintainer email="support@linker-robotics.com">lxp</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<exec_depend>ament_index_python</exec_depend>
|
||||
<exec_depend>apriltag_msgs</exec_depend>
|
||||
<exec_depend>apriltag_ros</exec_depend>
|
||||
<exec_depend>cv_bridge</exec_depend>
|
||||
<exec_depend>image_proc</exec_depend>
|
||||
<exec_depend>launch</exec_depend>
|
||||
<exec_depend>launch_ros</exec_depend>
|
||||
<exec_depend>linker_hand_ros2_sdk</exec_depend>
|
||||
<exec_depend>rclcpp_components</exec_depend>
|
||||
<exec_depend>rclpy</exec_depend>
|
||||
<exec_depend>realsense2_camera</exec_depend>
|
||||
<exec_depend>rosbag2</exec_depend>
|
||||
<exec_depend>sensor_msgs</exec_depend>
|
||||
<exec_depend>std_msgs</exec_depend>
|
||||
<exec_depend>std_srvs</exec_depend>
|
||||
<exec_depend>tf2_msgs</exec_depend>
|
||||
<exec_depend>python3-numpy</exec_depend>
|
||||
<exec_depend>python3-opencv</exec_depend>
|
||||
<exec_depend>python3-scipy</exec_depend>
|
||||
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/g20_thumb_apriltag_calibration
|
||||
[install]
|
||||
install_scripts=$base/lib/g20_thumb_apriltag_calibration
|
||||
@@ -0,0 +1,33 @@
|
||||
from glob import glob
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
|
||||
package_name = "g20_thumb_apriltag_calibration"
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version="0.1.0",
|
||||
packages=find_packages(),
|
||||
data_files=[
|
||||
(
|
||||
"share/ament_index/resource_index/packages",
|
||||
["resource/" + package_name],
|
||||
),
|
||||
("share/" + package_name, ["package.xml", "README.md"]),
|
||||
("share/" + package_name + "/config", glob("config/*.yaml")),
|
||||
("share/" + package_name + "/launch", glob("launch/*.launch.py")),
|
||||
],
|
||||
install_requires=["setuptools", "numpy", "scipy"],
|
||||
tests_require=["pytest"],
|
||||
zip_safe=True,
|
||||
maintainer="lxp",
|
||||
maintainer_email="support@linker-robotics.com",
|
||||
description="Front RealSense AprilTag calibration for the left G20 thumb",
|
||||
license="MIT",
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"calibration_node = g20_thumb_apriltag_calibration.node:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from g20_thumb_apriltag_calibration.acquisition import (
|
||||
ContinuousSweepCollector,
|
||||
Observation,
|
||||
PointCollector,
|
||||
StateSample,
|
||||
TagQuality,
|
||||
aggregate_observations,
|
||||
aggregate_sweep_observations,
|
||||
interpolate_state_u8,
|
||||
tag_quality_is_valid,
|
||||
)
|
||||
from g20_thumb_apriltag_calibration.core import PAIR_NAMES
|
||||
|
||||
|
||||
def _observation(index: int, angle_rad: float = 0.0) -> Observation:
|
||||
quaternion = tuple(
|
||||
float(value)
|
||||
for value in Rotation.from_rotvec([0.0, angle_rad, 0.0]).as_quat()
|
||||
)
|
||||
return Observation(
|
||||
stamp_ns=index,
|
||||
received_at=index / 30.0,
|
||||
relative_quaternion_xyzw={pair: quaternion for pair in PAIR_NAMES},
|
||||
tag_quality={
|
||||
role: TagQuality(hamming=0, decision_margin=50.0, edge_pixels=45.0)
|
||||
for role in ("t0", "t3", "t4", "t5")
|
||||
},
|
||||
state_u8=tuple(float(value) for value in range(20)),
|
||||
)
|
||||
|
||||
|
||||
def test_stable_window_then_thirty_frame_capture() -> None:
|
||||
collector = PointCollector(
|
||||
stable_frames=15,
|
||||
capture_frames=30,
|
||||
minimum_settle_seconds=0.4,
|
||||
maximum_stable_spread_rad=np.deg2rad(0.3),
|
||||
)
|
||||
collector.start(0.0)
|
||||
result = None
|
||||
for index in range(15):
|
||||
result = collector.add(_observation(index), index / 30.0)
|
||||
assert result is None
|
||||
assert collector.state == "capturing"
|
||||
assert collector.stable_frames_seen == 15
|
||||
assert collector.capture_frames_seen == 0
|
||||
for index in range(15, 45):
|
||||
result = collector.add(_observation(index), index / 30.0)
|
||||
assert result is not None
|
||||
assert collector.capture_frames_seen == 30
|
||||
assert result["valid_frames"] == 30
|
||||
assert len(result["state_u8_median"]) == 20
|
||||
|
||||
|
||||
def test_missing_tags_eventually_pauses_point_collector() -> None:
|
||||
collector = PointCollector(settle_timeout_seconds=5.0)
|
||||
collector.start(10.0)
|
||||
collector.poll(15.01)
|
||||
assert collector.state == "failed"
|
||||
assert collector.reason == "settle_timeout"
|
||||
|
||||
|
||||
def test_unstable_window_does_not_enter_capture() -> None:
|
||||
collector = PointCollector(maximum_stable_spread_rad=np.deg2rad(0.3))
|
||||
collector.start(0.0)
|
||||
for index in range(15):
|
||||
angle = np.deg2rad(1.0 if index % 2 else -1.0)
|
||||
collector.add(_observation(index, angle), index / 30.0)
|
||||
assert collector.state == "settling"
|
||||
assert collector.reason.endswith("not_stable")
|
||||
|
||||
|
||||
def test_isolated_invalid_frame_is_skipped_without_losing_valid_window() -> None:
|
||||
collector = PointCollector()
|
||||
collector.start(0.0)
|
||||
for index in range(14):
|
||||
collector.add(_observation(index), index / 30.0)
|
||||
collector.mark_invalid_frame()
|
||||
assert collector.stable_frames_seen == 14
|
||||
collector.add(_observation(15), 0.5)
|
||||
assert collector.state == "capturing"
|
||||
assert collector.reason == ""
|
||||
|
||||
|
||||
def test_three_consecutive_invalid_frames_clear_stability_window() -> None:
|
||||
collector = PointCollector()
|
||||
collector.start(0.0)
|
||||
for index in range(14):
|
||||
collector.add(_observation(index), index / 30.0)
|
||||
for _ in range(3):
|
||||
collector.mark_invalid_frame()
|
||||
assert collector.stable_frames_seen == 0
|
||||
collector.add(_observation(15), 0.5)
|
||||
assert collector.state == "settling"
|
||||
|
||||
|
||||
def test_bad_tag_quality_is_filtered() -> None:
|
||||
good = TagQuality(hamming=0, decision_margin=31.0, edge_pixels=40.0)
|
||||
bad_hamming = TagQuality(hamming=1, decision_margin=50.0, edge_pixels=50.0)
|
||||
thresholds = {
|
||||
"maximum_hamming": 0,
|
||||
"minimum_decision_margin": 30.0,
|
||||
"minimum_edge_pixels": 40.0,
|
||||
}
|
||||
assert tag_quality_is_valid(good, **thresholds)
|
||||
assert not tag_quality_is_valid(bad_hamming, **thresholds)
|
||||
|
||||
|
||||
def test_aggregate_keeps_worst_tag_quality() -> None:
|
||||
observations = [_observation(0), _observation(1)]
|
||||
aggregate = aggregate_observations(observations)
|
||||
assert aggregate["tag_quality"]["t0"] == {
|
||||
"minimum_decision_margin": 50.0,
|
||||
"minimum_edge_pixels": 45.0,
|
||||
"maximum_hamming": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_state_is_interpolated_at_camera_timestamp() -> None:
|
||||
before = tuple([255.0] + [0.0] * 19)
|
||||
after = tuple([235.0] + [0.0] * 19)
|
||||
samples = [
|
||||
StateSample(stamp_ns=1_000_000_000, position_u8=before),
|
||||
StateSample(stamp_ns=1_100_000_000, position_u8=after),
|
||||
]
|
||||
matched = interpolate_state_u8(
|
||||
samples,
|
||||
1_025_000_000,
|
||||
maximum_skew_ns=60_000_000,
|
||||
)
|
||||
assert matched is not None
|
||||
state, skew = matched
|
||||
assert state[0] == 250.0
|
||||
assert skew == 25_000_000
|
||||
assert (
|
||||
interpolate_state_u8(
|
||||
samples,
|
||||
1_300_000_000,
|
||||
maximum_skew_ns=60_000_000,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def _synchronised_observation(
|
||||
index: int, motor_value: float
|
||||
) -> Observation:
|
||||
state = [255.0] * 20
|
||||
state[0] = motor_value
|
||||
return replace(
|
||||
_observation(index),
|
||||
state_u8=tuple(state),
|
||||
state_stamp_ns=index,
|
||||
state_sync_error_ns=5_000_000,
|
||||
)
|
||||
|
||||
|
||||
def test_continuous_sweep_completes_after_full_span_and_endpoint_hold() -> None:
|
||||
collector = ContinuousSweepCollector(
|
||||
endpoint_hold_seconds=0.2,
|
||||
minimum_valid_frames=20,
|
||||
minimum_state_span_u8=240.0,
|
||||
)
|
||||
collector.start(0.0, motor_index=0, start_u8=255, target_u8=0)
|
||||
result = None
|
||||
for index, value in enumerate(np.linspace(255.0, 0.0, 60)):
|
||||
result = collector.add(
|
||||
_synchronised_observation(index, float(value)),
|
||||
index * 0.05,
|
||||
)
|
||||
assert result is None
|
||||
for offset in range(1, 6):
|
||||
result = collector.add(
|
||||
_synchronised_observation(60 + offset, 0.0),
|
||||
3.0 + offset * 0.05,
|
||||
)
|
||||
if result is not None:
|
||||
break
|
||||
assert result is not None
|
||||
assert collector.state == "complete"
|
||||
assert collector.state_span_u8 == 255.0
|
||||
|
||||
bins = aggregate_sweep_observations(
|
||||
result,
|
||||
motor_index=0,
|
||||
start_u8=255,
|
||||
target_u8=0,
|
||||
endpoint_tolerance_u8=2.0,
|
||||
)
|
||||
assert 0 in bins
|
||||
assert 255 in bins
|
||||
assert len(bins) >= 50
|
||||
@@ -0,0 +1,56 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_front_tag_parameters_match_namespaced_detector() -> None:
|
||||
config = yaml.safe_load(
|
||||
(PACKAGE_ROOT / "config" / "front_tags.yaml").read_text()
|
||||
)
|
||||
|
||||
detector = config["/apriltag/apriltag"]["ros__parameters"]
|
||||
calibration = config["g20_thumb_calibration"]["ros__parameters"]
|
||||
|
||||
assert detector["tag"]["ids"] == calibration["tag_ids"]
|
||||
assert detector["tag"]["frames"] == calibration["tag_frames"]
|
||||
assert detector["tag"]["sizes"] == calibration["tag_sizes_m"]
|
||||
assert detector["tag"]["ids"] == [0, 1, 2, 3]
|
||||
assert detector["detector"]["decimate"] == 1.5
|
||||
assert detector["detector"]["refine"] is True
|
||||
assert detector["detector"]["debug"] is False
|
||||
|
||||
|
||||
def test_trial_uses_image_plane_angles_and_thirty_pixel_tags() -> None:
|
||||
config = yaml.safe_load(
|
||||
(PACKAGE_ROOT / "config" / "calibration.yaml").read_text()
|
||||
)
|
||||
parameters = config["g20_thumb_calibration"]["ros__parameters"]
|
||||
|
||||
assert parameters["angle_estimation_mode"] == "image_plane_2d"
|
||||
assert parameters["minimum_edge_pixels"] == 30.0
|
||||
assert parameters["repetitions"] == 1
|
||||
assert parameters["command_step"] == 8
|
||||
assert parameters["scan_mode"] == "continuous"
|
||||
assert parameters["continuous_motion_mode"] == "endpoint"
|
||||
assert parameters["auto_start_tip"] is True
|
||||
assert parameters["minimum_detection_hz"] == 15.0
|
||||
assert parameters["stable_frames"] == 5
|
||||
assert parameters["capture_frames"] == 8
|
||||
assert parameters["validation_command_count"] == 5
|
||||
assert parameters["continuous_minimum_bins"] >= 32
|
||||
assert parameters["continuous_maximum_bin_gap"] <= 16
|
||||
assert parameters["continuous_segment_minimum_seconds"] >= 0.1
|
||||
assert parameters["continuous_segment_timeout_seconds"] >= 5.0
|
||||
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"
|
||||
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from g20_thumb_apriltag_calibration.core import (
|
||||
BASELINE_COMMAND,
|
||||
DIRECTION_DECREASING,
|
||||
DIRECTION_INCREASING,
|
||||
PAIR_IP,
|
||||
PAIR_MCP,
|
||||
PAIR_ROOT,
|
||||
PHASE_ROOT,
|
||||
PHASE_TIP,
|
||||
build_command,
|
||||
create_final_payload,
|
||||
fit_calibration_curves,
|
||||
image_plane_tag_quaternion_xyzw,
|
||||
isotonic_nonincreasing,
|
||||
relative_quaternion_xyzw,
|
||||
rotation_inlier_fraction,
|
||||
rotation_rms_rad,
|
||||
scan_targets,
|
||||
validate_final_payload,
|
||||
)
|
||||
|
||||
|
||||
def _quaternion(base: Rotation, axis: np.ndarray, angle: float) -> list[float]:
|
||||
value = base * Rotation.from_rotvec(axis * angle)
|
||||
return [float(component) for component in value.as_quat()]
|
||||
|
||||
|
||||
def _synthetic_records(
|
||||
repetitions: int = 3,
|
||||
command_step: int = 1,
|
||||
) -> list[dict]:
|
||||
bases = {
|
||||
PAIR_ROOT: Rotation.from_euler("xyz", [0.2, -0.1, 0.3]),
|
||||
PAIR_MCP: Rotation.from_euler("xyz", [-0.15, 0.1, 0.25]),
|
||||
PAIR_IP: Rotation.from_euler("xyz", [0.05, 0.2, -0.2]),
|
||||
}
|
||||
axes = {
|
||||
PAIR_ROOT: np.asarray([0.2, 0.9, -0.1], dtype=float),
|
||||
PAIR_MCP: np.asarray([-0.1, 0.3, 0.95], dtype=float),
|
||||
PAIR_IP: np.asarray([0.05, -0.2, 0.98], dtype=float),
|
||||
}
|
||||
axes = {key: value / np.linalg.norm(value) for key, value in axes.items()}
|
||||
records: list[dict] = []
|
||||
for phase in (PHASE_ROOT, PHASE_TIP):
|
||||
for cycle, direction, command in scan_targets(
|
||||
repetitions, command_step
|
||||
):
|
||||
progress = (255 - command) / 255.0
|
||||
branch = (
|
||||
0.008 * np.sin(np.pi * progress)
|
||||
if direction == DIRECTION_INCREASING
|
||||
else 0.0
|
||||
)
|
||||
root = 0.82 * progress + branch if phase == PHASE_ROOT else 0.0
|
||||
mcp = 1.16 * progress + branch if phase == PHASE_TIP else 0.0
|
||||
ip = 1.018 * mcp + 0.001 * np.sin(np.pi * progress)
|
||||
rotations = {
|
||||
PAIR_ROOT: _quaternion(bases[PAIR_ROOT], axes[PAIR_ROOT], root),
|
||||
PAIR_MCP: _quaternion(bases[PAIR_MCP], axes[PAIR_MCP], mcp),
|
||||
PAIR_IP: _quaternion(bases[PAIR_IP], axes[PAIR_IP], ip),
|
||||
}
|
||||
records.append(
|
||||
{
|
||||
"kind": "sample",
|
||||
"phase": phase,
|
||||
"cycle": cycle,
|
||||
"direction": direction,
|
||||
"command_u8": command,
|
||||
"relative_quaternion_xyzw": rotations,
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def test_build_command_changes_only_selected_channel() -> None:
|
||||
result = build_command(15, 37)
|
||||
assert len(result) == 20
|
||||
assert result[15] == 37
|
||||
assert result[:15] == list(BASELINE_COMMAND[:15])
|
||||
assert result[16:] == list(BASELINE_COMMAND[16:])
|
||||
with pytest.raises(ValueError):
|
||||
build_command(5, 10)
|
||||
|
||||
|
||||
def test_three_cycle_scan_has_every_integer_in_both_directions() -> None:
|
||||
targets = scan_targets(3, command_step=1)
|
||||
assert len(targets) == 3 * 2 * 256
|
||||
assert targets[0] == (0, DIRECTION_DECREASING, 255)
|
||||
assert targets[255] == (0, DIRECTION_DECREASING, 0)
|
||||
assert targets[256] == (0, DIRECTION_INCREASING, 0)
|
||||
assert targets[511] == (0, DIRECTION_INCREASING, 255)
|
||||
|
||||
|
||||
def test_quick_scan_has_bounded_sparse_grid_and_endpoints() -> None:
|
||||
targets = scan_targets(1, command_step=8)
|
||||
assert len(targets) == 66
|
||||
assert targets[0] == (0, DIRECTION_DECREASING, 255)
|
||||
assert targets[32] == (0, DIRECTION_DECREASING, 0)
|
||||
assert targets[33] == (0, DIRECTION_INCREASING, 0)
|
||||
assert targets[-1] == (0, DIRECTION_INCREASING, 255)
|
||||
increasing = [
|
||||
command
|
||||
for _, direction, command in targets
|
||||
if direction == DIRECTION_INCREASING
|
||||
]
|
||||
assert max(np.diff(increasing)) == 8
|
||||
|
||||
|
||||
def test_relative_rotation_cancels_camera_orientation() -> None:
|
||||
camera_to_parent = Rotation.from_euler("xyz", [0.4, -0.2, 0.1])
|
||||
parent_to_child = Rotation.from_rotvec([0.1, 0.3, -0.2])
|
||||
camera_to_child = camera_to_parent * parent_to_child
|
||||
actual = Rotation.from_quat(
|
||||
relative_quaternion_xyzw(
|
||||
camera_to_parent.as_quat(), camera_to_child.as_quat()
|
||||
)
|
||||
)
|
||||
assert (parent_to_child.inv() * actual).magnitude() < 1e-10
|
||||
|
||||
|
||||
def test_image_plane_tag_rotation_uses_ordered_opposite_edges() -> None:
|
||||
angle = np.deg2rad(27.0)
|
||||
x_axis_image = np.asarray([np.cos(angle), -np.sin(angle)])
|
||||
y_axis_image = np.asarray([np.sin(angle), np.cos(angle)])
|
||||
corners = np.asarray(
|
||||
[
|
||||
-x_axis_image - y_axis_image,
|
||||
x_axis_image - y_axis_image,
|
||||
1.1 * x_axis_image + y_axis_image,
|
||||
-0.9 * x_axis_image + y_axis_image,
|
||||
]
|
||||
)
|
||||
actual = Rotation.from_quat(image_plane_tag_quaternion_xyzw(corners))
|
||||
expected = Rotation.from_rotvec([0.0, 0.0, angle])
|
||||
assert (expected.inv() * actual).magnitude() < 1e-10
|
||||
|
||||
|
||||
def test_static_rms_rejects_isolated_planar_pnp_flip() -> None:
|
||||
quaternions = [
|
||||
Rotation.from_rotvec([0.0, np.deg2rad(0.1 * np.sin(index)), 0.0]).as_quat()
|
||||
for index in range(149)
|
||||
]
|
||||
quaternions.append(
|
||||
Rotation.from_rotvec([0.0, np.deg2rad(27.0), 0.0]).as_quat()
|
||||
)
|
||||
|
||||
threshold = np.deg2rad(5.0)
|
||||
assert rotation_rms_rad(quaternions) > np.deg2rad(2.0)
|
||||
assert rotation_rms_rad(
|
||||
quaternions, outlier_threshold_rad=threshold
|
||||
) < np.deg2rad(0.5)
|
||||
assert rotation_inlier_fraction(
|
||||
quaternions, outlier_threshold_rad=threshold
|
||||
) == pytest.approx(149 / 150)
|
||||
|
||||
|
||||
def test_isotonic_projection_is_nonincreasing() -> None:
|
||||
projected = isotonic_nonincreasing([3.0, 2.0, 2.4, 1.0, 0.0])
|
||||
assert np.all(np.diff(projected) <= 0.0)
|
||||
assert projected.tolist() == pytest.approx([3.0, 2.2, 2.2, 1.0, 0.0])
|
||||
|
||||
|
||||
def test_fit_produces_complete_runtime_payload() -> None:
|
||||
fit = fit_calibration_curves(_synthetic_records())
|
||||
assert fit.joints["thumb_cmc_pitch"]["decreasing_rad"][0] == pytest.approx(
|
||||
0.82, abs=2e-3
|
||||
)
|
||||
assert fit.joints["thumb_mcp"]["decreasing_rad"][0] == pytest.approx(
|
||||
1.16, abs=2e-3
|
||||
)
|
||||
assert fit.joints["thumb_ip"]["passive"] is True
|
||||
assert fit.ip_coupling["r_squared"] > 0.999
|
||||
for joint in fit.joints.values():
|
||||
assert len(joint["angle_rad"]) == 256
|
||||
assert len(joint["decreasing_rad"]) == 256
|
||||
assert len(joint["increasing_rad"]) == 256
|
||||
assert joint["angle_rad"][255] == 0.0
|
||||
assert joint["decreasing_rad"][255] == 0.0
|
||||
assert joint["increasing_rad"][255] == 0.0
|
||||
|
||||
payload = create_final_payload(
|
||||
serial_number="G20_LEFT_TEST",
|
||||
fit=fit,
|
||||
validation_errors_rad=[0.01, -0.02],
|
||||
passed=True,
|
||||
)
|
||||
assert set(payload) == {
|
||||
"schema_version",
|
||||
"model",
|
||||
"side",
|
||||
"serial_number",
|
||||
"angle_unit",
|
||||
"command_range",
|
||||
"zero_command_u8",
|
||||
"baseline_command_u8",
|
||||
"joints",
|
||||
"ip_coupling",
|
||||
"quality",
|
||||
}
|
||||
assert payload["schema_version"] == 2
|
||||
for joint in payload["joints"].values():
|
||||
assert "angle_rad" in joint
|
||||
assert "decreasing_rad" not in joint
|
||||
assert "increasing_rad" not in joint
|
||||
validate_final_payload(payload)
|
||||
|
||||
invalid = copy.deepcopy(payload)
|
||||
invalid["joints"]["thumb_mcp"]["angle_rad"].pop()
|
||||
with pytest.raises(ValueError):
|
||||
validate_final_payload(invalid)
|
||||
|
||||
|
||||
def test_sparse_fit_interpolates_complete_monotonic_runtime_payload() -> None:
|
||||
fit = fit_calibration_curves(
|
||||
_synthetic_records(repetitions=1, command_step=8)
|
||||
)
|
||||
for joint in fit.joints.values():
|
||||
combined = np.asarray(joint["angle_rad"], dtype=float)
|
||||
assert combined.shape == (256,)
|
||||
assert combined[255] == 0.0
|
||||
assert np.all(np.diff(combined) <= 1e-10)
|
||||
for direction in ("decreasing_rad", "increasing_rad"):
|
||||
curve = np.asarray(joint[direction], dtype=float)
|
||||
assert curve.shape == (256,)
|
||||
assert curve[255] == 0.0
|
||||
assert np.all(np.diff(curve) <= 1e-10)
|
||||
assert fit.joints["thumb_cmc_pitch"]["decreasing_rad"][0] == pytest.approx(
|
||||
0.82, abs=2e-3
|
||||
)
|
||||
assert fit.joints["thumb_mcp"]["decreasing_rad"][0] == pytest.approx(
|
||||
1.16, abs=2e-3
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
from g20_thumb_apriltag_calibration.storage import (
|
||||
append_jsonl,
|
||||
atomic_write_json,
|
||||
completed_scan_keys,
|
||||
load_json,
|
||||
load_jsonl,
|
||||
)
|
||||
|
||||
|
||||
def test_jsonl_checkpoint_and_resume_keys(tmp_path) -> None:
|
||||
raw_path = tmp_path / "raw_samples.jsonl"
|
||||
record = {
|
||||
"kind": "sample",
|
||||
"phase": "root",
|
||||
"cycle": 0,
|
||||
"direction": "decreasing",
|
||||
"command_u8": 255,
|
||||
}
|
||||
append_jsonl(raw_path, record)
|
||||
append_jsonl(raw_path, {"kind": "validation", "command_u8": 10})
|
||||
loaded = load_jsonl(raw_path)
|
||||
assert loaded[0] == record
|
||||
assert completed_scan_keys(loaded) == {("root", 0, "decreasing", 255)}
|
||||
|
||||
checkpoint_path = tmp_path / "checkpoint.json"
|
||||
atomic_write_json(checkpoint_path, {"state": "PAUSED", "records": 1})
|
||||
assert load_json(checkpoint_path) == {"state": "PAUSED", "records": 1}
|
||||
|
||||
|
||||
def test_resume_ignores_only_a_truncated_final_jsonl_record(tmp_path) -> None:
|
||||
raw_path = tmp_path / "raw_samples.jsonl"
|
||||
append_jsonl(raw_path, {"kind": "sample", "phase": "root"})
|
||||
with raw_path.open("a", encoding="utf-8") as stream:
|
||||
stream.write('{"kind":"sample"\n\n')
|
||||
assert load_jsonl(raw_path) == [{"kind": "sample", "phase": "root"}]
|
||||
Reference in New Issue
Block a user