拇指零位标定

This commit is contained in:
lxp
2026-07-30 17:05:22 +08:00
parent 9210373fb2
commit ce9d0129b9
9 changed files with 2378 additions and 0 deletions
+3
View File
@@ -66,6 +66,9 @@ Thumbs.db
*.verification.json
*_mapping_quality.json
# Device-specific robot descriptions derived from local CMC pitch calibration
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf
# ROS bag / MCAP recordings and CAN captures
rosbag2_*/
/bags/
@@ -266,3 +266,68 @@ D405 的彩色流来自 `depth_module`,启动文件会同时设置
启用 rosbag 后保存裁剪后的原始图像和配套 `CameraInfo`,避免新增一个全分辨率图像
订阅者;同时使用 MCAP `zstd_fast` 压缩并按 10 GiB 分卷。快速标定通常不需要录制;
若用于正式可追溯验收,再启用并检查磁盘空间。
## 5. CMC Pitch 双零位角测量
只测量命令 255 时 `thumb_cmc_pitch` 的掌心相对零位角和画面水平投影零位角时,
使用独立启动文件。它只拟合 CMC 的二维零位轨迹圆,不运行完整 0~255 角度映射,
也不会生成或修改 URDF:
```bash
ros2 launch g20_thumb_apriltag_calibration \
front_cmc_pitch_zero.launch.py \
serial_number:=G20_LEFT_001
```
该流程只要求 T0(ID 0)和 T3(ID 1)有效。T4/T5 可以留在手上,但丢失不会阻塞。
预检完成后查看中文状态:
```bash
ros2 topic echo --once --full-length \
/g20_thumb_cmc_pitch_zero/status_text \
--field data
```
状态显示“等待开始”后启动三轮测量:
```bash
ros2 service call \
/g20_thumb_cmc_pitch_zero/start \
std_srvs/srv/Trigger {}
```
查看带红色画面水平线、T0/T3 方向箭头、青色轨迹点、紫色拟合圆心和径向零位线
的调试画面:
```bash
ros2 run image_view image_view --ros-args \
--remap image:=/g20_thumb_cmc_pitch_zero/debug_image
```
每轮只控制电机 0 执行一次 `255→64` 端点运动和一次 `64→255` 返回运动。运动期间
连续采集 `T3中心−T0中心`,按机械手状态分箱后拟合图像平面圆;返回 255 后使用
“拟合圆心→T3零位中心”的径向线计算角度。运动前和返回后各采集 30 帧静态零位,
三轮轨迹合并后得到最终圆心。其他 19 个命令保持固定基准。任何其他节点同时发布
`/g20/cb_left_hand_control_cmd` 时,`start` 服务会拒绝启动。
T0用于消除相机或整只手的平移抖动。T3标签自身的箭头只负责在径向线的两个相反
方向中选择与旧版一致的正方向,不再决定角度大小。若轨迹跨度、圆弧、半径、径向
RMS/P95或回零误差不合格,节点暂停或写出 `quality.passed=false`。
完成后只生成:
```text
calibration_output/G20_LEFT_001/<时间戳>/
g20_left_G20_LEFT_001_thumb_cmc_pitch_zero.json
```
其中两个核心字段是:
```text
zero_angles.palm_relative_zero_rad
zero_angles.table_projected_zero_rad
```
前者是圆心径向零位线相对 T0 方向的角度,后者是圆心径向零位线相对相机画面
水平向右方向的角度。桌面投影角不使用 T0 的方向,但会用 T0 中心抵消平移;
它仍不是真实三维桌面检测,因此会随相机滚转和机械手摆放改变。
@@ -0,0 +1,45 @@
g20_thumb_cmc_pitch_zero:
ros__parameters:
t0_id: 0
t3_id: 1
baseline_command_u8:
[255, 255, 255, 255, 255, 255, 193, 148, 105, 42,
245, 255, 255, 255, 255, 255, 255, 255, 255, 255]
repetitions: 3
zero_capture_frames: 30
# Each round sends one 255->64 endpoint command and one 64->255 return
# command. All valid T3-minus-T0 centres observed during both motions are
# state-binned and fitted to one image-plane circle.
trajectory_command_u8: 64
trajectory_bin_size_u8: 8.0
trajectory_minimum_frames: 45
trajectory_minimum_bins: 18
trajectory_minimum_state_span_u8: 160.0
trajectory_minimum_radius_px: 20.0
trajectory_minimum_arc_deg: 20.0
trajectory_maximum_radial_rms_px: 2.0
trajectory_maximum_p95_radial_error_px: 3.5
trajectory_endpoint_settle_seconds: 0.3
trajectory_timeout_seconds: 30.0
settle_seconds: 0.5
move_timeout_seconds: 20.0
capture_timeout_seconds: 15.0
state_tolerance_u8: 2.0
preflight_frames: 60
minimum_detection_rate: 0.95
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 40.0
maximum_static_std_deg: 0.5
maximum_static_position_rms_px: 1.5
maximum_round_difference_deg: 1.0
maximum_return_error_deg: 1.0
maximum_anchor_drift_deg: 1.0
# T3 +x is used only to choose which of the two radial-line directions is
# positive. A large mismatch means the tag was mounted nearly orthogonal
# to the physical CMC radial direction.
maximum_axis_alignment_deg: 45.0
maximum_zero_radial_error_px: 4.0
publish_debug_image: true
debug_max_rate_hz: 10.0
debug_scale: 0.75
@@ -0,0 +1,512 @@
"""Pure helpers for image-trajectory CMC pitch zero-angle measurement."""
from __future__ import annotations
import math
from typing import Any, Mapping, Sequence
import numpy as np
from scipy.optimize import least_squares
def wrap_angle_rad(angle_rad: float) -> float:
"""Wrap one finite angle to [-pi, pi)."""
angle = float(angle_rad)
if not math.isfinite(angle):
raise ValueError("angle must be finite")
return (angle + math.pi) % (2.0 * math.pi) - math.pi
def signed_angle_difference_rad(angle_rad: float, reference_rad: float) -> float:
"""Return the wrapped signed rotation from reference to angle."""
return wrap_angle_rad(float(angle_rad) - float(reference_rad))
def tag_x_axis_angle_rad(
corners_xy: Sequence[Sequence[float]],
) -> float:
"""Return the ordered AprilTag +x direction in a y-up image frame."""
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")
x_axis = (corners[1] - corners[0]) + (corners[2] - corners[3])
if float(np.linalg.norm(x_axis)) < 1.0e-9:
raise ValueError("tag x-axis is degenerate")
return wrap_angle_rad(
math.atan2(-float(x_axis[1]), float(x_axis[0]))
)
def circular_mean_rad(angles_rad: Sequence[float]) -> float:
"""Return the circular mean of at least one finite angle."""
angles = np.asarray(angles_rad, dtype=float)
if angles.ndim != 1 or angles.size == 0 or not np.all(np.isfinite(angles)):
raise ValueError("angles_rad must contain finite angles")
vector = complex(
float(np.mean(np.cos(angles))),
float(np.mean(np.sin(angles))),
)
if abs(vector) < 1.0e-12:
raise ValueError("angles do not have a unique circular mean")
return wrap_angle_rad(math.atan2(vector.imag, vector.real))
def circular_median_rad(angles_rad: Sequence[float]) -> float:
"""Return a robust circular median around the circular mean."""
angles = np.asarray(angles_rad, dtype=float)
reference = circular_mean_rad(angles)
deltas = np.asarray(
[signed_angle_difference_rad(value, reference) for value in angles],
dtype=float,
)
return wrap_angle_rad(reference + float(np.median(deltas)))
def circular_std_rad(angles_rad: Sequence[float]) -> float:
"""Return circular standard deviation in radians."""
angles = np.asarray(angles_rad, dtype=float)
if angles.ndim != 1 or angles.size == 0 or not np.all(np.isfinite(angles)):
raise ValueError("angles_rad must contain finite angles")
resultant = float(
math.hypot(
float(np.mean(np.cos(angles))),
float(np.mean(np.sin(angles))),
)
)
resultant = min(1.0, max(1.0e-15, resultant))
return math.sqrt(max(0.0, -2.0 * math.log(resultant)))
def maximum_pairwise_angle_difference_rad(
angles_rad: Sequence[float],
) -> float:
"""Return the largest wrapped distance between any two angles."""
angles = [float(value) for value in angles_rad]
if not angles or not all(math.isfinite(value) for value in angles):
raise ValueError("angles_rad must contain finite angles")
return max(
(
abs(signed_angle_difference_rad(left, right))
for index, left in enumerate(angles)
for right in angles[index + 1:]
),
default=0.0,
)
def summarize_zero_frames(
frames: Sequence[Mapping[str, float]],
) -> dict[str, float]:
"""Summarize one stable command-255 capture window.
Image coordinates use x-right/y-down. The T3-minus-T0 centre is retained
so a later trajectory-circle fit is insensitive to camera/hand translation.
"""
if not frames:
raise ValueError("at least one frame is required")
required = (
"t0_x_px",
"t0_y_px",
"t3_x_px",
"t3_y_px",
"t0_rad",
"t3_rad",
)
values: dict[str, list[float]] = {name: [] for name in required}
for frame in frames:
for name in required:
value = float(frame[name])
if not math.isfinite(value):
raise ValueError(f"{name} must be finite")
values[name].append(value)
t0_xy = np.column_stack((values["t0_x_px"], values["t0_y_px"]))
t3_xy = np.column_stack((values["t3_x_px"], values["t3_y_px"]))
relative_xy = t3_xy - t0_xy
relative_median = np.median(relative_xy, axis=0)
relative_error = np.linalg.norm(relative_xy - relative_median, axis=1)
t0_median = np.median(t0_xy, axis=0)
t3_median = np.median(t3_xy, axis=0)
return {
"relative_x_px": float(relative_median[0]),
"relative_y_px": float(relative_median[1]),
"t0_x_px": float(t0_median[0]),
"t0_y_px": float(t0_median[1]),
"t3_x_px": float(t3_median[0]),
"t3_y_px": float(t3_median[1]),
"t0_rad": circular_median_rad(values["t0_rad"]),
"t3_rad": circular_median_rad(values["t3_rad"]),
"t0_std_rad": circular_std_rad(values["t0_rad"]),
"t3_std_rad": circular_std_rad(values["t3_rad"]),
"relative_position_rms_px": float(
math.sqrt(float(np.mean(np.square(relative_error))))
),
}
def _fit_circle(points_xy: np.ndarray) -> tuple[np.ndarray, float]:
"""Fit a geometric circle with robust nonlinear least squares."""
points = np.asarray(points_xy, dtype=float)
if points.ndim != 2 or points.shape[1] != 2 or len(points) < 3:
raise ValueError("circle fit requires at least three 2D points")
origin = np.mean(points, axis=0)
centered = points - origin
matrix = np.column_stack(
(2.0 * centered[:, 0], 2.0 * centered[:, 1], np.ones(len(points)))
)
rhs = np.sum(np.square(centered), axis=1)
solution, _, rank, _ = np.linalg.lstsq(matrix, rhs, rcond=None)
if rank < 3:
raise ValueError("trajectory points are degenerate")
centre0 = solution[:2] + origin
radius0_squared = (
float(solution[2]) + float(np.dot(solution[:2], solution[:2]))
)
if radius0_squared <= 0.0:
raise ValueError("trajectory circle radius is invalid")
radius0 = math.sqrt(radius0_squared)
def residual(parameters: np.ndarray) -> np.ndarray:
centre = parameters[:2]
radius = parameters[2]
return np.linalg.norm(points - centre, axis=1) - radius
initial_error = residual(np.asarray([*centre0, radius0], dtype=float))
robust_scale = max(
0.25,
1.4826
* float(
np.median(
np.abs(initial_error - float(np.median(initial_error)))
)
),
)
result = least_squares(
residual,
np.asarray([*centre0, radius0], dtype=float),
loss="soft_l1",
f_scale=robust_scale,
max_nfev=1000,
)
centre = np.asarray(result.x[:2], dtype=float)
radius = float(abs(result.x[2]))
if not result.success or not np.all(np.isfinite(centre)):
raise ValueError("trajectory circle optimization failed")
if not math.isfinite(radius) or radius <= 0.0:
raise ValueError("trajectory circle radius is invalid")
return centre, radius
def _trajectory_arc_rad(points_xy: np.ndarray, centre_xy: np.ndarray) -> float:
angles = np.mod(
np.arctan2(
points_xy[:, 1] - centre_xy[1],
points_xy[:, 0] - centre_xy[0],
),
2.0 * math.pi,
)
if len(angles) < 2:
return 0.0
angles = np.sort(angles)
gaps = np.diff(np.r_[angles, angles[0] + 2.0 * math.pi])
return float(2.0 * math.pi - np.max(gaps))
def fit_image_circle_trajectory(
observations: Sequence[Mapping[str, float]],
*,
bin_size_u8: float,
minimum_frames: int,
minimum_bins: int,
minimum_state_span_u8: float,
minimum_radius_px: float,
minimum_arc_rad: float,
maximum_radial_rms_px: float,
maximum_p95_radial_error_px: float,
) -> dict[str, Any]:
"""Fit the T3-minus-T0 image trajectory to a robust circle.
Repeated detector frames at the same hand state are collapsed into state
bins before fitting so slow endpoints cannot dominate the circle.
"""
if len(observations) < minimum_frames:
raise ValueError(
f"trajectory_frames_too_few:{len(observations)}/{minimum_frames}"
)
required = ("state_u8", "t0_x_px", "t0_y_px", "t3_x_px", "t3_y_px")
rows: list[tuple[float, float, float]] = []
for observation in observations:
values = [float(observation[name]) for name in required]
if not all(math.isfinite(value) for value in values):
continue
state, t0_x, t0_y, t3_x, t3_y = values
rows.append((state, t3_x - t0_x, t3_y - t0_y))
if len(rows) < minimum_frames:
raise ValueError(
f"trajectory_valid_frames_too_few:{len(rows)}/{minimum_frames}"
)
samples = np.asarray(rows, dtype=float)
state_span = float(np.ptp(samples[:, 0]))
if state_span < minimum_state_span_u8:
raise ValueError(
"trajectory_state_span_too_small:"
f"{state_span:.1f}/{minimum_state_span_u8:.1f}"
)
bins: dict[int, list[np.ndarray]] = {}
for state, x_value, y_value in samples:
key = int(round(float(state) / bin_size_u8))
bins.setdefault(key, []).append(np.asarray([x_value, y_value]))
binned_points = np.asarray(
[np.median(np.asarray(values), axis=0) for _, values in sorted(bins.items())],
dtype=float,
)
if len(binned_points) < minimum_bins:
raise ValueError(
f"trajectory_bins_too_few:{len(binned_points)}/{minimum_bins}"
)
centre, radius = _fit_circle(binned_points)
radial_error = np.abs(
np.linalg.norm(binned_points - centre, axis=1) - radius
)
median_error = float(np.median(radial_error))
mad = 1.4826 * float(np.median(np.abs(radial_error - median_error)))
inlier_limit = max(0.75, median_error + 3.5 * max(mad, 0.1))
inliers = radial_error <= inlier_limit
if int(np.count_nonzero(inliers)) >= max(6, minimum_bins // 2):
centre, radius = _fit_circle(binned_points[inliers])
radial_error = np.abs(
np.linalg.norm(binned_points - centre, axis=1) - radius
)
radial_rms = math.sqrt(float(np.mean(np.square(radial_error))))
radial_p95 = float(np.percentile(radial_error, 95.0))
arc_rad = _trajectory_arc_rad(binned_points, centre)
failures: list[str] = []
if radius < minimum_radius_px:
failures.append(f"radius={radius:.2f}px")
if arc_rad < minimum_arc_rad:
failures.append(f"arc={math.degrees(arc_rad):.2f}deg")
if radial_rms > maximum_radial_rms_px:
failures.append(f"rms={radial_rms:.2f}px")
if radial_p95 > maximum_p95_radial_error_px:
failures.append(f"p95={radial_p95:.2f}px")
if failures:
raise ValueError("trajectory_circle_quality_failed:" + ",".join(failures))
return {
"method": "t3_minus_t0_image_trajectory_circle",
"centre_relative_xy_px": [float(centre[0]), float(centre[1])],
"radius_px": radius,
"radial_rms_px": radial_rms,
"radial_p95_px": radial_p95,
"arc_rad": arc_rad,
"state_span_u8": state_span,
"frames": len(rows),
"bins": len(binned_points),
"passed": True,
}
def measure_zero_from_circle(
circle: Mapping[str, Any],
zero_summary: Mapping[str, float],
) -> dict[str, float]:
"""Measure command-255 radial angle from a fitted trajectory circle.
The radius has two equivalent line directions. T3's ordered +x axis is
used only to choose the direction closest to the historical convention;
it does not set the radial angle itself.
"""
centre = np.asarray(circle["centre_relative_xy_px"], dtype=float)
point = np.asarray(
[
float(zero_summary["relative_x_px"]),
float(zero_summary["relative_y_px"]),
],
dtype=float,
)
vector = point - centre
distance = float(np.linalg.norm(vector))
if not math.isfinite(distance) or distance < 1.0e-9:
raise ValueError("zero point is at the fitted circle centre")
first = wrap_angle_rad(math.atan2(-float(vector[1]), float(vector[0])))
second = wrap_angle_rad(first + math.pi)
t3_rad = float(zero_summary["t3_rad"])
first_error = abs(signed_angle_difference_rad(first, t3_rad))
second_error = abs(signed_angle_difference_rad(second, t3_rad))
table_rad = first if first_error <= second_error else second
alignment_error = min(first_error, second_error)
t0_rad = float(zero_summary["t0_rad"])
radius = float(circle["radius_px"])
return {
"palm_rad": signed_angle_difference_rad(table_rad, t0_rad),
"table_rad": table_rad,
"t0_rad": t0_rad,
"t3_rad": t3_rad,
"axis_alignment_error_rad": alignment_error,
"zero_radial_error_px": abs(distance - radius),
}
def build_trajectory_zero_angle_payload(
*,
serial_number: str,
rounds: Sequence[Mapping[str, Mapping[str, float]]],
trajectory_quality: Mapping[str, Any],
maximum_round_difference_rad: float,
maximum_return_error_rad: float,
maximum_anchor_drift_rad: float,
maximum_axis_alignment_rad: float,
maximum_zero_radial_error_px: float,
detection_rate: float,
minimum_detection_rate: float,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Create the compact JSON and detailed circle-fit status report."""
if len(rounds) != 3:
raise ValueError("exactly three rounds are required")
if not 0.0 <= detection_rate <= 1.0:
raise ValueError("detection_rate must be in [0, 1]")
palm_rounds: list[float] = []
table_rounds: list[float] = []
return_errors: list[float] = []
anchor_drift: list[float] = []
alignment_errors: list[float] = []
zero_radial_errors: list[float] = []
for round_value in rounds:
before = round_value["zero_before"]
after = round_value["zero_after"]
palm_rounds.append(
circular_mean_rad(
[float(before["palm_rad"]), float(after["palm_rad"])]
)
)
table_rounds.append(
circular_mean_rad(
[float(before["table_rad"]), float(after["table_rad"])]
)
)
return_errors.append(
abs(
signed_angle_difference_rad(
float(after["table_rad"]),
float(before["table_rad"]),
)
)
)
anchor_drift.append(
abs(
signed_angle_difference_rad(
float(after["t0_rad"]),
float(before["t0_rad"]),
)
)
)
alignment_errors.extend(
[
float(before["axis_alignment_error_rad"]),
float(after["axis_alignment_error_rad"]),
]
)
zero_radial_errors.extend(
[
float(before["zero_radial_error_px"]),
float(after["zero_radial_error_px"]),
]
)
palm_difference = maximum_pairwise_angle_difference_rad(palm_rounds)
table_difference = maximum_pairwise_angle_difference_rad(table_rounds)
passed = bool(
bool(trajectory_quality.get("passed", False))
and detection_rate >= minimum_detection_rate
and palm_difference <= maximum_round_difference_rad
and table_difference <= maximum_round_difference_rad
and max(return_errors) <= maximum_return_error_rad
and max(anchor_drift) <= maximum_anchor_drift_rad
and max(alignment_errors) <= maximum_axis_alignment_rad
and max(zero_radial_errors) <= maximum_zero_radial_error_px
)
payload: dict[str, Any] = {
"schema_version": 1,
"model": "G20",
"side": "left",
"serial_number": str(serial_number),
"joint": "thumb_cmc_pitch",
"angle_unit": "rad",
"zero_command_u8": 255,
"zero_angles": {
"palm_relative_zero_rad": circular_median_rad(palm_rounds),
"table_projected_zero_rad": circular_median_rad(table_rounds),
},
"quality": {
"passed": passed,
"three_run_palm_max_difference_rad": palm_difference,
"three_run_table_max_difference_rad": table_difference,
},
}
report: dict[str, Any] = {
"measurement_method": "t3_center_image_trajectory_circle",
"detection_rate": float(detection_rate),
"trajectory_circle": dict(trajectory_quality),
"palm_rounds_rad": palm_rounds,
"table_rounds_rad": table_rounds,
"return_errors_rad": return_errors,
"anchor_drift_rad": anchor_drift,
"axis_alignment_errors_rad": alignment_errors,
"zero_radial_errors_px": zero_radial_errors,
"passed": passed,
}
return payload, report
def validate_zero_angle_payload(payload: Mapping[str, Any]) -> None:
"""Validate the deliberately small public JSON schema."""
if set(payload) != {
"schema_version",
"model",
"side",
"serial_number",
"joint",
"angle_unit",
"zero_command_u8",
"zero_angles",
"quality",
}:
raise ValueError("unexpected top-level zero calibration fields")
if payload["schema_version"] != 1:
raise ValueError("schema_version must be 1")
if payload["model"] != "G20" or payload["side"] != "left":
raise ValueError("payload must describe the left G20")
if payload["joint"] != "thumb_cmc_pitch":
raise ValueError("payload must describe thumb_cmc_pitch")
if payload["angle_unit"] != "rad" or payload["zero_command_u8"] != 255:
raise ValueError("zero calibration must use radians and command 255")
zero_angles = payload["zero_angles"]
if set(zero_angles) != {
"palm_relative_zero_rad",
"table_projected_zero_rad",
}:
raise ValueError("unexpected zero angle fields")
if not all(math.isfinite(float(value)) for value in zero_angles.values()):
raise ValueError("zero angles must be finite")
quality = payload["quality"]
if set(quality) != {
"passed",
"three_run_palm_max_difference_rad",
"three_run_table_max_difference_rad",
}:
raise ValueError("unexpected quality fields")
if not isinstance(quality["passed"], bool):
raise ValueError("quality.passed must be boolean")
for name in (
"three_run_palm_max_difference_rad",
"three_run_table_max_difference_rad",
):
value = float(quality[name])
if not math.isfinite(value) or value < 0.0:
raise ValueError(f"quality.{name} must be finite and non-negative")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,238 @@
"""Launch front-camera trajectory-circle CMC pitch zero measurement."""
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,
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"
or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
or serial_number in {".", ".."}
):
raise RuntimeError(
"serial_number is required and may contain only letters, "
"digits, dot, underscore and dash"
)
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:
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)
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)),
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(),
)
raw_topic = "/camera/camera/color/image_raw"
camera_info_topic = "/camera/camera/color/camera_info"
rect_topic = "/camera/camera/color/image_rect"
vision_container = ComposableNodeContainer(
name="g20_thumb_zero_vision_container",
namespace="/",
package="rclcpp_components",
executable="component_container_mt",
composable_node_descriptions=[
ComposableNode(
package="image_proc",
plugin="image_proc::RectifyNode",
name="rectify_color",
namespace="/camera/camera/color",
remappings=[
("image", raw_topic),
("camera_info", camera_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="/apriltag",
parameters=[
tag_config,
{
"detector.decimate": ParameterValue(
LaunchConfiguration("apriltag_decimate"),
value_type=float,
)
},
],
remappings=[
("image_rect", rect_topic),
("camera_info", camera_info_topic),
],
extra_arguments=[{"use_intra_process_comms": True}],
),
],
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,
}
],
)
zero_node = Node(
package="g20_thumb_apriltag_calibration",
executable="cmc_pitch_zero_node",
name="g20_thumb_cmc_pitch_zero",
output="screen",
parameters=[
zero_config,
{
"serial_number": serial_number,
"session_dir": str(session_dir),
"commands_enabled": ParameterValue(
LaunchConfiguration("commands_enabled"),
value_type=bool,
),
"image_topic": rect_topic,
"publish_debug_image": ParameterValue(
LaunchConfiguration("publish_debug_image"),
value_type=bool,
),
},
],
)
return [
LogInfo(msg=f"G20 CMC pitch zero session: {session_dir}"),
LogInfo(
msg=(
"Only T0(ID 0) and T3(ID 1) are required; "
"T4/T5 detections are ignored"
)
),
LogInfo(
msg=(
"Motor 0 performs three 255->64->255 sweeps; "
"zero angles come from the fitted T3-centre trajectory radius"
)
),
camera,
vision_container,
sdk,
zero_node,
]
def generate_launch_description() -> LaunchDescription:
package_share = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
)
return LaunchDescription(
[
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument("camera_serial_number", default_value=""),
DeclareLaunchArgument(
"color_profile", default_value="1280x720x30"
),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
DeclareLaunchArgument("commands_enabled", default_value="true"),
DeclareLaunchArgument(
"publish_debug_image", default_value="true"
),
DeclareLaunchArgument("start_camera", default_value="true"),
DeclareLaunchArgument("start_sdk", default_value="true"),
DeclareLaunchArgument(
"output_root",
default_value=str(Path.cwd() / "calibration_output"),
),
DeclareLaunchArgument("session_dir", default_value=""),
DeclareLaunchArgument(
"zero_config",
default_value=str(
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(
package_share / "config" / "front_tags.yaml"
),
),
OpaqueFunction(function=_launch_stack),
]
)
@@ -28,6 +28,10 @@ setup(
entry_points={
"console_scripts": [
"calibration_node = g20_thumb_apriltag_calibration.node:main",
(
"cmc_pitch_zero_node = "
"g20_thumb_apriltag_calibration.zero_node:main"
),
],
},
)
@@ -94,3 +94,48 @@ def test_realsense_color_and_camera_info_use_matching_qos() -> None:
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()
)
parameters = config["g20_thumb_cmc_pitch_zero"]["ros__parameters"]
assert parameters["t0_id"] == 0
assert parameters["t3_id"] == 1
assert parameters["baseline_command_u8"] == [
255,
255,
255,
255,
255,
255,
193,
148,
105,
42,
245,
255,
255,
255,
255,
255,
255,
255,
255,
255,
]
assert parameters["repetitions"] == 3
assert parameters["zero_capture_frames"] == 30
assert parameters["trajectory_command_u8"] <= 64
assert parameters["trajectory_minimum_state_span_u8"] >= 160.0
assert parameters["trajectory_minimum_bins"] >= 18
assert parameters["trajectory_minimum_arc_deg"] >= 20.0
assert parameters["trajectory_maximum_radial_rms_px"] <= 2.0
assert parameters["trajectory_maximum_p95_radial_error_px"] <= 3.5
assert parameters["minimum_detection_rate"] == 0.95
assert parameters["minimum_edge_pixels"] == 40.0
assert parameters["maximum_static_std_deg"] == 0.5
assert parameters["maximum_static_position_rms_px"] <= 1.5
assert parameters["maximum_round_difference_deg"] == 1.0
@@ -0,0 +1,272 @@
from __future__ import annotations
import math
from g20_thumb_apriltag_calibration.zero_calibration import (
build_trajectory_zero_angle_payload,
circular_median_rad,
fit_image_circle_trajectory,
measure_zero_from_circle,
signed_angle_difference_rad,
summarize_zero_frames,
tag_x_axis_angle_rad,
validate_zero_angle_payload,
)
import numpy as np
import pytest
def _corners(angle_rad: float) -> np.ndarray:
x_axis = np.asarray([math.cos(angle_rad), -math.sin(angle_rad)])
y_axis = np.asarray([math.sin(angle_rad), math.cos(angle_rad)])
centre = np.asarray([400.0, 300.0])
return np.asarray(
[
centre - 20.0 * x_axis - 20.0 * y_axis,
centre + 20.0 * x_axis - 20.0 * y_axis,
centre + 20.0 * x_axis + 20.0 * y_axis,
centre - 20.0 * x_axis + 20.0 * y_axis,
]
)
def _frame(
table_angle_rad: float,
*,
state_u8: float = 255.0,
t0_angle_rad: float = math.radians(3.0),
t3_angle_rad: float | None = None,
t0_xy: tuple[float, float] = (500.0, 300.0),
circle_xy: tuple[float, float] = (-120.0, 80.0),
radius_px: float = 90.0,
) -> dict[str, float]:
# table_angle_rad uses a y-up convention while image y grows downwards.
relative = np.asarray(circle_xy) + radius_px * np.asarray(
[math.cos(table_angle_rad), -math.sin(table_angle_rad)]
)
t0 = np.asarray(t0_xy)
t3 = t0 + relative
return {
"state_u8": state_u8,
"t0_x_px": float(t0[0]),
"t0_y_px": float(t0[1]),
"t3_x_px": float(t3[0]),
"t3_y_px": float(t3[1]),
"t0_rad": t0_angle_rad,
"t3_rad": (
table_angle_rad if t3_angle_rad is None else t3_angle_rad
),
}
def _trajectory(noise_px: float = 0.15) -> list[dict[str, float]]:
rng = np.random.default_rng(7)
observations: list[dict[str, float]] = []
for states in (
np.linspace(255.0, 64.0, 70),
np.linspace(64.0, 255.0, 70),
):
for state in states:
fraction = (255.0 - state) / (255.0 - 64.0)
angle = math.radians(10.0 + 65.0 * fraction)
shift = rng.normal(0.0, 0.35, size=2)
frame = _frame(
angle,
state_u8=float(state),
t0_xy=(500.0 + shift[0], 300.0 + shift[1]),
)
frame["t3_x_px"] += float(rng.normal(0.0, noise_px))
frame["t3_y_px"] += float(rng.normal(0.0, noise_px))
observations.append(frame)
return observations
def _fit(observations: list[dict[str, float]] | None = None) -> dict:
return fit_image_circle_trajectory(
_trajectory() if observations is None else observations,
bin_size_u8=8.0,
minimum_frames=45,
minimum_bins=18,
minimum_state_span_u8=160.0,
minimum_radius_px=20.0,
minimum_arc_rad=math.radians(20.0),
maximum_radial_rms_px=2.0,
maximum_p95_radial_error_px=3.5,
)
def _zero_summary(
angle_rad: float,
*,
t0_rad: float = math.radians(3.0),
t3_rad: float | None = None,
) -> dict[str, float]:
frames = [
_frame(
angle_rad + math.radians(index - 14.5) * 1.0e-4,
t0_angle_rad=t0_rad,
t3_angle_rad=t3_rad,
)
for index in range(30)
]
return summarize_zero_frames(frames)
def _measurement(
palm_rad: float,
table_rad: float,
*,
t0_rad: float,
radial_error_px: float = 0.2,
) -> dict[str, float]:
return {
"palm_rad": palm_rad,
"table_rad": table_rad,
"t0_rad": t0_rad,
"t3_rad": table_rad,
"axis_alignment_error_rad": math.radians(2.0),
"zero_radial_error_px": radial_error_px,
}
def _round(
palm_rad: float,
table_rad: float,
*,
return_delta: float = 0.002,
) -> dict[str, dict[str, float]]:
t0_rad = table_rad - palm_rad
return {
"zero_before": _measurement(
palm_rad, table_rad, t0_rad=t0_rad
),
"zero_after": _measurement(
palm_rad + return_delta,
table_rad + return_delta,
t0_rad=t0_rad,
),
}
def test_tag_angle_uses_ordered_opposite_edges() -> None:
expected = math.radians(27.0)
assert tag_x_axis_angle_rad(_corners(expected)) == pytest.approx(expected)
def test_circular_statistics_cross_pi_without_jumping() -> None:
values = [math.radians(179.0), math.radians(-179.0), math.pi]
result = circular_median_rad(values)
assert abs(signed_angle_difference_rad(result, math.pi)) < math.radians(1.1)
def test_circle_fit_recovers_center_radius_and_rejects_anchor_translation() -> None:
fit = _fit()
assert fit["centre_relative_xy_px"] == pytest.approx(
[-120.0, 80.0], abs=0.8
)
assert fit["radius_px"] == pytest.approx(90.0, abs=0.8)
assert fit["arc_rad"] >= math.radians(60.0)
assert fit["radial_rms_px"] < 0.5
assert fit["passed"] is True
def test_circle_fit_is_robust_to_sparse_bad_tag_centres() -> None:
observations = _trajectory()
for index in (9, 31, 57, 92, 121):
observations[index]["t3_x_px"] += 18.0
observations[index]["t3_y_px"] -= 15.0
fit = _fit(observations)
assert fit["centre_relative_xy_px"] == pytest.approx(
[-120.0, 80.0], abs=1.5
)
assert fit["radius_px"] == pytest.approx(90.0, abs=1.5)
def test_zero_angle_comes_from_circle_radius_not_tag_rotation() -> None:
circle = _fit()
expected_table = math.radians(10.0)
summary = _zero_summary(
expected_table,
t0_rad=math.radians(3.0),
t3_rad=math.radians(31.0),
)
result = measure_zero_from_circle(circle, summary)
assert result["table_rad"] == pytest.approx(expected_table, abs=0.01)
assert result["palm_rad"] == pytest.approx(
math.radians(7.0), abs=0.01
)
assert result["axis_alignment_error_rad"] == pytest.approx(
math.radians(21.0), abs=0.01
)
def test_circle_fit_rejects_short_state_span() -> None:
observations = [
_frame(math.radians(10.0 + index * 0.1), state_u8=255.0 - index)
for index in range(50)
]
with pytest.raises(ValueError, match="trajectory_state_span_too_small"):
_fit(observations)
def test_payload_remains_small_and_contains_circle_derived_angles() -> None:
rounds = [
_round(-0.50, -0.30),
_round(-0.498, -0.299),
_round(-0.501, -0.302),
]
payload, report = build_trajectory_zero_angle_payload(
serial_number="G20_LEFT_TEST",
rounds=rounds,
trajectory_quality=_fit(),
maximum_round_difference_rad=math.radians(1.0),
maximum_return_error_rad=math.radians(1.0),
maximum_anchor_drift_rad=math.radians(1.0),
maximum_axis_alignment_rad=math.radians(45.0),
maximum_zero_radial_error_px=4.0,
detection_rate=0.99,
minimum_detection_rate=0.95,
)
validate_zero_angle_payload(payload)
assert set(payload["zero_angles"]) == {
"palm_relative_zero_rad",
"table_projected_zero_rad",
}
assert payload["zero_angles"]["palm_relative_zero_rad"] == pytest.approx(
-0.499
)
assert payload["zero_angles"]["table_projected_zero_rad"] == pytest.approx(
-0.299
)
assert payload["quality"]["passed"] is True
assert report["measurement_method"] == "t3_center_image_trajectory_circle"
@pytest.mark.parametrize(("detection_rate", "radial_error"), [(0.8, 0.2), (0.99, 8.0)])
def test_quality_fails_for_detection_or_zero_circle_error(
detection_rate: float, radial_error: float
) -> None:
rounds = [
{
"zero_before": _measurement(
-0.50, -0.30, t0_rad=0.20, radial_error_px=radial_error
),
"zero_after": _measurement(
-0.499, -0.299, t0_rad=0.20, radial_error_px=radial_error
),
}
for _ in range(3)
]
payload, _ = build_trajectory_zero_angle_payload(
serial_number="G20_LEFT_TEST",
rounds=rounds,
trajectory_quality=_fit(),
maximum_round_difference_rad=math.radians(1.0),
maximum_return_error_rad=math.radians(1.0),
maximum_anchor_drift_rad=math.radians(1.0),
maximum_axis_alignment_rad=math.radians(45.0),
maximum_zero_radial_error_px=4.0,
detection_rate=detection_rate,
minimum_detection_rate=0.95,
)
assert payload["quality"]["passed"] is False