机器人情绪系统设计
范围:Reachy Mini 机器人的情绪动作系统设计,涵盖情绪/舞蹈/呼吸状态切换、音效同步、产品鲜活感设计
综合自:reachy-mini-conversation-app
优先级:P0
概述
Reachy Mini 的情绪系统是一个精心设计的分层动画架构,它将机器人的"鲜活感"(aliveness)分解为多个独立的运动层,并通过智能融合创造出自然、持续的交互体验。这个系统的核心思想是:机器人永远不应该完全静止。
系统采用"主要动作 + 次要偏移"的双层融合架构,在 100Hz 控制循环中实时合成最终姿态。情绪动作、舞蹈、头部定位和呼吸状态作为主要动作顺序执行,而语音摆动和人脸追踪作为次要偏移叠加在主要动作之上。
核心架构
1. 分层运动系统
主要动作(Primary Moves)
主要动作是互斥的、顺序执行的运动:
队列顺序:情绪 → 舞蹈 → Goto 定位 → 呼吸
代码实现(moves.py):
class MovementManager:
"""协调顺序动作、附加偏移和机器人输出(100Hz)。
职责:
- 拥有实时循环,采样当前主要动作,融合次要偏移,调用 set_target
- 在 idle_inactivity_delay 后启动 BreathingMove
- 暴露线程安全 API
"""
def __init__(self, current_robot: ReachyMini, camera_worker=None):
self.move_queue: deque[Move] = deque() # 主要动作队列
self.state = MovementState()
self.idle_inactivity_delay = 0.3 # 秒
self.target_frequency = 100.0 # Hz
设计理由:
- 互斥执行:避免动作冲突,确保流畅过渡
- 队列管理:支持动作预排队,实现无缝衔接
- 单控制点:所有运动通过
set_target统一输出
次要偏移(Secondary Offsets)
次要偏移是叠加在主要动作上的实时偏移:
# 次要偏移组合
secondary_offsets = [
self.state.speech_offsets[0] + self.state.face_tracking_offsets[0], # x
self.state.speech_offsets[1] + self.state.face_tracking_offsets[1], # y
self.state.speech_offsets[2] + self.state.face_tracking_offsets[2], # z
self.state.speech_offsets[3] + self.state.face_tracking_offsets[3], # roll
self.state.speech_offsets[4] + self.state.face_tracking_offsets[4], # pitch
self.state.speech_offsets[5] + self.state.face_tracking_offsets[5], # yaw
]
设计理由:
- 加性融合:多个偏移可以同时作用
- 世界坐标系:使用
compose_world_offset进行姿态合成 - 线程安全:通过锁保护偏移更新
2. 呼吸状态配置
呼吸是机器人在空闲时自动进入的"待机动画",创造持续鲜活感。
呼吸参数设计
class BreathingMove(Move):
def __init__(self, interpolation_start_pose, interpolation_start_antennas,
interpolation_duration=1.0):
# 中性位置
self.neutral_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=True)
self.neutral_antennas = np.array([0.0, 0.0])
# 呼吸参数
self.breathing_z_amplitude = 0.005 # 5mm 轻微上下浮动
self.breathing_frequency = 0.1 # Hz(每分钟 6 次呼吸)
self.antenna_sway_amplitude = np.deg2rad(15) # 天线摆动 15 度
self.antenna_frequency = 0.5 # Hz(天线更快摆动)
呼吸动画分两阶段:
- 插值阶段:从当前姿态平滑过渡到中性位置
- 呼吸循环:持续的 Z 轴浮动 + 天线交替摆动
def evaluate(self, t: float):
if t < self.interpolation_duration:
# 阶段 1:插值到中性位置
interpolation_t = t / self.interpolation_duration
head_pose = linear_pose_interpolation(
self.interpolation_start_pose,
self.neutral_head_pose,
interpolation_t
)
else:
# 阶段 2:呼吸循环
breathing_time = t - self.interpolation_duration
# Z 轴轻微浮动
z_offset = self.breathing_z_amplitude * np.sin(
2 * np.pi * self.breathing_frequency * breathing_time
)
# 天线交替摆动(增加鲜活感)
antenna_sway = self.antenna_sway_amplitude * np.sin(
2 * np.pi * self.antenna_frequency * breathing_time
)
antennas = np.array([antenna_sway, -antenna_sway])
设计考量:
- 5mm 浮动:足够被感知但不分散注意力
- 6 次/分钟:接近人类呼吸频率,创造亲和感
- 天线交替:打破完全对称,增加有机感
呼吸触发条件
def _manage_breathing(self, current_time: float):
"""管理空闲时的自动呼吸"""
if (self.state.current_move is None
and not self.move_queue
and not self._is_listening
and not self._breathing_active):
idle_for = current_time - self.state.last_activity_time
if idle_for >= self.idle_inactivity_delay: # 0.3 秒
# 获取当前姿态作为插值起点
current_head_pose = self.current_robot.get_current_head_pose()
_, current_antennas = self.current_robot.get_current_joint_positions()
breathing_move = BreathingMove(
interpolation_start_pose=current_head_pose,
interpolation_start_antennas=current_antennas,
interpolation_duration=1.0
)
self.move_queue.append(breathing_move)
中断机制:任何新动作入队时,呼吸立即被中断:
if isinstance(self.state.current_move, BreathingMove) and self.move_queue:
self.state.current_move = None
self._breathing_active = False
logger.debug("由于新动作活动停止呼吸")