Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial support for VR headsets #241

Draft
wants to merge 22 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
49 changes: 49 additions & 0 deletions doc/getting_started/running_experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,52 @@ eeg_device = EEG(device=board_name)
# Run stimulus presentation
n170.present(duration=record_duration, eeg=eeg_device, save_fn=save_fn)
```


## Using virtual reality

### Heads up display

A heads-up display can be used for presenting experiments in a similar way to a monitor, without much modification.

#### Features to be added in future releases:

* Controller input
* Controller haptic feedback

### Prerequisites:
* Oculus Rift compatible VR headset, e.g. Oculus Rift or Meta Quest series.
* Native Windows installation with meta link compatible video card.
* EEG device, e.g. OpenBCI Cyton or Muse

If an experiment has the use_vr argument in its present method, it can have its stimulus presented to a subject's VR headset.
The N170 experiment for example, can have its stimulus displayed on the VR headset with a simple modification to the 'use_vr' argument, when presenting an experiment:

```python
# Run stimulus presentation with VR enabled.
n170.present(duration=record_duration, eeg=eeg_device, save_fn=save_fn, use_vr=True)
```

###

### Steps for running experiment in VR

1. Launch the Oculus app on the Windows computer and the IDE or CLI to be used for running the experiment.
2. Turn on the VR headset and put it on your head to make sure it is on and active, then take it off.
3. Go to the 'Devices' view in the Oculus app, it will show the headset as connected and active, along with any inactive or connected controllers.
4. Go to the 'Settings' view, under the 'Beta' title, enable 'Pass through over Oculus Link', double tapping the headset later with a fingertip will activate passthrough.
5. Put the VR headset onto the head, activate passthrough to help with wearing the eeg device.
6. Place the EEG device on top of the head.
7. Ensure the electrodes are touching the scalp ok and not being blocked by the headset strap.
8. From inside the VR headset's 'quick settings' dashboard, select 'Quest Link' and connect to the Oculus server running on windows, via air link or link cable.
9. Once the Oculus menu has finished loading on the VR headset, open the built-in Oculus desktop app by using the touch controllers or gamepad.
10. Try opening an eeg device raw data viwer and verify that the electrodes are receiving a good signal without too much noise, eg 'OpenBCI GUI'.
11. Run the EEG-ExPy experiment from the command line or IDE, it should load and take control from the Oculus desktop app.
12. Follow the experiment instructions, and press a key if necessary to begin the experiment and collect valid data.

### Other experiments can have VR added too.

1. Load/prepare stimulus in the same function as previously (def load_stimulus(self))
2. Present stimulus in the same function as previously(def present_stimulus(self, current_trial: int))
3. VR can be enabled for the experiment as part of the initializer to the base Experiment class, by default it is not enabled(use_vr=False) and will function the same as previously before VR functionality was added.

116 changes: 75 additions & 41 deletions eegnb/experiments/Experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,36 @@
obj.run()
"""

from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Callable
from psychopy import prefs
#change the pref libraty to PTB and set the latency mode to high precision
prefs.hardware['audioLib'] = 'PTB'
prefs.hardware['audioLatencyMode'] = 3

import os
from time import time
from glob import glob
from random import choice
from optparse import OptionParser
import random

import numpy as np
from pandas import DataFrame
from psychopy import visual, core, event
from psychopy import visual, event

from eegnb import generate_save_fn
from eegnb.devices.eeg import EEG


class BaseExperiment:

def __init__(self, exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter):
""" Initializer for the Base Experiment Class """
def __init__(self, exp_name, duration, eeg, save_fn, n_trials: int, iti: float, soa: float, jitter: float,
use_vr=False):
""" Initializer for the Base Experiment Class

Args:
n_trials (int): Number of trials/stimulus
iti (float): Inter-trial interval
soa (float): Stimulus on arrival
jitter (float): Random delay between stimulus
use_vr (bool): Use VR for displaying stimulus
"""

self.exp_name = exp_name
self.instruction_text = """\nWelcome to the {} experiment!\nStay still, focus on the centre of the screen, and try not to blink. \nThis block will run for %s seconds.\n
Expand All @@ -43,7 +49,8 @@ def __init__(self, exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter)
self.iti = iti
self.soa = soa
self.jitter = jitter

self.use_vr = use_vr

@abstractmethod
def load_stimulus(self):
"""
Expand Down Expand Up @@ -76,7 +83,9 @@ def setup(self, instructions=True):
self.trials = DataFrame(dict(parameter=self.parameter, timestamp=np.zeros(self.n_trials)))

# Setting up Graphics
self.window = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True)
self.window = (
visual.Rift(monoscopic=True, headLocked=True) if self.use_vr
else visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True))

# Loading the stimulus from the specific experiment, throws an error if not overwritten in the specific experiment
self.stim = self.load_stimulus()
Expand Down Expand Up @@ -112,20 +121,36 @@ def show_instructions(self):
# Disabling the cursor during display of instructions
self.window.mouseVisible = False

# Displaying the instructions on the screen
text = visual.TextStim(win=self.window, text=self.instruction_text, color=[-1, -1, -1])
# Waiting for the user to press the spacebar to start the experiment
while len(event.getKeys(keyList="space")) == 0:
# Displaying the instructions on the screen
text = visual.TextStim(win=self.window, text=self.instruction_text, color=[-1, -1, -1])
self.__draw(lambda: self.__draw_instructions(text))

# Enabling the cursor again
self.window.mouseVisible = True

def __draw_instructions(self, text):
text.draw()
self.window.flip()

# Waiting for the user to press the spacebar to start the experiment
event.waitKeys(keyList="space")

# Enabling the cursor again
self.window.mouseVisible = True

def __draw(self, present_stimulus: Callable):
"""
Set the current eye position and projection for all given stimulus,
then draw all stimulus and flip the window/buffer
"""
if self.use_vr:
tracking_state = self.window.getTrackingState()
self.window.calcEyePoses(tracking_state.headPose.thePose)
self.window.setDefaultView()
present_stimulus()

def run(self, instructions=True):
""" Do the present operation for a bunch of experiments """

def iti_with_jitter():
return self.iti + np.random.rand() * self.jitter

# Setup the experiment, alternatively could get rid of this line, something to think about
self.setup(instructions)

Expand All @@ -137,29 +162,38 @@ def run(self, instructions=True):

print("EEG Stream started")

# Run trial until a key is pressed or experiment duration has expired.
start = time()

# Iterate through the events
for ii, trial in self.trials.iterrows():

# Intertrial interval
core.wait(self.iti + np.random.rand() * self.jitter)

# Stimulus presentation overwritten by specific experiment
self.present_stimulus(ii, trial)

# Offset
core.wait(self.soa)
self.window.flip()

# Exiting the loop condition, looks ugly and needs to be fixed
if len(event.getKeys()) > 0 or (time() - start) > self.record_duration:
break

# Clearing the screen for the next trial
event.clearEvents()

# Closing the EEG stream
current_trial = current_trial_end = -1
current_trial_begin = None

# Current trial being rendered
rendering_trial = -1
while len(event.getKeys()) == 0 and (time() - start) < self.record_duration:

current_experiment_seconds = time() - start
# Do not present stimulus until current trial begins(Adhere to inter-trial interval).
if current_trial_end < current_experiment_seconds:
current_trial += 1
current_trial_begin = current_experiment_seconds + iti_with_jitter()
current_trial_end = current_trial_begin + self.soa

# Do not present stimulus after trial has ended(stimulus on arrival interval).
elif current_trial_begin < current_experiment_seconds:

# if current trial number changed get new choice of image.
if rendering_trial < current_trial:
# Some form of presenting the stimulus - sometimes order changed in lower files like ssvep
# Stimulus presentation overwritten by specific experiment
self.__draw(lambda: self.present_stimulus(current_trial, current_trial))
rendering_trial = current_trial
else:
self.__draw(lambda: self.window.flip())

# Clearing the screen for the next trial
event.clearEvents()

# Closing the EEG stream
if self.eeg:
self.eeg.stop()

Expand Down
9 changes: 2 additions & 7 deletions eegnb/experiments/visual_n170/n170.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@
from time import time
from glob import glob
from random import choice
from optparse import OptionParser
import random

import numpy as np
from pandas import DataFrame
from psychopy import visual, core, event

from eegnb.devices.eeg import EEG
Expand All @@ -27,12 +22,12 @@ class VisualN170(Experiment.BaseExperiment):

def __init__(self, duration=120, eeg: Optional[EEG]=None, save_fn=None,

n_trials = 2010, iti = 0.4, soa = 0.3, jitter = 0.2):
n_trials = 2010, iti = 0.4, soa = 0.3, jitter = 0.2, use_vr = False):

# Set experiment name
exp_name = "Visual N170"
# Calling the super class constructor to initialize the experiment variables
super(VisualN170, self).__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter)
super(VisualN170, self).__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter, use_vr)

def load_stimulus(self):

Expand Down
4 changes: 2 additions & 2 deletions eegnb/experiments/visual_p300/p300.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ class VisualP300(Experiment.BaseExperiment):

def __init__(self, duration=120, eeg: Optional[EEG]=None, save_fn=None,

n_trials = 2010, iti = 0.4, soa = 0.3, jitter = 0.2):
n_trials = 2010, iti = 0.4, soa = 0.3, jitter = 0.2, use_vr = False):

exp_name = "Visual P300"
super().__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter)
super().__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter, use_vr)

def load_stimulus(self):

Expand Down
41 changes: 27 additions & 14 deletions eegnb/experiments/visual_ssvep/ssvep.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,20 @@

class VisualSSVEP(Experiment.BaseExperiment):

def __init__(self, duration=120, eeg: Optional[EEG]=None, save_fn=None, n_trials = 2010, iti = 0.5, soa = 3.0, jitter = 0.2):
def __init__(self, duration=120, eeg: Optional[EEG]=None, save_fn=None, n_trials = 2010, iti = 0.5, soa = 3.0, jitter = 0.2, use_vr=False):

self.use_vr = use_vr
exp_name = "Visual SSVEP"
super().__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter)
super().__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter, use_vr)

def load_stimulus(self):

self.grating = visual.GratingStim(win=self.window, mask="circle", size=80, sf=0.2)
grating_sf = 400 if self.use_vr else 0.2
self.grating = visual.GratingStim(win=self.window, mask="circle", size=80, sf=grating_sf)
self.grating_neg = visual.GratingStim(win=self.window, mask="circle", size=80, sf=grating_sf, phase=0.5)

self.grating_neg = visual.GratingStim(win=self.window, mask="circle", size=80, sf=0.2, phase=0.5)

fixation = visual.GratingStim(win=self.window, size=0.2, pos=[0, 0], sf=0.2, color=[1, 0, 0], autoDraw=True)
self.fixation = visual.GratingStim(win=self.window, pos=[0, 0], sf=grating_sf, color=[1, 0, 0])
self.fixation.size = 0.02 if self.use_vr else 0.2

# Generate the possible ssvep frequencies based on monitor refresh rate
def get_possible_ssvep_freqs(frame_rate, stim_type="single"):
Expand Down Expand Up @@ -65,7 +67,10 @@ def init_flicker_stim(frame_rate, cycle, soa):
return {"cycle": cycle, "freq": stim_freq, "n_cycles": n_cycles}

# Set up stimuli
frame_rate = np.round(self.window.getActualFrameRate()) # Frame rate, in Hz

# Frame rate, in Hz
# GetActualFrameRate() crashes in psychxr due to 'EndFrame called before BeginFrame'
frame_rate = np.round(self.window.displayRefreshRate if self.use_vr else self.window.getActualFrameRate())
freqs = get_possible_ssvep_freqs(frame_rate, stim_type="reversal")
self.stim_patterns = [
init_flicker_stim(frame_rate, 2, self.soa),
Expand Down Expand Up @@ -102,14 +107,22 @@ def present_stimulus(self, idx, trial):

# Present flickering stim
for _ in range(int(self.stim_patterns[ind]["n_cycles"])):
self.grating.setAutoDraw(True)

for _ in range(int(self.stim_patterns[ind]["cycle"][0])):
if self.use_vr:
tracking_state = self.window.getTrackingState()
self.window.calcEyePoses(tracking_state.headPose.thePose)
self.window.setDefaultView()
self.grating.draw()
self.fixation.draw()
self.window.flip()
self.grating.setAutoDraw(False)
self.grating_neg.setAutoDraw(True)

for _ in range(self.stim_patterns[ind]["cycle"][1]):
if self.use_vr:
tracking_state = self.window.getTrackingState()
self.window.calcEyePoses(tracking_state.headPose.thePose)
self.window.setDefaultView()
self.grating_neg.draw()
self.fixation.draw()
self.window.flip()
self.grating_neg.setAutoDraw(False)
pass

self.window.flip()
pass
2 changes: 1 addition & 1 deletion examples/visual_n170/00x__n170_run_experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
eeg_device = EEG(device=board_name)

# Experiment type
experiment = VisualN170(duration=record_duration, eeg=eeg_device, save_fn=save_fn)
experiment = VisualN170(duration=record_duration, eeg=eeg_device, save_fn=save_fn, use_vr=False)

###################################################################################################
# Run experiment
Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ recommonmark
versioneer
rst2pdf
docutils

# Oculus/Quest VR support - currently only supported on Windows.
psychxr>=0.2.4rc2; platform_system == "Windows"