Skip to content

Commit e74c41d

Browse files
authored
Refactored BaseExperiment class to enhance VR support and usability (#304)
* Refactored `BaseExperiment` class to enhance VR support and usability - Added stereoscopic VR rendering (`stereoscopic` parameter) with positional eye rendering. - Introduced trial loop method `_run_trial_loop` to simplify `run` and make it accessible to inheriting classes. - Fixed methods to use correct convention for being accessible by inheriting classes. - Added methods for inter-trial interval visualization (`present_iti`) and post-trial clean-up. - Modified variable intialization so that the defaults can be overriden before calling run(). - Allow early experiment exit when escape is pressed during instructions.
1 parent a90ab5e commit e74c41d

File tree

1 file changed

+121
-57
lines changed

1 file changed

+121
-57
lines changed

eegnb/experiments/Experiment.py

Lines changed: 121 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
obj.run()
99
"""
1010

11-
from abc import abstractmethod
11+
from abc import abstractmethod, ABC
1212
from typing import Callable
13+
from eegnb.devices.eeg import EEG
14+
from psychopy import prefs
1315
from psychopy.visual.rift import Rift
1416

1517
from time import time
@@ -22,39 +24,62 @@
2224
from eegnb import generate_save_fn
2325

2426

25-
class BaseExperiment:
27+
class BaseExperiment(ABC):
2628

2729
def __init__(self, exp_name, duration, eeg, save_fn, n_trials: int, iti: float, soa: float, jitter: float,
28-
use_vr=False, use_fullscr = True, screen_num=0):
30+
use_vr=False, use_fullscr = True, screen_num=0, stereoscopic = False):
2931
""" Initializer for the Base Experiment Class
3032
3133
Args:
34+
exp_name (str): Name of the experiment
35+
duration (float): Duration of the experiment in seconds
36+
eeg: EEG device object for recording
37+
save_fn (str): Save filename function for data
3238
n_trials (int): Number of trials/stimulus
3339
iti (float): Inter-trial interval
3440
soa (float): Stimulus on arrival
3541
jitter (float): Random delay between stimulus
3642
use_vr (bool): Use VR for displaying stimulus
43+
use_fullscr (bool): Use fullscreen mode
3744
screen_num (int): Screen number (if multiple monitors present)
45+
stereoscopic (bool): Use stereoscopic rendering for VR
3846
"""
3947

4048
self.exp_name = exp_name
4149
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
4250
Press spacebar to continue. \n""".format(self.exp_name)
4351
self.duration = duration
44-
self.eeg = eeg
52+
self.eeg: EEG = eeg
4553
self.save_fn = save_fn
4654
self.n_trials = n_trials
4755
self.iti = iti
4856
self.soa = soa
4957
self.jitter = jitter
5058
self.use_vr = use_vr
5159
self.screen_num = screen_num
60+
self.stereoscopic = stereoscopic
5261
if use_vr:
5362
# VR interface accessible by specific experiment classes for customizing and using controllers.
54-
self.rift: Rift = visual.Rift(monoscopic=True, headLocked=True)
63+
self.rift: Rift = visual.Rift(monoscopic=not stereoscopic, headLocked=True)
64+
# eye for presentation
65+
if stereoscopic:
66+
self.left_eye_x_pos = 0.2
67+
self.right_eye_x_pos = -0.2
68+
else:
69+
self.left_eye_x_pos = 0
70+
self.right_eye_x_pos = 0
71+
5572
self.use_fullscr = use_fullscr
5673
self.window_size = [1600,800]
5774

75+
# Initializing the record duration and the marker names
76+
self.record_duration = np.float32(self.duration)
77+
self.markernames = [1, 2]
78+
79+
# Setting up the trial and parameter list
80+
self.parameter = np.random.binomial(1, 0.5, self.n_trials)
81+
self.trials = DataFrame(dict(parameter=self.parameter, timestamp=np.zeros(self.n_trials)))
82+
5883
@abstractmethod
5984
def load_stimulus(self):
6085
"""
@@ -76,17 +101,20 @@ def present_stimulus(self, idx : int):
76101
"""
77102
raise NotImplementedError
78103

79-
def setup(self, instructions=True):
104+
def present_iti(self):
105+
"""
106+
Method that presents the inter-trial interval display for the specific experiment.
80107
81-
# Initializing the record duration and the marker names
82-
self.record_duration = np.float32(self.duration)
83-
self.markernames = [1, 2]
84-
85-
# Setting up the trial and parameter list
86-
self.parameter = np.random.binomial(1, 0.5, self.n_trials)
87-
self.trials = DataFrame(dict(parameter=self.parameter, timestamp=np.zeros(self.n_trials)))
108+
This method defines what is shown on the screen during the period between stimuli.
109+
It could be a blank screen, a fixation cross, or any other appropriate display.
110+
111+
This is an optional method - the default implementation simply flips the window with no additional content.
112+
Subclasses can override this method to provide custom ITI graphics.
113+
"""
114+
self.window.flip()
88115

89-
# Setting up Graphics
116+
def setup(self, instructions=True):
117+
# Setting up Graphics
90118
self.window = (
91119
self.rift if self.use_vr
92120
else visual.Window(self.window_size, monitor="testMonitor", units="deg",
@@ -97,7 +125,8 @@ def setup(self, instructions=True):
97125

98126
# Show Instruction Screen if not skipped by the user
99127
if instructions:
100-
self.show_instructions()
128+
if not self.show_instructions():
129+
return False
101130

102131
# Checking for EEG to setup the EEG stream
103132
if self.eeg:
@@ -112,7 +141,8 @@ def setup(self, instructions=True):
112141
print(
113142
f"No path for a save file was passed to the experiment. Saving data to {self.save_fn}"
114143
)
115-
144+
return True
145+
116146
def show_instructions(self):
117147
"""
118148
Method that shows the instructions for the specific Experiment
@@ -127,18 +157,22 @@ def show_instructions(self):
127157
self.window.mouseVisible = False
128158

129159
# clear/reset any old key/controller events
130-
self.__clear_user_input()
160+
self._clear_user_input()
131161

132162
# Waiting for the user to press the spacebar or controller button or trigger to start the experiment
133-
while not self.__user_input('start'):
163+
while not self._user_input('start'):
134164
# Displaying the instructions on the screen
135165
text = visual.TextStim(win=self.window, text=self.instruction_text, color=[-1, -1, -1])
136-
self.__draw(lambda: self.__draw_instructions(text))
166+
self._draw(lambda: self.__draw_instructions(text))
137167

138168
# Enabling the cursor again
139169
self.window.mouseVisible = True
140170

141-
def __user_input(self, input_type):
171+
if self._user_input('cancel'):
172+
return False
173+
return True
174+
175+
def _user_input(self, input_type):
142176
if input_type == 'start':
143177
key_input = 'spacebar'
144178
vr_inputs = [
@@ -155,6 +189,9 @@ def __user_input(self, input_type):
155189
('Xbox', 'B', None)
156190
]
157191

192+
else:
193+
raise Exception(f'Invalid input_type: {input_type}')
194+
158195
if len(event.getKeys(keyList=key_input)) > 0:
159196
return True
160197

@@ -192,10 +229,16 @@ def get_vr_input(self, vr_controller, button=None, trigger=False):
192229
return False
193230

194231
def __draw_instructions(self, text):
195-
text.draw()
232+
if self.use_vr and self.stereoscopic:
233+
for eye, x_pos in [("left", self.left_eye_x_pos), ("right", self.right_eye_x_pos)]:
234+
self.window.setBuffer(eye)
235+
text.pos = (x_pos, 0)
236+
text.draw()
237+
else:
238+
text.draw()
196239
self.window.flip()
197240

198-
def __draw(self, present_stimulus: Callable):
241+
def _draw(self, present_stimulus: Callable):
199242
"""
200243
Set the current eye position and projection for all given stimulus,
201244
then draw all stimulus and flip the window/buffer
@@ -206,7 +249,7 @@ def __draw(self, present_stimulus: Callable):
206249
self.window.setDefaultView()
207250
present_stimulus()
208251

209-
def __clear_user_input(self):
252+
def _clear_user_input(self):
210253
event.getKeys()
211254
self.clear_vr_input()
212255

@@ -216,14 +259,61 @@ def clear_vr_input(self):
216259
"""
217260
if self.use_vr:
218261
self.rift.updateInputState()
262+
263+
def _run_trial_loop(self, start_time, duration):
264+
"""
265+
Run the trial presentation loop
266+
267+
This method handles the common trial presentation logic.
268+
269+
Args:
270+
start_time (float): Time when the trial loop started
271+
duration (float): Maximum duration of the trial loop in seconds
219272
220-
def run(self, instructions=True):
221-
""" Do the present operation for a bunch of experiments """
273+
"""
222274

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

226-
# Setup the experiment, alternatively could get rid of this line, something to think about
278+
# Initialize trial variables
279+
current_trial = trial_end_time = -1
280+
trial_start_time = None
281+
rendering_trial = -1
282+
283+
# Clear/reset user input buffer
284+
self._clear_user_input()
285+
286+
# Run the trial loop
287+
while (time() - start_time) < duration:
288+
elapsed_time = time() - start_time
289+
290+
# Do not present stimulus until current trial begins(Adhere to inter-trial interval).
291+
if elapsed_time > trial_end_time:
292+
current_trial += 1
293+
294+
# Calculate timing for this trial
295+
trial_start_time = elapsed_time + iti_with_jitter()
296+
trial_end_time = trial_start_time + self.soa
297+
298+
# Do not present stimulus after trial has ended(stimulus on arrival interval).
299+
if elapsed_time >= trial_start_time:
300+
# if current trial number changed present new stimulus.
301+
if current_trial > rendering_trial:
302+
# Stimulus presentation overwritten by specific experiment
303+
self._draw(lambda: self.present_stimulus(current_trial))
304+
rendering_trial = current_trial
305+
else:
306+
self._draw(lambda: self.present_iti())
307+
308+
if self._user_input('cancel'):
309+
return False
310+
311+
return True
312+
313+
def run(self, instructions=True):
314+
""" Run the experiment """
315+
316+
# Setup the experiment
227317
self.setup(instructions)
228318

229319
print("Wait for the EEG-stream to start...")
@@ -234,37 +324,11 @@ def iti_with_jitter():
234324

235325
print("EEG Stream started")
236326

237-
# Run trial until a key is pressed or experiment duration has expired.
238-
start = time()
239-
current_trial = current_trial_end = -1
240-
current_trial_begin = None
241-
242-
# Current trial being rendered
243-
rendering_trial = -1
244-
245-
# Clear/reset user input buffer
246-
self.__clear_user_input()
247-
248-
while not self.__user_input('cancel') and (time() - start) < self.record_duration:
249-
250-
current_experiment_seconds = time() - start
251-
# Do not present stimulus until current trial begins(Adhere to inter-trial interval).
252-
if current_trial_end < current_experiment_seconds:
253-
current_trial += 1
254-
current_trial_begin = current_experiment_seconds + iti_with_jitter()
255-
current_trial_end = current_trial_begin + self.soa
256-
257-
# Do not present stimulus after trial has ended(stimulus on arrival interval).
258-
elif current_trial_begin < current_experiment_seconds:
259-
260-
# if current trial number changed get new choice of image.
261-
if rendering_trial < current_trial:
262-
# Some form of presenting the stimulus - sometimes order changed in lower files like ssvep
263-
# Stimulus presentation overwritten by specific experiment
264-
self.__draw(lambda: self.present_stimulus(current_trial))
265-
rendering_trial = current_trial
266-
else:
267-
self.__draw(lambda: self.window.flip())
327+
# Record experiment until a key is pressed or duration has expired.
328+
record_start_time = time()
329+
330+
# Run the trial loop
331+
self._run_trial_loop(record_start_time, self.record_duration)
268332

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

0 commit comments

Comments
 (0)