# Copyright (c) 2025, Yutang Li, SIAT (yt.li2@siat.ac.cn) # SPDX-License-Identifier: BSD-3-Clause """Head tracking via XR headset (HMD) for robot head joint control.""" import numpy as np from scipy.spatial.transform import Rotation as R from xr_utils import transform_xr_pose, is_valid_quaternion class HeadTracker: """Extracts yaw/pitch from HMD pose relative to a calibrated neutral orientation.""" def __init__(self, yaw_limit: float = 1.57, pitch_limit: float = 0.52): self.yaw_limit = yaw_limit # ±90° self.pitch_limit = pitch_limit # ±30° self.neutral_rot: R | None = None def reset(self): """Clear neutral calibration; next call to get_targets() will re-calibrate.""" self.neutral_rot = None def get_targets(self, xr_client) -> np.ndarray: """Return [yaw, pitch] in radians relative to neutral HMD pose. First call after reset() records neutral orientation and returns zeros. """ try: raw_pose = xr_client.get_pose("headset") if not is_valid_quaternion(raw_pose[3:]): return np.zeros(2, dtype=np.float32) _, head_world_quat_xyzw = transform_xr_pose(raw_pose[:3], raw_pose[3:]) head_rot = R.from_quat(head_world_quat_xyzw) if self.neutral_rot is None: self.neutral_rot = head_rot return np.zeros(2, dtype=np.float32) rel_rot = head_rot * self.neutral_rot.inv() euler_zyx = rel_rot.as_euler("ZYX") yaw = float(np.clip(euler_zyx[0], -self.yaw_limit, self.yaw_limit)) pitch = float(np.clip(euler_zyx[1], -self.pitch_limit, self.pitch_limit)) return np.array([yaw, pitch], dtype=np.float32) except Exception: return np.zeros(2, dtype=np.float32)