Commit ·
a946134
1
Parent(s): f9facd8
refactor(movement): use SDK compose_world_offset for proper pose composition
Browse files
reachy_mini_ha_voice/movement_manager.py
CHANGED
|
@@ -5,14 +5,24 @@ This module provides a centralized control system for robot movements,
|
|
| 5 |
inspired by the reachy_mini_conversation_app architecture.
|
| 6 |
|
| 7 |
Key features:
|
| 8 |
-
- Single
|
| 9 |
- Command queue pattern (thread-safe external API)
|
| 10 |
- Error throttling (prevents log explosion)
|
| 11 |
-
- Speech-driven head sway
|
| 12 |
-
- Breathing animation during idle
|
| 13 |
- Graceful shutdown
|
| 14 |
- Pose change detection (skip sending if no significant change)
|
| 15 |
- Robust connection recovery (faster reconnection attempts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
import logging
|
|
@@ -30,6 +40,15 @@ from scipy.spatial.transform import Rotation as R
|
|
| 30 |
if TYPE_CHECKING:
|
| 31 |
from reachy_mini import ReachyMini
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
logger = logging.getLogger(__name__)
|
| 34 |
|
| 35 |
|
|
@@ -665,36 +684,82 @@ class MovementManager:
|
|
| 665 |
logger.debug("Error getting face tracking offsets: %s", e)
|
| 666 |
|
| 667 |
def _compose_final_pose(self) -> Dict[str, float]:
|
| 668 |
-
"""Compose final pose from all sources.
|
| 669 |
-
# Primary pose (from actions)
|
| 670 |
-
pitch = self.state.target_pitch
|
| 671 |
-
yaw = self.state.target_yaw
|
| 672 |
-
roll = self.state.target_roll
|
| 673 |
-
x = self.state.target_x
|
| 674 |
-
y = self.state.target_y
|
| 675 |
-
z = self.state.target_z
|
| 676 |
-
|
| 677 |
-
# Add speech sway (secondary) - rotation and translation
|
| 678 |
-
pitch += self.state.speech_pitch
|
| 679 |
-
yaw += self.state.speech_yaw
|
| 680 |
-
roll += self.state.speech_roll
|
| 681 |
-
x += self.state.speech_x
|
| 682 |
-
y += self.state.speech_y
|
| 683 |
-
z += self.state.speech_z
|
| 684 |
-
|
| 685 |
-
# Add breathing
|
| 686 |
-
z += self.state.breathing_z
|
| 687 |
|
| 688 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
with self._face_tracking_lock:
|
| 690 |
face_offsets = self._face_tracking_offsets
|
| 691 |
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
|
| 699 |
# Antenna pose with freeze blending
|
| 700 |
target_antenna_left = self.state.target_antenna_left + self.state.breathing_antenna_left
|
|
@@ -713,12 +778,12 @@ class MovementManager:
|
|
| 713 |
antenna_right = target_antenna_right
|
| 714 |
|
| 715 |
return {
|
| 716 |
-
"pitch":
|
| 717 |
-
"yaw":
|
| 718 |
-
"roll":
|
| 719 |
-
"x":
|
| 720 |
-
"y":
|
| 721 |
-
"z":
|
| 722 |
"antenna_left": antenna_left,
|
| 723 |
"antenna_right": antenna_right,
|
| 724 |
"body_yaw": self.state.target_body_yaw,
|
|
|
|
| 5 |
inspired by the reachy_mini_conversation_app architecture.
|
| 6 |
|
| 7 |
Key features:
|
| 8 |
+
- Single 20Hz control loop (balanced between responsiveness and stability)
|
| 9 |
- Command queue pattern (thread-safe external API)
|
| 10 |
- Error throttling (prevents log explosion)
|
| 11 |
+
- Speech-driven head sway (based on conversation_app's SwayRollRT)
|
| 12 |
+
- Breathing animation during idle (based on conversation_app's BreathingMove)
|
| 13 |
- Graceful shutdown
|
| 14 |
- Pose change detection (skip sending if no significant change)
|
| 15 |
- Robust connection recovery (faster reconnection attempts)
|
| 16 |
+
- Proper pose composition using SDK's compose_world_offset (same as conversation_app)
|
| 17 |
+
- Antenna freeze during listening mode with smooth blend back
|
| 18 |
+
|
| 19 |
+
SDK Analysis Notes:
|
| 20 |
+
- get_current_head_pose() and get_current_joint_positions() are non-blocking
|
| 21 |
+
(they return cached Zenoh data from subscriptions)
|
| 22 |
+
- set_target() is the only method that sends Zenoh messages
|
| 23 |
+
- get_status() may trigger I/O, so it's cached in reachy_controller.py
|
| 24 |
+
|
| 25 |
+
Reference: reachy_mini_conversation_app/src/reachy_mini_conversation_app/moves.py
|
| 26 |
"""
|
| 27 |
|
| 28 |
import logging
|
|
|
|
| 40 |
if TYPE_CHECKING:
|
| 41 |
from reachy_mini import ReachyMini
|
| 42 |
|
| 43 |
+
# Import SDK utilities for pose composition (same as conversation_app)
|
| 44 |
+
try:
|
| 45 |
+
from reachy_mini.utils import create_head_pose
|
| 46 |
+
from reachy_mini.utils.interpolation import compose_world_offset
|
| 47 |
+
SDK_UTILS_AVAILABLE = True
|
| 48 |
+
except ImportError:
|
| 49 |
+
SDK_UTILS_AVAILABLE = False
|
| 50 |
+
logger.warning("SDK utils not available, using fallback pose composition")
|
| 51 |
+
|
| 52 |
logger = logging.getLogger(__name__)
|
| 53 |
|
| 54 |
|
|
|
|
| 684 |
logger.debug("Error getting face tracking offsets: %s", e)
|
| 685 |
|
| 686 |
def _compose_final_pose(self) -> Dict[str, float]:
|
| 687 |
+
"""Compose final pose from all sources.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
|
| 689 |
+
Uses SDK's compose_world_offset for proper pose composition (same as conversation_app).
|
| 690 |
+
Primary pose comes from actions, secondary offsets come from speech sway and face tracking.
|
| 691 |
+
"""
|
| 692 |
+
# Build primary head pose matrix (from actions)
|
| 693 |
+
if SDK_UTILS_AVAILABLE:
|
| 694 |
+
primary_head = create_head_pose(
|
| 695 |
+
x=self.state.target_x,
|
| 696 |
+
y=self.state.target_y,
|
| 697 |
+
z=self.state.target_z,
|
| 698 |
+
roll=self.state.target_roll,
|
| 699 |
+
pitch=self.state.target_pitch,
|
| 700 |
+
yaw=self.state.target_yaw,
|
| 701 |
+
degrees=False, # Our state is in radians
|
| 702 |
+
mm=False, # Our state is in meters
|
| 703 |
+
)
|
| 704 |
+
else:
|
| 705 |
+
# Fallback: build matrix manually
|
| 706 |
+
rotation = R.from_euler('xyz', [
|
| 707 |
+
self.state.target_roll,
|
| 708 |
+
self.state.target_pitch,
|
| 709 |
+
self.state.target_yaw,
|
| 710 |
+
])
|
| 711 |
+
primary_head = np.eye(4)
|
| 712 |
+
primary_head[:3, :3] = rotation.as_matrix()
|
| 713 |
+
primary_head[0, 3] = self.state.target_x
|
| 714 |
+
primary_head[1, 3] = self.state.target_y
|
| 715 |
+
primary_head[2, 3] = self.state.target_z
|
| 716 |
+
|
| 717 |
+
# Build secondary offset pose (speech sway + face tracking + breathing)
|
| 718 |
+
# Get face tracking offsets
|
| 719 |
with self._face_tracking_lock:
|
| 720 |
face_offsets = self._face_tracking_offsets
|
| 721 |
|
| 722 |
+
# Combine all secondary offsets
|
| 723 |
+
secondary_x = self.state.speech_x + face_offsets[0]
|
| 724 |
+
secondary_y = self.state.speech_y + face_offsets[1]
|
| 725 |
+
secondary_z = self.state.speech_z + self.state.breathing_z + face_offsets[2]
|
| 726 |
+
secondary_roll = self.state.speech_roll + face_offsets[3]
|
| 727 |
+
secondary_pitch = self.state.speech_pitch + face_offsets[4]
|
| 728 |
+
secondary_yaw = self.state.speech_yaw + face_offsets[5]
|
| 729 |
+
|
| 730 |
+
if SDK_UTILS_AVAILABLE:
|
| 731 |
+
secondary_head = create_head_pose(
|
| 732 |
+
x=secondary_x,
|
| 733 |
+
y=secondary_y,
|
| 734 |
+
z=secondary_z,
|
| 735 |
+
roll=secondary_roll,
|
| 736 |
+
pitch=secondary_pitch,
|
| 737 |
+
yaw=secondary_yaw,
|
| 738 |
+
degrees=False,
|
| 739 |
+
mm=False,
|
| 740 |
+
)
|
| 741 |
+
# Compose using SDK utility (same as conversation_app)
|
| 742 |
+
combined_head = compose_world_offset(primary_head, secondary_head, reorthonormalize=True)
|
| 743 |
+
else:
|
| 744 |
+
# Fallback: simple addition (less accurate but works)
|
| 745 |
+
secondary_rotation = R.from_euler('xyz', [secondary_roll, secondary_pitch, secondary_yaw])
|
| 746 |
+
secondary_head = np.eye(4)
|
| 747 |
+
secondary_head[:3, :3] = secondary_rotation.as_matrix()
|
| 748 |
+
secondary_head[0, 3] = secondary_x
|
| 749 |
+
secondary_head[1, 3] = secondary_y
|
| 750 |
+
secondary_head[2, 3] = secondary_z
|
| 751 |
+
|
| 752 |
+
# Simple composition: R_final = R_secondary @ R_primary, t_final = t_primary + t_secondary
|
| 753 |
+
combined_head = np.eye(4)
|
| 754 |
+
combined_head[:3, :3] = secondary_head[:3, :3] @ primary_head[:3, :3]
|
| 755 |
+
combined_head[:3, 3] = primary_head[:3, 3] + secondary_head[:3, 3]
|
| 756 |
+
|
| 757 |
+
# Extract final pose values from combined matrix
|
| 758 |
+
final_rotation = R.from_matrix(combined_head[:3, :3])
|
| 759 |
+
final_roll, final_pitch, final_yaw = final_rotation.as_euler('xyz')
|
| 760 |
+
final_x = combined_head[0, 3]
|
| 761 |
+
final_y = combined_head[1, 3]
|
| 762 |
+
final_z = combined_head[2, 3]
|
| 763 |
|
| 764 |
# Antenna pose with freeze blending
|
| 765 |
target_antenna_left = self.state.target_antenna_left + self.state.breathing_antenna_left
|
|
|
|
| 778 |
antenna_right = target_antenna_right
|
| 779 |
|
| 780 |
return {
|
| 781 |
+
"pitch": final_pitch,
|
| 782 |
+
"yaw": final_yaw,
|
| 783 |
+
"roll": final_roll,
|
| 784 |
+
"x": final_x,
|
| 785 |
+
"y": final_y,
|
| 786 |
+
"z": final_z,
|
| 787 |
"antenna_left": antenna_left,
|
| 788 |
"antenna_right": antenna_right,
|
| 789 |
"body_yaw": self.state.target_body_yaw,
|