Compare commits
15 Commits
bd3
..
a8ddcc6296
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ddcc6296 | |||
| 2356bd6247 | |||
| 889e0ea8db | |||
| e1fb458eff | |||
| 8de69c34a1 | |||
| d6b7bd6209 | |||
| 8a749a3687 | |||
| 08fe190b3a | |||
| f7aeef87a8 | |||
| 2b7c1f92e7 | |||
| 1ed36ecdd8 | |||
| 7f84225ba8 | |||
| ba9f1b25e8 | |||
| 0d606c2ba2 | |||
| 06c050e446 |
@@ -69,6 +69,9 @@ Thumbs.db
|
|||||||
*_mapping_quality.json
|
*_mapping_quality.json
|
||||||
|
|
||||||
# Device-specific robot descriptions derived from local calibration runs
|
# Device-specific robot descriptions derived from local calibration runs
|
||||||
|
# Includes full/partial zero-calibration outputs and local copies.
|
||||||
|
/src/linkerhand_calibration/urdf/*/*_zero_calibrated*.urdf
|
||||||
|
/src/linkerhand_calibration/urdf/*/*_transferred_from_*.urdf
|
||||||
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf
|
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf
|
||||||
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_calibrated_*.urdf
|
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_calibrated_*.urdf
|
||||||
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_zero_calibrated_*.urdf
|
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_zero_calibrated_*.urdf
|
||||||
|
|||||||
Submodule
+1
Submodule src/agillink_omnihand_sdk added at 026740d9fd
@@ -1,5 +0,0 @@
|
|||||||
"""Front-camera AprilTag calibration for the left LinkerHand G20 thumb."""
|
|
||||||
|
|
||||||
from .core import BASELINE_COMMAND, COMMAND_NAMES
|
|
||||||
|
|
||||||
__all__ = ["BASELINE_COMMAND", "COMMAND_NAMES"]
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
[develop]
|
|
||||||
script_dir=$base/lib/g20_thumb_apriltag_calibration
|
|
||||||
[install]
|
|
||||||
install_scripts=$base/lib/g20_thumb_apriltag_calibration
|
|
||||||
@@ -9,6 +9,20 @@ class HandConfig:
|
|||||||
joint_names_en: Optional[List[str]] = None
|
joint_names_en: Optional[List[str]] = None
|
||||||
init_pos: List[int] = field(default_factory=list)
|
init_pos: List[int] = field(default_factory=list)
|
||||||
preset_actions: Optional[Dict[str, List[int]]] = None
|
preset_actions: Optional[Dict[str, List[int]]] = None
|
||||||
|
preset_action_overrides: Optional[Dict[str, Dict[str, List[int]]]] = None
|
||||||
|
# Integer GUI values are divided by this scale before publishing.
|
||||||
|
# Legacy hands use raw u8 values (scale=1); O12 uses milliradians.
|
||||||
|
position_scale: int = 1
|
||||||
|
position_unit: str = "u8"
|
||||||
|
|
||||||
|
def get_preset_actions(self, hand_type: str) -> Dict[str, List[int]]:
|
||||||
|
"""Return preset actions with hand-specific values applied."""
|
||||||
|
actions = dict(self.preset_actions or {})
|
||||||
|
if self.preset_action_overrides:
|
||||||
|
actions.update(
|
||||||
|
self.preset_action_overrides.get(hand_type.lower(), {})
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 常量字典(仅构建一次)
|
# 常量字典(仅构建一次)
|
||||||
@@ -224,13 +238,20 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
|||||||
"叁": [92, 87, 255, 255, 255, 0],
|
"叁": [92, 87, 255, 255, 255, 0],
|
||||||
"肆": [92, 87, 255, 255, 255, 255],
|
"肆": [92, 87, 255, 255, 255, 255],
|
||||||
"伍": [255, 255, 255, 255, 255, 255],
|
"伍": [255, 255, 255, 255, 255, 255],
|
||||||
"OK": [139, 91, 103, 250, 250, 250],
|
"OK": [95, 75, 116, 255, 255, 255],
|
||||||
|
"拇指对中指": [88, 2, 255, 114, 255, 255],
|
||||||
"点赞": [250, 79, 0, 0, 0, 0],
|
"点赞": [250, 79, 0, 0, 0, 0],
|
||||||
"握拳": [102, 18, 0, 0, 0, 0],
|
"握拳": [102, 18, 0, 0, 0, 0],
|
||||||
}
|
},
|
||||||
|
preset_action_overrides={
|
||||||
|
"right": {
|
||||||
|
"OK": [95, 83, 122, 255, 255, 255],
|
||||||
|
"拇指对中指": [95, 8, 255, 114, 255, 255],
|
||||||
|
},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
"L6": HandConfig(
|
"L6": HandConfig(
|
||||||
joint_names_en=["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "pinky_mcp_pitch", "ring_mcp_pitch"],
|
joint_names_en=["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"],
|
||||||
joint_names=["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲"],
|
joint_names=["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲"],
|
||||||
init_pos=[250] * 6,
|
init_pos=[250] * 6,
|
||||||
preset_actions={
|
preset_actions={
|
||||||
@@ -258,5 +279,33 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
|||||||
"拇指压感准备2": [139, 18, 130, 0, 0, 0]
|
"拇指压感准备2": [139, 18, 130, 0, 0, 0]
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
"O12": HandConfig(
|
||||||
|
# O12 active-angle API order. Keep this independent from the
|
||||||
|
# physical motor order exposed by some SDK metadata.
|
||||||
|
joint_names_en=[
|
||||||
|
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
|
||||||
|
"index_abad", "index_mcp", "index_pip",
|
||||||
|
"middle_abad", "middle_mcp", "middle_pip",
|
||||||
|
"ring_mcp", "pinky_mcp",
|
||||||
|
],
|
||||||
|
joint_names=[
|
||||||
|
"拇指旋转", "拇指侧摆", "拇指根部", "拇指中部",
|
||||||
|
"食指侧摆", "食指根部", "食指中部",
|
||||||
|
"中指侧摆", "中指根部", "中指中部",
|
||||||
|
"无名指根部", "小指根部",
|
||||||
|
],
|
||||||
|
# Values are milliradians so QSlider can retain 0.001 rad resolution.
|
||||||
|
init_pos=[0] * 12,
|
||||||
|
preset_actions={
|
||||||
|
"展开": [0] * 12,
|
||||||
|
"食指轻弯": [0, 0, 0, 0, 0, 300, 400, 0, 0, 0, 0, 0],
|
||||||
|
"半握": [
|
||||||
|
300, -200, -200, -500,
|
||||||
|
0, 650, 700, 0, 650, 800, 700, 700,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
position_scale=1000,
|
||||||
|
position_unit="rad",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
HAND_CONFIGS = MappingProxyType(_HAND_CONFIGS)
|
HAND_CONFIGS = MappingProxyType(_HAND_CONFIGS)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import time, json
|
import time, json
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
@@ -33,6 +34,16 @@ _CANONICAL_COMMAND_NAMES = {
|
|||||||
"thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch",
|
"thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch",
|
||||||
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
|
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
|
||||||
],
|
],
|
||||||
|
"L6": [
|
||||||
|
"thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
|
||||||
|
],
|
||||||
|
"O12": [
|
||||||
|
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
|
||||||
|
"index_abad", "index_mcp", "index_pip",
|
||||||
|
"middle_abad", "middle_mcp", "middle_pip",
|
||||||
|
"ring_mcp", "pinky_mcp",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
_CANONICAL_COMMAND_BOUNDS = {
|
_CANONICAL_COMMAND_BOUNDS = {
|
||||||
@@ -43,17 +54,38 @@ _CANONICAL_COMMAND_BOUNDS = {
|
|||||||
*[(0, 255)] * 5,
|
*[(0, 255)] * 5,
|
||||||
],
|
],
|
||||||
"O6": [(0, 255)] * 6,
|
"O6": [(0, 255)] * 6,
|
||||||
|
"L6": [(0, 255)] * 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Integer slider bounds in milliradians, derived from the O12 active-angle
|
||||||
|
# limits. Right and left thumb signs differ; all other limits are shared.
|
||||||
|
_O12_COMMAND_BOUNDS_MRAD = {
|
||||||
|
"right": [
|
||||||
|
(0, 942), (-1387, 0), (-827, 0), (-1291, 0),
|
||||||
|
(-261, 261), (0, 1352), (0, 1530), (-261, 261),
|
||||||
|
(0, 1357), (0, 1815), (0, 1535), (0, 1535),
|
||||||
|
],
|
||||||
|
"left": [
|
||||||
|
(-942, 0), (0, 1387), (-827, 0), (-1291, 0),
|
||||||
|
(-261, 261), (0, 1352), (0, 1530), (-261, 261),
|
||||||
|
(0, 1357), (0, 1815), (0, 1535), (0, 1535),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ROS2NodeManager(QObject):
|
class ROS2NodeManager(QObject):
|
||||||
"""ROS2节点管理器,处理ROS通信"""
|
"""ROS2节点管理器,处理ROS通信"""
|
||||||
status_updated = pyqtSignal(str, str) # 状态类型, 消息内容
|
status_updated = pyqtSignal(str, str) # 状态类型, 消息内容
|
||||||
|
feedback_updated = pyqtSignal(object)
|
||||||
|
|
||||||
def __init__(self, node_name: str = "hand_control_node"):
|
def __init__(self, node_name: str = "hand_control_node"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.node = None
|
self.node = None
|
||||||
self.publisher = None
|
self.publisher = None
|
||||||
|
self.publisher_arc = None
|
||||||
|
self.state_subscription = None
|
||||||
|
self.speed_pub = None
|
||||||
|
self.torque_pub = None
|
||||||
self.joint_state = JointState()
|
self.joint_state = JointState()
|
||||||
self.joint_state.header = Header()
|
self.joint_state.header = Header()
|
||||||
|
|
||||||
@@ -83,14 +115,26 @@ class ROS2NodeManager(QObject):
|
|||||||
self.node.get_parameter('topic_prefix').value
|
self.node.get_parameter('topic_prefix').value
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.is_arc == True:
|
if self.hand_joint == "O12":
|
||||||
|
topic_base = f'/o12/{self.hand_type}'
|
||||||
|
self.publisher = self.node.create_publisher(
|
||||||
|
JointState, f'{topic_base}/joint_cmd', 10
|
||||||
|
)
|
||||||
|
self.state_subscription = self.node.create_subscription(
|
||||||
|
JointState,
|
||||||
|
f'{topic_base}/joint_states',
|
||||||
|
self._o12_feedback_callback,
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
elif self.is_arc == True:
|
||||||
# 创建发布者
|
# 创建发布者
|
||||||
self.publisher_arc = self.node.create_publisher(
|
self.publisher_arc = self.node.create_publisher(
|
||||||
JointState,
|
JointState,
|
||||||
self.topic(f'/cb_{self.hand_type}_hand_control_cmd_arc'),
|
self.topic(f'/cb_{self.hand_type}_hand_control_cmd_arc'),
|
||||||
10
|
10
|
||||||
)
|
)
|
||||||
# 创建发布者
|
# 创建旧型号发布者
|
||||||
|
if self.hand_joint != "O12":
|
||||||
self.publisher = self.node.create_publisher(
|
self.publisher = self.node.create_publisher(
|
||||||
JointState,
|
JointState,
|
||||||
self.topic(f'/cb_{self.hand_type}_hand_control_cmd'),
|
self.topic(f'/cb_{self.hand_type}_hand_control_cmd'),
|
||||||
@@ -100,6 +144,7 @@ class ROS2NodeManager(QObject):
|
|||||||
JointState, self.topic('/calibration_pose_snapshot'), 10
|
JointState, self.topic('/calibration_pose_snapshot'), 10
|
||||||
)
|
)
|
||||||
# 新增 speed / torque 发布者
|
# 新增 speed / torque 发布者
|
||||||
|
if self.hand_joint != "O12":
|
||||||
self.speed_pub = self.node.create_publisher(
|
self.speed_pub = self.node.create_publisher(
|
||||||
String, self.topic('/cb_hand_setting_cmd'), 10)
|
String, self.topic('/cb_hand_setting_cmd'), 10)
|
||||||
self.torque_pub = self.node.create_publisher(
|
self.torque_pub = self.node.create_publisher(
|
||||||
@@ -136,15 +181,36 @@ class ROS2NodeManager(QObject):
|
|||||||
while rclpy.ok() and self.node:
|
while rclpy.ok() and self.node:
|
||||||
rclpy.spin_once(self.node, timeout_sec=0.1)
|
rclpy.spin_once(self.node, timeout_sec=0.1)
|
||||||
|
|
||||||
def bound_positions(self, positions: List[int]) -> List[int]:
|
def _o12_feedback_callback(self, msg: JointState):
|
||||||
|
"""Forward 12-channel O12 radian feedback safely into the Qt thread."""
|
||||||
|
if len(msg.position) != 12:
|
||||||
|
self.status_updated.emit(
|
||||||
|
"error", f"O12位置反馈长度错误: {len(msg.position)},应为12"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.feedback_updated.emit([float(value) for value in msg.position])
|
||||||
|
|
||||||
|
def command_bounds(self, count: int):
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
bounds = _O12_COMMAND_BOUNDS_MRAD.get(self.hand_type)
|
||||||
|
else:
|
||||||
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
|
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
|
||||||
if not bounds or len(bounds) != len(positions):
|
if not bounds or len(bounds) != count:
|
||||||
return list(positions)
|
return [(0, 255)] * count
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
def bound_positions(self, positions: List[int]) -> List[int]:
|
||||||
|
bounds = self.command_bounds(len(positions))
|
||||||
return [
|
return [
|
||||||
max(minimum, min(maximum, int(value)))
|
max(minimum, min(maximum, int(value)))
|
||||||
for value, (minimum, maximum) in zip(positions, bounds)
|
for value, (minimum, maximum) in zip(positions, bounds)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def wire_positions(self, positions: List[int]) -> List[float]:
|
||||||
|
positions = self.bound_positions(positions)
|
||||||
|
scale = _HAND_CONFIGS[self.hand_joint].position_scale
|
||||||
|
return [float(value) / float(scale) for value in positions]
|
||||||
|
|
||||||
def publish_joint_state(self, positions: List[int]):
|
def publish_joint_state(self, positions: List[int]):
|
||||||
"""发布关节状态消息"""
|
"""发布关节状态消息"""
|
||||||
if not self.publisher or not self.node:
|
if not self.publisher or not self.node:
|
||||||
@@ -153,15 +219,21 @@ class ROS2NodeManager(QObject):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
positions = self.bound_positions(positions)
|
positions = self.bound_positions(positions)
|
||||||
|
wire_positions = self.wire_positions(positions)
|
||||||
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
|
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
|
||||||
self.joint_state.position = [float(pos) for pos in positions]
|
self.joint_state.position = wire_positions
|
||||||
# self.joint_state.velocity = [0.1] * len(positions)
|
# self.joint_state.velocity = [0.1] * len(positions)
|
||||||
# self.joint_state.effort = [0.01] * len(positions)
|
# self.joint_state.effort = [0.01] * len(positions)
|
||||||
# 如果有关节名称,添加到消息中
|
# 如果有关节名称,添加到消息中
|
||||||
#hand_config = HandConfig.from_hand_type(self.hand_joint)
|
#hand_config = HandConfig.from_hand_type(self.hand_joint)
|
||||||
hand_config = _HAND_CONFIGS[self.hand_joint]
|
hand_config = _HAND_CONFIGS[self.hand_joint]
|
||||||
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
||||||
if canonical_names and len(canonical_names) == len(positions):
|
# O12 SDK 1.1.8 may expose names in physical motor order while the
|
||||||
|
# position array uses active-angle order. An empty name array makes
|
||||||
|
# the documented position order unambiguous to the driver.
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
self.joint_state.name = []
|
||||||
|
elif canonical_names and len(canonical_names) == len(positions):
|
||||||
self.joint_state.name = canonical_names
|
self.joint_state.name = canonical_names
|
||||||
elif len(hand_config.joint_names) == len(positions):
|
elif len(hand_config.joint_names) == len(positions):
|
||||||
if hand_config.joint_names_en != None:
|
if hand_config.joint_names_en != None:
|
||||||
@@ -170,7 +242,7 @@ class ROS2NodeManager(QObject):
|
|||||||
self.joint_state.name = hand_config.joint_names
|
self.joint_state.name = hand_config.joint_names
|
||||||
|
|
||||||
self.publisher.publish(self.joint_state)
|
self.publisher.publish(self.joint_state)
|
||||||
if self.is_arc == True:
|
if self.is_arc == True and self.hand_joint != "O12":
|
||||||
if self.hand_joint == "O6":
|
if self.hand_joint == "O6":
|
||||||
if self.hand_type == "left":
|
if self.hand_type == "left":
|
||||||
pose = range_to_arc_left(positions,self.hand_joint)
|
pose = range_to_arc_left(positions,self.hand_joint)
|
||||||
@@ -205,14 +277,21 @@ class ROS2NodeManager(QObject):
|
|||||||
positions = self.bound_positions(positions)
|
positions = self.bound_positions(positions)
|
||||||
self.publish_joint_state(positions)
|
self.publish_joint_state(positions)
|
||||||
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
|
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
|
||||||
self.joint_state.position = [float(pos) for pos in positions]
|
self.joint_state.position = self.wire_positions(positions)
|
||||||
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
||||||
if canonical_names:
|
if canonical_names and self.hand_joint != "O12":
|
||||||
self.joint_state.name = canonical_names
|
self.joint_state.name = canonical_names
|
||||||
|
elif self.hand_joint == "O12":
|
||||||
|
self.joint_state.name = []
|
||||||
self.snapshot_publisher.publish(self.joint_state)
|
self.snapshot_publisher.publish(self.joint_state)
|
||||||
self.status_updated.emit("info", "当前标定姿势快照已发布")
|
self.status_updated.emit("info", "当前标定姿势快照已发布")
|
||||||
|
|
||||||
def publish_speed(self, val: int):
|
def publish_speed(self, val: int):
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
self.status_updated.emit(
|
||||||
|
"warning", "O12不使用旧版u8速度接口;请通过角度轨迹限制速度"
|
||||||
|
)
|
||||||
|
return
|
||||||
if self.hand_joint.upper() in ("O6", "L6"):
|
if self.hand_joint.upper() in ("O6", "L6"):
|
||||||
joint_len = 6
|
joint_len = 6
|
||||||
elif self.hand_joint == "L7":
|
elif self.hand_joint == "L7":
|
||||||
@@ -232,6 +311,11 @@ class ROS2NodeManager(QObject):
|
|||||||
self.speed_pub.publish(msg)
|
self.speed_pub.publish(msg)
|
||||||
|
|
||||||
def publish_torque(self, val: int):
|
def publish_torque(self, val: int):
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
self.status_updated.emit(
|
||||||
|
"warning", "O12不使用旧版u8扭矩接口;位置GUI已禁用该设置"
|
||||||
|
)
|
||||||
|
return
|
||||||
if self.hand_joint.upper() in ("O6", "L6"):
|
if self.hand_joint.upper() in ("O6", "L6"):
|
||||||
joint_len = 6
|
joint_len = 6
|
||||||
elif self.hand_joint == "L7":
|
elif self.hand_joint == "L7":
|
||||||
@@ -273,11 +357,17 @@ class HandControlGUI(QWidget):
|
|||||||
# 设置ROS管理器
|
# 设置ROS管理器
|
||||||
self.ros_manager = ros_manager
|
self.ros_manager = ros_manager
|
||||||
self.ros_manager.status_updated.connect(self.update_status)
|
self.ros_manager.status_updated.connect(self.update_status)
|
||||||
|
self.ros_manager.feedback_updated.connect(self.on_feedback_updated)
|
||||||
|
|
||||||
# 获取手部配置
|
# 获取手部配置
|
||||||
self.hand_joint = self.ros_manager.hand_joint
|
self.hand_joint = self.ros_manager.hand_joint
|
||||||
self.hand_type = self.ros_manager.hand_type
|
self.hand_type = self.ros_manager.hand_type
|
||||||
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
||||||
|
self.preset_actions = self.hand_config.get_preset_actions(self.hand_type)
|
||||||
|
self.feedback_positions = None
|
||||||
|
# O12 only publishes after an explicit slider/preset action. This
|
||||||
|
# prevents opening or closing the real hand merely by launching GUI.
|
||||||
|
self.command_dirty = self.hand_joint != "O12"
|
||||||
|
|
||||||
# 初始化UI
|
# 初始化UI
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
@@ -288,6 +378,20 @@ class HandControlGUI(QWidget):
|
|||||||
self.publish_timer.timeout.connect(self.publish_joint_state)
|
self.publish_timer.timeout.connect(self.publish_joint_state)
|
||||||
self.publish_timer.start()
|
self.publish_timer.start()
|
||||||
|
|
||||||
|
def format_position(self, value: int) -> str:
|
||||||
|
"""Format an integer GUI value in the hand's actual command unit."""
|
||||||
|
scale = self.hand_config.position_scale
|
||||||
|
if self.hand_config.position_unit == "rad":
|
||||||
|
radians = float(value) / float(scale)
|
||||||
|
return f"{radians:.3f} rad / {math.degrees(radians):.1f}°"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def command_positions(self) -> List[float]:
|
||||||
|
"""Return current slider values converted to wire units."""
|
||||||
|
return self.ros_manager.wire_positions(
|
||||||
|
[slider.value() for slider in self.sliders]
|
||||||
|
)
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""初始化用户界面"""
|
"""初始化用户界面"""
|
||||||
# 设置窗口属性
|
# 设置窗口属性
|
||||||
@@ -465,13 +569,11 @@ class HandControlGUI(QWidget):
|
|||||||
for i, (name, value) in enumerate(zip(
|
for i, (name, value) in enumerate(zip(
|
||||||
self.hand_config.joint_names, self.hand_config.init_pos
|
self.hand_config.joint_names, self.hand_config.init_pos
|
||||||
)):
|
)):
|
||||||
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
|
bounds = self.ros_manager.command_bounds(len(self.hand_config.init_pos))
|
||||||
minimum, maximum = (
|
minimum, maximum = bounds[i]
|
||||||
bounds[i] if bounds and i < len(bounds) else (0, 255)
|
|
||||||
)
|
|
||||||
value = max(minimum, min(maximum, int(value)))
|
value = max(minimum, min(maximum, int(value)))
|
||||||
# 创建标签
|
# 创建标签
|
||||||
label = QLabel(f"{name}: {value}")
|
label = QLabel(f"{name}: {self.format_position(value)}")
|
||||||
label.setMinimumWidth(120)
|
label.setMinimumWidth(120)
|
||||||
|
|
||||||
# 创建滑动条
|
# 创建滑动条
|
||||||
@@ -535,9 +637,9 @@ class HandControlGUI(QWidget):
|
|||||||
def create_system_preset_buttons(self, parent_layout):
|
def create_system_preset_buttons(self, parent_layout):
|
||||||
"""创建系统预设动作按钮"""
|
"""创建系统预设动作按钮"""
|
||||||
self.preset_buttons = [] # 清空按钮列表
|
self.preset_buttons = [] # 清空按钮列表
|
||||||
if self.hand_config.preset_actions:
|
if self.preset_actions:
|
||||||
buttons = []
|
buttons = []
|
||||||
for idx, (name, positions) in enumerate(self.hand_config.preset_actions.items()):
|
for idx, (name, positions) in enumerate(self.preset_actions.items()):
|
||||||
button = QPushButton(name)
|
button = QPushButton(name)
|
||||||
button.setProperty("category", "preset")
|
button.setProperty("category", "preset")
|
||||||
button.clicked.connect(
|
button.clicked.connect(
|
||||||
@@ -564,6 +666,9 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
# —— 2. 新增:速度与扭矩设置(每行一个)——
|
# —— 2. 新增:速度与扭矩设置(每行一个)——
|
||||||
quick_set_gb = QGroupBox("快速设置")
|
quick_set_gb = QGroupBox("快速设置")
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
quick_set_gb.setEnabled(False)
|
||||||
|
quick_set_gb.setToolTip("O12不使用旧版0-255速度/扭矩设置接口")
|
||||||
qv_layout = QVBoxLayout(quick_set_gb)
|
qv_layout = QVBoxLayout(quick_set_gb)
|
||||||
|
|
||||||
# 速度行
|
# 速度行
|
||||||
@@ -692,7 +797,10 @@ class HandControlGUI(QWidget):
|
|||||||
"""滑动条值改变事件处理"""
|
"""滑动条值改变事件处理"""
|
||||||
if 0 <= index < len(self.slider_labels):
|
if 0 <= index < len(self.slider_labels):
|
||||||
joint_name = self.hand_config.joint_names[index]
|
joint_name = self.hand_config.joint_names[index]
|
||||||
self.slider_labels[index].setText(f"{joint_name}: {value}")
|
self.slider_labels[index].setText(
|
||||||
|
f"{joint_name}: {self.format_position(value)}"
|
||||||
|
)
|
||||||
|
self.command_dirty = True
|
||||||
|
|
||||||
# 更新数值显示
|
# 更新数值显示
|
||||||
self.update_value_display()
|
self.update_value_display()
|
||||||
@@ -700,10 +808,23 @@ class HandControlGUI(QWidget):
|
|||||||
def update_value_display(self):
|
def update_value_display(self):
|
||||||
"""更新数值显示面板内容"""
|
"""更新数值显示面板内容"""
|
||||||
# 获取所有滑动条的当前值
|
# 获取所有滑动条的当前值
|
||||||
values = [slider.value() for slider in self.sliders]
|
command = self.command_positions()
|
||||||
|
command_text = ", ".join(f"{value:.3f}" for value in command)
|
||||||
|
lines = [f"目标({self.hand_config.position_unit}): [{command_text}]"]
|
||||||
|
if self.feedback_positions is not None:
|
||||||
|
feedback_text = ", ".join(
|
||||||
|
f"{value:.3f}" for value in self.feedback_positions
|
||||||
|
)
|
||||||
|
lines.append(f"反馈(rad): [{feedback_text}]")
|
||||||
|
self.value_display.setText("\n".join(lines))
|
||||||
|
|
||||||
# 格式化显示为列表形式
|
def on_feedback_updated(self, positions):
|
||||||
self.value_display.setText(f"{values}")
|
"""Display actual O12 joint feedback without moving command sliders."""
|
||||||
|
self.feedback_positions = list(positions)
|
||||||
|
self.update_value_display()
|
||||||
|
if hasattr(self, "connection_status"):
|
||||||
|
self.connection_status.setText("O12右手已连接并收到位置反馈")
|
||||||
|
self.connection_status.setObjectName("StatusInfo")
|
||||||
|
|
||||||
def on_preset_action_clicked(self, positions: List[int]):
|
def on_preset_action_clicked(self, positions: List[int]):
|
||||||
"""预设动作按钮点击事件处理"""
|
"""预设动作按钮点击事件处理"""
|
||||||
@@ -742,7 +863,25 @@ class HandControlGUI(QWidget):
|
|||||||
self.cycle_button.setText("循环运行预设动作")
|
self.cycle_button.setText("循环运行预设动作")
|
||||||
self.reset_preset_buttons_color()
|
self.reset_preset_buttons_color()
|
||||||
|
|
||||||
self.status_updated.emit("warning", "已停止所有动作")
|
if self.hand_joint == "O12" and self.feedback_positions is not None:
|
||||||
|
# Hold the latest measured pose instead of merely stopping topic
|
||||||
|
# publication while a previous position target is still active.
|
||||||
|
scale = self.hand_config.position_scale
|
||||||
|
hold_values = [
|
||||||
|
int(round(value * scale)) for value in self.feedback_positions
|
||||||
|
]
|
||||||
|
hold_values = self.ros_manager.bound_positions(hold_values)
|
||||||
|
for slider, value in zip(self.sliders, hold_values):
|
||||||
|
slider.blockSignals(True)
|
||||||
|
slider.setValue(value)
|
||||||
|
slider.blockSignals(False)
|
||||||
|
self.command_dirty = True
|
||||||
|
self.publish_joint_state()
|
||||||
|
self.update_value_display()
|
||||||
|
self.status_updated.emit("warning", "已下发当前反馈位置并保持")
|
||||||
|
else:
|
||||||
|
self.command_dirty = False
|
||||||
|
self.status_updated.emit("warning", "已停止连续动作和新的位置发布")
|
||||||
|
|
||||||
def on_save_pose_clicked(self):
|
def on_save_pose_clicked(self):
|
||||||
"""发布当前滑块姿势,供hand_pose_capture写入profile。"""
|
"""发布当前滑块姿势,供hand_pose_capture写入profile。"""
|
||||||
@@ -751,7 +890,7 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
def on_cycle_clicked(self):
|
def on_cycle_clicked(self):
|
||||||
"""循环运行预设动作按钮点击事件处理"""
|
"""循环运行预设动作按钮点击事件处理"""
|
||||||
if not self.hand_config.preset_actions:
|
if not self.preset_actions:
|
||||||
QMessageBox.warning(self, "无预设动作", "当前手部型号没有预设动作可循环运行")
|
QMessageBox.warning(self, "无预设动作", "当前手部型号没有预设动作可循环运行")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -774,19 +913,19 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
def run_next_action(self):
|
def run_next_action(self):
|
||||||
"""运行下一个预设动作"""
|
"""运行下一个预设动作"""
|
||||||
if not self.hand_config.preset_actions:
|
if not self.preset_actions:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 重置所有按钮颜色
|
# 重置所有按钮颜色
|
||||||
self.reset_preset_buttons_color()
|
self.reset_preset_buttons_color()
|
||||||
|
|
||||||
# 计算下一个动作索引
|
# 计算下一个动作索引
|
||||||
self.current_action_index = (self.current_action_index + 1) % len(self.hand_config.preset_actions)
|
self.current_action_index = (self.current_action_index + 1) % len(self.preset_actions)
|
||||||
|
|
||||||
# 获取下一个动作
|
# 获取下一个动作
|
||||||
action_names = list(self.hand_config.preset_actions.keys())
|
action_names = list(self.preset_actions.keys())
|
||||||
action_name = action_names[self.current_action_index]
|
action_name = action_names[self.current_action_index]
|
||||||
action_positions = self.hand_config.preset_actions[action_name]
|
action_positions = self.preset_actions[action_name]
|
||||||
|
|
||||||
# 执行动作
|
# 执行动作
|
||||||
self.on_preset_action_clicked(action_positions)
|
self.on_preset_action_clicked(action_positions)
|
||||||
@@ -811,6 +950,7 @@ class HandControlGUI(QWidget):
|
|||||||
"""关节类型改变事件处理"""
|
"""关节类型改变事件处理"""
|
||||||
self.hand_joint = joint_type
|
self.hand_joint = joint_type
|
||||||
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
||||||
|
self.preset_actions = self.hand_config.get_preset_actions(self.hand_type)
|
||||||
|
|
||||||
# 更新手部信息
|
# 更新手部信息
|
||||||
info_text = f"""手部类型: {self.hand_type}
|
info_text = f"""手部类型: {self.hand_type}
|
||||||
@@ -829,8 +969,11 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
def publish_joint_state(self):
|
def publish_joint_state(self):
|
||||||
"""发布当前关节状态"""
|
"""发布当前关节状态"""
|
||||||
|
if self.hand_joint == "O12" and not self.command_dirty:
|
||||||
|
return
|
||||||
positions = [slider.value() for slider in self.sliders]
|
positions = [slider.value() for slider in self.sliders]
|
||||||
self.ros_manager.publish_joint_state(positions)
|
self.ros_manager.publish_joint_state(positions)
|
||||||
|
self.command_dirty = False
|
||||||
|
|
||||||
def update_status(self, status_type: str, message: str):
|
def update_status(self, status_type: str, message: str):
|
||||||
"""更新状态显示"""
|
"""更新状态显示"""
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Start the HCAN-connected O12 right hand and its safe position GUI."""
|
||||||
|
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import IncludeLaunchDescription, TimerAction
|
||||||
|
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||||
|
from launch.substitutions import PathJoinSubstitution
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from launch_ros.substitutions import FindPackageShare
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
o12_driver = IncludeLaunchDescription(
|
||||||
|
PythonLaunchDescriptionSource(
|
||||||
|
PathJoinSubstitution([
|
||||||
|
FindPackageShare("omnihand_node"),
|
||||||
|
"launch",
|
||||||
|
"omnihand_pro_2025_node.launch.py",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
o12_gui = Node(
|
||||||
|
package="gui_control",
|
||||||
|
executable="gui_control",
|
||||||
|
name="o12_right_gui_control",
|
||||||
|
output="screen",
|
||||||
|
parameters=[{
|
||||||
|
"hand_type": "right",
|
||||||
|
"hand_joint": "O12",
|
||||||
|
# Commands are coalesced, so dragging a slider sends at most 10 Hz.
|
||||||
|
"topic_hz": 10,
|
||||||
|
"is_arc": False,
|
||||||
|
"topic_prefix": "",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
o12_driver,
|
||||||
|
# Give the CANFD driver a moment to claim and initialise the adapter.
|
||||||
|
TimerAction(period=1.5, actions=[o12_gui]),
|
||||||
|
])
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<test_depend>python3-pytest</test_depend>
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
<exec_depend>linker_hand_ros2_sdk</exec_depend>
|
<exec_depend>linker_hand_ros2_sdk</exec_depend>
|
||||||
|
<exec_depend>omnihand_node</exec_depend>
|
||||||
|
|
||||||
<export>
|
<export>
|
||||||
<build_type>ament_python</build_type>
|
<build_type>ament_python</build_type>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from gui_control.config.constants import HAND_CONFIGS
|
||||||
|
|
||||||
|
|
||||||
|
def test_l6_gui_uses_the_sdk_channel_order() -> None:
|
||||||
|
assert HAND_CONFIGS["L6"].joint_names_en == [
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch",
|
||||||
|
"ring_mcp_pitch",
|
||||||
|
"pinky_mcp_pitch",
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from gui_control.config.constants import HAND_CONFIGS
|
||||||
|
|
||||||
|
|
||||||
|
def test_o12_gui_uses_active_angle_order_and_milliradians() -> None:
|
||||||
|
config = HAND_CONFIGS["O12"]
|
||||||
|
|
||||||
|
assert config.joint_names_en == [
|
||||||
|
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
|
||||||
|
"index_abad", "index_mcp", "index_pip",
|
||||||
|
"middle_abad", "middle_mcp", "middle_pip",
|
||||||
|
"ring_mcp", "pinky_mcp",
|
||||||
|
]
|
||||||
|
assert config.position_scale == 1000
|
||||||
|
assert config.position_unit == "rad"
|
||||||
|
assert len(config.init_pos) == 12
|
||||||
|
assert all(len(pose) == 12 for pose in config.preset_actions.values())
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from gui_control.config.constants import HAND_CONFIGS
|
||||||
|
|
||||||
|
|
||||||
|
def test_o6_ok_preset_is_hand_specific() -> None:
|
||||||
|
config = HAND_CONFIGS["O6"]
|
||||||
|
|
||||||
|
assert config.get_preset_actions("left")["OK"] == [
|
||||||
|
95, 75, 116, 255, 255, 255,
|
||||||
|
]
|
||||||
|
assert config.get_preset_actions("right")["OK"] == [
|
||||||
|
95, 83, 122, 255, 255, 255,
|
||||||
|
]
|
||||||
|
assert config.get_preset_actions("left")["拇指对中指"] == [
|
||||||
|
88, 2, 255, 114, 255, 255,
|
||||||
|
]
|
||||||
|
assert config.get_preset_actions("right")["拇指对中指"] == [
|
||||||
|
95, 8, 255, 114, 255, 255,
|
||||||
|
]
|
||||||
+36
-2
@@ -1,3 +1,5 @@
|
|||||||
|
from collections import deque
|
||||||
|
|
||||||
import can
|
import can
|
||||||
import time, sys
|
import time, sys
|
||||||
import threading
|
import threading
|
||||||
@@ -57,6 +59,14 @@ class LinkerHandL6Can:
|
|||||||
self.normal_force, self.tangential_force, self.tangential_force_dir, self.approach_inc = [[-1] * 6 for _ in range(4)]
|
self.normal_force, self.tangential_force, self.tangential_force_dir, self.approach_inc = [[-1] * 6 for _ in range(4)]
|
||||||
self.is_lock = False
|
self.is_lock = False
|
||||||
self.version = None
|
self.version = None
|
||||||
|
# L6 replies to a six-byte 0x01 position command with an immediate
|
||||||
|
# byte-for-byte echo on the same CAN ID. A zero-payload 0x01 state
|
||||||
|
# query also replies on that ID, but with the measured positions.
|
||||||
|
# Keep the two transactions distinct so command echoes never enter
|
||||||
|
# the published feedback stream used by calibration.
|
||||||
|
self._position_echo_lock = threading.Lock()
|
||||||
|
self._pending_position_echoes = deque(maxlen=32)
|
||||||
|
self._position_echo_timeout_seconds = 0.02
|
||||||
# Start the receiving thread
|
# Start the receiving thread
|
||||||
self.running = True
|
self.running = True
|
||||||
self.receive_thread = threading.Thread(target=self.receive_response)
|
self.receive_thread = threading.Thread(target=self.receive_response)
|
||||||
@@ -111,6 +121,11 @@ class LinkerHandL6Can:
|
|||||||
frame_property_value = int(frame_property.value) if hasattr(frame_property, 'value') else frame_property
|
frame_property_value = int(frame_property.value) if hasattr(frame_property, 'value') else frame_property
|
||||||
data = [frame_property_value] + [int(val) for val in data_list]
|
data = [frame_property_value] + [int(val) for val in data_list]
|
||||||
msg = can.Message(arbitration_id=self.can_id, data=data, is_extended_id=False)
|
msg = can.Message(arbitration_id=self.can_id, data=data, is_extended_id=False)
|
||||||
|
if frame_property_value == 0x01 and len(data_list) == 6:
|
||||||
|
with self._position_echo_lock:
|
||||||
|
self._pending_position_echoes.append(
|
||||||
|
(time.monotonic(), tuple(int(value) for value in data_list))
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self.bus.send(msg)
|
self.bus.send(msg)
|
||||||
except can.CanError as e:
|
except can.CanError as e:
|
||||||
@@ -201,7 +216,23 @@ class LinkerHandL6Can:
|
|||||||
except:
|
except:
|
||||||
return
|
return
|
||||||
if frame_type == 0x01: # 0x01
|
if frame_type == 0x01: # 0x01
|
||||||
self.x01 = list(response_data)
|
response = tuple(int(value) for value in response_data)
|
||||||
|
now = time.monotonic()
|
||||||
|
is_position_echo = False
|
||||||
|
with self._position_echo_lock:
|
||||||
|
while (
|
||||||
|
self._pending_position_echoes
|
||||||
|
and now - self._pending_position_echoes[0][0]
|
||||||
|
> self._position_echo_timeout_seconds
|
||||||
|
):
|
||||||
|
self._pending_position_echoes.popleft()
|
||||||
|
for pending in tuple(self._pending_position_echoes):
|
||||||
|
if pending[1] == response:
|
||||||
|
self._pending_position_echoes.remove(pending)
|
||||||
|
is_position_echo = True
|
||||||
|
break
|
||||||
|
if not is_position_echo:
|
||||||
|
self.x01 = list(response)
|
||||||
elif frame_type == 0x02: # 0x02
|
elif frame_type == 0x02: # 0x02
|
||||||
self.x02 = list(response_data)
|
self.x02 = list(response_data)
|
||||||
elif frame_type == 0x05: # Set speed
|
elif frame_type == 0x05: # Set speed
|
||||||
@@ -391,7 +422,10 @@ class LinkerHandL6Can:
|
|||||||
return self.x35
|
return self.x35
|
||||||
|
|
||||||
def get_finger_order(self):
|
def get_finger_order(self):
|
||||||
return ["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
|
# L6 channel 1 is the physical CMC roll actuator. Older SDK releases
|
||||||
|
# exposed the channel as ``thumb_cmc_yaw`` even though the wire order
|
||||||
|
# and mechanism have always been roll.
|
||||||
|
return ["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
|
||||||
|
|
||||||
def show_fun_table(self):
|
def show_fun_table(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
+1
-1
@@ -372,7 +372,7 @@ class LinkerHandL6RS485:
|
|||||||
return [0] * 6
|
return [0] * 6
|
||||||
|
|
||||||
def get_finger_order(self):
|
def get_finger_order(self):
|
||||||
return ["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
|
return ["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# 便捷方法
|
# 便捷方法
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import ast
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE = Path(__file__).resolve().parents[1] / "linker_hand_ros2_sdk/LinkerHand/core"
|
||||||
|
EXPECTED = [
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch",
|
||||||
|
"ring_mcp_pitch",
|
||||||
|
"pinky_mcp_pitch",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _finger_order(path: Path, class_name: str) -> list[str]:
|
||||||
|
module = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
selected = next(
|
||||||
|
item
|
||||||
|
for item in module.body
|
||||||
|
if isinstance(item, ast.ClassDef) and item.name == class_name
|
||||||
|
)
|
||||||
|
method = next(
|
||||||
|
item
|
||||||
|
for item in selected.body
|
||||||
|
if isinstance(item, ast.FunctionDef) and item.name == "get_finger_order"
|
||||||
|
)
|
||||||
|
returned = next(item for item in method.body if isinstance(item, ast.Return))
|
||||||
|
return ast.literal_eval(returned.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_l6_can_and_rs485_publish_the_same_physical_channel_order() -> None:
|
||||||
|
assert _finger_order(PACKAGE / "can/linker_hand_l6_can.py", "LinkerHandL6Can") == EXPECTED
|
||||||
|
assert _finger_order(
|
||||||
|
PACKAGE / "rs485/linker_hand_l6_rs485.py", "LinkerHandL6RS485"
|
||||||
|
) == EXPECTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_l6_can_position_echo_does_not_replace_measured_feedback() -> None:
|
||||||
|
linker_hand_root = PACKAGE.parent
|
||||||
|
sys.path.insert(0, str(linker_hand_root))
|
||||||
|
try:
|
||||||
|
from core.can.linker_hand_l6_can import LinkerHandL6Can
|
||||||
|
finally:
|
||||||
|
sys.path.remove(str(linker_hand_root))
|
||||||
|
|
||||||
|
hand = LinkerHandL6Can.__new__(LinkerHandL6Can)
|
||||||
|
hand.can_id = 0x27
|
||||||
|
hand.x01 = [10, 20, 30, 40, 50, 60]
|
||||||
|
hand._position_echo_lock = threading.Lock()
|
||||||
|
command = (255, 2, 253, 253, 253, 253)
|
||||||
|
hand._pending_position_echoes = deque(
|
||||||
|
[(time.monotonic(), command)], maxlen=32
|
||||||
|
)
|
||||||
|
hand._position_echo_timeout_seconds = 0.02
|
||||||
|
|
||||||
|
hand.process_response(
|
||||||
|
SimpleNamespace(arbitration_id=0x27, data=bytes((0x01, *command)))
|
||||||
|
)
|
||||||
|
assert hand.x01 == [10, 20, 30, 40, 50, 60]
|
||||||
|
assert not hand._pending_position_echoes
|
||||||
|
|
||||||
|
measured = (250, 3, 252, 252, 252, 252)
|
||||||
|
hand.process_response(
|
||||||
|
SimpleNamespace(arbitration_id=0x27, data=bytes((0x01, *measured)))
|
||||||
|
)
|
||||||
|
assert hand.x01 == list(measured)
|
||||||
Submodule
+1
Submodule src/linkerhand-l30-sdk added at 0103fc55f9
+228
-25
@@ -1,17 +1,214 @@
|
|||||||
# G20 左右手 AprilTag 标定
|
# LinkerHand 专业标定包
|
||||||
|
|
||||||
|
## O12 右手 16-Tag 完整标定
|
||||||
|
|
||||||
|
O12 使用 vendor `omnihand_pro_2025_node` 的标准弧度接口,固定订阅
|
||||||
|
`/o12/right/joint_states`、发布 `/o12/right/joint_cmd`。标定程序不会使用
|
||||||
|
0–2000 混合控制接口,也不会按可能错位的 `JointState.name[]` 重排数据;12 路
|
||||||
|
`position[]` 顺序及 `thumb_roll → thumb_cmc_roll`、
|
||||||
|
`thumb_abad → thumb_cmc_yaw` 映射由产品 Profile 固定。
|
||||||
|
|
||||||
|
把 [o12_right_product.yaml](config/o12_right_product.yaml) 中的
|
||||||
|
`serial_number` 改成实物串号后,只需运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_calibration calibrate_hand --config \
|
||||||
|
src/linkerhand_calibration/config/o12_right_product.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
runner 会自动加载仓库内 vendor Jazzy overlay,启动 HCAN device 0/channel 0
|
||||||
|
节点、三相机、AprilTag 与标定节点;READY 后自动开始。启动前会确认 12 路
|
||||||
|
POSITION 模式、错误码、温度和反馈,并以不超过 3° 的低速点动执行固定通道预检。
|
||||||
|
正式扫描以 20 Hz 发布端点速度为零的弧度余弦轨迹;中止、堵转或质量失败时保持
|
||||||
|
当前位置。结果发布到 `calibration_output/<O12串号>/latest_passed`,其中 JSON
|
||||||
|
为 schema v7 弧度 knots,不生成 256 点 u8 表。
|
||||||
|
|
||||||
|
只验证配置、16 张 16 mm Tag、相机/外参、源 URDF 和 SDK 配置哈希而不运动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_calibration calibrate_hand --config \
|
||||||
|
src/linkerhand_calibration/config/o12_right_product.yaml --validate-only
|
||||||
|
```
|
||||||
|
|
||||||
|
## O6 右手局部标定(o6_right_8/v1)
|
||||||
|
|
||||||
|
O6 使用与 L6 相同的三机位八 Tag 观测拓扑,但保留 O6 自己的六通道协议与
|
||||||
|
URDF 关节名:正面 ID0/1/2 标定 `rh_thumb_cmc_pitch` 与
|
||||||
|
`rh_thumb_ip`,侧面 ID3/4/5 标定 `rh_pinky_mcp_pitch` 与
|
||||||
|
`rh_pinky_dip`,上面 ID6/7 标定 `rh_thumb_cmc_yaw`。六路 baseline 均为
|
||||||
|
`255`;每次只扫描通道 0、1 或 5,其他通道保持 255。经硬件确认,食指、中指、
|
||||||
|
无名指与小指同机构,因此小指 MCP/DIP 的实测结果会以迁移来源标记后用于其余
|
||||||
|
三指。O6 实测 IP/DIP 均存在稳定的非线性,因此使用通过独立 holdout 的双向
|
||||||
|
运行曲线和二次耦合模型。标准 URDF 无法表示二次 mimic,因此修正 URDF 与 L6
|
||||||
|
一样保留端点对齐的线性 `<mimic>`,使普通 URDF/RViz 中五个被动关节能正常联动,
|
||||||
|
同时把被动关节 limit 更新为实测范围;中间行程的精确双向非线性轨迹由下述标定桥
|
||||||
|
发布。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_calibration calibrate_hand --config \
|
||||||
|
src/linkerhand_calibration/config/o6_right_product.yaml --validate-only
|
||||||
|
|
||||||
|
ros2 run linkerhand_calibration calibrate_hand --config \
|
||||||
|
src/linkerhand_calibration/config/o6_right_product.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
由于 O6 的 FRONT/TOP 光轴接近正交,同一平面棋盘的同步视角天然更倾斜。O6 外参
|
||||||
|
允许最终批次 RMS 不超过 1.5 px,但仍保持 0.3° 旋转、1.5 mm 平移稳定性门限,
|
||||||
|
并在标定发布前额外用 20 mm 跨机位实体轴线 RMS 粗差门限拦截移动相机等明显错误:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 launch linkerhand_calibration three_camera_extrinsics.launch.py \
|
||||||
|
output_file:=$PWD/config/o6_three_camera_extrinsics.yaml \
|
||||||
|
checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027 \
|
||||||
|
maximum_reprojection_rms_px:=1.5
|
||||||
|
```
|
||||||
|
|
||||||
|
结果发布到 `calibration_output/<O6串号>/latest_partial_passed`,运行时 JSON、
|
||||||
|
修正 URDF 与 correction-input JSON 均使用 `o6_right_` 前缀。产品 YAML 内的实物
|
||||||
|
串号、相机身份、外参和四项输入哈希必须在启动硬件前通过校验。
|
||||||
|
|
||||||
|
标定完成后,以修正 URDF 启动 robot state publisher,并用同一会话中的 JSON 把
|
||||||
|
O6 六路反馈转换为 11 个 URDF 关节:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 launch linkerhand_calibration calibrated_joint_state_bridge.launch.py \
|
||||||
|
hand_type:=right \
|
||||||
|
calibration_file:=$PWD/calibration_output/O6_RIGHT_001/latest_partial_passed/o6_right_O6_RIGHT_001_partial_calibration.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## L6 右手局部标定(l6_right_8/v1)
|
||||||
|
|
||||||
|
本版只发布 `rh_thumb_cmc_pitch`、`rh_thumb_cmc_roll`、
|
||||||
|
`rh_pinky_mcp_pitch` 的静态零位与动态曲线,以及 `rh_thumb_dip`、
|
||||||
|
`rh_pinky_dip` 的视觉动态曲线。拇指 DIP 通过线性 mimic;小指 DIP 使用实测
|
||||||
|
双方向运行曲线和 MuJoCo 二次 equality,因为它的传动比会随屈曲角变化。生成的
|
||||||
|
修正 URDF 保留 `rh_pinky_dip` 的 `<mimic>`,因此在普通 URDF/RViz 中仍会跟随
|
||||||
|
MCP 运动;该线性回退精确对齐实测零位和闭合端点。中间行程的准确非线性轨迹由
|
||||||
|
MuJoCo equality 或下述标定桥提供。根据 L6 四指同机构的实机确认,食指、中指、
|
||||||
|
无名指的 MCP 零偏、行程、双向反馈曲线及 DIP 耦合从小指迁移;每指自己的
|
||||||
|
`origin.xyz`、axis、mesh 和惯量保持 CAD,不把迁移结果标成 Tag 实测。四指 MCP
|
||||||
|
以反馈 255 的展开端作为 CAD lower/zero 锚点;实测行程不会再被强制压回较短的
|
||||||
|
CAD upper,因此不会向四指 origin 写入系统性的负零偏。
|
||||||
|
结果指针为 `latest_partial_passed`,不会被当作六路主动关节的完整标定。
|
||||||
|
|
||||||
|
拇指 pitch/roll 根据两组已记录六路反馈值的实机/仿真姿态对比,以反馈 255
|
||||||
|
保持源 CAD joint zero,不叠加端点推断的静态偏置;小指及三指迁移仍以反馈 0
|
||||||
|
机械闭合姿态对齐源 CAD upper。该策略只改变坐标锚点,不改变视觉实测的双方向
|
||||||
|
行程曲线。现场姿态对比必须同时记录对应的六路反馈值。
|
||||||
|
|
||||||
|
先启动 SDK 和 GUI 做手动检查时使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linker_hand_ros2_sdk linker_hand_sdk --ros-args \
|
||||||
|
-p hand_type:=right -p hand_joint:=L6 -p can:=can0 -p topic_prefix:=/l6
|
||||||
|
|
||||||
|
ros2 run gui_control gui_control
|
||||||
|
```
|
||||||
|
|
||||||
|
正式一键标定由 runner 自己启动 SDK、三相机、AprilTag 和标定节点,不要同时
|
||||||
|
运行上面的 SDK/GUI 控制命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_calibration calibrate_hand --config \
|
||||||
|
src/linkerhand_calibration/config/l6_right_product.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
只检查 Profile、8 张 16 mm Tag、相机/外参哈希和只读源 URDF:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_calibration calibrate_hand --config \
|
||||||
|
src/linkerhand_calibration/config/l6_right_product.yaml --validate-only
|
||||||
|
```
|
||||||
|
|
||||||
|
离线回放与在线发布使用同一拟合/URDF写回路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_calibration calibrate_hand --config \
|
||||||
|
src/linkerhand_calibration/config/l6_right_product.yaml \
|
||||||
|
--offline-raw calibration_output/L6_RIGHT_001/<时间戳>/raw_samples.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
拟合通过后,程序先原子写入并重新校验
|
||||||
|
`l6_right_<序列号>_urdf_correction_input.json`,验证串号、Profile 和源 URDF
|
||||||
|
SHA-256 后,才把其中的原精度零偏、行程和 DIP 耦合参数交给现有 URDF 写回器。
|
||||||
|
面向运行时的 schema-v6 `*_partial_calibration.json` 及修正 URDF 的字段、数值和
|
||||||
|
格式保持兼容;单独的 correction-input JSON 是可审计的 URDF 生成依据,不是运行桥
|
||||||
|
的输入文件。
|
||||||
|
|
||||||
|
运行前将产品 YAML 中的 `serial_number` 改成实物串号。通道顺序固定为 pitch、
|
||||||
|
roll、index、middle、ring、pinky;旧 SDK 反馈中的 `thumb_cmc_yaw` 仅作为第二路
|
||||||
|
兼容别名读取,新产物和运行桥始终输出 `thumb_cmc_roll` / `rh_*` URDF 名。
|
||||||
|
预检和正式扫描均使用 L6 硬件速度 `1` 作为上限,并由 100 Hz 余弦缓入缓出
|
||||||
|
轨迹把完整 `255↔0` 行程固定为 `6 s`;短行程按距离同比缩短。SDK 会过滤 L6
|
||||||
|
在同一 CAN ID 回送的位置命令回显,标定只使用状态查询返回的真实反馈。标定
|
||||||
|
启动时还会检查重复 SDK/GUI 发布者,避免两个进程同时访问同一只手。
|
||||||
|
|
||||||
|
标定完成后,用生成的 JSON 将六路硬件反馈转换成包括小指 DIP 在内的 11 关节
|
||||||
|
`JointState`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 launch linkerhand_calibration calibrated_joint_state_bridge.launch.py \
|
||||||
|
hand_type:=right \
|
||||||
|
calibration_file:=$PWD/calibration_output/L6_RIGHT_001/latest_partial_passed/l6_right_L6_RIGHT_001_partial_calibration.json
|
||||||
|
```
|
||||||
|
|
||||||
|
schema v6 默认订阅 `/l6/cb_right_hand_state`,发布
|
||||||
|
`/sim/mujoco/l6/right/joint_state`。标准 URDF 的 `<mimic>` 本身只支持线性关系,
|
||||||
|
所以只查看 URDF 时小指 DIP 中间行程是端点对齐的近似;需要实测轨迹时使用该桥
|
||||||
|
或修正 URDF 内的 MuJoCo equality。
|
||||||
|
|
||||||
## G20右手正式一键标定
|
## G20右手正式一键标定
|
||||||
|
|
||||||
固定三相机和19张Tag安装完成后,用户只运行:
|
固定三相机和19张Tag安装完成后,用户只运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 run g20_thumb_apriltag_calibration calibrate_g20_right
|
ros2 run linkerhand_calibration calibrate_hand
|
||||||
```
|
```
|
||||||
|
|
||||||
|
旧 executable `calibrate_g20_right` 在本发行版内保留为同一入口的别名;
|
||||||
|
旧 ROS 包名前缀不再提供。新脚本和部署配置统一使用 `calibrate_hand`。
|
||||||
|
|
||||||
|
构建和正式调用统一为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
colcon build --packages-select linkerhand_calibration
|
||||||
|
ros2 run linkerhand_calibration calibrate_hand --config <产品配置.yaml>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码边界
|
||||||
|
|
||||||
|
- `core/`:无 ROS、无具体型号,包含领域类型、PnP/旋转数学、拟合接口、统一样本
|
||||||
|
契约、`TaskEvaluator/SessionSolver` 协议和 `UrdfCorrectionPlan`。其中
|
||||||
|
`core/urdf/patch.py` 是所有型号共用的声明式 URDF patch engine,统一负责属性级
|
||||||
|
文本修改、MuJoCo equality、mesh 安全复制、禁止覆盖和原子发布。
|
||||||
|
- `runtime/`:通用会话状态机与注册 Profile 分发;ROS 消息和硬件适配只能位于
|
||||||
|
`runtime/nodes`、`runtime/adapters`。
|
||||||
|
- `models/g20/`、`models/l6/`:只保留型号 Profile、拟合/零位策略以及把拟合结果
|
||||||
|
转成 `UrdfPatchSet` 的薄适配层,不再各自实现 XML/mesh 文件写入器。新增 O6 时
|
||||||
|
优先新增 Profile;只有测量链或传动模型不同的部分才增加小型拟合插件。
|
||||||
|
后续型号或左右手作为新的独立 Profile 加入 `models/`,不在通用层增加分支。
|
||||||
|
- `compat/`:v1 配置、旧路径、旧会话与旧单相机逻辑。旧 Python 包名仅保留
|
||||||
|
一版最小转发 shim,不包含算法副本。
|
||||||
|
|
||||||
|
产品配置在启动硬件前通过本地 `ProfileRegistry` 完成命令索引、任务、视角、
|
||||||
|
Tag、零位目标、URDF关节和文件哈希校验。v1 配置原文不改;v2 配置使用
|
||||||
|
`profile_id: MODEL/side/layout/vREVISION`。视角名和数量由 Profile 声明,通用层
|
||||||
|
不要求 `front/side/top`,也不假设固定 20 个命令。
|
||||||
|
|
||||||
|
仓库中存在已审定硬件会话时,可执行只读金标准检查(不会覆盖任何产物):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_calibration validate_g20_goldens \
|
||||||
|
calibration_output/G20_RIGHT_001
|
||||||
|
```
|
||||||
|
|
||||||
|
它严格核对完整整手、合并拇指、独立拇指、拟合失败和零位失败五个会话;完整
|
||||||
|
整手还会重新离线求解并要求 JSON、URDF 的 SHA-256 与正式产物一致。
|
||||||
|
|
||||||
完全独立地只标定大拇指4项任务时,使用:
|
完全独立地只标定大拇指4项任务时,使用:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 run g20_thumb_apriltag_calibration calibrate_g20_right \
|
ros2 run linkerhand_calibration calibrate_hand \
|
||||||
--scope thumb
|
--scope thumb
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -23,7 +220,7 @@ ros2 run g20_thumb_apriltag_calibration calibrate_g20_right \
|
|||||||
如果确实需要把新的拇指结果合并到一份已经通过的完整整手标定,才额外使用:
|
如果确实需要把新的拇指结果合并到一份已经通过的完整整手标定,才额外使用:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 run g20_thumb_apriltag_calibration calibrate_g20_right \
|
ros2 run linkerhand_calibration calibrate_hand \
|
||||||
--scope thumb \
|
--scope thumb \
|
||||||
--base-session calibration_output/G20_RIGHT_001/latest_passed
|
--base-session calibration_output/G20_RIGHT_001/latest_passed
|
||||||
```
|
```
|
||||||
@@ -35,7 +232,7 @@ ros2 run g20_thumb_apriltag_calibration calibrate_g20_right \
|
|||||||
只重新采集12项四指任务并合成完整整手URDF:
|
只重新采集12项四指任务并合成完整整手URDF:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 run g20_thumb_apriltag_calibration calibrate_g20_right \
|
ros2 run linkerhand_calibration calibrate_hand \
|
||||||
--scope fingers \
|
--scope fingers \
|
||||||
--base-session calibration_output/G20_RIGHT_001/<已通过的拇指会话时间戳>
|
--base-session calibration_output/G20_RIGHT_001/<已通过的拇指会话时间戳>
|
||||||
```
|
```
|
||||||
@@ -114,7 +311,7 @@ schema v4 JSON,不再生成schema v5运行文件。
|
|||||||
`tag_layout:=legacy_11`,两套配置和结果schema互不覆盖:
|
`tag_layout:=legacy_11`,两套配置和结果schema互不覆盖:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration three_camera_calibration.launch.py \
|
ros2 launch linkerhand_calibration three_camera_calibration.launch.py \
|
||||||
hand_type:=right \
|
hand_type:=right \
|
||||||
tag_layout:=g20_right_19 \
|
tag_layout:=g20_right_19 \
|
||||||
serial_number:=G20_RIGHT_001 \
|
serial_number:=G20_RIGHT_001 \
|
||||||
@@ -271,7 +468,7 @@ ID 9 必须在拇指横摆的完整行程中持续可见。
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /home/lxp/projects/linkerhand_retarget_ros2/config
|
mkdir -p /home/lxp/projects/linkerhand_retarget_ros2/config
|
||||||
ros2 launch g20_thumb_apriltag_calibration \
|
ros2 launch linkerhand_calibration \
|
||||||
three_camera_extrinsics.launch.py \
|
three_camera_extrinsics.launch.py \
|
||||||
output_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
|
output_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
|
||||||
checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027
|
checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027
|
||||||
@@ -287,11 +484,11 @@ PnP的最终外参偏差锁死采集。点击 `AUTO ON` 后,棋盘稳定1秒
|
|||||||
外参分两组采集。让棋盘静止且同时出现在正面/侧面画面中,每改变一次位置和倾角添加
|
外参分两组采集。让棋盘静止且同时出现在正面/侧面画面中,每改变一次位置和倾角添加
|
||||||
一个候选;随后以相同方法采集正面/上面。程序使用固定内参的
|
一个候选;随后以相同方法采集正面/上面。程序使用固定内参的
|
||||||
`stereoCalibrate` 联合优化唯一旋转/平移。采集准入和最终验收分离:FRONT和
|
`stereoCalibrate` 联合优化唯一旋转/平移。采集准入和最终验收分离:FRONT和
|
||||||
配对相机的单帧RMS分别不得超过1.5 px,同时组合RMS不得超过1.2 px;界面中
|
配对相机的单帧RMS分别不得超过1.5 px,候选组合RMS不得超过1.5 px;界面中
|
||||||
单相机1.2 px以内显示绿色、1.2~1.5 px显示黄色且仍可采集、超过1.5 px显示红色。
|
单相机1.2 px以内显示绿色、1.2~1.5 px显示黄色且仍可采集、超过1.5 px显示红色。
|
||||||
新姿态会与全部已采姿态比较,避免在少数姿态间反复采集。拟合先剔除粗大异常组,
|
新姿态会与全部已采姿态比较,避免在少数姿态间反复采集。拟合先剔除粗大异常组,
|
||||||
再在不低于15个内点的前提下有界裁剪联合误差最高的候选,最终1.2 px门限不会被
|
再在不低于15个内点的前提下有界裁剪联合误差最高的候选,只有最终批次组合RMS
|
||||||
放宽。两组均得到
|
不超过1.2 px才允许保存。两组均得到
|
||||||
至少15个内点且联合RMS、三折稳定性合格后 `SAVE` 才变绿。
|
至少15个内点且联合RMS、三折稳定性合格后 `SAVE` 才变绿。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -312,12 +509,11 @@ ros2 service call /g20_camera_extrinsics/save std_srvs/srv/Trigger {}
|
|||||||
先使用禁止运动模式检查三个机位、外参、内参和标签:
|
先使用禁止运动模式检查三个机位、外参、内参和标签:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration \
|
ros2 launch linkerhand_calibration \
|
||||||
three_camera_calibration.launch.py \
|
three_camera_calibration.launch.py \
|
||||||
hand_type:=left \
|
hand_type:=left \
|
||||||
serial_number:=G20_LEFT_001 \
|
serial_number:=G20_LEFT_001 \
|
||||||
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
|
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
|
||||||
source_urdf_path:=/home/lxp/projects/linkerhand_retarget_ros2/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left.urdf \
|
|
||||||
commands_enabled:=false
|
commands_enabled:=false
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -335,19 +531,20 @@ ros2 run image_view image_view --ros-args \
|
|||||||
确认全行程安全、MVS客户端已关闭且没有其他命令发布者后,重启正式流程:
|
确认全行程安全、MVS客户端已关闭且没有其他命令发布者后,重启正式流程:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration \
|
ros2 launch linkerhand_calibration \
|
||||||
three_camera_calibration.launch.py \
|
three_camera_calibration.launch.py \
|
||||||
hand_type:=left \
|
hand_type:=left \
|
||||||
serial_number:=G20_LEFT_001 \
|
serial_number:=G20_LEFT_001 \
|
||||||
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
|
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
|
||||||
source_urdf_path:=/home/lxp/projects/linkerhand_retarget_ros2/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left.urdf \
|
|
||||||
can_interface:=can0
|
can_interface:=can0
|
||||||
```
|
```
|
||||||
|
|
||||||
右手使用同一入口;默认自动选择右手SDK话题和原始URDF:
|
原始URDF及其mesh随`linkerhand_calibration`安装,默认根据`hand_type`自动选择。
|
||||||
|
如需调试其他CAD版本,仍可通过`source_urdf_path:=<绝对路径>`显式覆盖。
|
||||||
|
右手使用同一入口,并自动选择右手SDK话题和原始URDF:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration \
|
ros2 launch linkerhand_calibration \
|
||||||
three_camera_calibration.launch.py \
|
three_camera_calibration.launch.py \
|
||||||
hand_type:=right \
|
hand_type:=right \
|
||||||
serial_number:=G20_RIGHT_001 \
|
serial_number:=G20_RIGHT_001 \
|
||||||
@@ -502,15 +699,21 @@ ID 9的可见性和PnP稳定性。当前方向自动重试、失败轮次重试
|
|||||||
```text
|
```text
|
||||||
calibration_output/G20_LEFT_001/<时间戳>/
|
calibration_output/G20_LEFT_001/<时间戳>/
|
||||||
g20_left_G20_LEFT_001_calibration.json
|
g20_left_G20_LEFT_001_calibration.json
|
||||||
|
g20_left_G20_LEFT_001_calibration_urdf_correction_input.json
|
||||||
linkerhand_g20_left_zero_calibrated_G20_LEFT_001_<时间戳>.urdf
|
linkerhand_g20_left_zero_calibrated_G20_LEFT_001_<时间戳>.urdf
|
||||||
meshes/*.STL
|
meshes/*.STL
|
||||||
|
|
||||||
calibration_output/G20_RIGHT_001/<时间戳>/
|
calibration_output/G20_RIGHT_001/<时间戳>/
|
||||||
g20_right_G20_RIGHT_001_calibration.json
|
g20_right_G20_RIGHT_001_calibration.json
|
||||||
|
g20_right_G20_RIGHT_001_calibration_urdf_correction_input.json
|
||||||
linkerhand_g20_right_zero_calibrated_G20_RIGHT_001_<时间戳>.urdf
|
linkerhand_g20_right_zero_calibrated_G20_RIGHT_001_<时间戳>.urdf
|
||||||
meshes/*.STL
|
meshes/*.STL
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`*_urdf_correction_input.json` 会在修正 URDF 之前落盘并重新读取,且绑定源 URDF
|
||||||
|
哈希、型号、侧别、layout 和序列号。公开 schema-v4 运行 JSON 仍保持原格式,原有
|
||||||
|
G20 运行桥、金标准哈希和 URDF 写回数值不因该交接层改变。
|
||||||
|
|
||||||
文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`、
|
文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`、
|
||||||
`zero_angles.urdf_zero_offset_rad`、5个被动标记、模板来源和总体质量。新URDF每次从
|
`zero_angles.urdf_zero_offset_rad`、5个被动标记、模板来源和总体质量。新URDF每次从
|
||||||
指定原始CAD文件生成,采用 `T_original × Rot(axis, offset)`,绝不叠加旧校准文件或
|
指定原始CAD文件生成,采用 `T_original × Rot(axis, offset)`,绝不叠加旧校准文件或
|
||||||
@@ -526,7 +729,7 @@ calibration_output/G20_RIGHT_001/<时间戳>/
|
|||||||
命令。`--output-tag` 为新产物增加安全后缀,已有JSON、URDF和验证报告不会被覆盖:
|
命令。`--output-tag` 为新产物增加安全后缀,已有JSON、URDF和验证报告不会被覆盖:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m g20_thumb_apriltag_calibration.offline_replay \
|
python3 -m linkerhand_calibration.offline_replay \
|
||||||
calibration_output/G20_RIGHT_001/20260811_120146 \
|
calibration_output/G20_RIGHT_001/20260811_120146 \
|
||||||
--output-tag AXIS_FRAME_V3 \
|
--output-tag AXIS_FRAME_V3 \
|
||||||
--write
|
--write
|
||||||
@@ -543,7 +746,7 @@ python3 -m g20_thumb_apriltag_calibration.offline_replay \
|
|||||||
`JointState`(包括5个被动关节):
|
`JointState`(包括5个被动关节):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration calibrated_joint_state_bridge.launch.py \
|
ros2 launch linkerhand_calibration calibrated_joint_state_bridge.launch.py \
|
||||||
hand_type:=right \
|
hand_type:=right \
|
||||||
calibration_file:=$PWD/calibration_output/G20_RIGHT_001/20260811_120146/g20_right_G20_RIGHT_001_calibration.json
|
calibration_file:=$PWD/calibration_output/G20_RIGHT_001/20260811_120146/g20_right_G20_RIGHT_001_calibration.json
|
||||||
```
|
```
|
||||||
@@ -615,7 +818,7 @@ sudo apt-get install -y \
|
|||||||
cd /home/lxp/projects/linkerhand_retarget_ros2
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
source /opt/ros/jazzy/setup.bash
|
source /opt/ros/jazzy/setup.bash
|
||||||
colcon build --symlink-install \
|
colcon build --symlink-install \
|
||||||
--packages-select linker_hand_ros2_sdk g20_thumb_apriltag_calibration
|
--packages-select linker_hand_ros2_sdk linkerhand_calibration
|
||||||
source install/setup.bash
|
source install/setup.bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -642,7 +845,7 @@ source install/setup.bash
|
|||||||
先单独启动相机(不会连接机械手,也不会发送关节命令):
|
先单独启动相机(不会连接机械手,也不会发送关节命令):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 run g20_thumb_apriltag_calibration hikrobot_camera_node --ros-args \
|
ros2 run linkerhand_calibration hikrobot_camera_node --ros-args \
|
||||||
--remap __ns:=/camera/camera/color \
|
--remap __ns:=/camera/camera/color \
|
||||||
-p serial_number:=DB2163742 \
|
-p serial_number:=DB2163742 \
|
||||||
-p camera_info_url:=$HOME/.ros/camera_info/hikrobot_DB2163742.yaml
|
-p camera_info_url:=$HOME/.ros/camera_info/hikrobot_DB2163742.yaml
|
||||||
@@ -680,7 +883,7 @@ ros2 run camera_calibration cameracalibrator \
|
|||||||
但标定节点不会发送位置运动命令,也不会允许解锁全行程扫描:
|
但标定节点不会发送位置运动命令,也不会允许解锁全行程扫描:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
ros2 launch linkerhand_calibration front_thumb_calibration.launch.py \
|
||||||
serial_number:=G20_LEFT_001 \
|
serial_number:=G20_LEFT_001 \
|
||||||
camera_serial_number:=DB2163742 \
|
camera_serial_number:=DB2163742 \
|
||||||
commands_enabled:=false
|
commands_enabled:=false
|
||||||
@@ -691,7 +894,7 @@ ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
|||||||
速度,并使用单终点连续运动:
|
速度,并使用单终点连续运动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
ros2 launch linkerhand_calibration front_thumb_calibration.launch.py \
|
||||||
serial_number:=G20_LEFT_001 \
|
serial_number:=G20_LEFT_001 \
|
||||||
camera_serial_number:=DB2163742 \
|
camera_serial_number:=DB2163742 \
|
||||||
can_interface:=can0 \
|
can_interface:=can0 \
|
||||||
@@ -804,7 +1007,7 @@ calibration_output/<序列号>/<时间戳>/
|
|||||||
恢复时必须显式复用原目录,否则会创建新会话:
|
恢复时必须显式复用原目录,否则会创建新会话:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
ros2 launch linkerhand_calibration front_thumb_calibration.launch.py \
|
||||||
serial_number:=G20_LEFT_001 \
|
serial_number:=G20_LEFT_001 \
|
||||||
session_dir:=/绝对路径/calibration_output/G20_LEFT_001/20260727_120000
|
session_dir:=/绝对路径/calibration_output/G20_LEFT_001/20260727_120000
|
||||||
```
|
```
|
||||||
@@ -884,7 +1087,7 @@ for name, joint in data["joints"].items():
|
|||||||
URDF:
|
URDF:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration \
|
ros2 launch linkerhand_calibration \
|
||||||
front_cmc_pitch_zero.launch.py \
|
front_cmc_pitch_zero.launch.py \
|
||||||
serial_number:=G20_LEFT_001
|
serial_number:=G20_LEFT_001
|
||||||
```
|
```
|
||||||
@@ -961,7 +1164,7 @@ zero_angles.table_projected_zero_rad
|
|||||||
Roll同样固定使用“T3中心→拟合圆心”的内向径向矢量,不读取T3标签朝向。
|
Roll同样固定使用“T3中心→拟合圆心”的内向径向矢量,不读取T3标签朝向。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch g20_thumb_apriltag_calibration \
|
ros2 launch linkerhand_calibration \
|
||||||
front_cmc_roll_calibration.launch.py \
|
front_cmc_roll_calibration.launch.py \
|
||||||
serial_number:=G20_LEFT_001
|
serial_number:=G20_LEFT_001
|
||||||
```
|
```
|
||||||
+1
-1
@@ -21,7 +21,7 @@ cameras:
|
|||||||
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
|
||||||
|
|
||||||
artifacts:
|
artifacts:
|
||||||
source_urdf: src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right.urdf
|
source_urdf: package://linkerhand_calibration/urdf/g20_right/linkerhand_g20_right.urdf
|
||||||
source_urdf_sha256: eeb6ffb0e95d2a6acd4c26331ae68062e0d74160de4b552b4f6d395cce5ca4e8
|
source_urdf_sha256: eeb6ffb0e95d2a6acd4c26331ae68062e0d74160de4b552b4f6d395cce5ca4e8
|
||||||
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
|
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
|
||||||
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
|
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/l6_calibration/front/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.0
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [0, 1, 2]
|
||||||
|
frames: [front_base, thumb_pitch, thumb_dip]
|
||||||
|
sizes: [0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/l6_calibration/side/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.0
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [3, 4, 5]
|
||||||
|
frames: [side_base, pinky_pitch, pinky_dip]
|
||||||
|
sizes: [0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/l6_calibration/top/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.0
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [6, 7]
|
||||||
|
frames: [top_base, thumb_roll]
|
||||||
|
sizes: [0.016, 0.016]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
schema_version: 2
|
||||||
|
profile_id: L6/right/l6_right_8/v1
|
||||||
|
model: L6
|
||||||
|
side: right
|
||||||
|
tag_layout: l6_right_8
|
||||||
|
namespace: /l6_calibration
|
||||||
|
serial_number: L6_RIGHT_001
|
||||||
|
can_interface: can0
|
||||||
|
output_root: calibration_output
|
||||||
|
|
||||||
|
cameras:
|
||||||
|
front:
|
||||||
|
serial_number: DB2163742
|
||||||
|
camera_name: hikrobot_front_DB2163742
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
|
||||||
|
side:
|
||||||
|
serial_number: DB2163749
|
||||||
|
camera_name: hikrobot_side_DB2163749
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
|
||||||
|
top:
|
||||||
|
serial_number: DB2163739
|
||||||
|
camera_name: hikrobot_top_DB2163739
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
source_urdf: package://linkerhand_calibration/urdf/l6_right/linkerhand_l6v3.1_right.urdf
|
||||||
|
source_urdf_sha256: 298c1fbf5189648911426f530b50bdbeea4830cab9c54e20f46c532485df4666
|
||||||
|
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
|
||||||
|
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
|
||||||
|
calibration_config: package://linkerhand_calibration/config/l6_three_camera_calibration.yaml
|
||||||
|
calibration_config_sha256: 0934699c8225891e748deefef6791eb28355821b89aeadd1f7ff0b7f7b4d265f
|
||||||
|
tag_config: package://linkerhand_calibration/config/l6_right_8_tags.yaml
|
||||||
|
tag_config_sha256: be1499eb947b61d2fe360ae2c92307a87710480fae8a9dd4cd171fc959fdcbf5
|
||||||
|
|
||||||
|
release:
|
||||||
|
required_independent_passes: 1
|
||||||
|
static_repeatability_deg: 1.0
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
l6_calibration:
|
||||||
|
ros__parameters:
|
||||||
|
command_topic: /l6/cb_right_hand_control_cmd
|
||||||
|
state_topic: /l6/cb_right_hand_state
|
||||||
|
setting_topic: /l6/cb_hand_setting_cmd
|
||||||
|
front_camera_info_topic: /l6_calibration/front/camera/camera_info
|
||||||
|
front_detections_topic: /l6_calibration/front/apriltag/detections
|
||||||
|
side_camera_info_topic: /l6_calibration/side/camera/camera_info
|
||||||
|
side_detections_topic: /l6_calibration/side/apriltag/detections
|
||||||
|
top_camera_info_topic: /l6_calibration/top/camera/camera_info
|
||||||
|
top_detections_topic: /l6_calibration/top/apriltag/detections
|
||||||
|
|
||||||
|
baseline_command_u8: [255, 255, 255, 255, 255, 255]
|
||||||
|
# L6_RIGHT_001 measured a 250->5 travel of only ~0.9 s at speed 10,
|
||||||
|
# which left fewer than 32 useful feedback bins. Speed 1 is still only a
|
||||||
|
# firmware ceiling: different L6 motors complete a full stroke in 0.7-1.3 s.
|
||||||
|
# A 100 Hz cosine trajectory therefore sets the actual, model-level pace.
|
||||||
|
preflight_speed_u8: 1
|
||||||
|
formal_speed_u8: 1
|
||||||
|
speed_settle_seconds: 0.2
|
||||||
|
command_trajectory_full_range_seconds: 6.0
|
||||||
|
torque_u8: 80
|
||||||
|
repetitions: 4
|
||||||
|
preflight_checkpoints_u8: [255, 127, 0]
|
||||||
|
tag_size_m: 0.016
|
||||||
|
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
|
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
minimum_detection_rate: 0.95
|
||||||
|
# Per-Tag quality remains >=95%. With three independently detected Tags,
|
||||||
|
# the fully joined frame rate may be 0.95^3 ~= 85.7%.
|
||||||
|
minimum_joint_frame_rate: 0.85
|
||||||
|
minimum_feedback_hz: 25.0
|
||||||
|
maximum_state_image_skew_ms: 50.0
|
||||||
|
maximum_hamming: 0
|
||||||
|
minimum_decision_margin: 30.0
|
||||||
|
minimum_edge_pixels: 30.0
|
||||||
|
pnp_maximum_reprojection_error_px: 1.5
|
||||||
|
pnp_reprojection_tie_px: 1.5
|
||||||
|
pnp_maximum_pose_jump_deg: 35.0
|
||||||
|
pnp_maximum_translation_jump_m: 0.04
|
||||||
|
pnp_maximum_tag_tilt_deg: 75.0
|
||||||
|
pnp_tracker_reset_seconds: 5.0
|
||||||
|
|
||||||
|
minimum_sweep_frames: 40
|
||||||
|
minimum_state_span_u8: 240.0
|
||||||
|
minimum_sweep_bins: 32
|
||||||
|
maximum_bin_gap: 16
|
||||||
|
maximum_monotonic_correction_deg: 2.0
|
||||||
|
passive_maximum_monotonic_correction_deg: 3.0
|
||||||
|
maximum_validation_mae_deg: 1.0
|
||||||
|
maximum_validation_p95_deg: 2.0
|
||||||
|
maximum_validation_error_deg: 3.0
|
||||||
|
mimic_minimum_multiplier: 0.5
|
||||||
|
mimic_maximum_multiplier: 1.5
|
||||||
|
mimic_maximum_cycle_range: 0.03
|
||||||
|
mimic_maximum_residual_p95_deg: 2.0
|
||||||
|
|
||||||
|
endpoint_tolerance_u8: 2.0
|
||||||
|
endpoint_hold_seconds: 1.0
|
||||||
|
motor_stall_timeout_seconds: 2.0
|
||||||
|
position_timeout_seconds: 30.0
|
||||||
|
sweep_timeout_seconds: 90.0
|
||||||
|
automatic_sweep_retry_limit: 2
|
||||||
|
non_target_motion_tolerance_u8: 3.0
|
||||||
|
fixed_base_maximum_corner_drift_px: 2.0
|
||||||
|
fixed_base_movement_confirmation_frames: 5
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/o12_calibration/front/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [0, 1, 2, 3, 12, 13]
|
||||||
|
frames: [front_base, thumb_cmc, thumb_mcp, thumb_dip, middle_roll, index_roll]
|
||||||
|
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/o12_calibration/side/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [4, 5, 6, 7, 8, 9, 10, 11]
|
||||||
|
frames: [side_base, pinky_mcp, pinky_pip, pinky_dip, middle_pip, middle_dip, index_pip, index_dip]
|
||||||
|
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/o12_calibration/top/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [14, 15]
|
||||||
|
frames: [top_base, thumb_yaw]
|
||||||
|
sizes: [0.016, 0.016]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
schema_version: 3
|
||||||
|
profile_id: O12/right/o12_right_16/v1
|
||||||
|
model: O12
|
||||||
|
side: right
|
||||||
|
tag_layout: o12_right_16
|
||||||
|
namespace: /o12_calibration
|
||||||
|
serial_number: O12_RIGHT_001
|
||||||
|
output_root: calibration_output
|
||||||
|
|
||||||
|
sdk:
|
||||||
|
driver: omnihand_pro_2025_node
|
||||||
|
transport: hcan
|
||||||
|
setup: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/setup.bash
|
||||||
|
config: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/share/omnihand_node/config/omnihand_pro_2025_node.yaml
|
||||||
|
config_sha256: ca1791c822bf1e99f25db8806397c3f877100c9252fcce6c53a7a6b8cef7694a
|
||||||
|
|
||||||
|
cameras:
|
||||||
|
front:
|
||||||
|
serial_number: DB2163742
|
||||||
|
camera_name: hikrobot_front_DB2163742
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
|
||||||
|
side:
|
||||||
|
serial_number: DB2163749
|
||||||
|
camera_name: hikrobot_side_DB2163749
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
|
||||||
|
top:
|
||||||
|
serial_number: DB2163739
|
||||||
|
camera_name: hikrobot_top_DB2163739
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
source_urdf: package://linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703.urdf
|
||||||
|
source_urdf_sha256: 75b3c18992a3640d2d7d36719da5ff75428ded477b97d9cf29adbe43a6eb27eb
|
||||||
|
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
|
||||||
|
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
|
||||||
|
calibration_config: package://linkerhand_calibration/config/o12_three_camera_calibration.yaml
|
||||||
|
calibration_config_sha256: 9defe39a3f4b31c3764cbdcd9ee5b570da70e810215844515e8cc01e56414728
|
||||||
|
tag_config: package://linkerhand_calibration/config/o12_right_16_tags.yaml
|
||||||
|
tag_config_sha256: 41001c3afba74cc01eb524a75dc58561a37e9364fab029ea12d879156a008dab
|
||||||
|
|
||||||
|
release:
|
||||||
|
required_independent_passes: 1
|
||||||
|
static_repeatability_deg: 1.0
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
o12_calibration:
|
||||||
|
ros__parameters:
|
||||||
|
command_topic: /o12/right/joint_cmd
|
||||||
|
state_topic: /o12/right/joint_states
|
||||||
|
front_camera_info_topic: /o12_calibration/front/camera/camera_info
|
||||||
|
front_detections_topic: /o12_calibration/front/apriltag/detections
|
||||||
|
side_camera_info_topic: /o12_calibration/side/camera/camera_info
|
||||||
|
side_detections_topic: /o12_calibration/side/apriltag/detections
|
||||||
|
top_camera_info_topic: /o12_calibration/top/camera/camera_info
|
||||||
|
top_detections_topic: /o12_calibration/top/apriltag/detections
|
||||||
|
|
||||||
|
# O12 standard interface is radians. Raw 0..2000 mixed control is forbidden.
|
||||||
|
command_rate_hz: 20.0
|
||||||
|
repetitions: 4
|
||||||
|
tag_size_m: 0.016
|
||||||
|
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
|
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
minimum_detection_rate: 0.95
|
||||||
|
minimum_joint_frame_rate: 0.85
|
||||||
|
minimum_feedback_hz: 15.0
|
||||||
|
maximum_state_image_skew_ms: 50.0
|
||||||
|
minimum_sweep_frames: 40
|
||||||
|
minimum_state_span_fraction: 0.90
|
||||||
|
minimum_sweep_bins: 32
|
||||||
|
maximum_bin_gap: 2
|
||||||
|
endpoint_tolerance_rad: 0.01
|
||||||
|
endpoint_hold_seconds: 1.0
|
||||||
|
motor_stall_timeout_seconds: 2.0
|
||||||
|
position_timeout_seconds: 60.0
|
||||||
|
sweep_timeout_seconds: 180.0
|
||||||
|
automatic_sweep_retry_limit: 2
|
||||||
|
non_target_motion_tolerance_rad: 0.015
|
||||||
|
maximum_temperature_c: 70
|
||||||
|
# 当前 O12 固件返回空温度报告。仍发起查询;5秒无结果后依赖已验证的
|
||||||
|
# joint_error_states bit1 过热保护,并在会话记录中明确标注降级。
|
||||||
|
temperature_report_required: false
|
||||||
|
temperature_fallback_after_seconds: 5.0
|
||||||
|
# 现场确认初始保守速度过慢;正式/点动速度提高到2倍,避让另有硬上限。
|
||||||
|
motion_speed_scale: 2.0
|
||||||
|
maximum_hamming: 0
|
||||||
|
minimum_decision_margin: 30.0
|
||||||
|
minimum_edge_pixels: 30.0
|
||||||
|
fixed_base_maximum_corner_drift_px: 2.0
|
||||||
|
fixed_base_movement_confirmation_frames: 5
|
||||||
|
pnp_maximum_reprojection_error_px: 1.5
|
||||||
|
pnp_reprojection_tie_px: 1.5
|
||||||
|
pnp_maximum_pose_jump_deg: 35.0
|
||||||
|
pnp_maximum_translation_jump_m: 0.04
|
||||||
|
pnp_maximum_tag_tilt_deg: 75.0
|
||||||
|
pnp_tracker_reset_seconds: 5.0
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/o6_calibration/front/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.0
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [0, 1, 2]
|
||||||
|
frames: [front_base, thumb_pitch, thumb_ip]
|
||||||
|
sizes: [0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/o6_calibration/side/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.0
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [3, 4, 5]
|
||||||
|
frames: [side_base, pinky_pitch, pinky_dip]
|
||||||
|
sizes: [0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/o6_calibration/top/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.0
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [6, 7]
|
||||||
|
frames: [top_base, thumb_yaw]
|
||||||
|
sizes: [0.016, 0.016]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
schema_version: 2
|
||||||
|
profile_id: O6/right/o6_right_8/v1
|
||||||
|
model: O6
|
||||||
|
side: right
|
||||||
|
tag_layout: o6_right_8
|
||||||
|
namespace: /o6_calibration
|
||||||
|
serial_number: O6_RIGHT_001
|
||||||
|
can_interface: can0
|
||||||
|
output_root: calibration_output
|
||||||
|
|
||||||
|
cameras:
|
||||||
|
front:
|
||||||
|
serial_number: DB2163742
|
||||||
|
camera_name: hikrobot_front_DB2163742
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
|
||||||
|
side:
|
||||||
|
serial_number: DB2163749
|
||||||
|
camera_name: hikrobot_side_DB2163749
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
|
||||||
|
top:
|
||||||
|
serial_number: DB2163739
|
||||||
|
camera_name: hikrobot_top_DB2163739
|
||||||
|
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
source_urdf: package://linkerhand_calibration/urdf/o6_right/linkerhand_o6_right.urdf
|
||||||
|
source_urdf_sha256: 8f184faad699fbf771e388f109a4e8793b5cb190c33a87b2eba8491a3a37dd62
|
||||||
|
camera_extrinsics: config/o6_three_camera_extrinsics.yaml
|
||||||
|
camera_extrinsics_sha256: 29af61f7bf1bad6718cbbaa54b0536f0a471c83f5bb3554f264ab9d292e56ca4
|
||||||
|
calibration_config: package://linkerhand_calibration/config/o6_three_camera_calibration.yaml
|
||||||
|
calibration_config_sha256: ce20d998a4342dfaacb14568513aa9af5063df48566fabd42180acc8da47e4a6
|
||||||
|
tag_config: package://linkerhand_calibration/config/o6_right_8_tags.yaml
|
||||||
|
tag_config_sha256: 16abe7119b4764f86333dae8264247571d1e0bca45af959d558bef4fb5485f5e
|
||||||
|
|
||||||
|
release:
|
||||||
|
required_independent_passes: 1
|
||||||
|
static_repeatability_deg: 1.0
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
o6_calibration:
|
||||||
|
ros__parameters:
|
||||||
|
command_topic: /o6/cb_right_hand_control_cmd
|
||||||
|
state_topic: /o6/cb_right_hand_state
|
||||||
|
setting_topic: /o6/cb_hand_setting_cmd
|
||||||
|
front_camera_info_topic: /o6_calibration/front/camera/camera_info
|
||||||
|
front_detections_topic: /o6_calibration/front/apriltag/detections
|
||||||
|
side_camera_info_topic: /o6_calibration/side/camera/camera_info
|
||||||
|
side_detections_topic: /o6_calibration/side/apriltag/detections
|
||||||
|
top_camera_info_topic: /o6_calibration/top/camera/camera_info
|
||||||
|
top_detections_topic: /o6_calibration/top/apriltag/detections
|
||||||
|
|
||||||
|
baseline_command_u8: [255, 255, 255, 255, 255, 255]
|
||||||
|
# O6 has a different speed scale from L6. Motion is still bounded by the
|
||||||
|
# six-second cosine command trajectory; these values are firmware limits.
|
||||||
|
baseline_speed_u8: 80
|
||||||
|
preflight_speed_u8: 60
|
||||||
|
formal_speed_u8: 40
|
||||||
|
speed_settle_seconds: 0.2
|
||||||
|
command_trajectory_full_range_seconds: 6.0
|
||||||
|
torque_u8: 80
|
||||||
|
repetitions: 4
|
||||||
|
preflight_checkpoints_u8: [255, 127, 0]
|
||||||
|
tag_size_m: 0.016
|
||||||
|
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
|
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
minimum_detection_rate: 0.95
|
||||||
|
minimum_joint_frame_rate: 0.85
|
||||||
|
minimum_feedback_hz: 25.0
|
||||||
|
maximum_state_image_skew_ms: 50.0
|
||||||
|
maximum_hamming: 0
|
||||||
|
minimum_decision_margin: 30.0
|
||||||
|
minimum_edge_pixels: 30.0
|
||||||
|
pnp_maximum_reprojection_error_px: 1.5
|
||||||
|
pnp_reprojection_tie_px: 1.5
|
||||||
|
pnp_maximum_pose_jump_deg: 35.0
|
||||||
|
pnp_maximum_translation_jump_m: 0.04
|
||||||
|
pnp_maximum_tag_tilt_deg: 75.0
|
||||||
|
pnp_tracker_reset_seconds: 5.0
|
||||||
|
|
||||||
|
minimum_sweep_frames: 40
|
||||||
|
minimum_state_span_u8: 240.0
|
||||||
|
minimum_sweep_bins: 32
|
||||||
|
maximum_bin_gap: 16
|
||||||
|
maximum_monotonic_correction_deg: 2.0
|
||||||
|
passive_maximum_monotonic_correction_deg: 3.0
|
||||||
|
maximum_validation_mae_deg: 1.0
|
||||||
|
maximum_validation_p95_deg: 2.0
|
||||||
|
maximum_validation_error_deg: 3.0
|
||||||
|
mimic_minimum_multiplier: 0.5
|
||||||
|
mimic_maximum_multiplier: 2.2
|
||||||
|
mimic_maximum_cycle_range: 0.03
|
||||||
|
mimic_maximum_residual_p95_deg: 2.0
|
||||||
|
|
||||||
|
endpoint_tolerance_u8: 2.0
|
||||||
|
endpoint_hold_seconds: 1.0
|
||||||
|
motor_stall_timeout_seconds: 2.0
|
||||||
|
position_timeout_seconds: 30.0
|
||||||
|
sweep_timeout_seconds: 90.0
|
||||||
|
automatic_sweep_retry_limit: 2
|
||||||
|
non_target_motion_tolerance_u8: 3.0
|
||||||
|
fixed_base_maximum_corner_drift_px: 2.0
|
||||||
|
fixed_base_movement_confirmation_frames: 5
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""One-release compatibility surface for the former Python package name.
|
||||||
|
|
||||||
|
New code must import :mod:`linkerhand_calibration`. Only the documented
|
||||||
|
configuration loader is re-exported here; calibration algorithms continue to
|
||||||
|
have a single implementation in the renamed package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"g20_thumb_apriltag_calibration is deprecated; "
|
||||||
|
"import linkerhand_calibration instead",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
from linkerhand_calibration.product import ProductConfig, load_product_config
|
||||||
|
|
||||||
|
__all__ = ["ProductConfig", "load_product_config"]
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for the runtime joint-state bridge."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.calibrated_joint_state_bridge import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for offline replay."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.offline_replay import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for the former Python package."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.one_command import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
"""Publish calibrated G20 URDF angles from raw command/feedback u8 values."""
|
"""Publish profile-calibrated URDF angles from raw command/feedback u8 values."""
|
||||||
|
|
||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument
|
from launch.actions import DeclareLaunchArgument
|
||||||
@@ -14,10 +14,10 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
DeclareLaunchArgument("input_topic", default_value=""),
|
DeclareLaunchArgument("input_topic", default_value=""),
|
||||||
DeclareLaunchArgument("output_topic", default_value=""),
|
DeclareLaunchArgument("output_topic", default_value=""),
|
||||||
Node(
|
Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="calibrated_joint_state_bridge",
|
executable="calibrated_joint_state_bridge",
|
||||||
name=[
|
name=[
|
||||||
"g20_calibrated_joint_state_bridge_",
|
"calibrated_joint_state_bridge_",
|
||||||
LaunchConfiguration("hand_type"),
|
LaunchConfiguration("hand_type"),
|
||||||
],
|
],
|
||||||
output="screen",
|
output="screen",
|
||||||
+3
-3
@@ -48,7 +48,7 @@ def _launch_stack(context):
|
|||||||
tag_config = LaunchConfiguration("tag_config").perform(context)
|
tag_config = LaunchConfiguration("tag_config").perform(context)
|
||||||
zero_config = LaunchConfiguration("zero_config").perform(context)
|
zero_config = LaunchConfiguration("zero_config").perform(context)
|
||||||
camera = Node(
|
camera = Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="hikrobot_camera_node",
|
executable="hikrobot_camera_node",
|
||||||
name="hikrobot_camera",
|
name="hikrobot_camera",
|
||||||
namespace="/camera/camera/color",
|
namespace="/camera/camera/color",
|
||||||
@@ -158,7 +158,7 @@ def _launch_stack(context):
|
|||||||
)
|
)
|
||||||
|
|
||||||
zero_node = Node(
|
zero_node = Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="cmc_pitch_zero_node",
|
executable="cmc_pitch_zero_node",
|
||||||
name="g20_thumb_cmc_pitch_zero",
|
name="g20_thumb_cmc_pitch_zero",
|
||||||
output="screen",
|
output="screen",
|
||||||
@@ -203,7 +203,7 @@ def _launch_stack(context):
|
|||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
package_share = Path(
|
package_share = Path(
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
get_package_share_directory("linkerhand_calibration")
|
||||||
)
|
)
|
||||||
return LaunchDescription(
|
return LaunchDescription(
|
||||||
[
|
[
|
||||||
+3
-3
@@ -50,7 +50,7 @@ def _launch_stack(context):
|
|||||||
"calibration_config"
|
"calibration_config"
|
||||||
).perform(context)
|
).perform(context)
|
||||||
camera = Node(
|
camera = Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="hikrobot_camera_node",
|
executable="hikrobot_camera_node",
|
||||||
name="hikrobot_camera",
|
name="hikrobot_camera",
|
||||||
namespace="/camera/camera/color",
|
namespace="/camera/camera/color",
|
||||||
@@ -160,7 +160,7 @@ def _launch_stack(context):
|
|||||||
)
|
)
|
||||||
|
|
||||||
calibration_node = Node(
|
calibration_node = Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="cmc_roll_calibration_node",
|
executable="cmc_roll_calibration_node",
|
||||||
name="g20_thumb_cmc_roll_calibration",
|
name="g20_thumb_cmc_roll_calibration",
|
||||||
output="screen",
|
output="screen",
|
||||||
@@ -205,7 +205,7 @@ def _launch_stack(context):
|
|||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
package_share = Path(
|
package_share = Path(
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
get_package_share_directory("linkerhand_calibration")
|
||||||
)
|
)
|
||||||
return LaunchDescription(
|
return LaunchDescription(
|
||||||
[
|
[
|
||||||
+3
-3
@@ -82,7 +82,7 @@ def _launch_stack(context):
|
|||||||
)
|
)
|
||||||
|
|
||||||
camera = Node(
|
camera = Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="hikrobot_camera_node",
|
executable="hikrobot_camera_node",
|
||||||
name="hikrobot_camera",
|
name="hikrobot_camera",
|
||||||
namespace="/camera/camera/color",
|
namespace="/camera/camera/color",
|
||||||
@@ -234,7 +234,7 @@ def _launch_stack(context):
|
|||||||
)
|
)
|
||||||
|
|
||||||
calibration = Node(
|
calibration = Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="calibration_node",
|
executable="calibration_node",
|
||||||
name="g20_thumb_calibration",
|
name="g20_thumb_calibration",
|
||||||
output="screen",
|
output="screen",
|
||||||
@@ -315,7 +315,7 @@ def _launch_stack(context):
|
|||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
package_share = Path(
|
package_share = Path(
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
get_package_share_directory("linkerhand_calibration")
|
||||||
)
|
)
|
||||||
default_output = str(Path.cwd() / "calibration_output")
|
default_output = str(Path.cwd() / "calibration_output")
|
||||||
return LaunchDescription(
|
return LaunchDescription(
|
||||||
+106
-36
@@ -1,4 +1,4 @@
|
|||||||
"""Launch three Hikrobot views and one complete-G20 calibration owner."""
|
"""Launch three Hikrobot views and one registered hand calibration owner."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -26,20 +26,27 @@ from launch_ros.parameter_descriptions import ParameterValue
|
|||||||
VIEWS = ("front", "side", "top")
|
VIEWS = ("front", "side", "top")
|
||||||
|
|
||||||
|
|
||||||
def _default_source_urdf(hand_type: str) -> Path:
|
def _default_source_urdf(model: str, hand_type: str) -> Path:
|
||||||
relative = Path(
|
relative = (
|
||||||
"assets/robots/hands/linker_hand"
|
Path("urdf") / "l6_right" / "linkerhand_l6v3.1_right.urdf"
|
||||||
) / f"g20_{hand_type}" / f"linkerhand_g20_{hand_type}.urdf"
|
if model.upper() == "L6" and hand_type == "right"
|
||||||
workspace = Path.cwd() / "src/linkerhand_retarget/linkerhand_retarget" / relative
|
else Path("urdf")
|
||||||
|
/ f"{model.lower()}_{hand_type}"
|
||||||
|
/ f"linkerhand_{model.lower()}_{hand_type}.urdf"
|
||||||
|
)
|
||||||
|
package_source_or_share = Path(__file__).resolve().parents[1] / relative
|
||||||
try:
|
try:
|
||||||
installed = Path(get_package_share_directory("linkerhand_retarget")) / relative
|
installed = (
|
||||||
|
Path(get_package_share_directory("linkerhand_calibration"))
|
||||||
|
/ relative
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
installed = workspace
|
installed = package_source_or_share
|
||||||
return workspace if workspace.is_file() else installed
|
return installed if installed.is_file() else package_source_or_share
|
||||||
|
|
||||||
|
|
||||||
def _launch_stack(context):
|
def _launch_stack(context):
|
||||||
from g20_thumb_apriltag_calibration.product import (
|
from linkerhand_calibration.product import (
|
||||||
get_product_calibration_contract,
|
get_product_calibration_contract,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ def _launch_stack(context):
|
|||||||
raise RuntimeError(str(error)) from error
|
raise RuntimeError(str(error)) from error
|
||||||
requested_tag_config = LaunchConfiguration("tag_config").perform(context)
|
requested_tag_config = LaunchConfiguration("tag_config").perform(context)
|
||||||
package_share = Path(
|
package_share = Path(
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
get_package_share_directory("linkerhand_calibration")
|
||||||
)
|
)
|
||||||
tag_config = (
|
tag_config = (
|
||||||
Path(requested_tag_config).expanduser().resolve()
|
Path(requested_tag_config).expanduser().resolve()
|
||||||
@@ -66,6 +73,12 @@ def _launch_stack(context):
|
|||||||
/ (
|
/ (
|
||||||
"three_camera_tags_g20_right_19.yaml"
|
"three_camera_tags_g20_right_19.yaml"
|
||||||
if tag_layout == "g20_right_19"
|
if tag_layout == "g20_right_19"
|
||||||
|
else "o12_right_16_tags.yaml"
|
||||||
|
if tag_layout == "o12_right_16"
|
||||||
|
else "o6_right_8_tags.yaml"
|
||||||
|
if tag_layout == "o6_right_8"
|
||||||
|
else "l6_right_8_tags.yaml"
|
||||||
|
if tag_layout == "l6_right_8"
|
||||||
else "three_camera_tags_g20_right_15.yaml"
|
else "three_camera_tags_g20_right_15.yaml"
|
||||||
if tag_layout == "g20_right_15"
|
if tag_layout == "g20_right_15"
|
||||||
else "three_camera_tags.yaml"
|
else "three_camera_tags.yaml"
|
||||||
@@ -74,24 +87,31 @@ def _launch_stack(context):
|
|||||||
if not tag_config.is_file():
|
if not tag_config.is_file():
|
||||||
raise RuntimeError(f"tag config does not exist: {tag_config}")
|
raise RuntimeError(f"tag config does not exist: {tag_config}")
|
||||||
topic_prefix = f"/{model.lower()}"
|
topic_prefix = f"/{model.lower()}"
|
||||||
command_topic = f"{topic_prefix}/cb_{hand_type}_hand_control_cmd"
|
is_o12 = model == "O12"
|
||||||
state_topic = f"{topic_prefix}/cb_{hand_type}_hand_state"
|
command_topic = (
|
||||||
|
f"{topic_prefix}/{hand_type}/joint_cmd"
|
||||||
|
if is_o12 else f"{topic_prefix}/cb_{hand_type}_hand_control_cmd"
|
||||||
|
)
|
||||||
|
state_topic = (
|
||||||
|
f"{topic_prefix}/{hand_type}/joint_states"
|
||||||
|
if is_o12 else f"{topic_prefix}/cb_{hand_type}_hand_state"
|
||||||
|
)
|
||||||
info_topic = f"{topic_prefix}/cb_{hand_type}_hand_info"
|
info_topic = f"{topic_prefix}/cb_{hand_type}_hand_info"
|
||||||
requested_source = LaunchConfiguration("source_urdf_path").perform(context)
|
requested_source = LaunchConfiguration("source_urdf_path").perform(context)
|
||||||
source_urdf = (
|
source_urdf = (
|
||||||
Path(requested_source).expanduser().resolve()
|
Path(requested_source).expanduser().resolve()
|
||||||
if requested_source
|
if requested_source
|
||||||
else _default_source_urdf(hand_type).resolve()
|
else _default_source_urdf(model, hand_type).resolve()
|
||||||
)
|
)
|
||||||
if not source_urdf.is_file():
|
if not source_urdf.is_file():
|
||||||
raise RuntimeError(f"source URDF does not exist: {source_urdf}")
|
raise RuntimeError(f"source URDF does not exist: {source_urdf}")
|
||||||
expected_source_hash = LaunchConfiguration(
|
expected_source_hash = LaunchConfiguration(
|
||||||
"source_urdf_expected_sha256"
|
"source_urdf_expected_sha256"
|
||||||
).perform(context).strip().lower()
|
).perform(context).strip().lower()
|
||||||
if contract.profile.supports("urdf_zero_publication"):
|
if contract.typed_profile.artifacts.publish_corrected_urdf:
|
||||||
if re.fullmatch(r"[0-9a-f]{64}", expected_source_hash) is None:
|
if re.fullmatch(r"[0-9a-f]{64}", expected_source_hash) is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"G20 right product layout requires source_urdf_expected_sha256 confirmed "
|
"this profile requires source_urdf_expected_sha256 confirmed "
|
||||||
"by the CAD/hardware owner"
|
"by the CAD/hardware owner"
|
||||||
)
|
)
|
||||||
actual_source_hash = hashlib.sha256(source_urdf.read_bytes()).hexdigest()
|
actual_source_hash = hashlib.sha256(source_urdf.read_bytes()).hexdigest()
|
||||||
@@ -137,19 +157,20 @@ def _launch_stack(context):
|
|||||||
raw_topics = []
|
raw_topics = []
|
||||||
info_topics = []
|
info_topics = []
|
||||||
detection_topics = []
|
detection_topics = []
|
||||||
|
calibration_namespace = contract.typed_profile.namespace
|
||||||
for view in VIEWS:
|
for view in VIEWS:
|
||||||
namespace = f"/g20_calibration/{view}/camera"
|
namespace = f"{calibration_namespace}/{view}/camera"
|
||||||
raw_topic = f"{namespace}/image_raw"
|
raw_topic = f"{namespace}/image_raw"
|
||||||
info_topic = f"{namespace}/camera_info"
|
info_topic = f"{namespace}/camera_info"
|
||||||
rect_topic = f"{namespace}/image_rect"
|
rect_topic = f"{namespace}/image_rect"
|
||||||
detector_namespace = f"/g20_calibration/{view}/apriltag"
|
detector_namespace = f"{calibration_namespace}/{view}/apriltag"
|
||||||
detection_topic = f"{detector_namespace}/detections"
|
detection_topic = f"{detector_namespace}/detections"
|
||||||
raw_topics.append(raw_topic)
|
raw_topics.append(raw_topic)
|
||||||
info_topics.append(info_topic)
|
info_topics.append(info_topic)
|
||||||
detection_topics.append(detection_topic)
|
detection_topics.append(detection_topic)
|
||||||
cameras.append(
|
cameras.append(
|
||||||
Node(
|
Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="hikrobot_camera_node",
|
executable="hikrobot_camera_node",
|
||||||
name="hikrobot_camera",
|
name="hikrobot_camera",
|
||||||
namespace=namespace,
|
namespace=namespace,
|
||||||
@@ -165,7 +186,9 @@ def _launch_stack(context):
|
|||||||
"camera_name": LaunchConfiguration(
|
"camera_name": LaunchConfiguration(
|
||||||
f"{view}_camera_name"
|
f"{view}_camera_name"
|
||||||
),
|
),
|
||||||
"frame_id": f"g20_calibration_{view}_optical_frame",
|
"frame_id": (
|
||||||
|
f"{model.lower()}_calibration_{view}_optical_frame"
|
||||||
|
),
|
||||||
"image_width": 1624,
|
"image_width": 1624,
|
||||||
"image_height": 1240,
|
"image_height": 1240,
|
||||||
"frame_rate": ParameterValue(
|
"frame_rate": ParameterValue(
|
||||||
@@ -228,7 +251,7 @@ def _launch_stack(context):
|
|||||||
)
|
)
|
||||||
|
|
||||||
vision = ComposableNodeContainer(
|
vision = ComposableNodeContainer(
|
||||||
name="g20_three_camera_vision",
|
name=f"{model.lower()}_three_camera_vision",
|
||||||
namespace="/",
|
namespace="/",
|
||||||
package="rclcpp_components",
|
package="rclcpp_components",
|
||||||
executable="component_container_mt",
|
executable="component_container_mt",
|
||||||
@@ -236,14 +259,24 @@ def _launch_stack(context):
|
|||||||
output="screen",
|
output="screen",
|
||||||
emulate_tty=True,
|
emulate_tty=True,
|
||||||
)
|
)
|
||||||
sdk = Node(
|
sdk = (
|
||||||
|
Node(
|
||||||
|
package="omnihand_node",
|
||||||
|
executable="omnihand_pro_2025_node",
|
||||||
|
name="omnihand_pro_2025_node",
|
||||||
|
namespace="o12",
|
||||||
|
output="screen",
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
|
parameters=[LaunchConfiguration("vendor_sdk_config")],
|
||||||
|
)
|
||||||
|
if is_o12
|
||||||
|
else Node(
|
||||||
package="linker_hand_ros2_sdk",
|
package="linker_hand_ros2_sdk",
|
||||||
executable="linker_hand_sdk",
|
executable="linker_hand_sdk",
|
||||||
name="linker_hand_sdk",
|
name="linker_hand_sdk",
|
||||||
output="screen",
|
output="screen",
|
||||||
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
parameters=[
|
parameters=[{
|
||||||
{
|
|
||||||
"hand_type": hand_type,
|
"hand_type": hand_type,
|
||||||
"hand_joint": model,
|
"hand_joint": model,
|
||||||
"can": LaunchConfiguration("can_interface"),
|
"can": LaunchConfiguration("can_interface"),
|
||||||
@@ -260,22 +293,26 @@ def _launch_stack(context):
|
|||||||
# G20 velocity read sends another five synchronous CAN
|
# G20 velocity read sends another five synchronous CAN
|
||||||
# queries, so keep it off the trajectory-critical path.
|
# queries, so keep it off the trajectory-critical path.
|
||||||
"velocity_poll_rate": 1.0,
|
"velocity_poll_rate": 1.0,
|
||||||
# Calibration sends one endpoint command per sweep. Keep
|
# G20 sends an endpoint and L6 streams a bounded trajectory.
|
||||||
# polling the real motor state during the following motion;
|
# Keep polling the real motor state during either command path;
|
||||||
# otherwise the SDK republishes a stale state for 0.2 s and
|
# otherwise the SDK republishes stale state and creates large
|
||||||
# creates 17-27 command-unit holes in the trajectory bins.
|
# command-unit holes in the trajectory bins.
|
||||||
"defer_state_reads_while_commanding": False,
|
"defer_state_reads_while_commanding": False,
|
||||||
"repeat_position_commands": False,
|
"repeat_position_commands": False,
|
||||||
"is_touch": False,
|
"is_touch": False,
|
||||||
}
|
}],
|
||||||
],
|
)
|
||||||
)
|
)
|
||||||
calibration = Node(
|
calibration = Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="three_camera_calibration_node",
|
executable="three_camera_calibration_node",
|
||||||
name="g20_calibration",
|
name=f"{model.lower()}_calibration",
|
||||||
output="screen",
|
output="screen",
|
||||||
emulate_tty=True,
|
emulate_tty=True,
|
||||||
|
arguments=[
|
||||||
|
"--profile-id",
|
||||||
|
contract.typed_profile.key.profile_id,
|
||||||
|
],
|
||||||
parameters=[
|
parameters=[
|
||||||
LaunchConfiguration("calibration_config"),
|
LaunchConfiguration("calibration_config"),
|
||||||
{
|
{
|
||||||
@@ -294,7 +331,7 @@ def _launch_stack(context):
|
|||||||
# cb_<side>_hand_info has a subscriber. Calibration only used
|
# cb_<side>_hand_info has a subscriber. Calibration only used
|
||||||
# that topic to display a speed diagnostic, while those reads
|
# that topic to display a speed diagnostic, while those reads
|
||||||
# created 17-33 command-unit holes in position trajectories.
|
# created 17-33 command-unit holes in position trajectories.
|
||||||
"info_topic": "/g20_calibration/disabled_hand_info",
|
"info_topic": f"{calibration_namespace}/disabled_hand_info",
|
||||||
"command_topic": command_topic,
|
"command_topic": command_topic,
|
||||||
"state_topic": state_topic,
|
"state_topic": state_topic,
|
||||||
"camera_extrinsics_file": LaunchConfiguration(
|
"camera_extrinsics_file": LaunchConfiguration(
|
||||||
@@ -304,6 +341,18 @@ def _launch_stack(context):
|
|||||||
"source_urdf_expected_sha256": LaunchConfiguration(
|
"source_urdf_expected_sha256": LaunchConfiguration(
|
||||||
"source_urdf_expected_sha256"
|
"source_urdf_expected_sha256"
|
||||||
),
|
),
|
||||||
|
"camera_extrinsics_expected_sha256": LaunchConfiguration(
|
||||||
|
"camera_extrinsics_expected_sha256"
|
||||||
|
),
|
||||||
|
"calibration_config_expected_sha256": LaunchConfiguration(
|
||||||
|
"calibration_config_expected_sha256"
|
||||||
|
),
|
||||||
|
"tag_config_expected_sha256": LaunchConfiguration(
|
||||||
|
"tag_config_expected_sha256"
|
||||||
|
),
|
||||||
|
"sdk_config_expected_sha256": LaunchConfiguration(
|
||||||
|
"sdk_config_expected_sha256"
|
||||||
|
),
|
||||||
"corrected_urdf_output_dir": LaunchConfiguration(
|
"corrected_urdf_output_dir": LaunchConfiguration(
|
||||||
"corrected_urdf_output_dir"
|
"corrected_urdf_output_dir"
|
||||||
),
|
),
|
||||||
@@ -358,14 +407,22 @@ def _launch_stack(context):
|
|||||||
command_topic,
|
command_topic,
|
||||||
state_topic,
|
state_topic,
|
||||||
info_topic,
|
info_topic,
|
||||||
"/g20_calibration/status",
|
*(
|
||||||
|
[
|
||||||
|
"/o12/right/joint_control_mode_states",
|
||||||
|
"/o12/right/joint_error_states",
|
||||||
|
"/o12/right/joint_temperature_states",
|
||||||
|
]
|
||||||
|
if is_o12 else []
|
||||||
|
),
|
||||||
|
f"{calibration_namespace}/status",
|
||||||
],
|
],
|
||||||
output="screen",
|
output="screen",
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
LogInfo(
|
LogInfo(
|
||||||
msg=(
|
msg=(
|
||||||
f"G20 {hand_type} {tag_layout} three-camera session: {session_dir}; "
|
f"{model} {hand_type} {tag_layout} three-camera session: {session_dir}; "
|
||||||
f"source_urdf={source_urdf}"
|
f"source_urdf={source_urdf}"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -386,7 +443,7 @@ def _launch_stack(context):
|
|||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
package_share = Path(
|
package_share = Path(
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
get_package_share_directory("linkerhand_calibration")
|
||||||
)
|
)
|
||||||
info_root = Path.home() / ".ros" / "camera_info"
|
info_root = Path.home() / ".ros" / "camera_info"
|
||||||
return LaunchDescription(
|
return LaunchDescription(
|
||||||
@@ -473,6 +530,19 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
DeclareLaunchArgument(
|
DeclareLaunchArgument(
|
||||||
"source_urdf_expected_sha256", default_value=""
|
"source_urdf_expected_sha256", default_value=""
|
||||||
),
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_extrinsics_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"calibration_config_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"tag_config_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("vendor_sdk_config", default_value=""),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"sdk_config_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
DeclareLaunchArgument(
|
DeclareLaunchArgument(
|
||||||
"corrected_urdf_output_dir", default_value=""
|
"corrected_urdf_output_dir", default_value=""
|
||||||
),
|
),
|
||||||
+13
-3
@@ -26,7 +26,7 @@ def _launch(context):
|
|||||||
namespace = f"/g20_extrinsics/{view}/camera"
|
namespace = f"/g20_extrinsics/{view}/camera"
|
||||||
cameras.append(
|
cameras.append(
|
||||||
Node(
|
Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="hikrobot_camera_node",
|
executable="hikrobot_camera_node",
|
||||||
name="hikrobot_camera",
|
name="hikrobot_camera",
|
||||||
namespace=namespace,
|
namespace=namespace,
|
||||||
@@ -85,7 +85,7 @@ def _launch(context):
|
|||||||
output="screen",
|
output="screen",
|
||||||
)
|
)
|
||||||
solver = Node(
|
solver = Node(
|
||||||
package="g20_thumb_apriltag_calibration",
|
package="linkerhand_calibration",
|
||||||
executable="three_camera_extrinsics_node",
|
executable="three_camera_extrinsics_node",
|
||||||
name="g20_camera_extrinsics",
|
name="g20_camera_extrinsics",
|
||||||
output="screen",
|
output="screen",
|
||||||
@@ -112,6 +112,12 @@ def _launch(context):
|
|||||||
LaunchConfiguration("maximum_reprojection_rms_px"),
|
LaunchConfiguration("maximum_reprojection_rms_px"),
|
||||||
value_type=float,
|
value_type=float,
|
||||||
),
|
),
|
||||||
|
"maximum_candidate_pair_reprojection_rms_px": ParameterValue(
|
||||||
|
LaunchConfiguration(
|
||||||
|
"maximum_candidate_pair_reprojection_rms_px"
|
||||||
|
),
|
||||||
|
value_type=float,
|
||||||
|
),
|
||||||
"maximum_single_camera_reprojection_rms_px": ParameterValue(
|
"maximum_single_camera_reprojection_rms_px": ParameterValue(
|
||||||
LaunchConfiguration(
|
LaunchConfiguration(
|
||||||
"maximum_single_camera_reprojection_rms_px"
|
"maximum_single_camera_reprojection_rms_px"
|
||||||
@@ -138,7 +144,7 @@ def _launch(context):
|
|||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
package_share = Path(
|
package_share = Path(
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
get_package_share_directory("linkerhand_calibration")
|
||||||
)
|
)
|
||||||
camera_info = Path.home() / ".ros" / "camera_info"
|
camera_info = Path.home() / ".ros" / "camera_info"
|
||||||
return LaunchDescription(
|
return LaunchDescription(
|
||||||
@@ -182,6 +188,10 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
DeclareLaunchArgument(
|
DeclareLaunchArgument(
|
||||||
"maximum_reprojection_rms_px", default_value="1.2"
|
"maximum_reprojection_rms_px", default_value="1.2"
|
||||||
),
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"maximum_candidate_pair_reprojection_rms_px",
|
||||||
|
default_value="1.5",
|
||||||
|
),
|
||||||
DeclareLaunchArgument(
|
DeclareLaunchArgument(
|
||||||
"maximum_single_camera_reprojection_rms_px",
|
"maximum_single_camera_reprojection_rms_px",
|
||||||
default_value="1.5",
|
default_value="1.5",
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Profile-driven LinkerHand calibration and validated URDF correction."""
|
||||||
|
|
||||||
|
from .core import CalibrationProfile, ProfileKey
|
||||||
|
|
||||||
|
__all__ = ["CalibrationProfile", "ProfileKey"]
|
||||||
+14
-4
@@ -9,7 +9,7 @@ from typing import Any, Mapping, Sequence
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .core import PAIR_NAMES, robust_rotation_summary
|
from .core import robust_rotation_summary
|
||||||
from .pnp import SquareTagPose
|
from .pnp import SquareTagPose
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ TAG_PAIR_ROLES: dict[str, tuple[str, str]] = {
|
|||||||
"t3_t4": ("t3", "t4"),
|
"t3_t4": ("t3", "t4"),
|
||||||
"t4_t5": ("t4", "t5"),
|
"t4_t5": ("t4", "t5"),
|
||||||
}
|
}
|
||||||
|
PAIR_NAMES: tuple[str, ...] = tuple(TAG_PAIR_ROLES)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -116,7 +117,7 @@ def interpolate_state_u8(
|
|||||||
*,
|
*,
|
||||||
maximum_skew_ns: int,
|
maximum_skew_ns: int,
|
||||||
) -> tuple[tuple[float, ...], int] | None:
|
) -> tuple[tuple[float, ...], int] | None:
|
||||||
"""Interpolate the 20-D hand state at an image timestamp.
|
"""Interpolate a profile-sized hand state at an image timestamp.
|
||||||
|
|
||||||
The SDK publishes state independently from the camera. Continuous
|
The SDK publishes state independently from the camera. Continuous
|
||||||
calibration must therefore use the image timestamp instead of whichever
|
calibration must therefore use the image timestamp instead of whichever
|
||||||
@@ -150,7 +151,11 @@ def interpolate_state_u8(
|
|||||||
fraction = before_gap / denominator
|
fraction = before_gap / denominator
|
||||||
before_values = np.asarray(before.position_u8, dtype=float)
|
before_values = np.asarray(before.position_u8, dtype=float)
|
||||||
after_values = np.asarray(after.position_u8, dtype=float)
|
after_values = np.asarray(after.position_u8, dtype=float)
|
||||||
if before_values.shape != (20,) or after_values.shape != (20,):
|
if (
|
||||||
|
before_values.ndim != 1
|
||||||
|
or before_values.size == 0
|
||||||
|
or after_values.shape != before_values.shape
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
interpolated = before_values + fraction * (after_values - before_values)
|
interpolated = before_values + fraction * (after_values - before_values)
|
||||||
return (
|
return (
|
||||||
@@ -162,11 +167,16 @@ def interpolate_state_u8(
|
|||||||
if nearest is None:
|
if nearest is None:
|
||||||
return None
|
return None
|
||||||
gap = abs(int(stamp_ns) - int(nearest.stamp_ns))
|
gap = abs(int(stamp_ns) - int(nearest.stamp_ns))
|
||||||
if gap > maximum_skew_ns or len(nearest.position_u8) != 20:
|
if gap > maximum_skew_ns or not nearest.position_u8:
|
||||||
return None
|
return None
|
||||||
return (tuple(float(value) for value in nearest.position_u8), gap)
|
return (tuple(float(value) for value in nearest.position_u8), gap)
|
||||||
|
|
||||||
|
|
||||||
|
# Physical-angle profiles use the same timestamp interpolation. Keep the old
|
||||||
|
# public name for compatibility and offer a unit-neutral spelling to new code.
|
||||||
|
interpolate_state = interpolate_state_u8
|
||||||
|
|
||||||
|
|
||||||
class ContinuousSweepCollector:
|
class ContinuousSweepCollector:
|
||||||
"""Collect timestamp-synchronised observations during one end-to-end move."""
|
"""Collect timestamp-synchronised observations during one end-to-end move."""
|
||||||
|
|
||||||
+102
-28
@@ -1,4 +1,4 @@
|
|||||||
"""Map G20 u8 feedback to URDF joint angles using one calibration JSON.
|
"""Map model SDK u8 feedback to URDF joint angles using one calibration JSON.
|
||||||
|
|
||||||
The static encoder-zero corrections in ``zero_angles`` are already baked into
|
The static encoder-zero corrections in ``zero_angles`` are already baked into
|
||||||
the corrected URDF joint origins. This bridge therefore publishes only the
|
the corrected URDF joint origins. This bridge therefore publishes only the
|
||||||
@@ -17,6 +17,7 @@ import math
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from sensor_msgs.msg import JointState
|
from sensor_msgs.msg import JointState
|
||||||
@@ -26,6 +27,9 @@ from .full_hand import (
|
|||||||
infer_compact_payload_layout,
|
infer_compact_payload_layout,
|
||||||
validate_compact_payload,
|
validate_compact_payload,
|
||||||
)
|
)
|
||||||
|
from .models import get_default_registry, validate_schema_v6_runtime_payload
|
||||||
|
from .models.o12.artifacts import validate_o12_runtime_payload
|
||||||
|
from .core import ProfileKey
|
||||||
|
|
||||||
|
|
||||||
G20_COMMAND_NAMES: tuple[str, ...] = (
|
G20_COMMAND_NAMES: tuple[str, ...] = (
|
||||||
@@ -80,11 +84,17 @@ G20_URDF_JOINT_NAMES: tuple[str, ...] = (
|
|||||||
|
|
||||||
|
|
||||||
class CalibratedCommandMapper:
|
class CalibratedCommandMapper:
|
||||||
"""Validated, side-specific lookup from G20 u8 values to URDF radians."""
|
"""Validated, profile-specific lookup from SDK u8 values to URDF radians."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, payload: Mapping[str, Any], *, expected_side: str | None = None
|
self, payload: Mapping[str, Any], *, expected_side: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
schema_version = int(payload["schema_version"])
|
||||||
|
if schema_version == 7:
|
||||||
|
validate_o12_runtime_payload(payload)
|
||||||
|
elif schema_version == 6:
|
||||||
|
validate_schema_v6_runtime_payload(payload)
|
||||||
|
else:
|
||||||
validate_compact_payload(payload)
|
validate_compact_payload(payload)
|
||||||
side = str(payload["side"]).lower()
|
side = str(payload["side"]).lower()
|
||||||
if expected_side is not None and side != str(expected_side).lower():
|
if expected_side is not None and side != str(expected_side).lower():
|
||||||
@@ -95,30 +105,58 @@ class CalibratedCommandMapper:
|
|||||||
quality = payload["quality"]
|
quality = payload["quality"]
|
||||||
if quality.get("passed") is not True:
|
if quality.get("passed") is not True:
|
||||||
raise ValueError("calibration quality.passed must be true")
|
raise ValueError("calibration quality.passed must be true")
|
||||||
layout_id = infer_compact_payload_layout(payload)
|
layout_id = (
|
||||||
profile = get_hand_calibration_profile(side, layout_id)
|
str(payload["layout_id"])
|
||||||
|
if schema_version in {6, 7}
|
||||||
|
else infer_compact_payload_layout(payload)
|
||||||
|
)
|
||||||
self.side = side
|
self.side = side
|
||||||
self.layout_id = layout_id
|
self.layout_id = layout_id
|
||||||
|
self.model = str(payload["model"]).upper()
|
||||||
|
self.profile_id = str(
|
||||||
|
payload.get("profile_id", f"G20/{side}/{layout_id}/v1")
|
||||||
|
)
|
||||||
self.serial_number = str(payload["serial_number"])
|
self.serial_number = str(payload["serial_number"])
|
||||||
schema_version = int(payload["schema_version"])
|
|
||||||
self.input_domain = str(
|
self.input_domain = str(
|
||||||
payload.get(
|
payload.get(
|
||||||
"curve_input_domain",
|
"curve_input_domain",
|
||||||
"command_u8" if schema_version == 4 else "",
|
"command_u8" if schema_version == 4 else "",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.input_domain not in {"command_u8", "feedback_u8"}:
|
if self.input_domain not in {"command_u8", "feedback_u8", "feedback_rad"}:
|
||||||
raise ValueError("calibration curve_input_domain is invalid")
|
raise ValueError("calibration curve_input_domain is invalid")
|
||||||
|
if schema_version in {6, 7}:
|
||||||
|
self.command_names = tuple(str(value) for value in payload["command_names"])
|
||||||
|
self.urdf_joint_names = tuple(str(name) for name in payload["joints"])
|
||||||
|
self._motor_by_joint = {
|
||||||
|
name: int(payload["joints"][name]["motor_index"])
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
registered = get_default_registry().get(
|
||||||
|
ProfileKey.parse(self.profile_id)
|
||||||
|
)
|
||||||
|
self.feedback_name_aliases = dict(
|
||||||
|
registered.profile.command.feedback_name_aliases
|
||||||
|
)
|
||||||
|
self.feedback_by_index = bool(
|
||||||
|
registered.profile.command.feedback_by_index
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
profile = get_hand_calibration_profile(side, layout_id)
|
||||||
|
self.command_names = G20_COMMAND_NAMES
|
||||||
|
self.urdf_joint_names = G20_URDF_JOINT_NAMES
|
||||||
self._motor_by_joint = {
|
self._motor_by_joint = {
|
||||||
name: int(profile.joint_specs[name].motor_index)
|
name: int(profile.joint_specs[name].motor_index)
|
||||||
for name in G20_URDF_JOINT_NAMES
|
for name in self.urdf_joint_names
|
||||||
}
|
}
|
||||||
|
self.feedback_name_aliases = {}
|
||||||
|
self.feedback_by_index = False
|
||||||
self._curves = {
|
self._curves = {
|
||||||
name: tuple(
|
name: tuple(
|
||||||
float(value)
|
float(value)
|
||||||
for value in payload["joints"][name]["angle_rad"]
|
for value in payload["joints"][name]["angle_rad"]
|
||||||
)
|
)
|
||||||
for name in G20_URDF_JOINT_NAMES
|
for name in self.urdf_joint_names
|
||||||
}
|
}
|
||||||
self._decreasing_curves = {
|
self._decreasing_curves = {
|
||||||
name: tuple(
|
name: tuple(
|
||||||
@@ -127,7 +165,7 @@ class CalibratedCommandMapper:
|
|||||||
"decreasing_rad", payload["joints"][name]["angle_rad"]
|
"decreasing_rad", payload["joints"][name]["angle_rad"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for name in G20_URDF_JOINT_NAMES
|
for name in self.urdf_joint_names
|
||||||
}
|
}
|
||||||
self._increasing_curves = {
|
self._increasing_curves = {
|
||||||
name: tuple(
|
name: tuple(
|
||||||
@@ -136,24 +174,36 @@ class CalibratedCommandMapper:
|
|||||||
"increasing_rad", payload["joints"][name]["angle_rad"]
|
"increasing_rad", payload["joints"][name]["angle_rad"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for name in G20_URDF_JOINT_NAMES
|
for name in self.urdf_joint_names
|
||||||
}
|
}
|
||||||
self._previous_by_motor: dict[int, float] = {}
|
self._previous_by_motor: dict[int, float] = {}
|
||||||
self._direction_by_motor: dict[int, str] = {}
|
self._direction_by_motor: dict[int, str] = {}
|
||||||
self.direction_deadband_u8 = 0.5
|
self.direction_deadband_u8 = 0.002 if schema_version == 7 else 0.5
|
||||||
|
self._knots = {
|
||||||
|
name: tuple(float(value) for value in payload["joints"][name].get(
|
||||||
|
"curve_input_knots_rad", ()
|
||||||
|
))
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
self._raw_increasing_branch = {
|
||||||
|
name: str(payload["joints"][name].get(
|
||||||
|
"raw_increasing_curve_branch", "increasing"
|
||||||
|
))
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _command_index(value: float) -> int:
|
def _command_index(value: float) -> int:
|
||||||
command = float(value)
|
command = float(value)
|
||||||
if not math.isfinite(command):
|
if not math.isfinite(command):
|
||||||
raise ValueError("G20 command positions must be finite")
|
raise ValueError("calibrated command positions must be finite")
|
||||||
return max(0, min(255, int(math.floor(command + 0.5))))
|
return max(0, min(255, int(math.floor(command + 0.5))))
|
||||||
|
|
||||||
def map_positions(
|
def map_positions(
|
||||||
self, positions: Sequence[float], names: Sequence[str] = ()
|
self, positions: Sequence[float], names: Sequence[str] = ()
|
||||||
) -> tuple[float, ...]:
|
) -> tuple[float, ...]:
|
||||||
values = tuple(float(value) for value in positions)
|
values = tuple(float(value) for value in positions)
|
||||||
if names:
|
if names and not self.feedback_by_index:
|
||||||
if len(names) != len(values):
|
if len(names) != len(values):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"JointState names and positions must have equal length"
|
"JointState names and positions must have equal length"
|
||||||
@@ -161,19 +211,27 @@ class CalibratedCommandMapper:
|
|||||||
if len(set(names)) != len(names):
|
if len(set(names)) != len(names):
|
||||||
raise ValueError("JointState names must be unique")
|
raise ValueError("JointState names must be unique")
|
||||||
by_name = dict(zip((str(name) for name in names), values))
|
by_name = dict(zip((str(name) for name in names), values))
|
||||||
missing = [name for name in G20_COMMAND_NAMES if name not in by_name]
|
for alias, canonical in self.feedback_name_aliases.items():
|
||||||
|
if alias in by_name and canonical not in by_name:
|
||||||
|
by_name[canonical] = by_name[alias]
|
||||||
|
missing = [name for name in self.command_names if name not in by_name]
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"G20 command is missing named channels: " + ",".join(missing)
|
f"{self.model} feedback is missing named channels: "
|
||||||
|
+ ",".join(missing)
|
||||||
)
|
)
|
||||||
command = tuple(by_name[name] for name in G20_COMMAND_NAMES)
|
command = tuple(by_name[name] for name in self.command_names)
|
||||||
else:
|
else:
|
||||||
if len(values) != len(G20_COMMAND_NAMES):
|
if len(values) != len(self.command_names):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"unnamed G20 command must contain exactly 20 positions"
|
f"unnamed {self.model} feedback must contain exactly "
|
||||||
|
f"{len(self.command_names)} positions"
|
||||||
)
|
)
|
||||||
command = values
|
command = values
|
||||||
indices = tuple(self._command_index(value) for value in command)
|
indices = (
|
||||||
|
() if self.input_domain == "feedback_rad"
|
||||||
|
else tuple(self._command_index(value) for value in command)
|
||||||
|
)
|
||||||
direction_by_motor: dict[int, str | None] = {}
|
direction_by_motor: dict[int, str | None] = {}
|
||||||
for motor, value in enumerate(command):
|
for motor, value in enumerate(command):
|
||||||
previous = self._previous_by_motor.get(motor)
|
previous = self._previous_by_motor.get(motor)
|
||||||
@@ -185,9 +243,16 @@ class CalibratedCommandMapper:
|
|||||||
direction = "decreasing"
|
direction = "decreasing"
|
||||||
direction_by_motor[motor] = direction
|
direction_by_motor[motor] = direction
|
||||||
result: list[float] = []
|
result: list[float] = []
|
||||||
for name in G20_URDF_JOINT_NAMES:
|
for name in self.urdf_joint_names:
|
||||||
motor = self._motor_by_joint[name]
|
motor = self._motor_by_joint[name]
|
||||||
direction = direction_by_motor[motor]
|
direction = direction_by_motor[motor]
|
||||||
|
if self.input_domain == "feedback_rad" and direction is not None:
|
||||||
|
raw_increasing = self._raw_increasing_branch[name]
|
||||||
|
direction = (
|
||||||
|
raw_increasing
|
||||||
|
if direction == "increasing"
|
||||||
|
else "increasing" if raw_increasing == "decreasing" else "decreasing"
|
||||||
|
)
|
||||||
curves = (
|
curves = (
|
||||||
self._increasing_curves
|
self._increasing_curves
|
||||||
if direction == "increasing"
|
if direction == "increasing"
|
||||||
@@ -195,6 +260,9 @@ class CalibratedCommandMapper:
|
|||||||
if direction == "decreasing"
|
if direction == "decreasing"
|
||||||
else self._curves
|
else self._curves
|
||||||
)
|
)
|
||||||
|
if self.input_domain == "feedback_rad":
|
||||||
|
result.append(float(np.interp(command[motor], self._knots[name], curves[name])))
|
||||||
|
else:
|
||||||
result.append(curves[name][indices[motor]])
|
result.append(curves[name][indices[motor]])
|
||||||
for motor, value in enumerate(command):
|
for motor, value in enumerate(command):
|
||||||
self._previous_by_motor[motor] = value
|
self._previous_by_motor[motor] = value
|
||||||
@@ -214,20 +282,24 @@ def load_calibrated_command_mapper(
|
|||||||
return CalibratedCommandMapper(payload, expected_side=expected_side)
|
return CalibratedCommandMapper(payload, expected_side=expected_side)
|
||||||
|
|
||||||
|
|
||||||
def default_input_topic(hand_type: str, input_domain: str) -> str:
|
def default_input_topic(
|
||||||
|
hand_type: str, input_domain: str, model: str = "G20"
|
||||||
|
) -> str:
|
||||||
side = str(hand_type).lower()
|
side = str(hand_type).lower()
|
||||||
if side not in {"left", "right"}:
|
if side not in {"left", "right"}:
|
||||||
raise ValueError("hand_type must be left or right")
|
raise ValueError("hand_type must be left or right")
|
||||||
if input_domain == "feedback_u8":
|
if input_domain == "feedback_u8":
|
||||||
return f"/g20/cb_{side}_hand_state"
|
return f"/{str(model).lower()}/cb_{side}_hand_state"
|
||||||
if input_domain == "command_u8":
|
if input_domain == "command_u8":
|
||||||
return f"/g20/cb_{side}_hand_control_cmd"
|
return f"/{str(model).lower()}/cb_{side}_hand_control_cmd"
|
||||||
|
if input_domain == "feedback_rad":
|
||||||
|
return f"/{str(model).lower()}/{side}/joint_states"
|
||||||
raise ValueError("calibration curve_input_domain is invalid")
|
raise ValueError("calibration curve_input_domain is invalid")
|
||||||
|
|
||||||
|
|
||||||
class CalibratedJointStateBridge(Node):
|
class CalibratedJointStateBridge(Node):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__("g20_calibrated_joint_state_bridge")
|
super().__init__("calibrated_joint_state_bridge")
|
||||||
self.declare_parameter("hand_type", "right")
|
self.declare_parameter("hand_type", "right")
|
||||||
self.declare_parameter("calibration_file", "")
|
self.declare_parameter("calibration_file", "")
|
||||||
self.declare_parameter("input_topic", "")
|
self.declare_parameter("input_topic", "")
|
||||||
@@ -245,10 +317,11 @@ class CalibratedJointStateBridge(Node):
|
|||||||
input_topic = str(self.get_parameter("input_topic").value).strip()
|
input_topic = str(self.get_parameter("input_topic").value).strip()
|
||||||
output_topic = str(self.get_parameter("output_topic").value).strip()
|
output_topic = str(self.get_parameter("output_topic").value).strip()
|
||||||
self.input_topic = input_topic or default_input_topic(
|
self.input_topic = input_topic or default_input_topic(
|
||||||
hand_type, self.mapper.input_domain
|
hand_type, self.mapper.input_domain, self.mapper.model
|
||||||
)
|
)
|
||||||
self.output_topic = (
|
self.output_topic = (
|
||||||
output_topic or f"/sim/mujoco/g20/{hand_type}/joint_state"
|
output_topic
|
||||||
|
or f"/sim/mujoco/{self.mapper.model.lower()}/{hand_type}/joint_state"
|
||||||
)
|
)
|
||||||
self.publisher = self.create_publisher(JointState, self.output_topic, 10)
|
self.publisher = self.create_publisher(JointState, self.output_topic, 10)
|
||||||
self.subscription = self.create_subscription(
|
self.subscription = self.create_subscription(
|
||||||
@@ -256,7 +329,8 @@ class CalibratedJointStateBridge(Node):
|
|||||||
)
|
)
|
||||||
self._last_error = ""
|
self._last_error = ""
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"loaded {hand_type} G20 calibration for {self.mapper.serial_number}: "
|
f"loaded {self.mapper.profile_id} calibration for "
|
||||||
|
f"{self.mapper.serial_number}: "
|
||||||
f"{self.input_topic} ({self.mapper.input_domain}) -> "
|
f"{self.input_topic} ({self.mapper.input_domain}) -> "
|
||||||
f"{self.output_topic}"
|
f"{self.output_topic}"
|
||||||
)
|
)
|
||||||
@@ -273,7 +347,7 @@ class CalibratedJointStateBridge(Node):
|
|||||||
self._last_error = ""
|
self._last_error = ""
|
||||||
result = JointState()
|
result = JointState()
|
||||||
result.header = command.header
|
result.header = command.header
|
||||||
result.name = list(G20_URDF_JOINT_NAMES)
|
result.name = list(self.mapper.urdf_joint_names)
|
||||||
result.position = list(positions)
|
result.position = list(positions)
|
||||||
self.publisher.publish(result)
|
self.publisher.publish(result)
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Compatibility adapters for one-release calibration migrations."""
|
||||||
|
|
||||||
|
from .config_v1 import (
|
||||||
|
legacy_default_profile_key,
|
||||||
|
product_profile_key,
|
||||||
|
resolve_legacy_profile_alias,
|
||||||
|
)
|
||||||
|
from .defaults import (
|
||||||
|
default_product_config_path,
|
||||||
|
default_three_camera_config_path,
|
||||||
|
)
|
||||||
|
from .paths import resolve_renamed_package_path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"default_product_config_path",
|
||||||
|
"default_three_camera_config_path",
|
||||||
|
"legacy_default_profile_key",
|
||||||
|
"product_profile_key",
|
||||||
|
"resolve_legacy_profile_alias",
|
||||||
|
"resolve_renamed_package_path",
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Identity migration for deployed product configuration schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ..core import ProfileKey
|
||||||
|
|
||||||
|
|
||||||
|
def product_profile_key(raw: Mapping[str, Any]) -> ProfileKey:
|
||||||
|
version = int(raw.get("schema_version", -1))
|
||||||
|
if version in {2, 3}:
|
||||||
|
key = ProfileKey.parse(str(raw.get("profile_id", "")))
|
||||||
|
for field, actual in (
|
||||||
|
("model", key.model),
|
||||||
|
("side", key.side),
|
||||||
|
("tag_layout", key.layout),
|
||||||
|
):
|
||||||
|
configured = str(raw.get(field, "")).strip()
|
||||||
|
if configured and configured.lower() != actual.lower():
|
||||||
|
raise ValueError(f"{field} differs from profile_id")
|
||||||
|
return key
|
||||||
|
if version != 1:
|
||||||
|
raise ValueError("product config schema_version must be 1, 2 or 3")
|
||||||
|
model = str(raw.get("model", "")).strip().upper()
|
||||||
|
side = str(raw.get("side", "")).strip().lower()
|
||||||
|
layout = str(raw.get("tag_layout", "")).strip().lower()
|
||||||
|
if not layout and (model, side) == ("G20", "right"):
|
||||||
|
layout = "g20_right_19"
|
||||||
|
return ProfileKey(model, side, layout, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_default_profile_key() -> ProfileKey:
|
||||||
|
"""Preserve the former no-argument executable for one release."""
|
||||||
|
return ProfileKey("G20", "right", "g20_right_19", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_legacy_profile_alias(key: ProfileKey) -> ProfileKey:
|
||||||
|
"""Map retired layout identifiers to their reviewed physical profile."""
|
||||||
|
if (
|
||||||
|
key.model == "G20"
|
||||||
|
and key.side == "right"
|
||||||
|
and key.layout == "g20_right_15"
|
||||||
|
and key.revision == 1
|
||||||
|
):
|
||||||
|
return ProfileKey("G20", "right", "g20_right_19", 1)
|
||||||
|
return key
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""One-release default selection for invocations without ``--config``."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
|
||||||
|
def default_product_config_path() -> Path:
|
||||||
|
share = Path(get_package_share_directory("linkerhand_calibration"))
|
||||||
|
return share / "config/g20_right_product.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def default_three_camera_config_path() -> Path:
|
||||||
|
"""Resolve the installed calibration defaults through the ROS index."""
|
||||||
|
share = Path(get_package_share_directory("linkerhand_calibration"))
|
||||||
|
return share / "config/three_camera_calibration.yaml"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Legacy single-camera algorithms retained for one compatibility release."""
|
||||||
|
from .session_v1 import uses_coupled_full_hand_zero_solver
|
||||||
|
|
||||||
|
__all__ = ["uses_coupled_full_hand_zero_solver"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Version selection for replaying durable pre-v3 hardware sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def uses_coupled_full_hand_zero_solver(
|
||||||
|
session_start: Mapping[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Return the solver contract recorded by the legacy session header.
|
||||||
|
|
||||||
|
Capabilities are not consulted by the live runtime. This adapter reads
|
||||||
|
the durable v1 header only so offline replay can reproduce an artifact
|
||||||
|
created before the independent thumb solver was introduced.
|
||||||
|
"""
|
||||||
|
capabilities = {
|
||||||
|
str(value) for value in session_start.get("capabilities", ())
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
int(session_start.get("sample_schema_version", 1)) == 1
|
||||||
|
and "palm_axis_side_channel_v2" in capabilities
|
||||||
|
and "palm_axis_relative_motion_v3" not in capabilities
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Path compatibility for immutable v1 product configurations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_LEGACY_SOURCE_PREFIX = Path("src/g20_thumb_apriltag_calibration")
|
||||||
|
_CURRENT_SOURCE_PREFIX = Path("src/linkerhand_calibration")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_package_uri(value: str, workspace: Path) -> Path | None:
|
||||||
|
prefix = "package://"
|
||||||
|
if not value.startswith(prefix):
|
||||||
|
return None
|
||||||
|
package_name, separator, relative = value[len(prefix) :].partition("/")
|
||||||
|
if not separator or not package_name or not relative:
|
||||||
|
raise ValueError(f"invalid ROS package resource path: {value}")
|
||||||
|
workspace_candidate = (workspace / "src" / package_name / relative).resolve()
|
||||||
|
if workspace_candidate.exists():
|
||||||
|
return workspace_candidate
|
||||||
|
try:
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
package_share = Path(get_package_share_directory(package_name))
|
||||||
|
except Exception:
|
||||||
|
return workspace_candidate
|
||||||
|
return (package_share / relative).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_renamed_package_path(value: str | Path, workspace: Path) -> Path:
|
||||||
|
"""Resolve workspace paths, ROS package resources and the former prefix.
|
||||||
|
|
||||||
|
Deployed v1 product YAML files are kept byte-for-byte stable because the
|
||||||
|
artifact paths participate in operational review. Existing paths always
|
||||||
|
win; package URIs prefer a source-workspace copy, and the rename mapping
|
||||||
|
is used only when the literal legacy path no longer exists.
|
||||||
|
"""
|
||||||
|
text = str(value).strip()
|
||||||
|
package_resource = _resolve_package_uri(text, workspace)
|
||||||
|
if package_resource is not None:
|
||||||
|
return package_resource
|
||||||
|
raw = Path(text).expanduser()
|
||||||
|
candidate = raw if raw.is_absolute() else workspace / raw
|
||||||
|
candidate = candidate.resolve()
|
||||||
|
if candidate.exists() or raw.is_absolute():
|
||||||
|
return candidate
|
||||||
|
try:
|
||||||
|
suffix = raw.relative_to(_LEGACY_SOURCE_PREFIX)
|
||||||
|
except ValueError:
|
||||||
|
return candidate
|
||||||
|
return (workspace / _CURRENT_SOURCE_PREFIX / suffix).resolve()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Hardware- and model-independent calibration kernel."""
|
||||||
|
|
||||||
|
from .domain import (
|
||||||
|
ArtifactPolicy,
|
||||||
|
CalibrationProfile,
|
||||||
|
CommandLayout,
|
||||||
|
MeasurementPolicy,
|
||||||
|
MeasurementSpec,
|
||||||
|
MotionPolicy,
|
||||||
|
ProfileKey,
|
||||||
|
ProfileValidationError,
|
||||||
|
QualityPolicy,
|
||||||
|
SampleRecord,
|
||||||
|
ScopePolicy,
|
||||||
|
TagSpec,
|
||||||
|
TaskSpec,
|
||||||
|
ViewSpec,
|
||||||
|
VisionRigSpec,
|
||||||
|
ZeroSolvePolicy,
|
||||||
|
validate_profile,
|
||||||
|
)
|
||||||
|
from .domain.task import (
|
||||||
|
DIRECTION_DECREASING,
|
||||||
|
DIRECTION_INCREASING,
|
||||||
|
DIRECTIONS,
|
||||||
|
PHASE_ROOT,
|
||||||
|
PHASE_TIP,
|
||||||
|
)
|
||||||
|
from .fitting import FitResult, isotonic_nonincreasing
|
||||||
|
from .geometry import (
|
||||||
|
delta_rotation_vector,
|
||||||
|
fit_rotation_axis,
|
||||||
|
image_plane_tag_quaternion_xyzw,
|
||||||
|
normalize_quaternion_xyzw,
|
||||||
|
relative_quaternion_xyzw,
|
||||||
|
robust_rotation_summary,
|
||||||
|
rotation_inlier_fraction,
|
||||||
|
rotation_rms_rad,
|
||||||
|
rotation_spread_rad,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ArtifactPolicy",
|
||||||
|
"CalibrationProfile",
|
||||||
|
"CommandLayout",
|
||||||
|
"DIRECTION_DECREASING",
|
||||||
|
"DIRECTION_INCREASING",
|
||||||
|
"DIRECTIONS",
|
||||||
|
"FitResult",
|
||||||
|
"MeasurementPolicy",
|
||||||
|
"MeasurementSpec",
|
||||||
|
"MotionPolicy",
|
||||||
|
"PHASE_ROOT",
|
||||||
|
"PHASE_TIP",
|
||||||
|
"ProfileKey",
|
||||||
|
"ProfileValidationError",
|
||||||
|
"QualityPolicy",
|
||||||
|
"SampleRecord",
|
||||||
|
"ScopePolicy",
|
||||||
|
"TagSpec",
|
||||||
|
"TaskSpec",
|
||||||
|
"ViewSpec",
|
||||||
|
"VisionRigSpec",
|
||||||
|
"ZeroSolvePolicy",
|
||||||
|
"delta_rotation_vector",
|
||||||
|
"fit_rotation_axis",
|
||||||
|
"image_plane_tag_quaternion_xyzw",
|
||||||
|
"isotonic_nonincreasing",
|
||||||
|
"normalize_quaternion_xyzw",
|
||||||
|
"relative_quaternion_xyzw",
|
||||||
|
"robust_rotation_summary",
|
||||||
|
"rotation_inlier_fraction",
|
||||||
|
"rotation_rms_rad",
|
||||||
|
"rotation_spread_rad",
|
||||||
|
"validate_profile",
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Artifact schema and release validation contracts."""
|
||||||
|
|
||||||
|
from .release import ReleaseValidation, ReleaseValidator
|
||||||
|
|
||||||
|
__all__ = ["ReleaseValidation", "ReleaseValidator"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Release validation protocol used before atomic publication."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping, Protocol
|
||||||
|
|
||||||
|
from ..domain import CalibrationProfile
|
||||||
|
from ..urdf import UrdfCorrectionPlan
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReleaseValidation:
|
||||||
|
passed: bool
|
||||||
|
errors: tuple[str, ...] = ()
|
||||||
|
verified_hashes: Mapping[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseValidator(Protocol):
|
||||||
|
def validate_release(
|
||||||
|
self,
|
||||||
|
profile: CalibrationProfile,
|
||||||
|
plan: UrdfCorrectionPlan,
|
||||||
|
calibration_json: Path,
|
||||||
|
corrected_urdf: Path,
|
||||||
|
) -> ReleaseValidation: ...
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Calibration domain types."""
|
||||||
|
|
||||||
|
from .profile import (
|
||||||
|
ArtifactPolicy,
|
||||||
|
CalibrationProfile,
|
||||||
|
CommandLayout,
|
||||||
|
MeasurementPolicy,
|
||||||
|
MeasurementSpec,
|
||||||
|
MotionPolicy,
|
||||||
|
ProfileKey,
|
||||||
|
ProfileValidationError,
|
||||||
|
QualityPolicy,
|
||||||
|
ScopePolicy,
|
||||||
|
TagSpec,
|
||||||
|
TaskSpec,
|
||||||
|
ViewSpec,
|
||||||
|
VisionRigSpec,
|
||||||
|
ZeroSolvePolicy,
|
||||||
|
validate_profile,
|
||||||
|
)
|
||||||
|
from .sample import SampleRecord
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ArtifactPolicy",
|
||||||
|
"CalibrationProfile",
|
||||||
|
"CommandLayout",
|
||||||
|
"MeasurementPolicy",
|
||||||
|
"MeasurementSpec",
|
||||||
|
"MotionPolicy",
|
||||||
|
"ProfileKey",
|
||||||
|
"ProfileValidationError",
|
||||||
|
"QualityPolicy",
|
||||||
|
"SampleRecord",
|
||||||
|
"ScopePolicy",
|
||||||
|
"TagSpec",
|
||||||
|
"TaskSpec",
|
||||||
|
"ViewSpec",
|
||||||
|
"VisionRigSpec",
|
||||||
|
"ZeroSolvePolicy",
|
||||||
|
"validate_profile",
|
||||||
|
]
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"""Typed, hardware-independent calibration profile contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import math
|
||||||
|
from pathlib import PurePath
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class ProfileKey:
|
||||||
|
"""Stable identity for one independently reviewed hand profile."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
side: str
|
||||||
|
layout: str
|
||||||
|
revision: int = 1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "model", str(self.model).strip().upper())
|
||||||
|
object.__setattr__(self, "side", str(self.side).strip().lower())
|
||||||
|
object.__setattr__(self, "layout", str(self.layout).strip().lower())
|
||||||
|
object.__setattr__(self, "revision", int(self.revision))
|
||||||
|
if not self.model or not self.side or not self.layout:
|
||||||
|
raise ValueError("profile identity fields must be non-empty")
|
||||||
|
if self.side not in {"left", "right"}:
|
||||||
|
raise ValueError("profile side must be left or right")
|
||||||
|
if self.revision < 1:
|
||||||
|
raise ValueError("profile revision must be positive")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profile_id(self) -> str:
|
||||||
|
return f"{self.model}/{self.side}/{self.layout}/v{self.revision}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, value: str) -> "ProfileKey":
|
||||||
|
parts = str(value).strip().split("/")
|
||||||
|
if len(parts) != 4 or not parts[3].startswith("v"):
|
||||||
|
raise ValueError(
|
||||||
|
"profile_id must be MODEL/side/layout/vREVISION"
|
||||||
|
)
|
||||||
|
return cls(parts[0], parts[1], parts[2], int(parts[3][1:]))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandLayout:
|
||||||
|
"""Command channels, joint bindings, and the reviewed baseline pose."""
|
||||||
|
|
||||||
|
names: tuple[str, ...]
|
||||||
|
baseline_u8: tuple[int, ...]
|
||||||
|
command_index_by_joint: Mapping[str, int]
|
||||||
|
disabled_indices: frozenset[int] = frozenset()
|
||||||
|
# Calibration names are allowed to stay model-neutral while the source
|
||||||
|
# URDF keeps any vendor/side prefixes (for example ``rh_``).
|
||||||
|
urdf_joint_by_joint: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
# Older SDKs occasionally published a wrong label for a physically stable
|
||||||
|
# channel. Aliases are accepted only at the declared channel index.
|
||||||
|
feedback_name_aliases: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
# SDK speed commands are not necessarily one value per position channel.
|
||||||
|
# This mapping makes that protocol detail explicit in a profile.
|
||||||
|
speed_slot_by_command_index: Mapping[int, int] = field(default_factory=dict)
|
||||||
|
# Physical-angle SDKs use these fields. ``baseline_u8`` remains the
|
||||||
|
# deployed compatibility contract for legacy byte-command profiles.
|
||||||
|
unit: str = "u8"
|
||||||
|
baseline: tuple[float, ...] = ()
|
||||||
|
lower_bounds: tuple[float, ...] = ()
|
||||||
|
upper_bounds: tuple[float, ...] = ()
|
||||||
|
feedback_by_index: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def command_count(self) -> int:
|
||||||
|
return len(self.names)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def baseline_values(self) -> tuple[float, ...]:
|
||||||
|
source = self.baseline if self.baseline else self.baseline_u8
|
||||||
|
return tuple(float(value) for value in source)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def minimum_values(self) -> tuple[float, ...]:
|
||||||
|
return (
|
||||||
|
tuple(float(value) for value in self.lower_bounds)
|
||||||
|
if self.lower_bounds
|
||||||
|
else (0.0,) * self.command_count
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maximum_values(self) -> tuple[float, ...]:
|
||||||
|
return (
|
||||||
|
tuple(float(value) for value in self.upper_bounds)
|
||||||
|
if self.upper_bounds
|
||||||
|
else (255.0,) * self.command_count
|
||||||
|
)
|
||||||
|
|
||||||
|
def normalize(self, index: int, value: float) -> float:
|
||||||
|
lower = self.minimum_values[int(index)]
|
||||||
|
upper = self.maximum_values[int(index)]
|
||||||
|
if not math.isfinite(float(value)) or upper <= lower:
|
||||||
|
raise ValueError("command value or bounds are invalid")
|
||||||
|
return (float(value) - lower) / (upper - lower)
|
||||||
|
|
||||||
|
def denormalize(self, index: int, progress: float) -> float:
|
||||||
|
lower = self.minimum_values[int(index)]
|
||||||
|
upper = self.maximum_values[int(index)]
|
||||||
|
phase = float(progress)
|
||||||
|
if not math.isfinite(phase) or not 0.0 <= phase <= 1.0:
|
||||||
|
raise ValueError("normalized command progress must be in [0, 1]")
|
||||||
|
return lower + phase * (upper - lower)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TagSpec:
|
||||||
|
role: str
|
||||||
|
tag_id: int
|
||||||
|
fixed_reference: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ViewSpec:
|
||||||
|
name: str
|
||||||
|
tags: tuple[TagSpec, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VisionRigSpec:
|
||||||
|
"""Any number of named views and their Tag roles."""
|
||||||
|
|
||||||
|
views: tuple[ViewSpec, ...]
|
||||||
|
common_frame: str
|
||||||
|
extrinsic_reference_view: str
|
||||||
|
extrinsics_quality_limits: Mapping[str, float] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
minimum_capture_counts: Mapping[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def view_names(self) -> tuple[str, ...]:
|
||||||
|
return tuple(view.name for view in self.views)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag_ids(self) -> frozenset[int]:
|
||||||
|
return frozenset(tag.tag_id for view in self.views for tag in view.tags)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskSpec:
|
||||||
|
key: str
|
||||||
|
view: str
|
||||||
|
command_index: int
|
||||||
|
joints: tuple[str, ...]
|
||||||
|
auxiliary_commands: tuple[tuple[int, float], ...] = ()
|
||||||
|
validation_only: bool = False
|
||||||
|
start_u8: int = 255
|
||||||
|
end_u8: int = 0
|
||||||
|
preflight_speed_u8: int | None = None
|
||||||
|
formal_speed_u8: int | None = None
|
||||||
|
start: float | None = None
|
||||||
|
end: float | None = None
|
||||||
|
preflight_speed: float | None = None
|
||||||
|
formal_speed: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_value(self) -> float:
|
||||||
|
return float(self.start_u8 if self.start is None else self.start)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def end_value(self) -> float:
|
||||||
|
return float(self.end_u8 if self.end is None else self.end)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MotionPolicy:
|
||||||
|
"""Reviewed motion tasks and optional safe waypoint sequences."""
|
||||||
|
|
||||||
|
tasks: tuple[TaskSpec, ...]
|
||||||
|
preparation_waypoints_u8: tuple[tuple[int, ...], ...] = ()
|
||||||
|
safe_return_waypoints_u8: tuple[tuple[int, ...], ...] = ()
|
||||||
|
speed_parameters: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
precheck_sweeps: bool = False
|
||||||
|
steady_command_checkpoints: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MeasurementSpec:
|
||||||
|
joint: str
|
||||||
|
kind: str
|
||||||
|
view: str | None
|
||||||
|
parent_role: str | None
|
||||||
|
child_role: str | None
|
||||||
|
validation_source: str | None = None
|
||||||
|
# Some measured trajectories publish only a dynamic curve while their
|
||||||
|
# static URDF zero/axis remains CAD- or mimic-owned. For those joints a
|
||||||
|
# monocular 3-D axis-line residual is useful diagnostic evidence, but it
|
||||||
|
# must not reject an otherwise clean image/SO(3) trajectory merely because
|
||||||
|
# the hand was placed at a different valid position in the camera view.
|
||||||
|
pose_axis_line_required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MeasurementPolicy:
|
||||||
|
measurements: Mapping[str, MeasurementSpec]
|
||||||
|
cross_view_sources: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
image_curve_joints: frozenset[str] = frozenset()
|
||||||
|
directional_zero: bool = False
|
||||||
|
cross_view_roll_curve: bool = False
|
||||||
|
stable_cross_view_cone_bias: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZeroSolvePolicy:
|
||||||
|
active_joints: frozenset[str]
|
||||||
|
passive_joints: frozenset[str]
|
||||||
|
direct_zero_joints: tuple[str, ...]
|
||||||
|
axis_joints: tuple[str, ...]
|
||||||
|
mechanical_endpoint_joints: frozenset[str]
|
||||||
|
post_solve_endpoint_joints: frozenset[str]
|
||||||
|
mimic_source_by_joint: Mapping[str, str]
|
||||||
|
cad_frozen_joints: frozenset[str]
|
||||||
|
# ``upper_at_end`` means TaskSpec.end_u8 is the trusted source-URDF upper
|
||||||
|
# physical endpoint. The measured travel then defines the electrical
|
||||||
|
# zero and corrected [0, travel] coordinate range.
|
||||||
|
endpoint_anchor_by_joint: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
fitted_mimic_joints: frozenset[str] = frozenset()
|
||||||
|
# Passive coupling is not necessarily representable by the linear URDF
|
||||||
|
# ``mimic`` element. Profiles must opt in explicitly before a nonlinear
|
||||||
|
# runtime/MuJoCo relation may be published.
|
||||||
|
coupling_model_by_joint: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QualityPolicy:
|
||||||
|
training_cycles: tuple[int, ...]
|
||||||
|
holdout_cycle: int | None
|
||||||
|
hard_threshold_keys: frozenset[str]
|
||||||
|
retry_metric_scope: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
isolated_holdout: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScopePolicy:
|
||||||
|
calibrate_joints: Mapping[str, frozenset[str]]
|
||||||
|
frozen_joints: Mapping[str, frozenset[str]]
|
||||||
|
default_scope: str = "full"
|
||||||
|
|
||||||
|
def selected_joints(self, scope: str) -> frozenset[str]:
|
||||||
|
try:
|
||||||
|
return self.calibrate_joints[str(scope)]
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(f"unsupported calibration scope: {scope}") from error
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArtifactPolicy:
|
||||||
|
output_schema_version: int
|
||||||
|
calibration_filename: str
|
||||||
|
corrected_urdf_filename: str
|
||||||
|
protected_input_fields: frozenset[str]
|
||||||
|
publication_pointer: str = "latest_passed"
|
||||||
|
session_compatibility_tokens: frozenset[str] = frozenset()
|
||||||
|
publish_corrected_urdf: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalibrationProfile:
|
||||||
|
key: ProfileKey
|
||||||
|
namespace: str
|
||||||
|
command: CommandLayout
|
||||||
|
vision: VisionRigSpec
|
||||||
|
motion: MotionPolicy
|
||||||
|
measurement: MeasurementPolicy
|
||||||
|
zero: ZeroSolvePolicy
|
||||||
|
quality: QualityPolicy
|
||||||
|
scope: ScopePolicy
|
||||||
|
artifacts: ArtifactPolicy
|
||||||
|
# Per-URDF-joint provenance used by partial calibration artifacts.
|
||||||
|
# Known values are: measured_static_dynamic, measured_dynamic_cad_static,
|
||||||
|
# transferred_static_dynamic, transferred_dynamic_cad_static, cad_nominal,
|
||||||
|
# and mimic_nominal.
|
||||||
|
joint_coverage: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileValidationError(ValueError):
|
||||||
|
"""Raised before hardware startup when a profile is internally unsafe."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_profile(profile: CalibrationProfile) -> None:
|
||||||
|
"""Hard-check all cross-policy references before hardware is enabled."""
|
||||||
|
errors: list[str] = []
|
||||||
|
command = profile.command
|
||||||
|
baseline = command.baseline_values
|
||||||
|
if not command.names or len(command.names) != len(baseline):
|
||||||
|
errors.append("command names and baseline must be non-empty and aligned")
|
||||||
|
if len(set(command.names)) != len(command.names):
|
||||||
|
errors.append("command names must be unique")
|
||||||
|
if command.unit not in {"u8", "rad"}:
|
||||||
|
errors.append("command unit must be u8 or rad")
|
||||||
|
if command.unit == "u8" and any(value < 0 or value > 255 for value in baseline):
|
||||||
|
errors.append("baseline command values must be in [0, 255]")
|
||||||
|
if (
|
||||||
|
len(command.minimum_values) != command.command_count
|
||||||
|
or len(command.maximum_values) != command.command_count
|
||||||
|
):
|
||||||
|
errors.append("command bounds must align with command names")
|
||||||
|
elif any(
|
||||||
|
not math.isfinite(lower)
|
||||||
|
or not math.isfinite(upper)
|
||||||
|
or lower >= upper
|
||||||
|
or not lower <= value <= upper
|
||||||
|
for lower, upper, value in zip(
|
||||||
|
command.minimum_values, command.maximum_values, baseline
|
||||||
|
)
|
||||||
|
):
|
||||||
|
errors.append("command bounds or baseline values are invalid")
|
||||||
|
indices = set(range(command.command_count))
|
||||||
|
if not set(command.disabled_indices).issubset(indices):
|
||||||
|
errors.append("disabled command index is out of range")
|
||||||
|
if any(index not in indices for index in command.command_index_by_joint.values()):
|
||||||
|
errors.append("joint command index is out of range")
|
||||||
|
if command.urdf_joint_by_joint:
|
||||||
|
if not set(command.command_index_by_joint).issubset(
|
||||||
|
command.urdf_joint_by_joint
|
||||||
|
):
|
||||||
|
errors.append("every commanded joint must map to a URDF joint")
|
||||||
|
urdf_names = tuple(command.urdf_joint_by_joint.values())
|
||||||
|
if len(set(urdf_names)) != len(urdf_names):
|
||||||
|
errors.append("URDF joint mappings must be unique")
|
||||||
|
if any(
|
||||||
|
index not in indices or slot < 0
|
||||||
|
for index, slot in command.speed_slot_by_command_index.items()
|
||||||
|
):
|
||||||
|
errors.append("speed-slot mapping is invalid")
|
||||||
|
if any(
|
||||||
|
not str(alias).strip() or canonical not in command.names
|
||||||
|
for alias, canonical in command.feedback_name_aliases.items()
|
||||||
|
):
|
||||||
|
errors.append("feedback name alias is not part of the command schema")
|
||||||
|
|
||||||
|
view_names = profile.vision.view_names
|
||||||
|
if not view_names or len(set(view_names)) != len(view_names):
|
||||||
|
errors.append("vision views must be non-empty and unique")
|
||||||
|
if profile.vision.extrinsic_reference_view not in view_names:
|
||||||
|
errors.append("extrinsic reference view is not declared")
|
||||||
|
tag_ids = [tag.tag_id for view in profile.vision.views for tag in view.tags]
|
||||||
|
tag_roles = [tag.role for view in profile.vision.views for tag in view.tags]
|
||||||
|
if len(set(tag_ids)) != len(tag_ids):
|
||||||
|
errors.append("Tag IDs must be unique across views")
|
||||||
|
if len(set(tag_roles)) != len(tag_roles):
|
||||||
|
errors.append("Tag roles must be unique across views")
|
||||||
|
if not any(
|
||||||
|
tag.fixed_reference for view in profile.vision.views for tag in view.tags
|
||||||
|
):
|
||||||
|
errors.append("at least one fixed reference Tag is required")
|
||||||
|
|
||||||
|
task_keys = [task.key for task in profile.motion.tasks]
|
||||||
|
if not task_keys or len(set(task_keys)) != len(task_keys):
|
||||||
|
errors.append("motion task keys must be non-empty and unique")
|
||||||
|
measurement_names = set(profile.measurement.measurements)
|
||||||
|
for task in profile.motion.tasks:
|
||||||
|
if task.view not in view_names:
|
||||||
|
errors.append(f"task {task.key} uses an unknown view")
|
||||||
|
if task.command_index not in indices:
|
||||||
|
errors.append(f"task {task.key} command index is out of range")
|
||||||
|
if not task.joints or not set(task.joints).issubset(measurement_names):
|
||||||
|
errors.append(f"task {task.key} references unknown measurements")
|
||||||
|
if any(index not in indices for index, _ in task.auxiliary_commands):
|
||||||
|
errors.append(f"task {task.key} auxiliary index is out of range")
|
||||||
|
elif any(
|
||||||
|
not command.minimum_values[index] <= float(value) <= command.maximum_values[index]
|
||||||
|
for index, value in task.auxiliary_commands
|
||||||
|
):
|
||||||
|
errors.append(f"task {task.key} auxiliary command is out of range")
|
||||||
|
lower = command.minimum_values[task.command_index]
|
||||||
|
upper = command.maximum_values[task.command_index]
|
||||||
|
if not lower <= task.start_value <= upper or not lower <= task.end_value <= upper:
|
||||||
|
errors.append(f"task {task.key} sweep endpoint is out of range")
|
||||||
|
if task.start_value == task.end_value:
|
||||||
|
errors.append(f"task {task.key} sweep endpoints must differ")
|
||||||
|
for speed in (task.preflight_speed_u8, task.formal_speed_u8):
|
||||||
|
if speed is not None and not 0 <= speed <= 255:
|
||||||
|
errors.append(f"task {task.key} speed is out of range")
|
||||||
|
if command.unit == "rad" and (
|
||||||
|
task.formal_speed is None or float(task.formal_speed) <= 0.0
|
||||||
|
):
|
||||||
|
errors.append(f"task {task.key} physical speed must be positive")
|
||||||
|
for name, spec in profile.measurement.measurements.items():
|
||||||
|
if name != spec.joint:
|
||||||
|
errors.append(f"measurement mapping key differs for {name}")
|
||||||
|
if spec.view is not None and spec.view not in view_names:
|
||||||
|
errors.append(f"measurement {name} uses an unknown view")
|
||||||
|
for primary, validation in profile.measurement.cross_view_sources.items():
|
||||||
|
if primary not in measurement_names or validation not in measurement_names:
|
||||||
|
errors.append("cross-view measurement source is unknown")
|
||||||
|
|
||||||
|
zero = profile.zero
|
||||||
|
if zero.active_joints & zero.passive_joints:
|
||||||
|
errors.append("active and passive joints must be disjoint")
|
||||||
|
all_joints = zero.active_joints | zero.passive_joints
|
||||||
|
if not zero.active_joints.issubset(command.command_index_by_joint):
|
||||||
|
errors.append("every active joint must bind to a command channel")
|
||||||
|
if not set(zero.direct_zero_joints).issubset(zero.active_joints):
|
||||||
|
errors.append("direct zero targets must be active joints")
|
||||||
|
if not set(zero.axis_joints).issubset(all_joints):
|
||||||
|
errors.append("axis targets must be known joints")
|
||||||
|
if not zero.mechanical_endpoint_joints.issubset(zero.active_joints):
|
||||||
|
errors.append("mechanical endpoint targets must be active joints")
|
||||||
|
if not zero.post_solve_endpoint_joints.issubset(zero.active_joints):
|
||||||
|
errors.append("post-solve endpoint targets must be active joints")
|
||||||
|
if not set(zero.mimic_source_by_joint).issubset(zero.passive_joints):
|
||||||
|
errors.append("mimic targets must be passive joints")
|
||||||
|
if not set(zero.mimic_source_by_joint.values()).issubset(all_joints):
|
||||||
|
errors.append("mimic sources must be known joints")
|
||||||
|
if not set(zero.endpoint_anchor_by_joint).issubset(zero.active_joints):
|
||||||
|
errors.append("endpoint anchors must target active joints")
|
||||||
|
if not set(zero.endpoint_anchor_by_joint.values()).issubset(
|
||||||
|
{
|
||||||
|
"upper_at_end",
|
||||||
|
"lower_at_start",
|
||||||
|
"zero_at_start",
|
||||||
|
"cad_range_center",
|
||||||
|
}
|
||||||
|
):
|
||||||
|
errors.append("endpoint anchor policy is unsupported")
|
||||||
|
if not zero.fitted_mimic_joints.issubset(zero.passive_joints):
|
||||||
|
errors.append("fitted mimic targets must be passive joints")
|
||||||
|
if not zero.fitted_mimic_joints.issubset(zero.mimic_source_by_joint):
|
||||||
|
errors.append("fitted mimic target has no source mapping")
|
||||||
|
if not set(zero.coupling_model_by_joint).issubset(
|
||||||
|
zero.mimic_source_by_joint
|
||||||
|
):
|
||||||
|
errors.append("coupling model target has no source mapping")
|
||||||
|
if not set(zero.coupling_model_by_joint.values()).issubset(
|
||||||
|
{"linear_mimic", "quadratic_runtime"}
|
||||||
|
):
|
||||||
|
errors.append("coupling model policy is unsupported")
|
||||||
|
|
||||||
|
scopes = set(profile.scope.calibrate_joints)
|
||||||
|
if profile.scope.default_scope not in scopes:
|
||||||
|
errors.append("default scope is not declared")
|
||||||
|
if scopes != set(profile.scope.frozen_joints):
|
||||||
|
errors.append("scope calibration and frozen mappings must align")
|
||||||
|
for name in scopes:
|
||||||
|
selected = profile.scope.calibrate_joints[name]
|
||||||
|
frozen = profile.scope.frozen_joints[name]
|
||||||
|
if selected & frozen or selected | frozen != zero.active_joints:
|
||||||
|
errors.append(f"scope {name} must partition all active joints")
|
||||||
|
|
||||||
|
artifacts = profile.artifacts
|
||||||
|
if artifacts.output_schema_version < 1:
|
||||||
|
errors.append("artifact schema version must be positive")
|
||||||
|
for label, filename in (
|
||||||
|
("calibration", artifacts.calibration_filename),
|
||||||
|
("corrected URDF", artifacts.corrected_urdf_filename),
|
||||||
|
("publication pointer", artifacts.publication_pointer),
|
||||||
|
):
|
||||||
|
if not filename or PurePath(filename).name != filename:
|
||||||
|
errors.append(f"{label} filename must not contain a directory")
|
||||||
|
if not profile.namespace.startswith("/"):
|
||||||
|
errors.append("runtime namespace must be absolute")
|
||||||
|
if profile.joint_coverage:
|
||||||
|
valid_coverage = {
|
||||||
|
"measured_static_dynamic",
|
||||||
|
"measured_dynamic_cad_static",
|
||||||
|
"transferred_static_dynamic",
|
||||||
|
"transferred_dynamic_cad_static",
|
||||||
|
"cad_nominal",
|
||||||
|
"mimic_nominal",
|
||||||
|
}
|
||||||
|
if set(profile.joint_coverage) != all_joints:
|
||||||
|
errors.append("joint coverage must describe every profile joint")
|
||||||
|
if not set(profile.joint_coverage.values()).issubset(valid_coverage):
|
||||||
|
errors.append("joint coverage contains an unsupported status")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise ProfileValidationError("; ".join(errors))
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Normalized records shared by online evaluation and offline replay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import math
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SampleRecord:
|
||||||
|
task_key: str
|
||||||
|
measurement: str
|
||||||
|
view: str
|
||||||
|
cycle: int
|
||||||
|
direction: str
|
||||||
|
command_u8: int
|
||||||
|
timestamp_ns: int
|
||||||
|
values: Mapping[str, Any]
|
||||||
|
quality: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
command: float | None = None
|
||||||
|
command_unit: str = "u8"
|
||||||
|
progress_01: float | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.task_key or not self.measurement or not self.view:
|
||||||
|
raise ValueError("sample task, measurement, and view are required")
|
||||||
|
if self.cycle < 0:
|
||||||
|
raise ValueError("sample cycle or command is out of range")
|
||||||
|
if self.command_unit == "u8" and not 0 <= self.command_u8 <= 255:
|
||||||
|
raise ValueError("sample cycle or command is out of range")
|
||||||
|
if self.command_unit not in {"u8", "rad"}:
|
||||||
|
raise ValueError("sample command unit must be u8 or rad")
|
||||||
|
if self.command is not None and not math.isfinite(float(self.command)):
|
||||||
|
raise ValueError("sample command must be finite")
|
||||||
|
if self.progress_01 is not None and not 0.0 <= float(self.progress_01) <= 1.0:
|
||||||
|
raise ValueError("sample progress must be in [0, 1]")
|
||||||
|
if self.timestamp_ns < 0:
|
||||||
|
raise ValueError("sample timestamp must be non-negative")
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Shared task direction vocabulary."""
|
||||||
|
|
||||||
|
DIRECTION_DECREASING = "decreasing"
|
||||||
|
DIRECTION_INCREASING = "increasing"
|
||||||
|
DIRECTIONS: tuple[str, ...] = (
|
||||||
|
DIRECTION_DECREASING,
|
||||||
|
DIRECTION_INCREASING,
|
||||||
|
)
|
||||||
|
|
||||||
|
PHASE_ROOT = "root"
|
||||||
|
PHASE_TIP = "tip"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DIRECTION_DECREASING",
|
||||||
|
"DIRECTION_INCREASING",
|
||||||
|
"DIRECTIONS",
|
||||||
|
"PHASE_ROOT",
|
||||||
|
"PHASE_TIP",
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Pure curve and axis fitting."""
|
||||||
|
|
||||||
|
from .curve import FitResult, isotonic_nonincreasing
|
||||||
|
|
||||||
|
__all__ = ["FitResult", "isotonic_nonincreasing"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Model-independent curve fitting result and monotonic projection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..geometry import delta_rotation_vector
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
measurement_mode: str = "rotation"
|
||||||
|
trajectory_models: dict[str, Any] = field(default_factory=dict)
|
||||||
|
trajectory_quality: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Pure geometry used by online and offline calibration."""
|
||||||
|
|
||||||
|
from .extrinsics import (
|
||||||
|
CameraCalibrationIdentity,
|
||||||
|
CameraExtrinsics,
|
||||||
|
camera_info_fingerprint,
|
||||||
|
load_camera_extrinsics,
|
||||||
|
matrix_payload,
|
||||||
|
transform_matrix,
|
||||||
|
validate_camera_extrinsics_payload,
|
||||||
|
)
|
||||||
|
from .rotation import (
|
||||||
|
delta_rotation_vector,
|
||||||
|
fit_rotation_axis,
|
||||||
|
image_plane_tag_quaternion_xyzw,
|
||||||
|
normalize_quaternion_xyzw,
|
||||||
|
relative_quaternion_xyzw,
|
||||||
|
robust_rotation_summary,
|
||||||
|
rotation_inlier_fraction,
|
||||||
|
rotation_rms_rad,
|
||||||
|
rotation_spread_rad,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CameraCalibrationIdentity",
|
||||||
|
"CameraExtrinsics",
|
||||||
|
"camera_info_fingerprint",
|
||||||
|
"delta_rotation_vector",
|
||||||
|
"fit_rotation_axis",
|
||||||
|
"image_plane_tag_quaternion_xyzw",
|
||||||
|
"load_camera_extrinsics",
|
||||||
|
"matrix_payload",
|
||||||
|
"normalize_quaternion_xyzw",
|
||||||
|
"relative_quaternion_xyzw",
|
||||||
|
"robust_rotation_summary",
|
||||||
|
"rotation_inlier_fraction",
|
||||||
|
"rotation_rms_rad",
|
||||||
|
"rotation_spread_rad",
|
||||||
|
"transform_matrix",
|
||||||
|
"validate_camera_extrinsics_payload",
|
||||||
|
]
|
||||||
+66
-81
@@ -13,9 +13,6 @@ import yaml
|
|||||||
from scipy.spatial.transform import Rotation
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
|
||||||
VIEWS: tuple[str, ...] = ("front", "side", "top")
|
|
||||||
|
|
||||||
|
|
||||||
def camera_info_fingerprint(
|
def camera_info_fingerprint(
|
||||||
*,
|
*,
|
||||||
width: int,
|
width: int,
|
||||||
@@ -81,66 +78,61 @@ class CameraCalibrationIdentity:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ThreeCameraExtrinsics:
|
class CameraExtrinsics:
|
||||||
"""Transforms points from each camera optical frame into front optical."""
|
"""Transforms every declared camera into one Profile-selected reference."""
|
||||||
|
|
||||||
cameras: Mapping[str, CameraCalibrationIdentity]
|
cameras: Mapping[str, CameraCalibrationIdentity]
|
||||||
front_from_view: Mapping[str, np.ndarray]
|
reference_view: str
|
||||||
|
reference_from_view: Mapping[str, np.ndarray]
|
||||||
quality: Mapping[str, float]
|
quality: Mapping[str, float]
|
||||||
|
|
||||||
def transform(self, view: str) -> np.ndarray:
|
def transform(self, view: str) -> np.ndarray:
|
||||||
if view not in self.front_from_view:
|
if view not in self.reference_from_view:
|
||||||
raise KeyError(f"extrinsics do not contain view {view}")
|
raise KeyError(f"extrinsics do not contain view {view}")
|
||||||
return np.asarray(self.front_from_view[view], dtype=float).copy()
|
return np.asarray(self.reference_from_view[view], dtype=float).copy()
|
||||||
|
|
||||||
def camera_matches(
|
|
||||||
self,
|
def validate_camera_extrinsics_payload(
|
||||||
view: str,
|
payload: Mapping[str, Any],
|
||||||
*,
|
*,
|
||||||
serial_number: str,
|
required_views: Sequence[str],
|
||||||
width: int,
|
reference_view: str,
|
||||||
height: int,
|
quality_limits: Mapping[str, float] | None = None,
|
||||||
intrinsics_sha256: str,
|
minimum_capture_counts: Mapping[str, int] | None = None,
|
||||||
) -> bool:
|
) -> None:
|
||||||
expected = self.cameras.get(view)
|
|
||||||
return bool(
|
|
||||||
expected is not None
|
|
||||||
and expected.serial_number == str(serial_number)
|
|
||||||
and expected.width == int(width)
|
|
||||||
and expected.height == int(height)
|
|
||||||
and expected.intrinsics_sha256 == str(intrinsics_sha256)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_extrinsics_payload(payload: Mapping[str, Any]) -> None:
|
|
||||||
if int(payload.get("schema_version", -1)) != 1:
|
if int(payload.get("schema_version", -1)) != 1:
|
||||||
raise ValueError("camera extrinsics schema_version must be 1")
|
raise ValueError("camera extrinsics schema_version must be 1")
|
||||||
if payload.get("reference_view") != "front":
|
reference = str(reference_view)
|
||||||
raise ValueError("camera extrinsics reference_view must be front")
|
if payload.get("reference_view") != reference:
|
||||||
|
raise ValueError(
|
||||||
|
"camera extrinsics reference_view differs from the Profile"
|
||||||
|
)
|
||||||
cameras = payload.get("cameras")
|
cameras = payload.get("cameras")
|
||||||
transforms = payload.get("front_from_view")
|
transforms = payload.get(f"{reference}_from_view")
|
||||||
quality = payload.get("quality")
|
quality = payload.get("quality")
|
||||||
if not isinstance(cameras, Mapping) or set(cameras) != set(VIEWS):
|
views = tuple(str(view) for view in required_views)
|
||||||
raise ValueError("camera extrinsics must contain front/side/top cameras")
|
if not views or len(set(views)) != len(views):
|
||||||
if not isinstance(transforms, Mapping) or set(transforms) != set(VIEWS):
|
raise ValueError("required extrinsic views must be non-empty and unique")
|
||||||
raise ValueError("camera extrinsics must contain all three transforms")
|
if reference not in views:
|
||||||
|
raise ValueError("extrinsic reference view is not required")
|
||||||
|
if not isinstance(cameras, Mapping) or set(cameras) != set(views):
|
||||||
|
raise ValueError("camera extrinsics differ from the Profile views")
|
||||||
|
if not isinstance(transforms, Mapping) or set(transforms) != set(views):
|
||||||
|
raise ValueError("camera transforms differ from the Profile views")
|
||||||
if not isinstance(quality, Mapping) or not bool(quality.get("passed")):
|
if not isinstance(quality, Mapping) or not bool(quality.get("passed")):
|
||||||
raise ValueError("camera extrinsics quality is not passed")
|
raise ValueError("camera extrinsics quality is not passed")
|
||||||
quality_limits = {
|
for key, limit in dict(quality_limits or {}).items():
|
||||||
"reprojection_rms_px": 1.2,
|
|
||||||
"maximum_rotation_repeatability_deg": 0.3,
|
|
||||||
"maximum_translation_repeatability_m": 0.0015,
|
|
||||||
}
|
|
||||||
for key, limit in quality_limits.items():
|
|
||||||
value = float(quality.get(key, float("inf")))
|
value = float(quality.get(key, float("inf")))
|
||||||
if not np.isfinite(value) or value > limit:
|
if not np.isfinite(value) or value > limit:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"camera extrinsics {key}={value} exceeds {limit}"
|
f"camera extrinsics {key}={value} exceeds {limit}"
|
||||||
)
|
)
|
||||||
for key in ("front_side_captures", "front_top_captures"):
|
for key, minimum in dict(minimum_capture_counts or {}).items():
|
||||||
if int(quality.get(key, 0)) < 15:
|
if int(quality.get(key, 0)) < int(minimum):
|
||||||
raise ValueError(f"camera extrinsics {key} must be at least 15")
|
raise ValueError(
|
||||||
for view in VIEWS:
|
f"camera extrinsics {key} must be at least {minimum}"
|
||||||
|
)
|
||||||
|
for view in views:
|
||||||
identity = cameras[view]
|
identity = cameras[view]
|
||||||
if not isinstance(identity, Mapping):
|
if not isinstance(identity, Mapping):
|
||||||
raise ValueError(f"{view} camera identity must be an object")
|
raise ValueError(f"{view} camera identity must be an object")
|
||||||
@@ -158,14 +150,23 @@ def validate_extrinsics_payload(payload: Mapping[str, Any]) -> None:
|
|||||||
transform.get("translation_xyz_m", ()),
|
transform.get("translation_xyz_m", ()),
|
||||||
transform.get("quaternion_xyzw", ()),
|
transform.get("quaternion_xyzw", ()),
|
||||||
)
|
)
|
||||||
if view == "front" and not np.allclose(matrix, np.eye(4), atol=1.0e-9):
|
if view == reference and not np.allclose(
|
||||||
raise ValueError("front_from_view.front must be identity")
|
matrix, np.eye(4), atol=1.0e-9
|
||||||
serials = [str(cameras[view]["serial_number"]) for view in VIEWS]
|
):
|
||||||
if len(set(serials)) != len(VIEWS):
|
raise ValueError("reference-view transform must be identity")
|
||||||
|
serials = [str(cameras[view]["serial_number"]) for view in views]
|
||||||
|
if len(set(serials)) != len(views):
|
||||||
raise ValueError("camera extrinsics serial numbers must be unique")
|
raise ValueError("camera extrinsics serial numbers must be unique")
|
||||||
|
|
||||||
|
|
||||||
def load_three_camera_extrinsics(path: str | Path) -> ThreeCameraExtrinsics:
|
def load_camera_extrinsics(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
required_views: Sequence[str],
|
||||||
|
reference_view: str,
|
||||||
|
quality_limits: Mapping[str, float] | None = None,
|
||||||
|
minimum_capture_counts: Mapping[str, int] | None = None,
|
||||||
|
) -> CameraExtrinsics:
|
||||||
source = Path(path).expanduser().resolve()
|
source = Path(path).expanduser().resolve()
|
||||||
if not source.is_file():
|
if not source.is_file():
|
||||||
raise ValueError(f"camera extrinsics file does not exist: {source}")
|
raise ValueError(f"camera extrinsics file does not exist: {source}")
|
||||||
@@ -173,7 +174,15 @@ def load_three_camera_extrinsics(path: str | Path) -> ThreeCameraExtrinsics:
|
|||||||
payload = yaml.safe_load(stream)
|
payload = yaml.safe_load(stream)
|
||||||
if not isinstance(payload, Mapping):
|
if not isinstance(payload, Mapping):
|
||||||
raise ValueError("camera extrinsics file must contain an object")
|
raise ValueError("camera extrinsics file must contain an object")
|
||||||
validate_extrinsics_payload(payload)
|
validate_camera_extrinsics_payload(
|
||||||
|
payload,
|
||||||
|
required_views=required_views,
|
||||||
|
reference_view=reference_view,
|
||||||
|
quality_limits=quality_limits,
|
||||||
|
minimum_capture_counts=minimum_capture_counts,
|
||||||
|
)
|
||||||
|
views = tuple(str(view) for view in required_views)
|
||||||
|
transform_key = f"{reference_view}_from_view"
|
||||||
cameras = {
|
cameras = {
|
||||||
view: CameraCalibrationIdentity(
|
view: CameraCalibrationIdentity(
|
||||||
serial_number=str(payload["cameras"][view]["serial_number"]),
|
serial_number=str(payload["cameras"][view]["serial_number"]),
|
||||||
@@ -183,45 +192,21 @@ def load_three_camera_extrinsics(path: str | Path) -> ThreeCameraExtrinsics:
|
|||||||
payload["cameras"][view]["intrinsics_sha256"]
|
payload["cameras"][view]["intrinsics_sha256"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for view in VIEWS
|
for view in views
|
||||||
}
|
}
|
||||||
transforms = {
|
transforms = {
|
||||||
view: transform_matrix(
|
view: transform_matrix(
|
||||||
payload["front_from_view"][view]["translation_xyz_m"],
|
payload[transform_key][view]["translation_xyz_m"],
|
||||||
payload["front_from_view"][view]["quaternion_xyzw"],
|
payload[transform_key][view]["quaternion_xyzw"],
|
||||||
)
|
)
|
||||||
for view in VIEWS
|
for view in views
|
||||||
}
|
}
|
||||||
return ThreeCameraExtrinsics(
|
return CameraExtrinsics(
|
||||||
cameras=cameras,
|
cameras=cameras,
|
||||||
front_from_view=transforms,
|
reference_view=str(reference_view),
|
||||||
|
reference_from_view=transforms,
|
||||||
quality={
|
quality={
|
||||||
str(key): float(value) if isinstance(value, (int, float)) else value
|
str(key): float(value) if isinstance(value, (int, float)) else value
|
||||||
for key, value in payload["quality"].items()
|
for key, value in payload["quality"].items()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def dump_three_camera_extrinsics(
|
|
||||||
path: str | Path,
|
|
||||||
*,
|
|
||||||
cameras: Mapping[str, Mapping[str, Any]],
|
|
||||||
front_from_view: Mapping[str, Sequence[Sequence[float]]],
|
|
||||||
quality: Mapping[str, Any],
|
|
||||||
) -> None:
|
|
||||||
payload = {
|
|
||||||
"schema_version": 1,
|
|
||||||
"reference_view": "front",
|
|
||||||
"cameras": {view: dict(cameras[view]) for view in VIEWS},
|
|
||||||
"front_from_view": {
|
|
||||||
view: matrix_payload(front_from_view[view]) for view in VIEWS
|
|
||||||
},
|
|
||||||
"quality": dict(quality),
|
|
||||||
}
|
|
||||||
validate_extrinsics_payload(payload)
|
|
||||||
destination = Path(path).expanduser().resolve()
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
|
||||||
with temporary.open("w", encoding="utf-8") as stream:
|
|
||||||
yaml.safe_dump(payload, stream, allow_unicode=True, sort_keys=False)
|
|
||||||
temporary.replace(destination)
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Pure quaternion summaries and rotation-axis fitting."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
|
||||||
|
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-to-child orientation from two camera-to-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")
|
||||||
|
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 command 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)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Task acceptance and final-session solver contracts."""
|
||||||
|
|
||||||
|
from .interfaces import (
|
||||||
|
SessionSolution,
|
||||||
|
SessionSolver,
|
||||||
|
TaskEvaluation,
|
||||||
|
TaskEvaluator,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SessionSolution",
|
||||||
|
"SessionSolver",
|
||||||
|
"TaskEvaluation",
|
||||||
|
"TaskEvaluator",
|
||||||
|
]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Shared evaluator and final-solver interfaces."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
from ..domain import CalibrationProfile, SampleRecord, TaskSpec
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskEvaluation:
|
||||||
|
accepted: bool
|
||||||
|
failures: tuple[Mapping[str, Any], ...] = ()
|
||||||
|
rescan_measurements: frozenset[str] = frozenset()
|
||||||
|
rescan_cycles: frozenset[int] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SessionSolution:
|
||||||
|
passed: bool
|
||||||
|
calibration: Mapping[str, Any]
|
||||||
|
zero_offsets_rad: Mapping[str, float]
|
||||||
|
failures: tuple[Mapping[str, Any], ...] = ()
|
||||||
|
diagnostics: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskEvaluator(Protocol):
|
||||||
|
def evaluate_task(
|
||||||
|
self,
|
||||||
|
profile: CalibrationProfile,
|
||||||
|
task: TaskSpec,
|
||||||
|
samples: Sequence[SampleRecord],
|
||||||
|
) -> TaskEvaluation: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSolver(Protocol):
|
||||||
|
def solve_session(
|
||||||
|
self,
|
||||||
|
profile: CalibrationProfile,
|
||||||
|
samples: Sequence[SampleRecord],
|
||||||
|
) -> SessionSolution: ...
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""URDF correction authorization and validation types."""
|
||||||
|
|
||||||
|
from .plan import UrdfCorrectionPlan, build_correction_plan
|
||||||
|
from .patch import (
|
||||||
|
MujocoEqualityPatch,
|
||||||
|
UrdfJointPatch,
|
||||||
|
UrdfPatchSet,
|
||||||
|
apply_urdf_patch_text,
|
||||||
|
materialize_relative_mesh_assets,
|
||||||
|
write_urdf_patches,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MujocoEqualityPatch",
|
||||||
|
"UrdfCorrectionPlan",
|
||||||
|
"UrdfJointPatch",
|
||||||
|
"UrdfPatchSet",
|
||||||
|
"apply_urdf_patch_text",
|
||||||
|
"build_correction_plan",
|
||||||
|
"materialize_relative_mesh_assets",
|
||||||
|
"write_urdf_patches",
|
||||||
|
]
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Byte-preserving, declarative URDF patch application.
|
||||||
|
|
||||||
|
Model profiles decide *what* values are authorized. This module owns the
|
||||||
|
shared mechanics of locating those fields in the original XML text, changing
|
||||||
|
only the declared attributes, materializing mesh resources and atomically
|
||||||
|
publishing a new file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from typing import Mapping, Sequence
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UrdfJointPatch:
|
||||||
|
"""Authorized attribute replacements inside one top-level URDF joint."""
|
||||||
|
|
||||||
|
origin_rpy: str | None = None
|
||||||
|
limit_lower: str | None = None
|
||||||
|
limit_upper: str | None = None
|
||||||
|
mimic_multiplier: str | None = None
|
||||||
|
mimic_offset: str | None = None
|
||||||
|
|
||||||
|
def replacements(self) -> tuple[tuple[str, str, str], ...]:
|
||||||
|
values = (
|
||||||
|
("origin", "rpy", self.origin_rpy),
|
||||||
|
("limit", "lower", self.limit_lower),
|
||||||
|
("limit", "upper", self.limit_upper),
|
||||||
|
("mimic", "multiplier", self.mimic_multiplier),
|
||||||
|
("mimic", "offset", self.mimic_offset),
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
(element, attribute, str(value))
|
||||||
|
for element, attribute, value in values
|
||||||
|
if value is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MujocoEqualityPatch:
|
||||||
|
"""Replacement and optional topology assertion for one equality joint."""
|
||||||
|
|
||||||
|
polycoef: str
|
||||||
|
expected_joint1: str | None = None
|
||||||
|
expected_joint2: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UrdfPatchSet:
|
||||||
|
"""Complete declarative edit set for one generated URDF."""
|
||||||
|
|
||||||
|
joints: Mapping[str, UrdfJointPatch]
|
||||||
|
mujoco_equalities: Mapping[str, MujocoEqualityPatch] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
empty = [
|
||||||
|
name for name, patch in self.joints.items() if not patch.replacements()
|
||||||
|
]
|
||||||
|
if empty:
|
||||||
|
raise ValueError(
|
||||||
|
"URDF joint patch contains no replacements: "
|
||||||
|
+ ",".join(sorted(empty))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_attribute(
|
||||||
|
block: str, element: str, attribute: str, value: str
|
||||||
|
) -> str:
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"(<{element}\b[^>]*\b{attribute}\s*=\s*)([\"'])"
|
||||||
|
rf"(?P<value>[^\"']*)\2",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
match = pattern.search(block)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"{element} has no {attribute} attribute")
|
||||||
|
start, end = match.span("value")
|
||||||
|
return block[:start] + str(value) + block[end:]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_urdf_patch_text(
|
||||||
|
original_text: str,
|
||||||
|
root: ET.Element,
|
||||||
|
patches: UrdfPatchSet,
|
||||||
|
) -> str:
|
||||||
|
"""Apply declared patches without serializing unaffected XML."""
|
||||||
|
top_level_joints = {
|
||||||
|
str(joint.get("name")): joint for joint in root.findall("joint")
|
||||||
|
}
|
||||||
|
missing_joints = set(patches.joints) - set(top_level_joints)
|
||||||
|
if missing_joints:
|
||||||
|
raise ValueError(
|
||||||
|
"source URDF is missing target joints: "
|
||||||
|
+ ",".join(sorted(missing_joints))
|
||||||
|
)
|
||||||
|
|
||||||
|
equality_nodes = {
|
||||||
|
str(joint.get("name")): joint
|
||||||
|
for joint in root.findall("./mujoco/equality/joint")
|
||||||
|
}
|
||||||
|
missing_equalities = set(patches.mujoco_equalities) - set(equality_nodes)
|
||||||
|
if missing_equalities:
|
||||||
|
raise ValueError(
|
||||||
|
"source URDF is missing MuJoCo equalities: "
|
||||||
|
+ ",".join(sorted(missing_equalities))
|
||||||
|
)
|
||||||
|
for name, patch in patches.mujoco_equalities.items():
|
||||||
|
node = equality_nodes[name]
|
||||||
|
if (
|
||||||
|
patch.expected_joint1 is not None
|
||||||
|
and node.get("joint1") != patch.expected_joint1
|
||||||
|
):
|
||||||
|
raise ValueError(f"MuJoCo equality joint1 differs for {name}")
|
||||||
|
if (
|
||||||
|
patch.expected_joint2 is not None
|
||||||
|
and node.get("joint2") != patch.expected_joint2
|
||||||
|
):
|
||||||
|
raise ValueError(f"MuJoCo equality joint2 differs for {name}")
|
||||||
|
|
||||||
|
# Requiring the URDF ``type`` attribute excludes transmission and MuJoCo
|
||||||
|
# elements which also use the tag name ``joint``.
|
||||||
|
joint_pattern = re.compile(
|
||||||
|
r"<joint\b(?=[^>]*\btype\s*=)[^>]*\bname\s*=\s*"
|
||||||
|
r"([\"'])(?P<name>[^\"']+)\1[^>]*>"
|
||||||
|
r".*?</joint>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
applied_joints: set[str] = set()
|
||||||
|
|
||||||
|
def replace_joint(match: re.Match[str]) -> str:
|
||||||
|
name = match.group("name")
|
||||||
|
patch = patches.joints.get(name)
|
||||||
|
if patch is None:
|
||||||
|
return match.group(0)
|
||||||
|
if name in applied_joints:
|
||||||
|
raise ValueError(f"duplicate top-level URDF joint text: {name}")
|
||||||
|
block = match.group(0)
|
||||||
|
for element, attribute, value in patch.replacements():
|
||||||
|
block = _replace_attribute(block, element, attribute, value)
|
||||||
|
applied_joints.add(name)
|
||||||
|
return block
|
||||||
|
|
||||||
|
corrected = joint_pattern.sub(replace_joint, original_text)
|
||||||
|
if applied_joints != set(patches.joints):
|
||||||
|
missing = set(patches.joints) - applied_joints
|
||||||
|
raise ValueError(
|
||||||
|
"could not locate every target joint in source URDF text: "
|
||||||
|
+ ",".join(sorted(missing))
|
||||||
|
)
|
||||||
|
|
||||||
|
applied_equalities: set[str] = set()
|
||||||
|
for name, patch in patches.mujoco_equalities.items():
|
||||||
|
equality_pattern = re.compile(
|
||||||
|
rf"(<joint\b[^>]*\bname\s*=\s*([\"']))"
|
||||||
|
rf"{re.escape(name)}\2[^>]*>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
matches = list(equality_pattern.finditer(corrected))
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise ValueError(f"could not uniquely locate MuJoCo equality {name}")
|
||||||
|
match = matches[0]
|
||||||
|
replacement = _replace_attribute(
|
||||||
|
match.group(0), "joint", "polycoef", patch.polycoef
|
||||||
|
)
|
||||||
|
corrected = corrected[: match.start()] + replacement + corrected[match.end() :]
|
||||||
|
applied_equalities.add(name)
|
||||||
|
if applied_equalities != set(patches.mujoco_equalities):
|
||||||
|
raise ValueError("could not apply every MuJoCo equality patch")
|
||||||
|
return corrected
|
||||||
|
|
||||||
|
|
||||||
|
def _files_have_identical_contents(left: Path, right: Path) -> bool:
|
||||||
|
if left.stat().st_size != right.stat().st_size:
|
||||||
|
return False
|
||||||
|
with left.open("rb") as left_stream, right.open("rb") as right_stream:
|
||||||
|
while True:
|
||||||
|
left_chunk = left_stream.read(1024 * 1024)
|
||||||
|
right_chunk = right_stream.read(1024 * 1024)
|
||||||
|
if left_chunk != right_chunk:
|
||||||
|
return False
|
||||||
|
if not left_chunk:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_relative_mesh_assets(
|
||||||
|
*, source: Path, output: Path, urdf_root: ET.Element
|
||||||
|
) -> tuple[Path, ...]:
|
||||||
|
"""Copy safe relative mesh resources beside the generated URDF."""
|
||||||
|
filenames = sorted(
|
||||||
|
{
|
||||||
|
str(mesh.get("filename", "")).strip()
|
||||||
|
for mesh in urdf_root.findall(".//mesh")
|
||||||
|
if str(mesh.get("filename", "")).strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
materialized: list[Path] = []
|
||||||
|
for filename in filenames:
|
||||||
|
if "://" in filename or filename.startswith("package:"):
|
||||||
|
continue
|
||||||
|
relative = Path(filename)
|
||||||
|
if relative.is_absolute() or ".." in relative.parts:
|
||||||
|
raise ValueError(
|
||||||
|
f"URDF mesh path must be a safe relative path or URI: {filename}"
|
||||||
|
)
|
||||||
|
source_asset = (source.parent / relative).resolve()
|
||||||
|
if not source_asset.is_file():
|
||||||
|
raise ValueError(f"URDF mesh resource does not exist: {source_asset}")
|
||||||
|
destination_asset = (output / relative).resolve()
|
||||||
|
try:
|
||||||
|
destination_asset.relative_to(output)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(
|
||||||
|
f"URDF mesh destination escapes output directory: {filename}"
|
||||||
|
) from error
|
||||||
|
if destination_asset == source_asset:
|
||||||
|
materialized.append(destination_asset)
|
||||||
|
continue
|
||||||
|
destination_asset.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if destination_asset.exists():
|
||||||
|
if not destination_asset.is_file() or not _files_have_identical_contents(
|
||||||
|
source_asset, destination_asset
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"refusing to overwrite a different mesh resource: "
|
||||||
|
f"{destination_asset}"
|
||||||
|
)
|
||||||
|
materialized.append(destination_asset)
|
||||||
|
continue
|
||||||
|
temporary_asset = destination_asset.with_name(
|
||||||
|
f".{destination_asset.name}.{os.getpid()}.tmp"
|
||||||
|
)
|
||||||
|
if temporary_asset.exists():
|
||||||
|
raise ValueError(f"temporary mesh path is occupied: {temporary_asset}")
|
||||||
|
try:
|
||||||
|
shutil.copy2(source_asset, temporary_asset)
|
||||||
|
os.replace(temporary_asset, destination_asset)
|
||||||
|
finally:
|
||||||
|
if temporary_asset.exists():
|
||||||
|
temporary_asset.unlink()
|
||||||
|
materialized.append(destination_asset)
|
||||||
|
return tuple(materialized)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_complete_mesh_directory(source: Path, output: Path) -> None:
|
||||||
|
source_meshes = source.parent / "meshes"
|
||||||
|
if not source_meshes.is_dir():
|
||||||
|
return
|
||||||
|
destination_meshes = output / "meshes"
|
||||||
|
destination_meshes.mkdir(parents=True, exist_ok=True)
|
||||||
|
for mesh in source_meshes.iterdir():
|
||||||
|
if mesh.is_file():
|
||||||
|
shutil.copy2(mesh, destination_meshes / mesh.name)
|
||||||
|
|
||||||
|
|
||||||
|
def write_urdf_patches(
|
||||||
|
*,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
destination_urdf: str | Path,
|
||||||
|
patches: UrdfPatchSet,
|
||||||
|
forbidden_source_stem_patterns: Sequence[str] = (),
|
||||||
|
copy_complete_mesh_directory: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Validate and atomically materialize one patched URDF."""
|
||||||
|
source = Path(source_urdf).expanduser().resolve()
|
||||||
|
destination = Path(destination_urdf).expanduser().resolve()
|
||||||
|
if not source.is_file():
|
||||||
|
raise ValueError(f"source URDF does not exist: {source}")
|
||||||
|
for pattern in forbidden_source_stem_patterns:
|
||||||
|
if re.search(str(pattern), source.stem, re.IGNORECASE):
|
||||||
|
raise ValueError(
|
||||||
|
"source URDF must be the immutable original CAD URDF"
|
||||||
|
)
|
||||||
|
if destination == source or destination.exists():
|
||||||
|
raise ValueError(f"refusing to overwrite URDF: {destination}")
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tree = ET.parse(source)
|
||||||
|
root = tree.getroot()
|
||||||
|
corrected = apply_urdf_patch_text(
|
||||||
|
source.read_text(encoding="utf-8"), root, patches
|
||||||
|
)
|
||||||
|
materialize_relative_mesh_assets(
|
||||||
|
source=source, output=destination.parent, urdf_root=root
|
||||||
|
)
|
||||||
|
if copy_complete_mesh_directory:
|
||||||
|
_copy_complete_mesh_directory(source, destination.parent)
|
||||||
|
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
||||||
|
try:
|
||||||
|
with temporary.open("w", encoding="utf-8") as stream:
|
||||||
|
stream.write(corrected)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
finally:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MujocoEqualityPatch",
|
||||||
|
"UrdfJointPatch",
|
||||||
|
"UrdfPatchSet",
|
||||||
|
"apply_urdf_patch_text",
|
||||||
|
"materialize_relative_mesh_assets",
|
||||||
|
"write_urdf_patches",
|
||||||
|
]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""One authorization plan shared by URDF writers and validators."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from ..domain import CalibrationProfile
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UrdfCorrectionPlan:
|
||||||
|
source_sha256: str
|
||||||
|
allowed_active_joints: frozenset[str]
|
||||||
|
endpoint_limit_joints: frozenset[str]
|
||||||
|
mimic_source_by_joint: Mapping[str, str]
|
||||||
|
frozen_joints: frozenset[str]
|
||||||
|
frozen_offsets_rad: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
forbid_calibrated_source: bool = True
|
||||||
|
forbid_overwrite: bool = True
|
||||||
|
preserve_passive_joints: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if len(self.source_sha256) != 64 or any(
|
||||||
|
character not in "0123456789abcdef"
|
||||||
|
for character in self.source_sha256.lower()
|
||||||
|
):
|
||||||
|
raise ValueError("source URDF SHA-256 is invalid")
|
||||||
|
if self.allowed_active_joints & self.frozen_joints:
|
||||||
|
raise ValueError("allowed and frozen URDF joints overlap")
|
||||||
|
applied = self.allowed_active_joints | set(self.frozen_offsets_rad)
|
||||||
|
if not set(self.frozen_offsets_rad).issubset(self.frozen_joints):
|
||||||
|
raise ValueError("frozen offsets must belong to frozen joints")
|
||||||
|
if not self.endpoint_limit_joints.issubset(applied):
|
||||||
|
raise ValueError("endpoint limit joint is not an applied active joint")
|
||||||
|
if set(self.mimic_source_by_joint) & self.allowed_active_joints:
|
||||||
|
raise ValueError("dependent mimic joints cannot be active edit targets")
|
||||||
|
|
||||||
|
def authorize_offsets(self, offsets_rad: Mapping[str, float]) -> None:
|
||||||
|
required = self.allowed_active_joints | set(self.frozen_offsets_rad)
|
||||||
|
unexpected = set(offsets_rad) - required
|
||||||
|
if unexpected:
|
||||||
|
raise ValueError(
|
||||||
|
"URDF correction contains unauthorized joints: "
|
||||||
|
+ ", ".join(sorted(unexpected))
|
||||||
|
)
|
||||||
|
missing = required - set(offsets_rad)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
"URDF correction is missing active joints: "
|
||||||
|
+ ", ".join(sorted(missing))
|
||||||
|
)
|
||||||
|
changed_frozen = {
|
||||||
|
name
|
||||||
|
for name, expected in self.frozen_offsets_rad.items()
|
||||||
|
if abs(float(offsets_rad[name]) - float(expected)) > 1.0e-12
|
||||||
|
}
|
||||||
|
if changed_frozen:
|
||||||
|
raise ValueError(
|
||||||
|
"URDF correction changed frozen offsets: "
|
||||||
|
+ ", ".join(sorted(changed_frozen))
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_source(self, source_urdf: str | Path) -> None:
|
||||||
|
digest = hashlib.sha256(Path(source_urdf).read_bytes()).hexdigest()
|
||||||
|
if digest != self.source_sha256.lower():
|
||||||
|
raise ValueError("source URDF SHA-256 differs from correction plan")
|
||||||
|
|
||||||
|
|
||||||
|
def build_correction_plan(
|
||||||
|
profile: CalibrationProfile,
|
||||||
|
*,
|
||||||
|
source_sha256: str,
|
||||||
|
scope: str,
|
||||||
|
frozen_offsets_rad: Mapping[str, float] | None = None,
|
||||||
|
) -> UrdfCorrectionPlan:
|
||||||
|
"""Build one scope-aware edit authorization from typed policies."""
|
||||||
|
selected = profile.scope.selected_joints(scope)
|
||||||
|
frozen = profile.scope.frozen_joints[str(scope)]
|
||||||
|
expected_frozen = {
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in dict(frozen_offsets_rad or {}).items()
|
||||||
|
}
|
||||||
|
if set(expected_frozen) != set(frozen):
|
||||||
|
missing = set(frozen) - set(expected_frozen)
|
||||||
|
extra = set(expected_frozen) - set(frozen)
|
||||||
|
raise ValueError(
|
||||||
|
"frozen URDF offset state differs from scope policy: "
|
||||||
|
f"missing={','.join(sorted(missing)) or '-'};"
|
||||||
|
f"extra={','.join(sorted(extra)) or '-'}"
|
||||||
|
)
|
||||||
|
applied = selected | frozen
|
||||||
|
return UrdfCorrectionPlan(
|
||||||
|
source_sha256=source_sha256,
|
||||||
|
allowed_active_joints=selected,
|
||||||
|
endpoint_limit_joints=(
|
||||||
|
profile.zero.mechanical_endpoint_joints & applied
|
||||||
|
),
|
||||||
|
mimic_source_by_joint=profile.zero.mimic_source_by_joint,
|
||||||
|
frozen_joints=frozen | profile.zero.cad_frozen_joints,
|
||||||
|
frozen_offsets_rad=expected_frozen,
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Three-view compatibility policy over generic camera extrinsics."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .core.geometry.extrinsics import (
|
||||||
|
CameraCalibrationIdentity,
|
||||||
|
CameraExtrinsics,
|
||||||
|
camera_info_fingerprint,
|
||||||
|
load_camera_extrinsics,
|
||||||
|
matrix_payload,
|
||||||
|
transform_matrix,
|
||||||
|
validate_camera_extrinsics_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VIEWS: tuple[str, ...] = ("front", "side", "top")
|
||||||
|
_QUALITY_LIMITS = {
|
||||||
|
"reprojection_rms_px": 1.2,
|
||||||
|
"maximum_rotation_repeatability_deg": 0.3,
|
||||||
|
"maximum_translation_repeatability_m": 0.0015,
|
||||||
|
}
|
||||||
|
_MINIMUM_CAPTURE_COUNTS = {
|
||||||
|
"front_side_captures": 15,
|
||||||
|
"front_top_captures": 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThreeCameraExtrinsics:
|
||||||
|
"""Transforms points from each camera optical frame into front optical."""
|
||||||
|
|
||||||
|
cameras: Mapping[str, CameraCalibrationIdentity]
|
||||||
|
front_from_view: Mapping[str, np.ndarray]
|
||||||
|
quality: Mapping[str, float]
|
||||||
|
|
||||||
|
def transform(self, view: str) -> np.ndarray:
|
||||||
|
if view not in self.front_from_view:
|
||||||
|
raise KeyError(f"extrinsics do not contain view {view}")
|
||||||
|
return np.asarray(self.front_from_view[view], dtype=float).copy()
|
||||||
|
|
||||||
|
def camera_matches(
|
||||||
|
self,
|
||||||
|
view: str,
|
||||||
|
*,
|
||||||
|
serial_number: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
intrinsics_sha256: str,
|
||||||
|
) -> bool:
|
||||||
|
expected = self.cameras.get(view)
|
||||||
|
return bool(
|
||||||
|
expected is not None
|
||||||
|
and expected.serial_number == str(serial_number)
|
||||||
|
and expected.width == int(width)
|
||||||
|
and expected.height == int(height)
|
||||||
|
and expected.intrinsics_sha256 == str(intrinsics_sha256)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_extrinsics_payload(payload: Mapping[str, Any]) -> None:
|
||||||
|
validate_camera_extrinsics_payload(
|
||||||
|
payload,
|
||||||
|
required_views=VIEWS,
|
||||||
|
reference_view="front",
|
||||||
|
quality_limits=_QUALITY_LIMITS,
|
||||||
|
minimum_capture_counts=_MINIMUM_CAPTURE_COUNTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_three_camera_extrinsics(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
quality_limits: Mapping[str, float] | None = None,
|
||||||
|
minimum_capture_counts: Mapping[str, int] | None = None,
|
||||||
|
) -> ThreeCameraExtrinsics:
|
||||||
|
generic = load_camera_extrinsics(
|
||||||
|
path,
|
||||||
|
required_views=VIEWS,
|
||||||
|
reference_view="front",
|
||||||
|
quality_limits=(
|
||||||
|
_QUALITY_LIMITS if quality_limits is None else quality_limits
|
||||||
|
),
|
||||||
|
minimum_capture_counts=(
|
||||||
|
_MINIMUM_CAPTURE_COUNTS
|
||||||
|
if minimum_capture_counts is None
|
||||||
|
else minimum_capture_counts
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ThreeCameraExtrinsics(
|
||||||
|
cameras=generic.cameras,
|
||||||
|
front_from_view=generic.reference_from_view,
|
||||||
|
quality=generic.quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dump_three_camera_extrinsics(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
cameras: Mapping[str, Mapping[str, Any]],
|
||||||
|
front_from_view: Mapping[str, Sequence[Sequence[float]]],
|
||||||
|
quality: Mapping[str, Any],
|
||||||
|
quality_limits: Mapping[str, float] | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"reference_view": "front",
|
||||||
|
"cameras": {view: dict(cameras[view]) for view in VIEWS},
|
||||||
|
"front_from_view": {
|
||||||
|
view: matrix_payload(front_from_view[view]) for view in VIEWS
|
||||||
|
},
|
||||||
|
"quality": dict(quality),
|
||||||
|
}
|
||||||
|
validate_camera_extrinsics_payload(
|
||||||
|
payload,
|
||||||
|
required_views=VIEWS,
|
||||||
|
reference_view="front",
|
||||||
|
quality_limits=(
|
||||||
|
_QUALITY_LIMITS if quality_limits is None else quality_limits
|
||||||
|
),
|
||||||
|
minimum_capture_counts=_MINIMUM_CAPTURE_COUNTS,
|
||||||
|
)
|
||||||
|
destination = Path(path).expanduser().resolve()
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
||||||
|
with temporary.open("w", encoding="utf-8") as stream:
|
||||||
|
yaml.safe_dump(payload, stream, allow_unicode=True, sort_keys=False)
|
||||||
|
temporary.replace(destination)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CameraCalibrationIdentity",
|
||||||
|
"CameraExtrinsics",
|
||||||
|
"ThreeCameraExtrinsics",
|
||||||
|
"VIEWS",
|
||||||
|
"camera_info_fingerprint",
|
||||||
|
"dump_three_camera_extrinsics",
|
||||||
|
"load_camera_extrinsics",
|
||||||
|
"load_three_camera_extrinsics",
|
||||||
|
"matrix_payload",
|
||||||
|
"transform_matrix",
|
||||||
|
"validate_camera_extrinsics_payload",
|
||||||
|
"validate_extrinsics_payload",
|
||||||
|
]
|
||||||
+36
-5
@@ -605,6 +605,9 @@ class ThreeCameraExtrinsicsNode(Node):
|
|||||||
self.declare_parameter("minimum_captures_per_pair", 15)
|
self.declare_parameter("minimum_captures_per_pair", 15)
|
||||||
self.declare_parameter("maximum_pair_skew_ms", 100.0)
|
self.declare_parameter("maximum_pair_skew_ms", 100.0)
|
||||||
self.declare_parameter("maximum_reprojection_rms_px", 1.2)
|
self.declare_parameter("maximum_reprojection_rms_px", 1.2)
|
||||||
|
self.declare_parameter(
|
||||||
|
"maximum_candidate_pair_reprojection_rms_px", 1.5
|
||||||
|
)
|
||||||
self.declare_parameter(
|
self.declare_parameter(
|
||||||
"maximum_single_camera_reprojection_rms_px", 1.5
|
"maximum_single_camera_reprojection_rms_px", 1.5
|
||||||
)
|
)
|
||||||
@@ -640,6 +643,9 @@ class ThreeCameraExtrinsicsNode(Node):
|
|||||||
self.maximum_reprojection_rms_px = float(
|
self.maximum_reprojection_rms_px = float(
|
||||||
value("maximum_reprojection_rms_px")
|
value("maximum_reprojection_rms_px")
|
||||||
)
|
)
|
||||||
|
self.maximum_candidate_pair_reprojection_rms_px = float(
|
||||||
|
value("maximum_candidate_pair_reprojection_rms_px")
|
||||||
|
)
|
||||||
self.maximum_single_camera_reprojection_rms_px = float(
|
self.maximum_single_camera_reprojection_rms_px = float(
|
||||||
value("maximum_single_camera_reprojection_rms_px")
|
value("maximum_single_camera_reprojection_rms_px")
|
||||||
)
|
)
|
||||||
@@ -671,13 +677,21 @@ class ThreeCameraExtrinsicsNode(Node):
|
|||||||
if self.maximum_reprojection_rms_px <= 0.0:
|
if self.maximum_reprojection_rms_px <= 0.0:
|
||||||
raise ValueError("maximum_reprojection_rms_px must be positive")
|
raise ValueError("maximum_reprojection_rms_px must be positive")
|
||||||
if (
|
if (
|
||||||
self.maximum_single_camera_reprojection_rms_px
|
self.maximum_candidate_pair_reprojection_rms_px
|
||||||
< self.maximum_reprojection_rms_px
|
< self.maximum_reprojection_rms_px
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"maximum_single_camera_reprojection_rms_px must be at "
|
"maximum_candidate_pair_reprojection_rms_px must be at "
|
||||||
"least maximum_reprojection_rms_px"
|
"least maximum_reprojection_rms_px"
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
self.maximum_single_camera_reprojection_rms_px
|
||||||
|
< self.maximum_candidate_pair_reprojection_rms_px
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"maximum_single_camera_reprojection_rms_px must be at "
|
||||||
|
"least maximum_candidate_pair_reprojection_rms_px"
|
||||||
|
)
|
||||||
if self.gui_refresh_hz <= 0.0 or self.gui_refresh_hz > 10.0:
|
if self.gui_refresh_hz <= 0.0 or self.gui_refresh_hz > 10.0:
|
||||||
raise ValueError("gui_refresh_hz must be in (0, 10]")
|
raise ValueError("gui_refresh_hz must be in (0, 10]")
|
||||||
if self.auto_capture_stable_seconds < 0.5:
|
if self.auto_capture_stable_seconds < 0.5:
|
||||||
@@ -869,14 +883,20 @@ class ThreeCameraExtrinsicsNode(Node):
|
|||||||
selected.reprojection_rms_px,
|
selected.reprojection_rms_px,
|
||||||
self.maximum_single_camera_reprojection_rms_px,
|
self.maximum_single_camera_reprojection_rms_px,
|
||||||
)
|
)
|
||||||
if camera_rms_failed or pair_rms > self.maximum_reprojection_rms_px:
|
if (
|
||||||
|
camera_rms_failed
|
||||||
|
or pair_rms
|
||||||
|
> self.maximum_candidate_pair_reprojection_rms_px
|
||||||
|
):
|
||||||
return PairAssessment(
|
return PairAssessment(
|
||||||
ready=False,
|
ready=False,
|
||||||
reason_code="reprojection_rms_too_large",
|
reason_code="reprojection_rms_too_large",
|
||||||
message=(
|
message=(
|
||||||
f"当前组未通过采集门限,未计入。单相机上限="
|
f"当前组未通过采集门限,未计入。单相机上限="
|
||||||
f"{self.maximum_single_camera_reprojection_rms_px:.3f}px,"
|
f"{self.maximum_single_camera_reprojection_rms_px:.3f}px,"
|
||||||
f"组合上限={self.maximum_reprojection_rms_px:.3f}px;"
|
"候选组合上限="
|
||||||
|
f"{self.maximum_candidate_pair_reprojection_rms_px:.3f}px,"
|
||||||
|
f"最终批次上限={self.maximum_reprojection_rms_px:.3f}px;"
|
||||||
f"组合RMS={pair_rms:.3f}px,"
|
f"组合RMS={pair_rms:.3f}px,"
|
||||||
f"front={front.reprojection_rms_px:.3f}px,"
|
f"front={front.reprojection_rms_px:.3f}px,"
|
||||||
f"{other}={selected.reprojection_rms_px:.3f}px;"
|
f"{other}={selected.reprojection_rms_px:.3f}px;"
|
||||||
@@ -1352,7 +1372,9 @@ class ThreeCameraExtrinsicsNode(Node):
|
|||||||
)
|
)
|
||||||
cv2.putText(
|
cv2.putText(
|
||||||
canvas,
|
canvas,
|
||||||
f"Pair RMS {pair_rms}/{self.maximum_reprojection_rms_px:.3f}px "
|
"Pair RMS "
|
||||||
|
f"{pair_rms}/"
|
||||||
|
f"{self.maximum_candidate_pair_reprojection_rms_px:.3f}px "
|
||||||
f"Skew {skew}/{self.maximum_pair_skew_ns / 1e6:.1f}ms "
|
f"Skew {skew}/{self.maximum_pair_skew_ns / 1e6:.1f}ms "
|
||||||
f"Batch RMS {batch_rms}/{self.maximum_reprojection_rms_px:.3f}px "
|
f"Batch RMS {batch_rms}/{self.maximum_reprojection_rms_px:.3f}px "
|
||||||
f"stability {rotation}/"
|
f"stability {rotation}/"
|
||||||
@@ -1526,6 +1548,15 @@ class ThreeCameraExtrinsicsNode(Node):
|
|||||||
cameras=self.camera_identities,
|
cameras=self.camera_identities,
|
||||||
front_from_view=transforms,
|
front_from_view=transforms,
|
||||||
quality=quality,
|
quality=quality,
|
||||||
|
quality_limits={
|
||||||
|
"reprojection_rms_px": self.maximum_reprojection_rms_px,
|
||||||
|
"maximum_rotation_repeatability_deg": math.degrees(
|
||||||
|
self.maximum_rotation_repeatability_rad
|
||||||
|
),
|
||||||
|
"maximum_translation_repeatability_m": (
|
||||||
|
self.maximum_translation_repeatability_m
|
||||||
|
),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
response.success = True
|
response.success = True
|
||||||
response.message = f"外参已保存:{self.output_file};{quality}"
|
response.message = f"外参已保存:{self.output_file};{quality}"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""One-release module alias for the relocated model profile implementation."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .models.g20 import profile as _implementation
|
||||||
|
|
||||||
|
sys.modules[__name__] = _implementation
|
||||||
+1
-1
@@ -203,7 +203,7 @@ def configure_fastdds_large_image_transport() -> Path:
|
|||||||
from ament_index_python.packages import get_package_share_directory
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
profile = (
|
profile = (
|
||||||
Path(get_package_share_directory("g20_thumb_apriltag_calibration"))
|
Path(get_package_share_directory("linkerhand_calibration"))
|
||||||
/ "config"
|
/ "config"
|
||||||
/ "fastdds_large_images.xml"
|
/ "fastdds_large_images.xml"
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Model- and side-specific calibration policies."""
|
||||||
|
|
||||||
|
from .registry import (
|
||||||
|
EngineBindings,
|
||||||
|
ProfileRegistry,
|
||||||
|
RegisteredProfile,
|
||||||
|
get_default_registry,
|
||||||
|
)
|
||||||
|
from .runtime_schema import validate_schema_v6_runtime_payload
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EngineBindings",
|
||||||
|
"ProfileRegistry",
|
||||||
|
"RegisteredProfile",
|
||||||
|
"get_default_registry",
|
||||||
|
"validate_schema_v6_runtime_payload",
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Registered profiles for this hand family."""
|
||||||
|
|
||||||
|
from ..registry import ProfileRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def register_profiles(registry: ProfileRegistry) -> None:
|
||||||
|
from .legacy_11 import build_left_profile, build_right_profile
|
||||||
|
from .right_19 import build_profile
|
||||||
|
|
||||||
|
registry.register(build_profile())
|
||||||
|
registry.register(build_left_profile())
|
||||||
|
registry.register(build_right_profile())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["register_profiles"]
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Adapt reviewed family profiles to the shared typed contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...core import (
|
||||||
|
CalibrationProfile,
|
||||||
|
CommandLayout,
|
||||||
|
MeasurementPolicy,
|
||||||
|
MeasurementSpec,
|
||||||
|
MotionPolicy,
|
||||||
|
ProfileKey,
|
||||||
|
QualityPolicy,
|
||||||
|
ScopePolicy,
|
||||||
|
TagSpec,
|
||||||
|
TaskSpec,
|
||||||
|
ViewSpec,
|
||||||
|
VisionRigSpec,
|
||||||
|
ZeroSolvePolicy,
|
||||||
|
)
|
||||||
|
from .profile import MIMIC_DERIVED_FINGER_DIPS
|
||||||
|
from ..registry import EngineBindings, RegisteredProfile
|
||||||
|
from .artifacts import build_artifact_policy
|
||||||
|
from .motion import (
|
||||||
|
build_calibration_motion_command,
|
||||||
|
build_calibration_preparation_waypoints,
|
||||||
|
build_calibration_return_waypoints,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_HARD_THRESHOLD_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"minimum_detection_rate",
|
||||||
|
"maximum_reprojection_error_px",
|
||||||
|
"maximum_axis_cycle_difference_rad",
|
||||||
|
"maximum_pose_line_rms_m",
|
||||||
|
"maximum_hysteresis_rad",
|
||||||
|
"maximum_validation_error_rad",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(args: list[str] | None = None) -> None:
|
||||||
|
from .runner import main
|
||||||
|
|
||||||
|
main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_node(args: list[str] | None = None) -> None:
|
||||||
|
from .node import main
|
||||||
|
|
||||||
|
main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_profile(
|
||||||
|
*,
|
||||||
|
key: ProfileKey,
|
||||||
|
namespace: str,
|
||||||
|
hand_profile,
|
||||||
|
zero_profile,
|
||||||
|
mechanical_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
post_solve_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
) -> RegisteredProfile:
|
||||||
|
fixed_by_view = {
|
||||||
|
view: frozenset(roles)
|
||||||
|
for view, roles in hand_profile.preflight_view_roles.items()
|
||||||
|
}
|
||||||
|
views = tuple(
|
||||||
|
ViewSpec(
|
||||||
|
name=view,
|
||||||
|
tags=tuple(
|
||||||
|
TagSpec(
|
||||||
|
role=role,
|
||||||
|
tag_id=int(tag_id),
|
||||||
|
fixed_reference=role in fixed_by_view.get(view, frozenset()),
|
||||||
|
)
|
||||||
|
for role, tag_id in roles.items()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for view, roles in hand_profile.view_tags.items()
|
||||||
|
)
|
||||||
|
record_specs = hand_profile.record_specs
|
||||||
|
command_index_by_joint = {
|
||||||
|
name: int(spec.motor_index) for name, spec in record_specs.items()
|
||||||
|
}
|
||||||
|
command_index_by_joint.update(
|
||||||
|
{
|
||||||
|
name: int(spec.motor_index)
|
||||||
|
for name, spec in hand_profile.joint_specs.items()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
measurements = {
|
||||||
|
name: MeasurementSpec(
|
||||||
|
joint=name,
|
||||||
|
kind=str(spec.zero_kind or "curve"),
|
||||||
|
view=spec.view,
|
||||||
|
parent_role=spec.parent_role,
|
||||||
|
child_role=spec.child_role,
|
||||||
|
validation_source=(hand_profile.axis_validation_sources or {}).get(
|
||||||
|
name
|
||||||
|
),
|
||||||
|
pose_axis_line_required=bool(
|
||||||
|
spec.pose_axis_line_required
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, spec in record_specs.items()
|
||||||
|
}
|
||||||
|
tasks = tuple(
|
||||||
|
TaskSpec(
|
||||||
|
key=spec.key,
|
||||||
|
view=spec.view,
|
||||||
|
command_index=int(spec.motor_index),
|
||||||
|
joints=tuple(spec.joints),
|
||||||
|
auxiliary_commands=tuple(spec.auxiliary_commands),
|
||||||
|
validation_only=bool(spec.validation_only),
|
||||||
|
)
|
||||||
|
for spec in hand_profile.sweep_specs
|
||||||
|
)
|
||||||
|
active = frozenset(hand_profile.active_joints)
|
||||||
|
passive = frozenset(hand_profile.passive_joints)
|
||||||
|
thumb = frozenset(name for name in active if name.startswith("thumb_"))
|
||||||
|
fingers = active - thumb
|
||||||
|
typed = CalibrationProfile(
|
||||||
|
key=key,
|
||||||
|
namespace=namespace,
|
||||||
|
command=CommandLayout(
|
||||||
|
names=tuple(hand_profile.command_names),
|
||||||
|
baseline_u8=tuple(int(value) for value in hand_profile.baseline_command),
|
||||||
|
command_index_by_joint=command_index_by_joint,
|
||||||
|
disabled_indices=frozenset(
|
||||||
|
index
|
||||||
|
for index, name in enumerate(hand_profile.command_names)
|
||||||
|
if name.startswith("reserved_")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
vision=VisionRigSpec(
|
||||||
|
views=views,
|
||||||
|
common_frame="calibration_common",
|
||||||
|
extrinsic_reference_view=views[0].name,
|
||||||
|
extrinsics_quality_limits={
|
||||||
|
"reprojection_rms_px": 1.2,
|
||||||
|
"maximum_rotation_repeatability_deg": 0.3,
|
||||||
|
"maximum_translation_repeatability_m": 0.0015,
|
||||||
|
},
|
||||||
|
minimum_capture_counts={
|
||||||
|
"front_side_captures": 15,
|
||||||
|
"front_top_captures": 15,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
motion=MotionPolicy(
|
||||||
|
tasks=tasks,
|
||||||
|
precheck_sweeps=bool(hand_profile.precheck_sweeps),
|
||||||
|
steady_command_checkpoints=bool(
|
||||||
|
hand_profile.steady_command_checkpoints
|
||||||
|
),
|
||||||
|
),
|
||||||
|
measurement=MeasurementPolicy(
|
||||||
|
measurements=measurements,
|
||||||
|
cross_view_sources=dict(
|
||||||
|
hand_profile.axis_validation_sources or {}
|
||||||
|
),
|
||||||
|
image_curve_joints=frozenset(
|
||||||
|
hand_profile.image_trajectory_joints
|
||||||
|
),
|
||||||
|
directional_zero=bool(hand_profile.directional_zero),
|
||||||
|
cross_view_roll_curve=bool(hand_profile.cross_view_roll_curve),
|
||||||
|
stable_cross_view_cone_bias=bool(
|
||||||
|
hand_profile.stable_cross_view_cone_bias
|
||||||
|
),
|
||||||
|
),
|
||||||
|
zero=ZeroSolvePolicy(
|
||||||
|
active_joints=active,
|
||||||
|
passive_joints=passive,
|
||||||
|
direct_zero_joints=tuple(zero_profile.direct_zero_joints),
|
||||||
|
axis_joints=tuple(zero_profile.axis_joints),
|
||||||
|
mechanical_endpoint_joints=mechanical_endpoint_joints,
|
||||||
|
post_solve_endpoint_joints=post_solve_endpoint_joints,
|
||||||
|
mimic_source_by_joint={
|
||||||
|
target: source
|
||||||
|
for target, source in MIMIC_DERIVED_FINGER_DIPS.items()
|
||||||
|
if target in passive and source in active
|
||||||
|
},
|
||||||
|
cad_frozen_joints=frozenset(
|
||||||
|
passive - set(zero_profile.static_output_zero_offsets_rad)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
quality=QualityPolicy(
|
||||||
|
training_cycles=(0, 1, 2),
|
||||||
|
holdout_cycle=3 if key.layout != "legacy_11" else None,
|
||||||
|
hard_threshold_keys=_HARD_THRESHOLD_KEYS,
|
||||||
|
isolated_holdout=bool(hand_profile.isolated_holdout),
|
||||||
|
),
|
||||||
|
scope=ScopePolicy(
|
||||||
|
calibrate_joints={
|
||||||
|
"full": active,
|
||||||
|
"thumb": thumb,
|
||||||
|
"fingers": fingers,
|
||||||
|
},
|
||||||
|
frozen_joints={
|
||||||
|
"full": frozenset(),
|
||||||
|
"thumb": fingers,
|
||||||
|
"fingers": thumb,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
artifacts=build_artifact_policy(
|
||||||
|
frozenset(hand_profile.capabilities)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return RegisteredProfile(
|
||||||
|
profile=typed,
|
||||||
|
engine=EngineBindings(
|
||||||
|
hand_profile=hand_profile,
|
||||||
|
zero_profile=zero_profile,
|
||||||
|
motion_command=build_calibration_motion_command,
|
||||||
|
preparation_waypoints=build_calibration_preparation_waypoints,
|
||||||
|
return_waypoints=build_calibration_return_waypoints,
|
||||||
|
cli_main=_run_cli,
|
||||||
|
node_main=_run_node,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Runtime JSON and corrected-URDF naming policy."""
|
||||||
|
|
||||||
|
from ...core import ArtifactPolicy
|
||||||
|
|
||||||
|
|
||||||
|
def build_artifact_policy(
|
||||||
|
compatibility_tokens: frozenset[str],
|
||||||
|
) -> ArtifactPolicy:
|
||||||
|
return ArtifactPolicy(
|
||||||
|
output_schema_version=4,
|
||||||
|
calibration_filename="g20_{side}_{serial_number}_calibration.json",
|
||||||
|
corrected_urdf_filename=(
|
||||||
|
"linkerhand_g20_{side}_{serial_number}_zero_calibrated.urdf"
|
||||||
|
),
|
||||||
|
protected_input_fields=frozenset(
|
||||||
|
{
|
||||||
|
"source_urdf_sha256",
|
||||||
|
"camera_extrinsics_sha256",
|
||||||
|
"calibration_config_sha256",
|
||||||
|
"tag_config_sha256",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
session_compatibility_tokens=frozenset(compatibility_tokens),
|
||||||
|
publish_corrected_urdf=(
|
||||||
|
"urdf_zero_publication" in compatibility_tokens
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_artifact_policy"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Reviewed command-channel layouts for this hand family."""
|
||||||
|
|
||||||
|
G20_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",
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["G20_COMMAND_NAMES"]
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Read-only regression checks for the reviewed hardware sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_PASS_SESSIONS = {
|
||||||
|
"20260830_181154": "full",
|
||||||
|
"20260831_141123": "thumb",
|
||||||
|
"20260831_163843": "thumb",
|
||||||
|
}
|
||||||
|
_FAIL_SESSIONS = {
|
||||||
|
"20260831_111837": ("FIT-MODEL-401", "joint_fit_check_failed"),
|
||||||
|
"20260831_142322": ("VAL-QUALITY-501", "zero_model_validation_failed"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(session: Path) -> dict[str, Any]:
|
||||||
|
value = json.loads(
|
||||||
|
(session / "calibration_summary_zh.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"invalid golden summary: {session}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_golden_sessions(
|
||||||
|
serial_root: str | Path,
|
||||||
|
*,
|
||||||
|
replay_full_session: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Prove PASS/FAIL, hashes, partial freezes, and failure non-publication."""
|
||||||
|
root = Path(serial_root).expanduser().resolve()
|
||||||
|
results: dict[str, Any] = {}
|
||||||
|
for session_id, scope in _PASS_SESSIONS.items():
|
||||||
|
session = root / session_id
|
||||||
|
summary = _summary(session)
|
||||||
|
if summary.get("result") != "PASS":
|
||||||
|
raise ValueError(f"golden PASS changed: {session_id}")
|
||||||
|
if summary.get("calibration_scope") != scope:
|
||||||
|
raise ValueError(f"golden scope changed: {session_id}")
|
||||||
|
artifacts = summary["artifacts"]
|
||||||
|
hashes = summary["hashes"]
|
||||||
|
calibration_json = session / artifacts["json"]
|
||||||
|
corrected_urdf = session / artifacts["urdf"]
|
||||||
|
actual_json_hash = _sha256_file(calibration_json)
|
||||||
|
actual_urdf_hash = _sha256_file(corrected_urdf)
|
||||||
|
if actual_json_hash != hashes["calibration_json_sha256"]:
|
||||||
|
raise ValueError(f"golden JSON hash changed: {session_id}")
|
||||||
|
if actual_urdf_hash != hashes["corrected_urdf_sha256"]:
|
||||||
|
raise ValueError(f"golden URDF hash changed: {session_id}")
|
||||||
|
if session_id == "20260831_141123" and len(
|
||||||
|
summary.get("preserved_certified_zero_joints", ())
|
||||||
|
) != 12:
|
||||||
|
raise ValueError("merged thumb no longer freezes all finger zeros")
|
||||||
|
if session_id == "20260831_163843":
|
||||||
|
payload = json.loads(calibration_json.read_text(encoding="utf-8"))
|
||||||
|
if payload.get("artifact_type") != (
|
||||||
|
"g20_right_standalone_thumb_calibration"
|
||||||
|
):
|
||||||
|
raise ValueError("standalone thumb artifact type changed")
|
||||||
|
results[session_id] = {
|
||||||
|
"result": "PASS",
|
||||||
|
"json_sha256": actual_json_hash,
|
||||||
|
"urdf_sha256": actual_urdf_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
for session_id, (error_code, reason) in _FAIL_SESSIONS.items():
|
||||||
|
session = root / session_id
|
||||||
|
summary = _summary(session)
|
||||||
|
if (
|
||||||
|
summary.get("result") != "FAIL"
|
||||||
|
or summary.get("error_code") != error_code
|
||||||
|
or summary.get("reason") != reason
|
||||||
|
):
|
||||||
|
raise ValueError(f"golden failure decision changed: {session_id}")
|
||||||
|
results[session_id] = {
|
||||||
|
"result": "FAIL",
|
||||||
|
"error_code": error_code,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
failure_ids = set(_FAIL_SESSIONS)
|
||||||
|
for pointer_name in ("latest_passed", "latest_thumb_passed"):
|
||||||
|
pointer = root / pointer_name
|
||||||
|
if pointer.exists() and pointer.resolve().name in failure_ids:
|
||||||
|
raise ValueError(f"failure session was published through {pointer_name}")
|
||||||
|
|
||||||
|
if replay_full_session:
|
||||||
|
from .offline_replay import replay_session
|
||||||
|
|
||||||
|
session_id = "20260830_181154"
|
||||||
|
replay = replay_session(root / session_id, write_outputs=False)
|
||||||
|
expected = results[session_id]
|
||||||
|
if replay["computed_final_json_sha256"] != expected["json_sha256"]:
|
||||||
|
raise ValueError(
|
||||||
|
"full-session replay JSON is not byte-identical: "
|
||||||
|
f"computed={replay['computed_final_json_sha256']} "
|
||||||
|
f"expected={expected['json_sha256']}"
|
||||||
|
)
|
||||||
|
if replay["corrected_urdf_sha256"] != expected["urdf_sha256"]:
|
||||||
|
raise ValueError(
|
||||||
|
"full-session replay URDF is not byte-identical: "
|
||||||
|
f"computed={replay['corrected_urdf_sha256']} "
|
||||||
|
f"expected={expected['urdf_sha256']}"
|
||||||
|
)
|
||||||
|
results[session_id]["offline_replay"] = "byte_identical"
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main(args: list[str] | None = None) -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Validate the five reviewed calibration sessions"
|
||||||
|
)
|
||||||
|
parser.add_argument("serial_root")
|
||||||
|
parser.add_argument("--no-replay", action="store_true")
|
||||||
|
selected = parser.parse_args(args)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
validate_golden_sessions(
|
||||||
|
selected.serial_root,
|
||||||
|
replay_full_session=not selected.no_replay,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""One-release typed wrappers for the legacy 11-Tag layouts."""
|
||||||
|
|
||||||
|
from ...core import ProfileKey
|
||||||
|
from .profile import get_hand_calibration_profile
|
||||||
|
from ...urdf_zero import get_zero_calibration_profile
|
||||||
|
from ..registry import RegisteredProfile
|
||||||
|
from ._adapter import adapt_profile
|
||||||
|
|
||||||
|
|
||||||
|
LEFT_KEY = ProfileKey("G20", "left", "legacy_11", 1)
|
||||||
|
RIGHT_KEY = ProfileKey("G20", "right", "legacy_11", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_left_profile() -> RegisteredProfile:
|
||||||
|
hand = get_hand_calibration_profile(LEFT_KEY.side, LEFT_KEY.layout)
|
||||||
|
return adapt_profile(
|
||||||
|
key=LEFT_KEY,
|
||||||
|
namespace="/g20_calibration",
|
||||||
|
hand_profile=hand,
|
||||||
|
zero_profile=get_zero_calibration_profile(
|
||||||
|
LEFT_KEY.side, LEFT_KEY.layout
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_right_profile() -> RegisteredProfile:
|
||||||
|
hand = get_hand_calibration_profile(RIGHT_KEY.side, RIGHT_KEY.layout)
|
||||||
|
return adapt_profile(
|
||||||
|
key=RIGHT_KEY,
|
||||||
|
namespace="/g20_calibration",
|
||||||
|
hand_profile=hand,
|
||||||
|
zero_profile=get_zero_calibration_profile(
|
||||||
|
RIGHT_KEY.side, RIGHT_KEY.layout
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Reviewed motion and safe-waypoint strategy exports."""
|
||||||
|
|
||||||
|
from .profile import (
|
||||||
|
build_calibration_motion_command,
|
||||||
|
build_calibration_preparation_waypoints,
|
||||||
|
build_calibration_return_waypoints,
|
||||||
|
build_calibration_speed_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_calibration_motion_command",
|
||||||
|
"build_calibration_preparation_waypoints",
|
||||||
|
"build_calibration_return_waypoints",
|
||||||
|
"build_calibration_speed_profile",
|
||||||
|
]
|
||||||
+317
-29
@@ -26,7 +26,7 @@ from sensor_msgs.msg import CameraInfo, JointState
|
|||||||
from std_msgs.msg import String
|
from std_msgs.msg import String
|
||||||
from std_srvs.srv import Trigger
|
from std_srvs.srv import Trigger
|
||||||
|
|
||||||
from .acquisition import (
|
from ...acquisition import (
|
||||||
StateSample,
|
StateSample,
|
||||||
TagQuality,
|
TagQuality,
|
||||||
interpolate_state_u8,
|
interpolate_state_u8,
|
||||||
@@ -34,20 +34,20 @@ from .acquisition import (
|
|||||||
tag_quality_is_valid,
|
tag_quality_is_valid,
|
||||||
update_pnp_reset_watchdog,
|
update_pnp_reset_watchdog,
|
||||||
)
|
)
|
||||||
from .core import (
|
from ...core import (
|
||||||
COMMAND_NAMES,
|
|
||||||
DIRECTION_DECREASING,
|
DIRECTION_DECREASING,
|
||||||
DIRECTION_INCREASING,
|
DIRECTION_INCREASING,
|
||||||
robust_rotation_summary,
|
robust_rotation_summary,
|
||||||
)
|
)
|
||||||
from .extrinsics import (
|
from ...core.urdf import build_correction_plan
|
||||||
|
from ...extrinsics import (
|
||||||
ThreeCameraExtrinsics,
|
ThreeCameraExtrinsics,
|
||||||
camera_info_fingerprint,
|
camera_info_fingerprint,
|
||||||
load_three_camera_extrinsics,
|
load_three_camera_extrinsics,
|
||||||
matrix_payload,
|
matrix_payload,
|
||||||
transform_matrix,
|
transform_matrix,
|
||||||
)
|
)
|
||||||
from .full_hand import (
|
from .profile import (
|
||||||
G20_COMBINATION_REQUIRED_TARGET_KEYS,
|
G20_COMBINATION_REQUIRED_TARGET_KEYS,
|
||||||
G20_REFERENCE_THUMB_CMC_JOINTS,
|
G20_REFERENCE_THUMB_CMC_JOINTS,
|
||||||
G20_RIGHT_19_LAYOUT,
|
G20_RIGHT_19_LAYOUT,
|
||||||
@@ -73,23 +73,28 @@ from .full_hand import (
|
|||||||
fit_joint_image_curve,
|
fit_joint_image_curve,
|
||||||
get_hand_calibration_profile,
|
get_hand_calibration_profile,
|
||||||
)
|
)
|
||||||
from .hikrobot_camera import configure_fastdds_large_image_transport
|
from ...hikrobot_camera import configure_fastdds_large_image_transport
|
||||||
from .pnp import (
|
from .command_layout import G20_COMMAND_NAMES as COMMAND_NAMES
|
||||||
|
from ...pnp import (
|
||||||
SquareTagGroupPoseTracker,
|
SquareTagGroupPoseTracker,
|
||||||
SquareTagPose,
|
SquareTagPose,
|
||||||
SquareTagPoseTracker,
|
SquareTagPoseTracker,
|
||||||
)
|
)
|
||||||
from .product import get_product_calibration_contract
|
from ...product import get_product_calibration_contract
|
||||||
from .sample_schema import (
|
from ...sample_schema import (
|
||||||
SampleDataContractError,
|
SampleDataContractError,
|
||||||
canonical_sample_record,
|
canonical_sample_record,
|
||||||
fitting_sample_record,
|
fitting_sample_record,
|
||||||
fitting_sample_records,
|
fitting_sample_records,
|
||||||
validate_sample_records,
|
validate_sample_records,
|
||||||
)
|
)
|
||||||
from .storage import append_jsonl, append_jsonl_many, atomic_write_json
|
from ...storage import append_jsonl, append_jsonl_many, atomic_write_json
|
||||||
from .three_camera_diagnostics import render_three_camera_status_text_zh
|
from .reporting_zh import render_three_camera_status_text_zh
|
||||||
from .urdf_zero import (
|
from .urdf_input import (
|
||||||
|
build_g20_urdf_input_payload,
|
||||||
|
load_g20_urdf_input,
|
||||||
|
)
|
||||||
|
from .zero_solver import (
|
||||||
LEFT_ZERO_PROFILE,
|
LEFT_ZERO_PROFILE,
|
||||||
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
|
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
|
||||||
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
@@ -318,13 +323,19 @@ def _palm_axis_observer_schema(
|
|||||||
def _palm_axis_resume_policy(
|
def _palm_axis_resume_policy(
|
||||||
profile: HandCalibrationProfile,
|
profile: HandCalibrationProfile,
|
||||||
session_start: Mapping[str, Any],
|
session_start: Mapping[str, Any],
|
||||||
|
compatibility_tokens: frozenset[str] | None = None,
|
||||||
) -> tuple[bool, tuple[str, ...]]:
|
) -> tuple[bool, tuple[str, ...]]:
|
||||||
"""Validate optional palm-axis checkpoint data when a model uses it."""
|
"""Validate optional palm-axis checkpoint data when a model uses it."""
|
||||||
capability = "palm_axis_relative_motion_v3"
|
capability = "palm_axis_relative_motion_v3"
|
||||||
previous = {str(value) for value in session_start.get("capabilities", [])}
|
previous = {str(value) for value in session_start.get("capabilities", [])}
|
||||||
if not profile.palm_axis_observers and capability not in profile.capabilities:
|
current = (
|
||||||
|
frozenset(profile.capabilities)
|
||||||
|
if compatibility_tokens is None
|
||||||
|
else frozenset(compatibility_tokens)
|
||||||
|
)
|
||||||
|
if not profile.palm_axis_observers and capability not in current:
|
||||||
return True, ()
|
return True, ()
|
||||||
required_previous = set(profile.capabilities) - {capability}
|
required_previous = set(current) - {capability}
|
||||||
if not required_previous.issubset(previous):
|
if not required_previous.issubset(previous):
|
||||||
raise ValueError("resume checkpoint lacks required capabilities")
|
raise ValueError("resume checkpoint lacks required capabilities")
|
||||||
if capability in previous:
|
if capability in previous:
|
||||||
@@ -741,7 +752,7 @@ def _requires_pnp_tracker_reset_for_sweep(
|
|||||||
return False
|
return False
|
||||||
if is_fit_retry:
|
if is_fit_retry:
|
||||||
return True
|
return True
|
||||||
if profile.supports("precheck_sweeps"):
|
if profile.precheck_sweeps:
|
||||||
return bool(item.precheck)
|
return bool(item.precheck)
|
||||||
return not item.precheck and item.cycle == 0
|
return not item.precheck and item.cycle == 0
|
||||||
|
|
||||||
@@ -813,6 +824,52 @@ def _maximum_corner_drift_px(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resume_fixed_base_position_compatibility(
|
||||||
|
rows: Sequence[Mapping[str, Any]],
|
||||||
|
current_corners_by_view: Mapping[
|
||||||
|
str, Sequence[Sequence[float]] | None
|
||||||
|
],
|
||||||
|
maximum_corner_drift_px: float,
|
||||||
|
) -> tuple[dict[str, float], tuple[str, ...], tuple[str, ...]]:
|
||||||
|
"""Compare the previous and current session-start palm references.
|
||||||
|
|
||||||
|
Calibration measurements are invariant to one rigid hand placement, but
|
||||||
|
samples expressed in two independently established common frames must
|
||||||
|
never be combined. Fixed palm-Tag corners provide a camera-native check
|
||||||
|
before any durable task is imported.
|
||||||
|
"""
|
||||||
|
previous_corners_by_view: dict[str, Any] = {}
|
||||||
|
for row in rows:
|
||||||
|
if str(row.get("kind", "")) != "fixed_base_reference_locked":
|
||||||
|
continue
|
||||||
|
view = str(row.get("view", ""))
|
||||||
|
if view in current_corners_by_view:
|
||||||
|
# Keep the last lock in case a future compatible schema records a
|
||||||
|
# deliberate pre-scan relock in the same raw stream.
|
||||||
|
previous_corners_by_view[view] = row.get("corner_reference_xy")
|
||||||
|
|
||||||
|
drift_by_view_px: dict[str, float] = {}
|
||||||
|
changed_views: list[str] = []
|
||||||
|
unverifiable_views: list[str] = []
|
||||||
|
for view, current in current_corners_by_view.items():
|
||||||
|
previous = previous_corners_by_view.get(str(view))
|
||||||
|
if previous is None or current is None:
|
||||||
|
unverifiable_views.append(str(view))
|
||||||
|
continue
|
||||||
|
drift = _maximum_corner_drift_px(previous, current)
|
||||||
|
if not math.isfinite(drift):
|
||||||
|
unverifiable_views.append(str(view))
|
||||||
|
continue
|
||||||
|
drift_by_view_px[str(view)] = drift
|
||||||
|
if drift > float(maximum_corner_drift_px):
|
||||||
|
changed_views.append(str(view))
|
||||||
|
return (
|
||||||
|
drift_by_view_px,
|
||||||
|
tuple(sorted(changed_views)),
|
||||||
|
tuple(sorted(unverifiable_views)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _selected_pose_qualities(
|
def _selected_pose_qualities(
|
||||||
selected: Mapping[str, SquareTagPose],
|
selected: Mapping[str, SquareTagPose],
|
||||||
live_qualities: Mapping[str, TagQuality],
|
live_qualities: Mapping[str, TagQuality],
|
||||||
@@ -1421,6 +1478,19 @@ def _unresolved_fit_failure_tasks(
|
|||||||
# failures that mixed velocity lag or firmware tracking
|
# failures that mixed velocity lag or firmware tracking
|
||||||
# deadband with mechanical hysteresis are safe to revalidate.
|
# deadband with mechanical hysteresis are safe to revalidate.
|
||||||
return True
|
return True
|
||||||
|
if (
|
||||||
|
profile.layout_id == G20_RIGHT_19_LAYOUT
|
||||||
|
and metric == "axis_pose_line_rms_mm"
|
||||||
|
and joint_name in profile.record_specs
|
||||||
|
and not profile.record_specs[
|
||||||
|
joint_name
|
||||||
|
].pose_axis_line_required
|
||||||
|
):
|
||||||
|
# Current profile policy owns whether this monocular 3-D
|
||||||
|
# diagnostic is release-critical. Revalidate the complete
|
||||||
|
# raw task under that policy instead of making an old failure
|
||||||
|
# force another acquisition forever.
|
||||||
|
return True
|
||||||
if (
|
if (
|
||||||
profile.layout_id == G20_RIGHT_19_LAYOUT
|
profile.layout_id == G20_RIGHT_19_LAYOUT
|
||||||
and joint_name
|
and joint_name
|
||||||
@@ -2015,7 +2085,7 @@ def _build_sweep_plan(
|
|||||||
"""
|
"""
|
||||||
plan: list[SweepItem] = []
|
plan: list[SweepItem] = []
|
||||||
for spec in profile.sweep_specs:
|
for spec in profile.sweep_specs:
|
||||||
if profile.supports("precheck_sweeps"):
|
if profile.precheck_sweeps:
|
||||||
plan.extend(
|
plan.extend(
|
||||||
SweepItem(spec, -1, direction, precheck=True)
|
SweepItem(spec, -1, direction, precheck=True)
|
||||||
for direction in (
|
for direction in (
|
||||||
@@ -2708,6 +2778,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
)
|
)
|
||||||
self.profile = product_contract.profile
|
self.profile = product_contract.profile
|
||||||
self.zero_profile = product_contract.zero_profile
|
self.zero_profile = product_contract.zero_profile
|
||||||
|
self.calibration_profile = product_contract.typed_profile
|
||||||
self.serial_number = str(value("serial_number"))
|
self.serial_number = str(value("serial_number"))
|
||||||
if self.serial_number == "UNSET":
|
if self.serial_number == "UNSET":
|
||||||
raise ValueError("serial_number is required")
|
raise ValueError("serial_number is required")
|
||||||
@@ -2766,6 +2837,11 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
)
|
)
|
||||||
self.resumed_task_keys: tuple[str, ...] = ()
|
self.resumed_task_keys: tuple[str, ...] = ()
|
||||||
self.resume_source_session = ""
|
self.resume_source_session = ""
|
||||||
|
self.resume_checkpoint_pending = False
|
||||||
|
self.resume_position_policy = "not_requested"
|
||||||
|
self.resume_position_changed_views: tuple[str, ...] = ()
|
||||||
|
self.resume_position_unverifiable_views: tuple[str, ...] = ()
|
||||||
|
self.resume_position_drift_by_view_px: dict[str, float] = {}
|
||||||
self.camera_extrinsics_file = Path(
|
self.camera_extrinsics_file = Path(
|
||||||
str(value("camera_extrinsics_file"))
|
str(value("camera_extrinsics_file"))
|
||||||
).expanduser().resolve()
|
).expanduser().resolve()
|
||||||
@@ -5226,7 +5302,19 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
(
|
(
|
||||||
palm_axis_schema_compatible,
|
palm_axis_schema_compatible,
|
||||||
palm_axis_invalidated_tasks,
|
palm_axis_invalidated_tasks,
|
||||||
) = _palm_axis_resume_policy(self.profile, start)
|
) = _palm_axis_resume_policy(
|
||||||
|
self.profile,
|
||||||
|
start,
|
||||||
|
getattr(
|
||||||
|
getattr(
|
||||||
|
getattr(self, "calibration_profile", None),
|
||||||
|
"artifacts",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"session_compatibility_tokens",
|
||||||
|
frozenset(self.profile.capabilities),
|
||||||
|
),
|
||||||
|
)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"resume checkpoint algorithm capabilities differ"
|
"resume checkpoint algorithm capabilities differ"
|
||||||
@@ -5247,6 +5335,54 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
"resume checkpoint geometry, algorithm capabilities, Tag "
|
"resume checkpoint geometry, algorithm capabilities, Tag "
|
||||||
"layout, baseline or source URDF differs"
|
"layout, baseline or source URDF differs"
|
||||||
)
|
)
|
||||||
|
current_corners_by_view = {
|
||||||
|
str(view): getattr(runtime, "locked_base_corners_xy", None)
|
||||||
|
for view, runtime in getattr(self, "views", {}).items()
|
||||||
|
}
|
||||||
|
if not current_corners_by_view:
|
||||||
|
raise RuntimeError(
|
||||||
|
"resume checkpoint requires current fixed-base references"
|
||||||
|
)
|
||||||
|
(
|
||||||
|
position_drift_by_view_px,
|
||||||
|
position_changed_views,
|
||||||
|
position_unverifiable_views,
|
||||||
|
) = _resume_fixed_base_position_compatibility(
|
||||||
|
rows,
|
||||||
|
current_corners_by_view,
|
||||||
|
float(
|
||||||
|
getattr(self, "fixed_base_maximum_corner_drift_px", 2.0)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.resume_source_session = source.parent.name
|
||||||
|
self.resume_position_drift_by_view_px = dict(
|
||||||
|
position_drift_by_view_px
|
||||||
|
)
|
||||||
|
self.resume_position_changed_views = position_changed_views
|
||||||
|
self.resume_position_unverifiable_views = (
|
||||||
|
position_unverifiable_views
|
||||||
|
)
|
||||||
|
start_position_invalidated_tasks: tuple[str, ...] = ()
|
||||||
|
if position_changed_views or position_unverifiable_views:
|
||||||
|
if str(getattr(self, "recalibration_scope", "full")) != "full":
|
||||||
|
affected = sorted(
|
||||||
|
{*position_changed_views, *position_unverifiable_views}
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
"resume checkpoint start pose differs or cannot be "
|
||||||
|
"verified for partial recalibration: "
|
||||||
|
+ ",".join(affected)
|
||||||
|
)
|
||||||
|
start_position_invalidated_tasks = tuple(
|
||||||
|
spec.key for spec in self.profile.sweep_specs
|
||||||
|
)
|
||||||
|
self.resume_position_policy = (
|
||||||
|
"discard_all_tasks_for_new_start_pose"
|
||||||
|
if position_changed_views
|
||||||
|
else "discard_all_tasks_for_unverified_start_pose"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.resume_position_policy = "reuse_same_start_pose"
|
||||||
try:
|
try:
|
||||||
changed_tag_size_ids, size_invalidated_tasks = (
|
changed_tag_size_ids, size_invalidated_tasks = (
|
||||||
resume_tasks_invalidated_by_tag_size_changes(
|
resume_tasks_invalidated_by_tag_size_changes(
|
||||||
@@ -5261,6 +5397,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
) from error
|
) from error
|
||||||
invalidated_task_set = set(size_invalidated_tasks)
|
invalidated_task_set = set(size_invalidated_tasks)
|
||||||
invalidated_task_set.update(palm_axis_invalidated_tasks)
|
invalidated_task_set.update(palm_axis_invalidated_tasks)
|
||||||
|
invalidated_task_set.update(start_position_invalidated_tasks)
|
||||||
scope_invalidated_tasks = tuple(
|
scope_invalidated_tasks = tuple(
|
||||||
getattr(self, "recalibration_task_keys", ()) or ()
|
getattr(self, "recalibration_task_keys", ()) or ()
|
||||||
)
|
)
|
||||||
@@ -5368,7 +5505,6 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
)
|
)
|
||||||
self.resumed_task_keys = completed
|
self.resumed_task_keys = completed
|
||||||
G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps(self)
|
G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps(self)
|
||||||
self.resume_source_session = source.parent.name
|
|
||||||
missing_tasks = [
|
missing_tasks = [
|
||||||
spec.key
|
spec.key
|
||||||
for spec in self.profile.sweep_specs
|
for spec in self.profile.sweep_specs
|
||||||
@@ -5398,6 +5534,20 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
"scope_invalidated_task_keys": list(
|
"scope_invalidated_task_keys": list(
|
||||||
scope_invalidated_tasks
|
scope_invalidated_tasks
|
||||||
),
|
),
|
||||||
|
"start_position_policy": self.resume_position_policy,
|
||||||
|
"start_position_changed_views": list(
|
||||||
|
position_changed_views
|
||||||
|
),
|
||||||
|
"start_position_unverifiable_views": list(
|
||||||
|
position_unverifiable_views
|
||||||
|
),
|
||||||
|
"start_position_drift_by_view_px": {
|
||||||
|
view: round(float(value), 6)
|
||||||
|
for view, value in position_drift_by_view_px.items()
|
||||||
|
},
|
||||||
|
"start_position_invalidated_task_keys": list(
|
||||||
|
start_position_invalidated_tasks
|
||||||
|
),
|
||||||
"imported_record_count": len(reusable),
|
"imported_record_count": len(reusable),
|
||||||
"imported_attempt_floor_by_task": attempt_floor_by_task,
|
"imported_attempt_floor_by_task": attempt_floor_by_task,
|
||||||
"source_raw_samples_sha256": _file_sha256(source),
|
"source_raw_samples_sha256": _file_sha256(source),
|
||||||
@@ -5551,6 +5701,19 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
return response
|
return response
|
||||||
self.started = True
|
self.started = True
|
||||||
self.startup_baseline_recovered = False
|
self.startup_baseline_recovered = False
|
||||||
|
self.resumed_task_keys = ()
|
||||||
|
self.resume_source_session = ""
|
||||||
|
self.resume_checkpoint_pending = bool(
|
||||||
|
self.resume_raw_samples_path is not None
|
||||||
|
)
|
||||||
|
self.resume_position_policy = (
|
||||||
|
"pending_new_start_pose_check"
|
||||||
|
if self.resume_checkpoint_pending
|
||||||
|
else "not_requested"
|
||||||
|
)
|
||||||
|
self.resume_position_changed_views = ()
|
||||||
|
self.resume_position_unverifiable_views = ()
|
||||||
|
self.resume_position_drift_by_view_px = {}
|
||||||
self.sweep_items = []
|
self.sweep_items = []
|
||||||
self.pnp_task_spec = None
|
self.pnp_task_spec = None
|
||||||
selected_sweep_specs = list(self.profile.sweep_specs)
|
selected_sweep_specs = list(self.profile.sweep_specs)
|
||||||
@@ -5646,7 +5809,19 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
self.recalibration_task_keys
|
self.recalibration_task_keys
|
||||||
),
|
),
|
||||||
"command_names": list(_command_names(self)),
|
"command_names": list(_command_names(self)),
|
||||||
"capabilities": sorted(self.profile.capabilities),
|
# Retained only as an old-session serialization token. Live
|
||||||
|
# decisions use the typed motion/measurement policies.
|
||||||
|
"capabilities": sorted(
|
||||||
|
getattr(
|
||||||
|
getattr(
|
||||||
|
getattr(self, "calibration_profile", None),
|
||||||
|
"artifacts",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
"session_compatibility_tokens",
|
||||||
|
frozenset(self.profile.capabilities),
|
||||||
|
)
|
||||||
|
),
|
||||||
"reference_finger": self.profile.reference_finger,
|
"reference_finger": self.profile.reference_finger,
|
||||||
"view_tags": {
|
"view_tags": {
|
||||||
view: dict(tags)
|
view: dict(tags)
|
||||||
@@ -5706,10 +5881,16 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if self.resume_raw_samples_path is not None:
|
if self.resume_raw_samples_path is not None:
|
||||||
self.state = STATE_IMPORTING_BASE
|
if not self.resume_raw_samples_path.is_file():
|
||||||
self.reason = "reading_base_session_records"
|
raise RuntimeError(
|
||||||
|
"resume raw samples do not exist: "
|
||||||
|
f"{self.resume_raw_samples_path}"
|
||||||
|
)
|
||||||
|
self.resume_source_session = (
|
||||||
|
self.resume_raw_samples_path.parent.name
|
||||||
|
)
|
||||||
self.base_import_progress = {
|
self.base_import_progress = {
|
||||||
"phase": "reading",
|
"phase": "waiting_for_new_start_pose_reference",
|
||||||
"records_read": 0,
|
"records_read": 0,
|
||||||
"bytes_read": 0,
|
"bytes_read": 0,
|
||||||
"total_bytes": int(
|
"total_bytes": int(
|
||||||
@@ -5717,10 +5898,9 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
),
|
),
|
||||||
"fraction": 0.0,
|
"fraction": 0.0,
|
||||||
}
|
}
|
||||||
self._publish_status(time.monotonic())
|
|
||||||
restored_tasks = self._restore_durable_task_checkpoint()
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
self.started = False
|
self.started = False
|
||||||
|
self.resume_checkpoint_pending = False
|
||||||
response.success = False
|
response.success = False
|
||||||
response.message = f"CFG-RESUME-009:{error}"
|
response.message = f"CFG-RESUME-009:{error}"
|
||||||
return response
|
return response
|
||||||
@@ -5728,8 +5908,11 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
response.success = True
|
response.success = True
|
||||||
response.message = (
|
response.message = (
|
||||||
"three-camera calibration started"
|
"three-camera calibration started"
|
||||||
if restored_tasks == 0
|
if not self.resume_checkpoint_pending
|
||||||
else f"calibration resumed with {restored_tasks} completed tasks"
|
else (
|
||||||
|
"three-camera calibration started; checkpoint will be "
|
||||||
|
"verified after the new start-pose reference is locked"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -8157,7 +8340,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if profile.supports("steady_command_checkpoints"):
|
if profile.steady_command_checkpoints:
|
||||||
command_store = getattr(
|
command_store = getattr(
|
||||||
self, "command_records_by_joint", None
|
self, "command_records_by_joint", None
|
||||||
)
|
)
|
||||||
@@ -8438,7 +8621,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
"maximum",
|
"maximum",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if not profile.supports("directional_zero"):
|
if not profile.directional_zero:
|
||||||
checks = checks + (
|
checks = checks + (
|
||||||
(
|
(
|
||||||
"hysteresis_deg",
|
"hysteresis_deg",
|
||||||
@@ -8584,6 +8767,35 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
# error is an admissibility check for constrained circles.
|
# error is an admissibility check for constrained circles.
|
||||||
for metric, actual, limit in axis_checks:
|
for metric, actual, limit in axis_checks:
|
||||||
if actual > limit:
|
if actual > limit:
|
||||||
|
if (
|
||||||
|
metric == "axis_pose_line_rms_mm"
|
||||||
|
and not joint_spec.pose_axis_line_required
|
||||||
|
):
|
||||||
|
append_jsonl(
|
||||||
|
self.raw_path,
|
||||||
|
{
|
||||||
|
"kind": (
|
||||||
|
"position_invariant_quality_diagnostic"
|
||||||
|
),
|
||||||
|
"task_name": spec.key,
|
||||||
|
"joint": joint_name,
|
||||||
|
"cycle": cycle + 1,
|
||||||
|
"metric": metric,
|
||||||
|
"actual": round(float(actual), 6),
|
||||||
|
"reference_limit": round(
|
||||||
|
float(limit), 6
|
||||||
|
),
|
||||||
|
"decision": "diagnostic_only",
|
||||||
|
"authoritative_quality": [
|
||||||
|
"tag_pnp",
|
||||||
|
"image_trajectory",
|
||||||
|
"relative_rotation",
|
||||||
|
"synchronisation",
|
||||||
|
"isolated_holdout",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
continue
|
||||||
failure_joint = joint_name
|
failure_joint = joint_name
|
||||||
quality_sources: tuple[str, ...] = ()
|
quality_sources: tuple[str, ...] = ()
|
||||||
if metric == "axis_pose_line_rms_mm":
|
if metric == "axis_pose_line_rms_mm":
|
||||||
@@ -8789,7 +9001,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
if index != outlier
|
if index != outlier
|
||||||
]
|
]
|
||||||
failures.append(failure)
|
failures.append(failure)
|
||||||
if profile.supports("cross_view_roll_curve"):
|
if profile.cross_view_roll_curve:
|
||||||
for primary_name, validation_name in (
|
for primary_name, validation_name in (
|
||||||
profile.axis_validation_sources or {}
|
profile.axis_validation_sources or {}
|
||||||
).items():
|
).items():
|
||||||
@@ -11565,6 +11777,41 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
name: published_zero_offsets[name]
|
name: published_zero_offsets[name]
|
||||||
for name in self.zero_profile.direct_zero_joints
|
for name in self.zero_profile.direct_zero_joints
|
||||||
}
|
}
|
||||||
|
correction_plan = None
|
||||||
|
typed_profile = getattr(self, "calibration_profile", None)
|
||||||
|
if typed_profile is not None:
|
||||||
|
frozen_names = typed_profile.scope.frozen_joints[
|
||||||
|
self.recalibration_scope
|
||||||
|
]
|
||||||
|
correction_plan = build_correction_plan(
|
||||||
|
typed_profile,
|
||||||
|
source_sha256=_file_sha256(self.source_urdf_path),
|
||||||
|
scope=self.recalibration_scope,
|
||||||
|
frozen_offsets_rad={
|
||||||
|
name: urdf_offsets[name] for name in frozen_names
|
||||||
|
},
|
||||||
|
)
|
||||||
|
urdf_input_path = self.final_path.with_name(
|
||||||
|
f"{self.final_path.stem}_urdf_correction_input.json"
|
||||||
|
)
|
||||||
|
atomic_write_json(
|
||||||
|
urdf_input_path,
|
||||||
|
build_g20_urdf_input_payload(
|
||||||
|
side=self.hand_type,
|
||||||
|
layout_id=self.profile.layout_id,
|
||||||
|
serial_number=self.serial_number,
|
||||||
|
source_urdf=self.source_urdf_path,
|
||||||
|
offsets_rad=urdf_offsets,
|
||||||
|
endpoint_anchored_offsets_rad=endpoint_zero_offsets,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
urdf_offsets, endpoint_zero_offsets = load_g20_urdf_input(
|
||||||
|
urdf_input_path,
|
||||||
|
source_urdf=self.source_urdf_path,
|
||||||
|
side=self.hand_type,
|
||||||
|
layout_id=self.profile.layout_id,
|
||||||
|
serial_number=self.serial_number,
|
||||||
|
)
|
||||||
self.corrected_urdf_path = write_zero_corrected_urdf(
|
self.corrected_urdf_path = write_zero_corrected_urdf(
|
||||||
source_urdf=self.source_urdf_path,
|
source_urdf=self.source_urdf_path,
|
||||||
output_directory=self.corrected_urdf_output_dir,
|
output_directory=self.corrected_urdf_output_dir,
|
||||||
@@ -11572,6 +11819,7 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
offsets_rad=urdf_offsets,
|
offsets_rad=urdf_offsets,
|
||||||
endpoint_anchored_offsets_rad=endpoint_zero_offsets,
|
endpoint_anchored_offsets_rad=endpoint_zero_offsets,
|
||||||
timestamp=stamp,
|
timestamp=stamp,
|
||||||
|
correction_plan=correction_plan,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if self.standalone_thumb_calibration:
|
if self.standalone_thumb_calibration:
|
||||||
@@ -11885,6 +12133,21 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
elif self.startup_baseline_recovered and self._all_preflight_ready(now):
|
elif self.startup_baseline_recovered and self._all_preflight_ready(now):
|
||||||
locker = getattr(self, "_lock_fixed_base_references", None)
|
locker = getattr(self, "_lock_fixed_base_references", None)
|
||||||
if locker is None or locker():
|
if locker is None or locker():
|
||||||
|
if getattr(self, "resume_checkpoint_pending", False):
|
||||||
|
self.resume_checkpoint_pending = False
|
||||||
|
self.state = STATE_IMPORTING_BASE
|
||||||
|
self.reason = "reading_base_session_records"
|
||||||
|
self.base_import_progress = {
|
||||||
|
"phase": "reading",
|
||||||
|
"records_read": 0,
|
||||||
|
"bytes_read": 0,
|
||||||
|
"total_bytes": int(
|
||||||
|
self.resume_raw_samples_path.stat().st_size
|
||||||
|
),
|
||||||
|
"fraction": 0.0,
|
||||||
|
}
|
||||||
|
self._publish_status(time.monotonic())
|
||||||
|
self._restore_durable_task_checkpoint()
|
||||||
self._start_next_sweep()
|
self._start_next_sweep()
|
||||||
else:
|
else:
|
||||||
self.reason = "locking_fixed_base_references"
|
self.reason = "locking_fixed_base_references"
|
||||||
@@ -12932,7 +13195,32 @@ class G20ThreeCameraCalibrationNode(Node):
|
|||||||
"camera_extrinsics_error": self.extrinsics_error,
|
"camera_extrinsics_error": self.extrinsics_error,
|
||||||
"resume": {
|
"resume": {
|
||||||
"used": bool(self.resumed_task_keys),
|
"used": bool(self.resumed_task_keys),
|
||||||
|
"checkpoint_requested": bool(
|
||||||
|
self.resume_raw_samples_path is not None
|
||||||
|
),
|
||||||
|
"checkpoint_pending": bool(
|
||||||
|
getattr(self, "resume_checkpoint_pending", False)
|
||||||
|
),
|
||||||
"source_session": self.resume_source_session,
|
"source_session": self.resume_source_session,
|
||||||
|
"start_position_policy": getattr(
|
||||||
|
self, "resume_position_policy", "not_requested"
|
||||||
|
),
|
||||||
|
"start_position_changed_views": list(
|
||||||
|
getattr(self, "resume_position_changed_views", ())
|
||||||
|
),
|
||||||
|
"start_position_unverifiable_views": list(
|
||||||
|
getattr(
|
||||||
|
self,
|
||||||
|
"resume_position_unverifiable_views",
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"start_position_drift_by_view_px": {
|
||||||
|
view: round(float(value), 6)
|
||||||
|
for view, value in getattr(
|
||||||
|
self, "resume_position_drift_by_view_px", {}
|
||||||
|
).items()
|
||||||
|
},
|
||||||
"recalibration_scope": self.recalibration_scope,
|
"recalibration_scope": self.recalibration_scope,
|
||||||
"recalibration_task_keys": list(
|
"recalibration_task_keys": list(
|
||||||
self.recalibration_task_keys
|
self.recalibration_task_keys
|
||||||
+140
-13
@@ -19,8 +19,10 @@ import numpy as np
|
|||||||
from scipy.spatial.transform import Rotation
|
from scipy.spatial.transform import Rotation
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .extrinsics import load_three_camera_extrinsics
|
from ...compat import default_three_camera_config_path
|
||||||
from .full_hand import (
|
from ...compat.legacy import uses_coupled_full_hand_zero_solver
|
||||||
|
from ...extrinsics import load_three_camera_extrinsics
|
||||||
|
from .profile import (
|
||||||
G20_REFERENCE_THUMB_CMC_JOINTS,
|
G20_REFERENCE_THUMB_CMC_JOINTS,
|
||||||
G20_RIGHT_19_LAYOUT,
|
G20_RIGHT_19_LAYOUT,
|
||||||
RIGHT_19_END_ON_IMAGE_CURVE_JOINTS,
|
RIGHT_19_END_ON_IMAGE_CURVE_JOINTS,
|
||||||
@@ -36,10 +38,15 @@ from .full_hand import (
|
|||||||
get_hand_calibration_profile,
|
get_hand_calibration_profile,
|
||||||
validate_compact_payload,
|
validate_compact_payload,
|
||||||
)
|
)
|
||||||
from .product import get_product_calibration_contract
|
from ...product import get_product_calibration_contract
|
||||||
from .sample_schema import fitting_sample_record, fitting_sample_records
|
from ...sample_schema import fitting_sample_record, fitting_sample_records
|
||||||
from .storage import atomic_write_json
|
from ...storage import atomic_write_json
|
||||||
from .urdf_zero import (
|
from .publication import clamp_compact_payload_to_urdf_limits
|
||||||
|
from .urdf_input import (
|
||||||
|
build_g20_urdf_input_payload,
|
||||||
|
load_g20_urdf_input,
|
||||||
|
)
|
||||||
|
from .zero_solver import (
|
||||||
JointAxisMeasurement,
|
JointAxisMeasurement,
|
||||||
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
|
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
|
||||||
UrdfKinematicModel,
|
UrdfKinematicModel,
|
||||||
@@ -76,6 +83,42 @@ def _sha256(path: Path) -> str:
|
|||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_sha256(payload: Mapping[str, Any]) -> str:
|
||||||
|
encoded = (
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_published_quality(
|
||||||
|
session: Path, *, side: str, serial_number: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recover v1 random-validation aggregates that raw JSONL omitted."""
|
||||||
|
path = session / f"g20_{side}_{serial_number}_calibration.json"
|
||||||
|
if not path.is_file():
|
||||||
|
raise ValueError(
|
||||||
|
"legacy replay requires its published JSON because v1 raw "
|
||||||
|
"samples omitted random-validation observations"
|
||||||
|
)
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
quality = payload.get("quality")
|
||||||
|
if not isinstance(quality, Mapping) or set(quality) != {
|
||||||
|
"passed",
|
||||||
|
"validation_mae_rad",
|
||||||
|
"validation_p95_rad",
|
||||||
|
}:
|
||||||
|
raise ValueError("legacy published JSON has an invalid quality block")
|
||||||
|
if not bool(quality["passed"]):
|
||||||
|
raise ValueError("legacy published JSON did not pass validation")
|
||||||
|
values = (
|
||||||
|
float(quality["validation_mae_rad"]),
|
||||||
|
float(quality["validation_p95_rad"]),
|
||||||
|
)
|
||||||
|
if any(not math.isfinite(value) or value < 0.0 for value in values):
|
||||||
|
raise ValueError("legacy published quality contains an invalid error")
|
||||||
|
return dict(quality)
|
||||||
|
|
||||||
|
|
||||||
def _output_suffix(output_tag: str | None) -> str:
|
def _output_suffix(output_tag: str | None) -> str:
|
||||||
"""Return a filename-safe suffix for a non-destructive replay variant."""
|
"""Return a filename-safe suffix for a non-destructive replay variant."""
|
||||||
if output_tag is None:
|
if output_tag is None:
|
||||||
@@ -795,6 +838,7 @@ def _quality_failures(
|
|||||||
if (
|
if (
|
||||||
profile.record_specs[name].zero_kind
|
profile.record_specs[name].zero_kind
|
||||||
!= "axis_cross_view_validation"
|
!= "axis_cross_view_validation"
|
||||||
|
and profile.record_specs[name].pose_axis_line_required
|
||||||
and cross_view_side_line_source(axis) is None
|
and cross_view_side_line_source(axis) is None
|
||||||
and axis.pose_axis_line_rms_m
|
and axis.pose_axis_line_rms_m
|
||||||
> float(parameters["axis_maximum_pose_line_rms_m"])
|
> float(parameters["axis_maximum_pose_line_rms_m"])
|
||||||
@@ -1009,9 +1053,8 @@ def replay_session(
|
|||||||
output_tag: str | None = None,
|
output_tag: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
session = Path(session_dir).expanduser().resolve()
|
session = Path(session_dir).expanduser().resolve()
|
||||||
package_root = Path(__file__).resolve().parents[1]
|
|
||||||
config = (
|
config = (
|
||||||
package_root / "config" / "three_camera_calibration.yaml"
|
default_three_camera_config_path()
|
||||||
if config_file is None
|
if config_file is None
|
||||||
else Path(config_file).expanduser().resolve()
|
else Path(config_file).expanduser().resolve()
|
||||||
)
|
)
|
||||||
@@ -1024,6 +1067,7 @@ def replay_session(
|
|||||||
palm_axis_records_by_source,
|
palm_axis_records_by_source,
|
||||||
raw_path,
|
raw_path,
|
||||||
) = _load_raw_session(session)
|
) = _load_raw_session(session)
|
||||||
|
legacy_coupled_zero_solver = uses_coupled_full_hand_zero_solver(start)
|
||||||
model = str(start.get("model", "G20")).strip().upper()
|
model = str(start.get("model", "G20")).strip().upper()
|
||||||
side = str(start["hand_type"]).lower()
|
side = str(start["hand_type"]).lower()
|
||||||
requested_layout_id = str(
|
requested_layout_id = str(
|
||||||
@@ -1199,8 +1243,32 @@ def replay_session(
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{name} has {len(records)}/18 steady command checkpoints"
|
f"{name} has {len(records)}/18 steady command checkpoints"
|
||||||
)
|
)
|
||||||
command_fits[name] = _fit_curve(
|
zero_command = int(
|
||||||
name, records, profile=profile, baseline=baseline
|
baseline[profile.joint_specs[name].motor_index]
|
||||||
|
)
|
||||||
|
if name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS:
|
||||||
|
command_fits[name] = fit_joint_image_curve(
|
||||||
|
records,
|
||||||
|
maximum_radial_rms_px=float(
|
||||||
|
parameters["image_trajectory_maximum_radial_rms_px"]
|
||||||
|
),
|
||||||
|
maximum_radial_p95_px=float(
|
||||||
|
parameters["image_trajectory_maximum_radial_p95_px"]
|
||||||
|
),
|
||||||
|
minimum_radius_px=float(
|
||||||
|
parameters["image_trajectory_minimum_radius_px"]
|
||||||
|
),
|
||||||
|
minimum_arc_rad=math.radians(
|
||||||
|
float(parameters["trajectory_minimum_arc_deg"])
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
command_fits[name] = fit_rotation_joint_curve(
|
||||||
|
records,
|
||||||
|
zero_command_u8=zero_command,
|
||||||
|
canonical_zero_direction=canonical_zero_direction(
|
||||||
|
profile, name
|
||||||
|
),
|
||||||
)
|
)
|
||||||
command_gap_limit = math.radians(
|
command_gap_limit = math.radians(
|
||||||
float(parameters.get("command_maximum_direction_gap_deg", 2.0))
|
float(parameters.get("command_maximum_direction_gap_deg", 2.0))
|
||||||
@@ -1300,10 +1368,23 @@ def replay_session(
|
|||||||
source_urdf=source_urdf,
|
source_urdf=source_urdf,
|
||||||
repetitions=repetitions,
|
repetitions=repetitions,
|
||||||
)
|
)
|
||||||
|
palm_records_for_fit = palm_axis_records_by_source
|
||||||
|
if legacy_coupled_zero_solver:
|
||||||
|
palm_records_for_fit = {
|
||||||
|
name: [
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in record.items()
|
||||||
|
if key != "child_pose_common"
|
||||||
|
}
|
||||||
|
for record in records
|
||||||
|
]
|
||||||
|
for name, records in palm_axis_records_by_source.items()
|
||||||
|
}
|
||||||
palm_orientation_measurements, palm_orientation_rejections = (
|
palm_orientation_measurements, palm_orientation_rejections = (
|
||||||
fit_partial_palm_orientation_measurements(
|
fit_partial_palm_orientation_measurements(
|
||||||
sources=profile.palm_orientation_sources,
|
sources=profile.palm_orientation_sources,
|
||||||
records_by_joint=palm_axis_records_by_source,
|
records_by_joint=palm_records_for_fit,
|
||||||
motor_by_source=profile.palm_axis_motor_by_source,
|
motor_by_source=profile.palm_axis_motor_by_source,
|
||||||
baseline_command_u8=baseline,
|
baseline_command_u8=baseline,
|
||||||
cycles=range(repetitions),
|
cycles=range(repetitions),
|
||||||
@@ -1543,7 +1624,7 @@ def replay_session(
|
|||||||
fits: Mapping[str, JointCurveFit],
|
fits: Mapping[str, JointCurveFit],
|
||||||
arguments: Mapping[str, Any],
|
arguments: Mapping[str, Any],
|
||||||
):
|
):
|
||||||
if layout_id != G20_RIGHT_19_LAYOUT:
|
if layout_id != G20_RIGHT_19_LAYOUT or legacy_coupled_zero_solver:
|
||||||
return solve_urdf_zero_offsets(curves=fits, **arguments)
|
return solve_urdf_zero_offsets(curves=fits, **arguments)
|
||||||
thumb_profile = get_right_19_thumb_zero_profile()
|
thumb_profile = get_right_19_thumb_zero_profile()
|
||||||
thumb_names = set(thumb_profile.direct_zero_joints)
|
thumb_names = set(thumb_profile.direct_zero_joints)
|
||||||
@@ -1640,11 +1721,19 @@ def replay_session(
|
|||||||
f"{output_suffix}.urdf"
|
f"{output_suffix}.urdf"
|
||||||
)
|
)
|
||||||
final_urdf = session / expected_urdf_name
|
final_urdf = session / expected_urdf_name
|
||||||
|
urdf_input_path = session / (
|
||||||
|
f"g20_{side}_{hand_serial}_calibration{output_suffix}"
|
||||||
|
"_urdf_correction_input.json"
|
||||||
|
)
|
||||||
report_path = session / (
|
report_path = session / (
|
||||||
f"g20_{side}_{hand_serial}_offline_validation{output_suffix}.json"
|
f"g20_{side}_{hand_serial}_offline_validation{output_suffix}.json"
|
||||||
)
|
)
|
||||||
if write_outputs:
|
if write_outputs:
|
||||||
existing = [path for path in (final_json, final_urdf, report_path) if path.exists()]
|
existing = [
|
||||||
|
path
|
||||||
|
for path in (final_json, final_urdf, urdf_input_path, report_path)
|
||||||
|
if path.exists()
|
||||||
|
]
|
||||||
if existing:
|
if existing:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"refusing to overwrite replay outputs: "
|
"refusing to overwrite replay outputs: "
|
||||||
@@ -1665,6 +1754,29 @@ def replay_session(
|
|||||||
else published_zero_offsets
|
else published_zero_offsets
|
||||||
)
|
)
|
||||||
with tempfile.TemporaryDirectory(prefix="offline_replay_", dir=session) as temporary:
|
with tempfile.TemporaryDirectory(prefix="offline_replay_", dir=session) as temporary:
|
||||||
|
correction_json = (
|
||||||
|
urdf_input_path
|
||||||
|
if write_outputs
|
||||||
|
else Path(temporary) / urdf_input_path.name
|
||||||
|
)
|
||||||
|
atomic_write_json(
|
||||||
|
correction_json,
|
||||||
|
build_g20_urdf_input_payload(
|
||||||
|
side=side,
|
||||||
|
layout_id=layout_id,
|
||||||
|
serial_number=hand_serial,
|
||||||
|
source_urdf=source_urdf,
|
||||||
|
offsets_rad=urdf_offsets,
|
||||||
|
endpoint_anchored_offsets_rad=endpoint_zero_offsets,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
urdf_offsets, endpoint_zero_offsets = load_g20_urdf_input(
|
||||||
|
correction_json,
|
||||||
|
source_urdf=source_urdf,
|
||||||
|
side=side,
|
||||||
|
layout_id=layout_id,
|
||||||
|
serial_number=hand_serial,
|
||||||
|
)
|
||||||
candidate = write_zero_corrected_urdf(
|
candidate = write_zero_corrected_urdf(
|
||||||
source_urdf=source_urdf,
|
source_urdf=source_urdf,
|
||||||
output_directory=temporary,
|
output_directory=temporary,
|
||||||
@@ -1800,6 +1912,19 @@ def replay_session(
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
payload, _clipped_runtime_joints = (
|
||||||
|
clamp_compact_payload_to_urdf_limits(payload, candidate)
|
||||||
|
)
|
||||||
|
legacy_quality_source = None
|
||||||
|
if legacy_coupled_zero_solver:
|
||||||
|
payload["quality"] = _legacy_published_quality(
|
||||||
|
session,
|
||||||
|
side=side,
|
||||||
|
serial_number=hand_serial,
|
||||||
|
)
|
||||||
|
legacy_quality_source = (
|
||||||
|
"published_v1_artifact_random_validation_omitted_from_raw"
|
||||||
|
)
|
||||||
validate_compact_payload(payload)
|
validate_compact_payload(payload)
|
||||||
if write_outputs:
|
if write_outputs:
|
||||||
if _sha256(source_urdf) != source_hash_before:
|
if _sha256(source_urdf) != source_hash_before:
|
||||||
@@ -1901,6 +2026,8 @@ def replay_session(
|
|||||||
"maximum_residual_zero_offset_deg": math.degrees(maximum_residual_offset),
|
"maximum_residual_zero_offset_deg": math.degrees(maximum_residual_offset),
|
||||||
},
|
},
|
||||||
"corrected_urdf_sha256": candidate_hash,
|
"corrected_urdf_sha256": candidate_hash,
|
||||||
|
"computed_final_json_sha256": _payload_sha256(payload),
|
||||||
|
"legacy_quality_source": legacy_quality_source,
|
||||||
"final_json": str(final_json) if write_outputs else None,
|
"final_json": str(final_json) if write_outputs else None,
|
||||||
"corrected_urdf": str(final_urdf) if write_outputs else None,
|
"corrected_urdf": str(final_urdf) if write_outputs else None,
|
||||||
}
|
}
|
||||||
+28
-4
@@ -17,15 +17,15 @@ import xml.etree.ElementTree as ET
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .core import COMMAND_NAMES
|
from .command_layout import G20_COMMAND_NAMES as COMMAND_NAMES
|
||||||
from .trajectory import (
|
from ...trajectory import (
|
||||||
_angle_for_circle,
|
_angle_for_circle,
|
||||||
_fit_circle_with_axis,
|
_fit_circle_with_axis,
|
||||||
_fit_joint_curve,
|
_fit_joint_curve,
|
||||||
_fit_plane_axis,
|
_fit_plane_axis,
|
||||||
_orient_circle_positive,
|
_orient_circle_positive,
|
||||||
)
|
)
|
||||||
from .zero_calibration import (
|
from ...zero_calibration import (
|
||||||
_fit_circle,
|
_fit_circle,
|
||||||
_trajectory_arc_rad,
|
_trajectory_arc_rad,
|
||||||
circular_median_rad,
|
circular_median_rad,
|
||||||
@@ -51,6 +51,12 @@ class JointSpec:
|
|||||||
child_role: str | None
|
child_role: str | None
|
||||||
source_joint: str | None = None
|
source_joint: str | None = None
|
||||||
zero_kind: str | None = None
|
zero_kind: str | None = None
|
||||||
|
# Keep the physical axis-line gate only when that line contributes to a
|
||||||
|
# released URDF zero/axis decision. Curve-only passive measurements may
|
||||||
|
# retain the monocular line residual as a diagnostic while their image
|
||||||
|
# trajectory, relative rotation, synchronisation and holdout gates remain
|
||||||
|
# release-critical.
|
||||||
|
pose_axis_line_required: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def measured(self) -> bool:
|
def measured(self) -> bool:
|
||||||
@@ -129,6 +135,12 @@ class HandCalibrationProfile:
|
|||||||
command_names: tuple[str, ...] = COMMAND_NAMES
|
command_names: tuple[str, ...] = COMMAND_NAMES
|
||||||
baseline_command: tuple[int, ...] = THREE_CAMERA_BASELINE_COMMAND
|
baseline_command: tuple[int, ...] = THREE_CAMERA_BASELINE_COMMAND
|
||||||
capabilities: frozenset[str] = frozenset()
|
capabilities: frozenset[str] = frozenset()
|
||||||
|
precheck_sweeps: bool = False
|
||||||
|
steady_command_checkpoints: bool = False
|
||||||
|
directional_zero: bool = False
|
||||||
|
isolated_holdout: bool = False
|
||||||
|
cross_view_roll_curve: bool = False
|
||||||
|
stable_cross_view_cone_bias: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def command_count(self) -> int:
|
def command_count(self) -> int:
|
||||||
@@ -537,7 +549,13 @@ def _build_right_19_profile() -> HandCalibrationProfile:
|
|||||||
zero_kind="urdf_axis_chain",
|
zero_kind="urdf_axis_chain",
|
||||||
),
|
),
|
||||||
"thumb_ip": JointSpec(
|
"thumb_ip": JointSpec(
|
||||||
"thumb_ip", 15, False, "front", "thumb_mcp", "thumb_ip",
|
"thumb_ip",
|
||||||
|
15,
|
||||||
|
False,
|
||||||
|
"front",
|
||||||
|
"thumb_mcp",
|
||||||
|
"thumb_ip",
|
||||||
|
pose_axis_line_required=False,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
for finger in ("index", "middle", "ring", "pinky"):
|
for finger in ("index", "middle", "ring", "pinky"):
|
||||||
@@ -703,6 +721,12 @@ def _build_right_19_profile() -> HandCalibrationProfile:
|
|||||||
"palm_axis_relative_motion_v3",
|
"palm_axis_relative_motion_v3",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
precheck_sweeps=True,
|
||||||
|
steady_command_checkpoints=True,
|
||||||
|
directional_zero=True,
|
||||||
|
isolated_holdout=True,
|
||||||
|
cross_view_roll_curve=True,
|
||||||
|
stable_cross_view_cone_bias=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
+28
-4
@@ -15,15 +15,16 @@ import xml.etree.ElementTree as ET
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.spatial.transform import Rotation
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
from .full_hand import (
|
from .profile import (
|
||||||
G20_COMBINATION_REQUIRED_TARGET_KEYS,
|
G20_COMBINATION_REQUIRED_TARGET_KEYS,
|
||||||
G20_RIGHT_19_LAYOUT,
|
G20_RIGHT_19_LAYOUT,
|
||||||
get_hand_calibration_profile,
|
get_hand_calibration_profile,
|
||||||
validate_compact_payload,
|
validate_compact_payload,
|
||||||
)
|
)
|
||||||
from .product import ProductConfig, sha256_file
|
from ...product import ProductConfig, sha256_file
|
||||||
from .storage import atomic_write_json
|
from ...storage import atomic_write_json
|
||||||
from .urdf_zero import (
|
from ...core.urdf import UrdfCorrectionPlan, build_correction_plan
|
||||||
|
from .zero_solver import (
|
||||||
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
get_zero_calibration_profile,
|
get_zero_calibration_profile,
|
||||||
)
|
)
|
||||||
@@ -202,6 +203,7 @@ def verify_corrected_urdf(
|
|||||||
*,
|
*,
|
||||||
expected_offsets_rad: Mapping[str, float] | None = None,
|
expected_offsets_rad: Mapping[str, float] | None = None,
|
||||||
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
|
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
|
||||||
|
correction_plan: UrdfCorrectionPlan | None = None,
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
"""Prove that only active-joint origin.rpy attributes changed.
|
"""Prove that only active-joint origin.rpy attributes changed.
|
||||||
|
|
||||||
@@ -210,6 +212,13 @@ def verify_corrected_urdf(
|
|||||||
"""
|
"""
|
||||||
source_text = Path(source).read_text(encoding="utf-8")
|
source_text = Path(source).read_text(encoding="utf-8")
|
||||||
corrected_text = Path(corrected).read_text(encoding="utf-8")
|
corrected_text = Path(corrected).read_text(encoding="utf-8")
|
||||||
|
if correction_plan is not None:
|
||||||
|
correction_plan.verify_source(source)
|
||||||
|
if expected_offsets_rad is None:
|
||||||
|
raise ValueError(
|
||||||
|
"correction-plan validation requires expected offsets"
|
||||||
|
)
|
||||||
|
correction_plan.authorize_offsets(expected_offsets_rad)
|
||||||
before = _joint_blocks(source_text)
|
before = _joint_blocks(source_text)
|
||||||
after = _joint_blocks(corrected_text)
|
after = _joint_blocks(corrected_text)
|
||||||
if set(before) != set(after):
|
if set(before) != set(after):
|
||||||
@@ -243,6 +252,12 @@ def verify_corrected_urdf(
|
|||||||
for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
|
for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
|
||||||
}
|
}
|
||||||
if endpoint_offsets:
|
if endpoint_offsets:
|
||||||
|
if correction_plan is not None and not set(endpoint_offsets).issubset(
|
||||||
|
correction_plan.endpoint_limit_joints
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"endpoint offsets are not authorized by the correction plan"
|
||||||
|
)
|
||||||
source_joints = _joint_elements(source)
|
source_joints = _joint_elements(source)
|
||||||
corrected_joints = _joint_elements(corrected)
|
corrected_joints = _joint_elements(corrected)
|
||||||
for name, offset in endpoint_offsets.items():
|
for name, offset in endpoint_offsets.items():
|
||||||
@@ -745,11 +760,20 @@ def finalize_session_artifacts(
|
|||||||
name: offsets[name]
|
name: offsets[name]
|
||||||
for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
|
for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
|
||||||
}
|
}
|
||||||
|
typed_profile = config.calibration_contract.typed_profile
|
||||||
|
frozen_names = typed_profile.scope.frozen_joints[calibration_scope]
|
||||||
|
correction_plan = build_correction_plan(
|
||||||
|
typed_profile,
|
||||||
|
source_sha256=config.source_urdf_sha256,
|
||||||
|
scope=calibration_scope,
|
||||||
|
frozen_offsets_rad={name: offsets[name] for name in frozen_names},
|
||||||
|
)
|
||||||
changed_joints = verify_corrected_urdf(
|
changed_joints = verify_corrected_urdf(
|
||||||
config.source_urdf,
|
config.source_urdf,
|
||||||
paths["urdf"],
|
paths["urdf"],
|
||||||
expected_offsets_rad=offsets,
|
expected_offsets_rad=offsets,
|
||||||
endpoint_anchored_offsets_rad=endpoint_offsets,
|
endpoint_anchored_offsets_rad=endpoint_offsets,
|
||||||
|
correction_plan=correction_plan,
|
||||||
)
|
)
|
||||||
if standalone_thumb:
|
if standalone_thumb:
|
||||||
clipped_runtime_joints = {}
|
clipped_runtime_joints = {}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Independent reviewed profile for the right 19-Tag product layout."""
|
||||||
|
|
||||||
|
from ...core import ProfileKey
|
||||||
|
from .profile import G20_RIGHT_19_LAYOUT, get_hand_calibration_profile
|
||||||
|
from .zero_policy import (
|
||||||
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
|
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
|
||||||
|
get_zero_calibration_profile,
|
||||||
|
)
|
||||||
|
from ..registry import RegisteredProfile
|
||||||
|
from ._adapter import adapt_profile
|
||||||
|
|
||||||
|
|
||||||
|
KEY = ProfileKey("G20", "right", G20_RIGHT_19_LAYOUT, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_profile() -> RegisteredProfile:
|
||||||
|
hand = get_hand_calibration_profile(KEY.side, KEY.layout)
|
||||||
|
zero = get_zero_calibration_profile(KEY.side, KEY.layout)
|
||||||
|
return adapt_profile(
|
||||||
|
key=KEY,
|
||||||
|
namespace="/g20_calibration",
|
||||||
|
hand_profile=hand,
|
||||||
|
zero_profile=zero,
|
||||||
|
mechanical_endpoint_joints=RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
|
post_solve_endpoint_joints=RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
|
||||||
|
)
|
||||||
+11
-7
@@ -20,11 +20,15 @@ from rclpy.node import Node
|
|||||||
from std_msgs.msg import String
|
from std_msgs.msg import String
|
||||||
from std_srvs.srv import Trigger
|
from std_srvs.srv import Trigger
|
||||||
|
|
||||||
from .hikrobot_camera import configure_fastdds_large_image_transport
|
from ...hikrobot_camera import configure_fastdds_large_image_transport
|
||||||
from .operator_report import ProgressEstimator, build_failure_report, render_progress_zh
|
from ...operator_report import (
|
||||||
from .product import ProductConfig, load_product_config, sha256_file
|
ProgressEstimator,
|
||||||
|
build_failure_report,
|
||||||
|
render_progress_zh,
|
||||||
|
)
|
||||||
|
from ...product import ProductConfig, load_product_config, sha256_file
|
||||||
from .publication import atomic_session_pointer, finalize_session_artifacts
|
from .publication import atomic_session_pointer, finalize_session_artifacts
|
||||||
from .storage import atomic_write_json
|
from ...storage import atomic_write_json
|
||||||
|
|
||||||
|
|
||||||
EXIT_PASS = 0
|
EXIT_PASS = 0
|
||||||
@@ -137,7 +141,7 @@ class ProgressConsole:
|
|||||||
def _default_product_config() -> Path:
|
def _default_product_config() -> Path:
|
||||||
try:
|
try:
|
||||||
installed = Path(
|
installed = Path(
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
get_package_share_directory("linkerhand_calibration")
|
||||||
) / "config" / "g20_right_product.yaml"
|
) / "config" / "g20_right_product.yaml"
|
||||||
if installed.is_file():
|
if installed.is_file():
|
||||||
return installed
|
return installed
|
||||||
@@ -145,7 +149,7 @@ def _default_product_config() -> Path:
|
|||||||
pass
|
pass
|
||||||
return (
|
return (
|
||||||
Path.cwd()
|
Path.cwd()
|
||||||
/ "src/g20_thumb_apriltag_calibration/config/g20_right_product.yaml"
|
/ "src/linkerhand_calibration/config/g20_right_product.yaml"
|
||||||
).resolve()
|
).resolve()
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +192,7 @@ def _launch_command(
|
|||||||
return [
|
return [
|
||||||
"ros2",
|
"ros2",
|
||||||
"launch",
|
"launch",
|
||||||
"g20_thumb_apriltag_calibration",
|
"linkerhand_calibration",
|
||||||
"three_camera_calibration.launch.py",
|
"three_camera_calibration.launch.py",
|
||||||
*(f"{name}:={value}" for name, value in values.items()),
|
*(f"{name}:={value}" for name, value in values.items()),
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Exact JSON handoff between G20 fitting and the existing URDF writer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
ARTIFACT_TYPE = "linkerhand_g20_urdf_correction_input"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: str | Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with Path(path).expanduser().resolve().open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_g20_urdf_input_payload(
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
layout_id: str,
|
||||||
|
serial_number: str,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
offsets_rad: Mapping[str, float],
|
||||||
|
endpoint_anchored_offsets_rad: Mapping[str, float],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the exact correction parameters that will be read from disk."""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"artifact_type": ARTIFACT_TYPE,
|
||||||
|
"profile_id": f"G20/{str(side).lower()}/{layout_id}",
|
||||||
|
"model": "G20",
|
||||||
|
"side": str(side).lower(),
|
||||||
|
"layout_id": str(layout_id),
|
||||||
|
"serial_number": str(serial_number),
|
||||||
|
"source_urdf_sha256": _sha256_file(source_urdf),
|
||||||
|
"offsets_rad": {
|
||||||
|
str(name): float(value) for name, value in sorted(offsets_rad.items())
|
||||||
|
},
|
||||||
|
"endpoint_anchored_offsets_rad": {
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in sorted(endpoint_anchored_offsets_rad.items())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
validate_g20_urdf_input_payload(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def validate_g20_urdf_input_payload(payload: Mapping[str, Any]) -> None:
|
||||||
|
required = {
|
||||||
|
"schema_version", "artifact_type", "profile_id", "model", "side",
|
||||||
|
"layout_id", "serial_number", "source_urdf_sha256", "offsets_rad",
|
||||||
|
"endpoint_anchored_offsets_rad",
|
||||||
|
}
|
||||||
|
if set(payload) != required:
|
||||||
|
raise ValueError("G20 URDF correction input has unexpected fields")
|
||||||
|
if (
|
||||||
|
payload.get("schema_version") != SCHEMA_VERSION
|
||||||
|
or payload.get("artifact_type") != ARTIFACT_TYPE
|
||||||
|
or payload.get("model") != "G20"
|
||||||
|
or payload.get("side") not in {"left", "right"}
|
||||||
|
):
|
||||||
|
raise ValueError("G20 URDF correction input identity is invalid")
|
||||||
|
expected_profile = (
|
||||||
|
f"G20/{payload['side']}/{payload['layout_id']}"
|
||||||
|
)
|
||||||
|
if payload.get("profile_id") != expected_profile:
|
||||||
|
raise ValueError("G20 URDF correction input profile is invalid")
|
||||||
|
source_hash = str(payload.get("source_urdf_sha256", ""))
|
||||||
|
if len(source_hash) != 64 or any(
|
||||||
|
character not in "0123456789abcdef" for character in source_hash
|
||||||
|
):
|
||||||
|
raise ValueError("G20 URDF correction input source hash is invalid")
|
||||||
|
offsets = payload.get("offsets_rad")
|
||||||
|
endpoints = payload.get("endpoint_anchored_offsets_rad")
|
||||||
|
if not isinstance(offsets, Mapping) or not offsets:
|
||||||
|
raise ValueError("G20 URDF correction input offsets are missing")
|
||||||
|
if not isinstance(endpoints, Mapping) or not set(endpoints) <= set(offsets):
|
||||||
|
raise ValueError("G20 URDF correction input endpoint offsets are invalid")
|
||||||
|
if any(not math.isfinite(float(value)) for value in offsets.values()):
|
||||||
|
raise ValueError("G20 URDF correction input contains a non-finite offset")
|
||||||
|
if any(not math.isfinite(float(value)) for value in endpoints.values()):
|
||||||
|
raise ValueError("G20 URDF correction input has a non-finite endpoint")
|
||||||
|
|
||||||
|
|
||||||
|
def load_g20_urdf_input(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
side: str,
|
||||||
|
layout_id: str,
|
||||||
|
serial_number: str,
|
||||||
|
) -> tuple[dict[str, float], dict[str, float]]:
|
||||||
|
"""Read, validate and authenticate correction parameters from JSON."""
|
||||||
|
source = Path(path).expanduser().resolve()
|
||||||
|
try:
|
||||||
|
payload = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"could not read G20 URDF correction input: {source}") from error
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise ValueError("G20 URDF correction input must be a JSON object")
|
||||||
|
validate_g20_urdf_input_payload(payload)
|
||||||
|
if (
|
||||||
|
payload["side"] != str(side).lower()
|
||||||
|
or payload["layout_id"] != str(layout_id)
|
||||||
|
or payload["serial_number"] != str(serial_number)
|
||||||
|
):
|
||||||
|
raise ValueError("G20 URDF correction input does not match the session")
|
||||||
|
if payload["source_urdf_sha256"] != _sha256_file(source_urdf):
|
||||||
|
raise ValueError("G20 source URDF changed after calibration JSON was written")
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in payload["offsets_rad"].items()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in payload["endpoint_anchored_offsets_rad"].items()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_g20_urdf_input_payload",
|
||||||
|
"load_g20_urdf_input",
|
||||||
|
"validate_g20_urdf_input_payload",
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Reviewed static-zero, endpoint, and mimic topology exports."""
|
||||||
|
|
||||||
|
from .profile import MIMIC_DERIVED_FINGER_DIPS
|
||||||
|
from .zero_solver import (
|
||||||
|
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
|
||||||
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
|
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
|
||||||
|
get_zero_calibration_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MIMIC_DERIVED_FINGER_DIPS",
|
||||||
|
"RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS",
|
||||||
|
"RIGHT_19_MECHANICAL_ENDPOINT_JOINTS",
|
||||||
|
"RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS",
|
||||||
|
"get_zero_calibration_profile",
|
||||||
|
]
|
||||||
+59
-166
@@ -5,10 +5,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import math
|
import math
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
@@ -17,8 +15,15 @@ from scipy.optimize import least_squares
|
|||||||
from scipy.spatial.transform import Rotation
|
from scipy.spatial.transform import Rotation
|
||||||
from scipy.stats import t as student_t
|
from scipy.stats import t as student_t
|
||||||
|
|
||||||
from .core import fit_rotation_axis, robust_rotation_summary
|
from ...core import fit_rotation_axis, robust_rotation_summary
|
||||||
from .full_hand import (
|
from ...core.urdf import (
|
||||||
|
UrdfCorrectionPlan,
|
||||||
|
UrdfJointPatch,
|
||||||
|
UrdfPatchSet,
|
||||||
|
materialize_relative_mesh_assets as _materialize_relative_mesh_assets,
|
||||||
|
write_urdf_patches,
|
||||||
|
)
|
||||||
|
from .profile import (
|
||||||
G20_RIGHT_19_LAYOUT,
|
G20_RIGHT_19_LAYOUT,
|
||||||
IMAGE_TRAJECTORY_JOINTS,
|
IMAGE_TRAJECTORY_JOINTS,
|
||||||
LEFT_HAND_PROFILE,
|
LEFT_HAND_PROFILE,
|
||||||
@@ -28,8 +33,8 @@ from .full_hand import (
|
|||||||
JointCurveFit,
|
JointCurveFit,
|
||||||
get_hand_calibration_profile,
|
get_hand_calibration_profile,
|
||||||
)
|
)
|
||||||
from .sample_schema import explicit_domain_value
|
from ...sample_schema import explicit_domain_value
|
||||||
from .trajectory import (
|
from ...trajectory import (
|
||||||
_fit_circle_with_axis,
|
_fit_circle_with_axis,
|
||||||
_fit_joint_curve,
|
_fit_joint_curve,
|
||||||
_fit_plane_axis,
|
_fit_plane_axis,
|
||||||
@@ -66,6 +71,15 @@ class ZeroCalibrationProfile:
|
|||||||
# thumb kernel instead uses only the serial thumb chain, so its nuisance
|
# thumb kernel instead uses only the serial thumb chain, so its nuisance
|
||||||
# palm pose cannot be influenced by finger observations.
|
# palm pose cannot be influenced by finger observations.
|
||||||
base_pose_strategy: str = "full_hand"
|
base_pose_strategy: str = "full_hand"
|
||||||
|
# A serial-chain model may use a separate palm-root joint axis to fix the
|
||||||
|
# otherwise free rotation about its primary root axis. G20 retains its
|
||||||
|
# historical defaults; other model profiles can name the physical anchor
|
||||||
|
# explicitly without introducing model-specific branches in the solver.
|
||||||
|
orientation_anchor_joint: str | None = None
|
||||||
|
# A model whose feedback direction is mechanically reviewed may use the
|
||||||
|
# signed rotation axes to disambiguate the otherwise mirrored palm-frame
|
||||||
|
# branches. The default remains undirected for legacy G20/L6 profiles.
|
||||||
|
directed_base_axis_joints: frozenset[str] = frozenset()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reference_finger(self) -> str:
|
def reference_finger(self) -> str:
|
||||||
@@ -3237,7 +3251,7 @@ def solve_urdf_zero_offsets(
|
|||||||
np.ptp(np.asarray(curves[name].angle_rad))
|
np.ptp(np.asarray(curves[name].angle_rad))
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
orientation_anchor = (
|
orientation_anchor = profile.orientation_anchor_joint or (
|
||||||
"thumb_cmc_pitch"
|
"thumb_cmc_pitch"
|
||||||
if profile.base_pose_strategy == "thumb_serial"
|
if profile.base_pose_strategy == "thumb_serial"
|
||||||
else f"{profile.reference_finger}_mcp_pitch"
|
else f"{profile.reference_finger}_mcp_pitch"
|
||||||
@@ -3304,14 +3318,13 @@ def solve_urdf_zero_offsets(
|
|||||||
predicted_axis, _ = predicted_local(item, pose_zero_offsets)
|
predicted_axis, _ = predicted_local(item, pose_zero_offsets)
|
||||||
predicted_axis = rotation.apply(predicted_axis)
|
predicted_axis = rotation.apply(predicted_axis)
|
||||||
observed_axis = np.asarray(item.axis_common_xyz, dtype=float)
|
observed_axis = np.asarray(item.axis_common_xyz, dtype=float)
|
||||||
|
alignment = float(
|
||||||
|
np.clip(predicted_axis @ observed_axis, -1.0, 1.0)
|
||||||
|
)
|
||||||
error = math.acos(
|
error = math.acos(
|
||||||
abs(
|
alignment
|
||||||
float(
|
if item.joint in profile.directed_base_axis_joints
|
||||||
np.clip(
|
else abs(alignment)
|
||||||
predicted_axis @ observed_axis, -1.0, 1.0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
errors_by_joint.setdefault(item.joint, []).append(error)
|
errors_by_joint.setdefault(item.joint, []).append(error)
|
||||||
branch_score = sum(
|
branch_score = sum(
|
||||||
@@ -3850,7 +3863,7 @@ def solve_urdf_zero_offsets(
|
|||||||
if maximum_cone_mismatch > maximum_axis_cone_mismatch_rad:
|
if maximum_cone_mismatch > maximum_axis_cone_mismatch_rad:
|
||||||
cone_range = float(np.ptp(cone_mismatches))
|
cone_range = float(np.ptp(cone_mismatches))
|
||||||
stable_product_bias = bool(
|
stable_product_bias = bool(
|
||||||
profile.hand.supports("stable_cross_view_cone_bias")
|
profile.hand.stable_cross_view_cone_bias
|
||||||
and maximum_systematic_axis_cone_bias_rad is not None
|
and maximum_systematic_axis_cone_bias_rad is not None
|
||||||
and len(cone_mismatches) >= 4
|
and len(cone_mismatches) >= 4
|
||||||
and maximum_cone_mismatch
|
and maximum_cone_mismatch
|
||||||
@@ -4372,80 +4385,6 @@ def solve_urdf_zero_offsets(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _files_have_identical_contents(left: Path, right: Path) -> bool:
|
|
||||||
if left.stat().st_size != right.stat().st_size:
|
|
||||||
return False
|
|
||||||
with left.open("rb") as left_stream, right.open("rb") as right_stream:
|
|
||||||
while True:
|
|
||||||
left_chunk = left_stream.read(1024 * 1024)
|
|
||||||
right_chunk = right_stream.read(1024 * 1024)
|
|
||||||
if left_chunk != right_chunk:
|
|
||||||
return False
|
|
||||||
if not left_chunk:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _materialize_relative_mesh_assets(
|
|
||||||
*, source: Path, output: Path, urdf_root: ET.Element
|
|
||||||
) -> tuple[Path, ...]:
|
|
||||||
"""Copy relative mesh resources so a session-local URDF remains loadable."""
|
|
||||||
filenames = sorted(
|
|
||||||
{
|
|
||||||
str(mesh.get("filename", "")).strip()
|
|
||||||
for mesh in urdf_root.findall(".//mesh")
|
|
||||||
if str(mesh.get("filename", "")).strip()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
materialized: list[Path] = []
|
|
||||||
for filename in filenames:
|
|
||||||
# URI-backed resources are resolved by the URDF consumer. Only local
|
|
||||||
# relative resources need to follow a URDF copied to a session folder.
|
|
||||||
if "://" in filename or filename.startswith("package:"):
|
|
||||||
continue
|
|
||||||
relative = Path(filename)
|
|
||||||
if relative.is_absolute() or ".." in relative.parts:
|
|
||||||
raise ValueError(
|
|
||||||
f"URDF mesh path must be a safe relative path or URI: {filename}"
|
|
||||||
)
|
|
||||||
source_asset = (source.parent / relative).resolve()
|
|
||||||
if not source_asset.is_file():
|
|
||||||
raise ValueError(f"URDF mesh resource does not exist: {source_asset}")
|
|
||||||
destination_asset = (output / relative).resolve()
|
|
||||||
try:
|
|
||||||
destination_asset.relative_to(output)
|
|
||||||
except ValueError as error:
|
|
||||||
raise ValueError(
|
|
||||||
f"URDF mesh destination escapes output directory: {filename}"
|
|
||||||
) from error
|
|
||||||
if destination_asset == source_asset:
|
|
||||||
materialized.append(destination_asset)
|
|
||||||
continue
|
|
||||||
destination_asset.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if destination_asset.exists():
|
|
||||||
if not destination_asset.is_file() or not _files_have_identical_contents(
|
|
||||||
source_asset, destination_asset
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
f"refusing to overwrite a different mesh resource: "
|
|
||||||
f"{destination_asset}"
|
|
||||||
)
|
|
||||||
materialized.append(destination_asset)
|
|
||||||
continue
|
|
||||||
temporary_asset = destination_asset.with_name(
|
|
||||||
f".{destination_asset.name}.{os.getpid()}.tmp"
|
|
||||||
)
|
|
||||||
if temporary_asset.exists():
|
|
||||||
raise ValueError(f"temporary mesh path is occupied: {temporary_asset}")
|
|
||||||
try:
|
|
||||||
shutil.copy2(source_asset, temporary_asset)
|
|
||||||
os.replace(temporary_asset, destination_asset)
|
|
||||||
finally:
|
|
||||||
if temporary_asset.exists():
|
|
||||||
temporary_asset.unlink()
|
|
||||||
materialized.append(destination_asset)
|
|
||||||
return tuple(materialized)
|
|
||||||
|
|
||||||
|
|
||||||
def write_zero_corrected_urdf(
|
def write_zero_corrected_urdf(
|
||||||
*,
|
*,
|
||||||
source_urdf: str | Path,
|
source_urdf: str | Path,
|
||||||
@@ -4454,18 +4393,12 @@ def write_zero_corrected_urdf(
|
|||||||
offsets_rad: Mapping[str, float],
|
offsets_rad: Mapping[str, float],
|
||||||
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
|
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
|
||||||
timestamp: str | None = None,
|
timestamp: str | None = None,
|
||||||
|
correction_plan: UrdfCorrectionPlan | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
source = Path(source_urdf).expanduser().resolve()
|
source = Path(source_urdf).expanduser().resolve()
|
||||||
output = Path(output_directory).expanduser().resolve()
|
output = Path(output_directory).expanduser().resolve()
|
||||||
if not source.is_file():
|
if not source.is_file():
|
||||||
raise ValueError(f"source URDF does not exist: {source}")
|
raise ValueError(f"source URDF does not exist: {source}")
|
||||||
if (
|
|
||||||
"zero_calibrated" in source.stem.lower()
|
|
||||||
or re.search(r"calibrated_20\d{6}", source.stem.lower())
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"source_urdf must be the original CAD URDF, not a calibrated URDF"
|
|
||||||
)
|
|
||||||
if not offsets_rad:
|
if not offsets_rad:
|
||||||
raise ValueError("offsets_rad must contain at least one joint")
|
raise ValueError("offsets_rad must contain at least one joint")
|
||||||
offsets = {str(name): float(value) for name, value in offsets_rad.items()}
|
offsets = {str(name): float(value) for name, value in offsets_rad.items()}
|
||||||
@@ -4475,12 +4408,20 @@ def write_zero_corrected_urdf(
|
|||||||
}
|
}
|
||||||
if not set(endpoint_offsets) <= set(offsets):
|
if not set(endpoint_offsets) <= set(offsets):
|
||||||
raise ValueError("endpoint-anchored offsets must be URDF zero targets")
|
raise ValueError("endpoint-anchored offsets must be URDF zero targets")
|
||||||
|
if correction_plan is not None:
|
||||||
|
correction_plan.verify_source(source)
|
||||||
|
correction_plan.authorize_offsets(offsets)
|
||||||
|
if not set(endpoint_offsets).issubset(
|
||||||
|
correction_plan.endpoint_limit_joints
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"endpoint offsets are not authorized by the correction plan"
|
||||||
|
)
|
||||||
if any(
|
if any(
|
||||||
not math.isfinite(value) or abs(value) > math.radians(90.0)
|
not math.isfinite(value) or abs(value) > math.radians(90.0)
|
||||||
for value in offsets.values()
|
for value in offsets.values()
|
||||||
):
|
):
|
||||||
raise ValueError("URDF zero offsets must be finite and within +/-90deg")
|
raise ValueError("URDF zero offsets must be finite and within +/-90deg")
|
||||||
output.mkdir(parents=True, exist_ok=True)
|
|
||||||
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
if re.fullmatch(r"\d{8}_\d{6}", stamp) is None:
|
if re.fullmatch(r"\d{8}_\d{6}", stamp) is None:
|
||||||
raise ValueError("URDF zero timestamp must use YYYYMMDD_HHMMSS")
|
raise ValueError("URDF zero timestamp must use YYYYMMDD_HHMMSS")
|
||||||
@@ -4490,12 +4431,8 @@ def write_zero_corrected_urdf(
|
|||||||
)
|
)
|
||||||
if not safe_serial:
|
if not safe_serial:
|
||||||
raise ValueError("serial_number must not be empty")
|
raise ValueError("serial_number must not be empty")
|
||||||
destination = output / f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf"
|
|
||||||
if destination == source or destination.exists():
|
|
||||||
raise ValueError(f"refusing to overwrite URDF: {destination}")
|
|
||||||
tree = ET.parse(source)
|
tree = ET.parse(source)
|
||||||
root = tree.getroot()
|
root = tree.getroot()
|
||||||
original_text = source.read_text(encoding="utf-8")
|
|
||||||
replacement_rpy: dict[str, str] = {}
|
replacement_rpy: dict[str, str] = {}
|
||||||
replacement_upper: dict[str, str] = {}
|
replacement_upper: dict[str, str] = {}
|
||||||
replacement_mimic_offset: dict[str, str] = {}
|
replacement_mimic_offset: dict[str, str] = {}
|
||||||
@@ -4553,72 +4490,28 @@ def write_zero_corrected_urdf(
|
|||||||
missing = sorted(set(offsets) - found)
|
missing = sorted(set(offsets) - found)
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError("source URDF is missing target joints: " + ",".join(missing))
|
raise ValueError("source URDF is missing target joints: " + ",".join(missing))
|
||||||
joint_pattern = re.compile(
|
patch_names = (
|
||||||
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>"
|
set(replacement_rpy)
|
||||||
r".*?</joint>",
|
| set(replacement_upper)
|
||||||
re.DOTALL,
|
| set(replacement_mimic_offset)
|
||||||
)
|
)
|
||||||
edits: list[tuple[int, int, str]] = []
|
joint_patches = {
|
||||||
for match in joint_pattern.finditer(original_text):
|
name: UrdfJointPatch(
|
||||||
name = match.group("name")
|
origin_rpy=replacement_rpy.get(name),
|
||||||
if name not in replacement_rpy:
|
limit_upper=replacement_upper.get(name),
|
||||||
if name not in replacement_upper and name not in replacement_mimic_offset:
|
mimic_offset=replacement_mimic_offset.get(name),
|
||||||
continue
|
|
||||||
block = match.group(0)
|
|
||||||
if name in replacement_rpy:
|
|
||||||
origin_match = re.search(
|
|
||||||
r"<origin\b[^>]*\brpy\s*=\s*([\"'])(?P<rpy>[^\"']*)\1",
|
|
||||||
block,
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
)
|
||||||
if origin_match is None:
|
for name in sorted(patch_names)
|
||||||
raise ValueError(f"joint {name} origin has no rpy attribute")
|
}
|
||||||
edits.append((
|
destination = output / (
|
||||||
match.start() + origin_match.start("rpy"),
|
f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf"
|
||||||
match.start() + origin_match.end("rpy"),
|
|
||||||
replacement_rpy[name],
|
|
||||||
))
|
|
||||||
if name in replacement_upper:
|
|
||||||
limit_match = re.search(
|
|
||||||
r"<limit\b[^>]*\bupper\s*=\s*([\"'])(?P<upper>[^\"']*)\1",
|
|
||||||
block,
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
)
|
||||||
if limit_match is None:
|
return write_urdf_patches(
|
||||||
raise ValueError(f"joint {name} limit has no upper attribute")
|
source_urdf=source,
|
||||||
edits.append((
|
destination_urdf=destination,
|
||||||
match.start() + limit_match.start("upper"),
|
patches=UrdfPatchSet(joints=joint_patches),
|
||||||
match.start() + limit_match.end("upper"),
|
forbidden_source_stem_patterns=(
|
||||||
replacement_upper[name],
|
r"zero_calibrated",
|
||||||
))
|
r"calibrated_20\d{6}",
|
||||||
if name in replacement_mimic_offset:
|
),
|
||||||
mimic_match = re.search(
|
|
||||||
r"<mimic\b[^>]*\boffset\s*=\s*([\"'])(?P<offset>[^\"']*)\1",
|
|
||||||
block,
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
)
|
||||||
if mimic_match is None:
|
|
||||||
raise ValueError(f"joint {name} mimic has no offset attribute")
|
|
||||||
edits.append((
|
|
||||||
match.start() + mimic_match.start("offset"),
|
|
||||||
match.start() + mimic_match.end("offset"),
|
|
||||||
replacement_mimic_offset[name],
|
|
||||||
))
|
|
||||||
expected_edit_count = (
|
|
||||||
len(replacement_rpy)
|
|
||||||
+ len(replacement_upper)
|
|
||||||
+ len(replacement_mimic_offset)
|
|
||||||
)
|
|
||||||
if len(edits) != expected_edit_count:
|
|
||||||
raise ValueError("could not locate every target joint field in source URDF text")
|
|
||||||
corrected_text = original_text
|
|
||||||
for start, end, value in reversed(edits):
|
|
||||||
corrected_text = corrected_text[:start] + value + corrected_text[end:]
|
|
||||||
_materialize_relative_mesh_assets(source=source, output=output, urdf_root=root)
|
|
||||||
temporary = destination.with_suffix(".urdf.tmp")
|
|
||||||
with temporary.open("w", encoding="utf-8") as stream:
|
|
||||||
stream.write(corrected_text)
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
os.replace(temporary, destination)
|
|
||||||
return destination
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Registered L6 calibration profiles."""
|
||||||
|
|
||||||
|
from ..registry import ProfileRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def register_profiles(registry: ProfileRegistry) -> None:
|
||||||
|
from .left_transfer import build_profile as build_left_transfer_profile
|
||||||
|
from .profile import build_profile
|
||||||
|
|
||||||
|
registry.register(build_profile())
|
||||||
|
registry.register(build_left_transfer_profile())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["register_profiles"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user