Skip to content

Commit 864d15d

Browse files
committed
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().
1 parent 0720676 commit 864d15d

File tree

1 file changed

+123
-60
lines changed

1 file changed

+123
-60
lines changed

eegnb/experiments/Experiment.py

Lines changed: 123 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
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
1314
from psychopy import prefs
1415
from psychopy.visual.rift import Rift
1516
#change the pref libraty to PTB and set the latency mode to high precision
@@ -26,37 +27,59 @@
2627
from eegnb import generate_save_fn
2728

2829

29-
class BaseExperiment:
30+
class BaseExperiment(ABC):
3031

3132
def __init__(self, exp_name, duration, eeg, save_fn, n_trials: int, iti: float, soa: float, jitter: float,
32-
use_vr=False, use_fullscr = True):
33+
use_vr=False, use_fullscr = True, stereoscopic = False):
3334
""" Initializer for the Base Experiment Class
3435
3536
Args:
37+
exp_name (str): Name of the experiment
38+
duration (float): Duration of the experiment in seconds
39+
eeg: EEG device object for recording
40+
save_fn (str): Save filename function for data
3641
n_trials (int): Number of trials/stimulus
3742
iti (float): Inter-trial interval
3843
soa (float): Stimulus on arrival
3944
jitter (float): Random delay between stimulus
4045
use_vr (bool): Use VR for displaying stimulus
46+
use_fullscr (bool): Use fullscreen mode
47+
stereoscopic (bool): Use stereoscopic rendering for VR
4148
"""
4249

4350
self.exp_name = exp_name
44-
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
45-
Press spacebar to continue. \n""".format(self.exp_name)
51+
self.instruction_text = None
4652
self.duration = duration
47-
self.eeg = eeg
53+
self.eeg: EEG = eeg
4854
self.save_fn = save_fn
4955
self.n_trials = n_trials
5056
self.iti = iti
5157
self.soa = soa
5258
self.jitter = jitter
5359
self.use_vr = use_vr
60+
self.stereoscopic = stereoscopic
5461
if use_vr:
5562
# VR interface accessible by specific experiment classes for customizing and using controllers.
56-
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+
5772
self.use_fullscr = use_fullscr
5873
self.window_size = [1600,800]
5974

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+
6083
@abstractmethod
6184
def load_stimulus(self):
6285
"""
@@ -78,17 +101,20 @@ def present_stimulus(self, idx : int):
78101
"""
79102
raise NotImplementedError
80103

81-
def setup(self, instructions=True):
104+
def present_iti(self):
105+
"""
106+
Method that presents the inter-trial interval display for the specific experiment.
82107
83-
# Initializing the record duration and the marker names
84-
self.record_duration = np.float32(self.duration)
85-
self.markernames = [1, 2]
86-
87-
# Setting up the trial and parameter list
88-
self.parameter = np.random.binomial(1, 0.5, self.n_trials)
89-
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()
90115

91-
# Setting up Graphics
116+
def setup(self, instructions=True):
117+
# Setting up Graphics
92118
self.window = (
93119
self.rift if self.use_vr
94120
else visual.Window(self.window_size, monitor="testMonitor", units="deg", fullscr=self.use_fullscr))
@@ -98,7 +124,7 @@ def setup(self, instructions=True):
98124

99125
# Show Instruction Screen if not skipped by the user
100126
if instructions:
101-
self.show_instructions()
127+
return self.show_instructions()
102128

103129
# Checking for EEG to setup the EEG stream
104130
if self.eeg:
@@ -113,7 +139,8 @@ def setup(self, instructions=True):
113139
print(
114140
f"No path for a save file was passed to the experiment. Saving data to {self.save_fn}"
115141
)
116-
142+
return True
143+
117144
def show_instructions(self):
118145
"""
119146
Method that shows the instructions for the specific Experiment
@@ -122,24 +149,30 @@ def show_instructions(self):
122149
"""
123150

124151
# Splitting instruction text into lines
125-
self.instruction_text = self.instruction_text % self.duration
152+
if self.instruction_text is None:
153+
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
154+
Press spacebar to continue. \n""".format(self.exp_name) % self.duration
126155

127156
# Disabling the cursor during display of instructions
128157
self.window.mouseVisible = False
129158

130159
# clear/reset any old key/controller events
131-
self.__clear_user_input()
160+
self._clear_user_input()
132161

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

139168
# Enabling the cursor again
140169
self.window.mouseVisible = True
141170

142-
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):
143176
if input_type == 'start':
144177
key_input = 'spacebar'
145178
vr_inputs = [
@@ -156,6 +189,9 @@ def __user_input(self, input_type):
156189
('Xbox', 'B', None)
157190
]
158191

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

@@ -193,10 +229,16 @@ def get_vr_input(self, vr_controller, button=None, trigger=False):
193229
return False
194230

195231
def __draw_instructions(self, text):
196-
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()
197239
self.window.flip()
198240

199-
def __draw(self, present_stimulus: Callable):
241+
def _draw(self, present_stimulus: Callable):
200242
"""
201243
Set the current eye position and projection for all given stimulus,
202244
then draw all stimulus and flip the window/buffer
@@ -207,7 +249,7 @@ def __draw(self, present_stimulus: Callable):
207249
self.window.setDefaultView()
208250
present_stimulus()
209251

210-
def __clear_user_input(self):
252+
def _clear_user_input(self):
211253
event.getKeys()
212254
self.clear_vr_input()
213255

@@ -217,14 +259,61 @@ def clear_vr_input(self):
217259
"""
218260
if self.use_vr:
219261
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
220272
221-
def run(self, instructions=True):
222-
""" Do the present operation for a bunch of experiments """
273+
"""
223274

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

227-
# 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
228317
self.setup(instructions)
229318

230319
print("Wait for the EEG-stream to start...")
@@ -235,37 +324,11 @@ def iti_with_jitter():
235324

236325
print("EEG Stream started")
237326

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

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

0 commit comments

Comments
 (0)