Source code for pyheadtracker.supperware

"""
This module is used to interact with Supperware head trackers via MIDI.

Classes:
- `HeadTracker1`: A class for reading orientation data from the Head Tracker 1 device via MIDI.
"""

import mido
import numpy as np
import time
from typing import Optional
from .dtypes import YPR, Quaternion, HTBase


[docs] class HeadTracker1(HTBase): """ Class for interacting with the Supperware Head Tracker 1 This class can be used to set up and read data from the Supperware Head Tracker 1 device [1] via MIDI. All relevant settings mentioned in the MIDI protocol documentation [2] can be applied. A gist [3] by Abhaya Parthy provided helpful information to write this class. Attributes ---------- device_name : str The name of the MIDI device to connect to. device_name_output : str The name of the MIDI device to send data to. If not specified, it defaults to the same as `device_name`. inport : mido.Input The MIDI input port for reading data. This attribute is initialized to `None` and set when the `open()` method is called. outport : mido.Output The MIDI output port for sending data. This attribute is initialized to `None` and set when the `open()` method is called. refresh_rate : int The rate in Hertz at which the device should send updates. Possible values are 25, 50, or 100. raw_format : bool If True, the device will send raw data without any adjustments. Not implemented yet. Default is False. compass_on : bool If True, the compass will be enabled. Default is False. orient_format : str The format for orientation data. Possible values are "ypr" (Yaw, Pitch, Roll), "q" (Quaternion), or "orth" (Orthogonal matrix). Default is "q". gestures : str Enable gesture recognition for resetting orientation. Possible values are "preserve" (keep existing state), "on" (enable gestures), or "off" (disable gestures). Default is "preserve". chirality : str Whether the head tracker cable is attached to the left or right side of the head. Possible values are "left" (left side) or "right" (right side), or "preserve" (keep existing state). Default is "preserve". central_pull : bool If True, the head tracker will pull back to the center when the compass is disabled. Default is False. central_pull_rate : float The rate in degree per second at which the head tracker will pull back to the center when `central_pull` is enabled. Default is 0.3. travel_mode : str The travel mode of the head tracker for yaw correction. Possible values are "preserve" (keep existing state), "off" (disable travel mode), "slow" (enable slow travel mode), or "fast" (enable fast travel mode). Default is "preserve". Methods ------- open(compass_force_calibration: bool = False) Opens the MIDI input and output ports and configures the head tracker with the specified settings. close() Closes the MIDI input and output ports gracefully. zero_orientation() Resets the head tracker orientation. zero() Resets the head tracker orientation. set_travel_mode(travel_mode: str) Sets the travel mode of the head tracker for yaw correction. calibrate_compass() Calibrates the compass of the head tracker. read_orientation() Reads the orientation data from the MIDI device and returns it in the specified format (Quaternion or YPR). References ---------- [1] https://supperware.co.uk/headtracker-overview [2] https://supperware.net/downloads/head-tracker/head%20tracker%20protocol.pdf [3] https://gist.github.com/abhayap/8701b710f32e592c52e771e938243e87 """
[docs] def __init__( self, device_name: str = "Head Tracker", device_name_output: Optional[str] = None, refresh_rate: int = 50, raw_format: bool = False, compass_on: bool = False, orient_format: str = "q", gestures_on: str = "preserve", chirality: str = "preserve", central_pull: bool = False, central_pull_rate: float = 0.3, travel_mode: str = "preserve", ): """ Parameters ---------- device_name : str The name of the MIDI device to connect to. Default is "Head Tracker". device_name_output : str, optional The name of the MIDI device to send data to. If not specified, it defaults to the same as `device_name`. refresh_rate : int, optional The rate in Hertz at which the device should send updates. Possible values are 25, 50, or 100. Default is 50. raw_format : bool, optional If True, the device will send raw data without any adjustments. Not implemented yet. Default is False. compass_on : bool, optional If True, the compass will be enabled. Default is False. orient_format : str, optional The format for orientation data. Possible values are "ypr" (Yaw, Pitch, Roll), "q" (Quaternion), or "orth" (Orthogonal matrix). Default is "q". gestures_on : str, optional Enable gesture recognition for resetting orientation. Possible values are "preserve" (keep existing state), "on" (enable gestures), or "off" (disable gestures). Default is "preserve". chirality : str, optional Whether the head tracker cable is attached to the left or right side of the head. Possible values are "left" (left side) or "right" (right side), or "preserve" (keep existing state). Default is "preserve". central_pull : bool, optional If True, the head tracker will pull back to the center when the compass is disabled. Default is False. central_pull_rate : float, optional The rate in degree per second at which the head tracker will pull back to the center when `central_pull` is enabled. Default is 0.3. travel_mode : str, optional The travel mode of the head tracker for yaw correction. Possible values are "preserve" (keep existing state), "off" (disable travel mode), "slow" (enable slow travel mode), or "fast" (enable fast travel mode). Default is "preserve". Raises ------ RuntimeError If the specified MIDI device cannot be opened or does not exist. Make sure the device is connected and the name is correct. On Windows, only exclusive connections to MIDI devices are possible, so ensure no other application is using the device. """ self.device_name = device_name try: with mido.open_input(self.device_name) as inport: pass except IOError as e: raise RuntimeError( f"Could not open MIDI input device '{self.device_name}': {e}\nAvailable devices: {mido.get_input_names()}" ) try: with mido.open_output(device_name_output or device_name) as outport: pass except IOError as e: raise RuntimeError( f"Could not open MIDI output device '{device_name_output or device_name}': {e}\nAvailable devices: {mido.get_output_names()}" ) assert refresh_rate in [25, 50, 100], "Refresh rate must be 25, 50, or 100 Hz" assert orient_format in [ "ypr", "q", "orth", ], "Orientation format must be 'ypr', 'q', or 'orth'" assert gestures_on in [ "preserve", "on", "off", ], "Gestures must be 'preserve', 'on', or 'off'" assert chirality in [ "preserve", "left", "right", ], "Chirality must be 'preserve', 'left', or 'right'" assert travel_mode in [ "preserve", "off", "slow", "fast", ], "Travel mode must be 'preserve', 'off', 'slow', or 'fast'" if device_name_output is None: self.device_name_output = device_name else: self.device_name_output = device_name_output self.inport = None self.outport = None self.refresh_rate = refresh_rate self.raw_format = raw_format self.compass_on = compass_on self.orient_format = orient_format self.gestures = gestures_on self.chirality = chirality self.central_pull = central_pull self.central_pull_rate = central_pull_rate self.travel_mode = travel_mode
[docs] def open(self, compass_force_calibration: bool = False): """Open the head tracker connection. This method opens the MIDI input and output ports and sends a message to configure the head tracker with the specified settings. Parameters ---------- compass_force_calibration : bool, optional If True, forces the compass to calibrate when opening the connection. Default is False. """ # Open device if self.inport is None: self.inport = mido.open_input(self.device_name) if self.outport is None: self.outport = mido.open_output(self.device_name_output) # Start of message to open headtracker msg = [0, 33, 66, 0] # Parameter 0 - sensor setup msg.append(0) refresh_rate_bin = ( "00" if self.refresh_rate == 50 else "01" if self.refresh_rate == 25 else "10" ) msg.append(int(f"0b1{refresh_rate_bin}1000", 2)) # Parameter 1 - data output and formatting msg.append(1) raw_format_bin = ( "00" if not self.raw_format else "01" if self.compass_on else "10" ) orientation_format_bin = ( "00" if self.orient_format == "ypr" else "01" if self.orient_format == "q" else "10" ) msg.append(int(f"0b0{raw_format_bin}{orientation_format_bin}01", 2)) # Parameter 2 is just resetting/calibrating the sensors --> not needed for opening connection # Parameter 3 - Compass control # TODO: Enable verbose mode to check compass quality msg.append(3) msg.append( int( f"0b00{int(self.compass_on)}{int(not self.central_pull)}{int(compass_force_calibration)}00", 2, ) ) # Parameter 4 - gestures and chirality if self.gestures != "preserve" or self.chirality != "preserve": msg.append(4) gestures_bin = ( "000" if self.gestures == "preserve" else "100" if self.gestures == "off" else "110" ) chirality_bin = ( "00" if self.chirality == "preserve" else "01" if self.chirality == "right" else "10" ) msg.append( int( f"0b00{gestures_bin}{chirality_bin}", 2, ) ) # Parameter 5 - state readback # Parameter 6 - central pull if self.central_pull: msg.append(6) msg.append(int(np.round(self.central_pull_rate / 0.05)) - 1) # Send message msg_enc = mido.Message("sysex", data=msg) self.outport.send(msg_enc) if self.travel_mode != "preserve": self.set_travel_mode(self.travel_mode)
# TODO: Return a status message or confirmation if successful
[docs] def close(self): """Close the connection to the head tracker. This method closes the MIDI input and output ports gracefully. """ if self.inport is not None: self.inport.close() if self.outport is not None: msg_enc = mido.Message("sysex", data=[0, 33, 66, 0, 1, 0]) self.outport.send(msg_enc) self.outport.close() time.sleep(0.2) # Allow some time for the device to process the command
[docs] def zero_orientation(self): """Zero the head tracker sensors. This method sends a message to the head tracker to reset its orientation. Duplicates the functionality of `zero()` for consistency throughout the package. """ self.zero()
[docs] def zero(self): """Zero the head tracker sensors. This method sends a message to the head tracker to reset its orientation. """ if self.outport is not None: msg_enc = mido.Message("sysex", data=[0, 33, 66, 1, 0, 1]) self.outport.send(msg_enc)
[docs] def set_travel_mode(self, travel_mode: str): """Set the travel mode of the head tracker. This method sends a message to the head tracker to set the travel mode for yaw correction. Parameters ---------- travel_mode : str The travel mode to set. Must be one of: preserve, off, slow, fast. """ self.travel_mode = travel_mode assert travel_mode in [ "preserve", "off", "slow", "fast", ], "Travel mode must be one of: preserve, off, slow, fast" travel_mode_bin = ( "0b000" if travel_mode == "preserve" else ( "0b100" if travel_mode == "off" else "0b110" if travel_mode == "slow" else "0b111" ) ) msg = [0, 33, 66, 1, 1, int(travel_mode_bin, 2)] msg_enc = mido.Message("sysex", data=msg) if self.outport is not None: self.outport.send(msg_enc)
[docs] def calibrate_compass(self): """Calibrate the compass. This method sends a message to the head tracker to calibrate the compass. """ cal_message = int( f"0b00{int(self.compass_on)}{int(not self.central_pull)}100", 2, ) msg = [0, 33, 66, 0, 3, cal_message] msg_enc = mido.Message("sysex", data=msg) if self.outport is not None: self.outport.send(msg_enc)
[docs] def read_orientation(self): """Read the orientation data from the head tracker. This method reads the orientation data from the MIDI device and returns it in the specified format (Quaternion or YPR). Returns ------- Quaternion or YPR or np.ndarray or None The orientation data in the specified format (Quaternion, YPR, or np.ndarray for orth). Returns None if no data is available. """ if self.orient_format == "ypr": return self.__read_orientation_ypr() elif self.orient_format == "q": return self.__read_orientation_q() elif self.orient_format == "orth": return self.__read_orientation_orth()
# TODO: Read raw data def __read_orientation_ypr(self): """Read orientation data in YPR format from the MIDI device. Returns ------- YPR or None The orientation data in YPR format. Returns None if no data is available. """ if self.inport is None: raise RuntimeError("MIDI input port is not open. Call open() first.") for msg in self.inport: # Check if it's orientation data if msg.data[3] == 64: yaw = self.__convert_14bit(msg.data[5], msg.data[6]) pitch = -self.__convert_14bit(msg.data[7], msg.data[8]) roll = self.__convert_14bit(msg.data[9], msg.data[10]) return YPR(yaw, pitch, roll, "ypr") def __read_orientation_q(self): """Read orientation data in Quaternion format from the MIDI device. Returns ------- Quaternion or None The orientation data in Quaternion format. Returns None if no data is available. """ if self.inport is None: raise RuntimeError("MIDI input port is not open. Call open() first.") for msg in self.inport: # Check if it's orientation data if msg.data[3] == 64: qw = self.__convert_14bit(msg.data[5], msg.data[6]) qy = -self.__convert_14bit(msg.data[7], msg.data[8]) qx = self.__convert_14bit(msg.data[9], msg.data[10]) qz = self.__convert_14bit(msg.data[11], msg.data[12]) return Quaternion(qw, qx, qy, qz) def __read_orientation_orth(self): """Read orientation data in orthogonal matrix format from the MIDI device. Returns ------- np.ndarray or None The orientation data in orthogonal matrix format. Returns None if no data is available. """ if self.inport is None: raise RuntimeError("MIDI input port is not open. Call open() first.") for msg in self.inport: # Check if it's orientation data if msg.data[3] == 64: m11 = self.__convert_14bit(msg.data[5], msg.data[6]) m12 = self.__convert_14bit(msg.data[7], msg.data[8]) m13 = self.__convert_14bit(msg.data[9], msg.data[10]) m21 = self.__convert_14bit(msg.data[11], msg.data[12]) m22 = self.__convert_14bit(msg.data[13], msg.data[14]) m23 = self.__convert_14bit(msg.data[15], msg.data[16]) m31 = self.__convert_14bit(msg.data[17], msg.data[18]) m32 = self.__convert_14bit(msg.data[19], msg.data[20]) m33 = self.__convert_14bit(msg.data[21], msg.data[22]) return np.array([[m11, m12, m13], [m21, m22, m23], [m31, m32, m33]]) def __convert_14bit(self, msb, lsb): """Convert two 7-bit MIDI bytes into 14 bit integer and then to a float. Parameters ---------- msb : int The most significant byte (MSB) of the 14-bit value. lsb : int The least significant byte (LSB) of the 14-bit value. Returns ------- float The converted 14-bit value as a float. """ i = (128 * msb) + lsb if i >= 8192: i -= 16384 return i * 0.00048828125