From b591bade5ef49301000e9ca3fff61751d47df0b5 Mon Sep 17 00:00:00 2001 From: Parv Agarwal <65726543+Parvfect@users.noreply.github.com> Date: Wed, 10 Aug 2022 17:35:41 +0100 Subject: [PATCH] Experiment Class Refactor (update to #183), converting specific experiments to subclasses (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First commit * Second commit * Modifications * Lol * Lol * Incorporated N170 and p300, looking good for a PR * ssvep update * Implementing subclasses instead of loose functions * fix: fixed import (brainflow updated API) * Playing around still * Fixing import errors * Adding abstractmethod decorators * Still working on the import error * Guess what's finally working * Comments and naming ticks * More comments * Live coding demonstration * ssvep adapted * Adapting Auditory Oddball * changing save_fn to self.save_fun * This maybe the last big change * utils file changed, changes work through cli Co-authored-by: Erik Bjäreholt --- eegnb/cli/utils.py | 28 ++- eegnb/experiments/Experiment.py | 168 +++++++++++++ eegnb/experiments/Experiment_readme.txt | 13 + eegnb/experiments/__init__.py | 4 + eegnb/experiments/auditory_oddball/aob.py | 185 +++++---------- eegnb/experiments/visual_n170/n170.py | 133 +++-------- eegnb/experiments/visual_p300/p300.py | 129 +++------- eegnb/experiments/visual_ssvep/ssvep.py | 274 +++++++--------------- eegnb/experiments/visual_vep/vep.py | 20 +- 9 files changed, 438 insertions(+), 516 deletions(-) create mode 100644 eegnb/experiments/Experiment.py create mode 100644 eegnb/experiments/Experiment_readme.txt diff --git a/eegnb/cli/utils.py b/eegnb/cli/utils.py index 00a769c5..322a16dd 100644 --- a/eegnb/cli/utils.py +++ b/eegnb/cli/utils.py @@ -7,24 +7,26 @@ from eegnb.devices.eeg import EEG -from eegnb.experiments.visual_n170 import n170 -from eegnb.experiments.visual_p300 import p300 -from eegnb.experiments.visual_ssvep import ssvep +from eegnb.experiments import VisualN170 +from eegnb.experiments import VisualP300 +from eegnb.experiments import VisualSSVEP +from eegnb.experiments import AuditoryOddball from eegnb.experiments.visual_cueing import cueing from eegnb.experiments.visual_codeprose import codeprose -from eegnb.experiments.auditory_oddball import aob, diaconescu +from eegnb.experiments.auditory_oddball import diaconescu from eegnb.experiments.auditory_ssaep import ssaep, ssaep_onefreq +# New Experiment Class structure has a different initilization, to be noted experiments = { - "visual-N170": n170, - "visual-P300": p300, - "visual-SSVEP": ssvep, + "visual-N170": VisualN170(), + "visual-P300": VisualP300(), + "visual-SSVEP": VisualSSVEP(), "visual-cue": cueing, "visual-codeprose": codeprose, "auditory-SSAEP orig": ssaep, "auditory-SSAEP onefreq": ssaep_onefreq, - "auditory-oddball orig": aob, + "auditory-oddball orig": AuditoryOddball(), "auditory-oddball diaconescu": diaconescu, } @@ -42,7 +44,15 @@ def run_experiment( ): if experiment in experiments: module = experiments[experiment] - module.present(duration=record_duration, eeg=eeg_device, save_fn=save_fn) # type: ignore + + # Condition added for different run types of old and new experiment class structure + if experiment == "visual-N170" or experiment == "visual-P300" or experiment == "visual-SSVEP" or experiment == "auditory-oddball orig": + module.duration = record_duration + module.eeg = eeg_device + module.save_fn = save_fn + module.run() + else: + module.present(duration=record_duration, eeg=eeg_device, save_fn=save_fn) # type: ignore else: print("\nError: Unknown experiment '{}'".format(experiment)) print("\nExperiment can be one of:") diff --git a/eegnb/experiments/Experiment.py b/eegnb/experiments/Experiment.py new file mode 100644 index 00000000..35da046a --- /dev/null +++ b/eegnb/experiments/Experiment.py @@ -0,0 +1,168 @@ +""" +Initial run of the Experiment Class Refactor base class + +Specific experiments are implemented as sub classes that overload a load_stimulus and present_stimulus method + +Running each experiment: +obj = VisualP300({parameters}) +obj.run() +""" + +from abc import ABC, abstractmethod +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 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 """ + + 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 + Press spacebar to continue. \n""".format(self.exp_name) + self.duration = duration + self.eeg = eeg + self.save_fn = save_fn + self.n_trials = n_trials + self.iti = iti + self.soa = soa + self.jitter = jitter + + @abstractmethod + def load_stimulus(self): + """ + Method that loads the stimulus for the specific experiment, overwritten by the specific experiment + Returns the stimulus object in the form of [{stim1},{stim2},...] + Throws error if not overwritten in the specific experiment + """ + raise NotImplementedError + + @abstractmethod + def present_stimulus(self, idx : int): + """ + Method that presents the stimulus for the specific experiment, overwritten by the specific experiment + Displays the stimulus on the screen + Pushes EEG Sample if EEG is enabled + Throws error if not overwritten in the specific experiment + + idx : Trial index for the current trial + """ + raise NotImplementedError + + def setup(self, instructions=True): + + # Initializing the record duration and the marker names + self.record_duration = np.float32(self.duration) + self.markernames = [1, 2] + + # Setting up the trial and parameter list + self.parameter = np.random.binomial(1, 0.5, self.n_trials) + 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) + + # Loading the stimulus from the specific experiment, throws an error if not overwritten in the specific experiment + self.stim = self.load_stimulus() + + # Show Instruction Screen if not skipped by the user + if instructions: + self.show_instructions() + + # Checking for EEG to setup the EEG stream + if self.eeg: + # If no save_fn passed, generate a new unnamed save file + if self.save_fn is None: + # Generating a random int for the filename + random_id = random.randint(1000,10000) + # Generating save function + self.save_fn = generate_save_fn(self.eeg.device_name, "visual_n170", random_id, random_id, "unnamed") + print( + f"No path for a save file was passed to the experiment. Saving data to {self.save_fn}" + ) + + def show_instructions(self): + """ + Method that shows the instructions for the specific Experiment + In the usual case it is not overwritten, the instruction text can be overwritten by the specific experiment + No parameters accepted, can be skipped through passing a False while running the Experiment + """ + + # Splitting instruction text into lines + self.instruction_text = self.instruction_text % self.duration + + # 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]) + 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 run(self, instructions=True): + """ Do the present operation for a bunch of experiments """ + + # Setup the experiment, alternatively could get rid of this line, something to think about + self.setup(instructions) + + print("Wait for the EEG-stream to start...") + + # Start EEG Stream, wait for signal to settle, and then pull timestamp for start point + if self.eeg: + self.eeg.start(self.save_fn, duration=self.record_duration + 5) + + print("EEG Stream started") + + 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 + if self.eeg: + self.eeg.stop() + + # Closing the window + self.window.close() + + + \ No newline at end of file diff --git a/eegnb/experiments/Experiment_readme.txt b/eegnb/experiments/Experiment_readme.txt new file mode 100644 index 00000000..acaa44b0 --- /dev/null +++ b/eegnb/experiments/Experiment_readme.txt @@ -0,0 +1,13 @@ + + +Looking for a general implementation structure where base class implements and passes the following functions, + +def load_stimulus() -> stim (some form of dd array) + +def present_stimulus() -> given trial details does specific thing for experiment + +** Slight issue is that a lot of parameters will have to be passed which is not the best in practice + +Stuff that can be overwritten in general ... +instruction_text +parameter/trial diff --git a/eegnb/experiments/__init__.py b/eegnb/experiments/__init__.py index e69de29b..e17326ff 100644 --- a/eegnb/experiments/__init__.py +++ b/eegnb/experiments/__init__.py @@ -0,0 +1,4 @@ +from .visual_n170.n170 import VisualN170 +from .visual_p300.p300 import VisualP300 +from .visual_ssvep.ssvep import VisualSSVEP +from .auditory_oddball.aob import AuditoryOddball \ No newline at end of file diff --git a/eegnb/experiments/auditory_oddball/aob.py b/eegnb/experiments/auditory_oddball/aob.py index d9d28896..32ccd1b2 100644 --- a/eegnb/experiments/auditory_oddball/aob.py +++ b/eegnb/experiments/auditory_oddball/aob.py @@ -3,156 +3,83 @@ from psychopy import visual, core, event, sound from time import time +from eegnb.devices.eeg import EEG +from eegnb.experiments import Experiment -__title__ = "Auditory oddball (orig)" +class AuditoryOddball(Experiment.BaseExperiment): + + def __init__(self, duration=120, eeg: EEG=None, save_fn=None, n_trials = 2010, iti = 0.3, soa = 0.2, jitter = 0.2, secs=0.2, volume=0.8, random_state=42, s1_freq="C", s2_freq="D", s1_octave=5, s2_octave=6): + """ -def present( - save_fn = None, - eeg=None, - duration=120, - n_trials=2010, - iti=0.3, - soa=0.2, - jitter=0.2, - secs=0.2, - volume=0.8, - random_state=42, - s1_freq="C", - s2_freq="D", - s1_octave=5, - s2_octave=6, -): + Auditory Oddball Experiment + =========================== - """ + Unique Parameters: + ----------- - Auditory Oddball Experiment - =========================== + secs - duration of the sound in seconds (default 0.2) + volume - volume of the sounds in [0,1] (default 0.8) - Parameters: - ----------- + random_state - random seed (default 42) - duration - duration of the recording in seconds (default 10) + s1_freq - frequency of first tone + s2_freq - frequency of second tone - n_trials - number of trials (default 10) + s1_octave - octave of first tone + s2_octave - octave of second tone - iti - intertrial interval (default 0.3) + """ - soa - stimulus onset asynchrony, = interval between end of stimulus - and next trial (default 0.2) + exp_name = "Auditory Oddball" + super().__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter) + self.secs = secs + self.volume = volume + self.random_state = random_state + self.s1_freq = s1_freq + self.s2_freq = s2_freq + self.s1_octave = s1_octave + self.s2_octave = s2_octave - jitter - jitter in the intertrial intervals (default 0.2) + def load_stimulus(self): + """ Loads the Stimulus """ + + # Set up trial parameters + np.random.seed(self.random_state) + + # Initialize stimuli + aud1, aud2 = sound.Sound(self.s1_freq, octave=self.s1_octave, secs=self.secs), sound.Sound(self.s2_freq, octave=self.s2_octave, secs=self.secs) + aud1.setVolume(self.volume) + aud2.setVolume(self.volume) + self.auds = [aud1, aud2] - secs - duration of the sound in seconds (default 0.2) + # Setup trial list + sound_ind = np.random.binomial(1, 0.25, self.n_trials) + itis = self.iti + np.random.rand(self.n_trials) * self.jitter + self.trials = DataFrame(dict(sound_ind=sound_ind, iti=itis)) + self.trials["soa"] = self.soa + self.trials["secs"] = self.secs - volume - volume of the sounds in [0,1] (default 0.8) + self.fixation = visual.GratingStim(win=self.window, size=0.2, pos=[0, 0], sf=0, rgb=[1, 0, 0]) + self.fixation.setAutoDraw(True) + self.window.flip() - random_state - random seed (default 42) - - s1_freq - frequency of first tone - s2_freq - frequency of second tone - - s1_octave - octave of first tone - s2_octave - octave of second tone - - """ - - # Set up trial parameters - np.random.seed(random_state) - markernames = [1, 2] - record_duration = np.float32(duration) - - # Initialize stimuli - aud1 = sound.Sound(s1_freq, octave=s1_octave, secs=secs) - aud1.setVolume(volume) - aud2 = sound.Sound(s2_freq, octave=s2_octave, secs=secs) - aud2.setVolume(volume) - auds = [aud1, aud2] - - # Setup trial list - sound_ind = np.random.binomial(1, 0.25, n_trials) - itis = iti + np.random.rand(n_trials) * jitter - trials = DataFrame(dict(sound_ind=sound_ind, iti=itis)) - trials["soa"] = soa - trials["secs"] = secs - - # Setup graphics - mywin = visual.Window( - [1920, 1080], monitor="testMonitor", units="deg", fullscr=True - ) - fixation = visual.GratingStim(win=mywin, size=0.2, pos=[0, 0], sf=0, rgb=[1, 0, 0]) - fixation.setAutoDraw(True) - mywin.flip() - - # Show the instructions screen - show_instructions(10) - - # Start EEG Stream, wait for signal to settle, and then pull timestamp for start point - if eeg: - eeg.start(save_fn, duration=record_duration) - start = time() - - # Iterate through the events - for ii, trial in trials.iterrows(): - - # Intertrial interval - core.wait(trial["iti"]) + return + + def present_stimulus(self, idx : int, trial): + """ Presents the Stimulus """ # Select and play sound ind = int(trial["sound_ind"]) - auds[ind].stop() - auds[ind].play() + self.auds[ind].stop() + self.auds[ind].play() # Push sample - if eeg: + if self.eeg: timestamp = time() - marker = [markernames[ind]] + marker = [self.markernames[ind]] marker = list(map(int, marker)) + self.eeg.push_sample(marker=marker, timestamp=timestamp) - eeg.push_sample(marker=marker, timestamp=timestamp) - - mywin.flip() - - # Offset - core.wait(soa) - if len(event.getKeys()) > 0: - break - if (time() - start) > record_duration: - break - event.clearEvents() - - # Cleanup - if eeg: - eeg.stop() - - mywin.close() - - -def show_instructions(duration): - - instruction_text = """ - Welcome to the aMMN experiment! - - Stay still, focus on the centre of the screen, and try not to blink. - - This block will run for %s seconds. - - Press spacebar to continue. - - """ - instruction_text = instruction_text % duration - - # graphics - mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True) - - mywin.mouseVisible = False - - # Instructions - text = visual.TextStim(win=mywin, text=instruction_text, color=[-1, -1, -1]) - text.draw() - mywin.flip() - event.waitKeys(keyList="space") - mywin.mouseVisible = True - mywin.close() diff --git a/eegnb/experiments/visual_n170/n170.py b/eegnb/experiments/visual_n170/n170.py index 3f9822bd..0ff0dbed 100644 --- a/eegnb/experiments/visual_n170/n170.py +++ b/eegnb/experiments/visual_n170/n170.py @@ -1,3 +1,5 @@ +""" eeg-notebooks/eegnb/experiments/visual_n170/n170.py """ + from psychopy import prefs #change the pref libraty to PTB and set the latency mode to high precision prefs.hardware['audioLib'] = 'PTB' @@ -14,110 +16,49 @@ from pandas import DataFrame from psychopy import visual, core, event -from eegnb import generate_save_fn from eegnb.devices.eeg import EEG from eegnb.stimuli import FACE_HOUSE +from eegnb.experiments import Experiment -__title__ = "Visual N170" +class VisualN170(Experiment.BaseExperiment): -def present(duration=120, eeg: EEG=None, save_fn=None, + def __init__(self, duration=120, eeg: EEG=None, save_fn=None, n_trials = 2010, iti = 0.4, soa = 0.3, jitter = 0.2): - - record_duration = np.float32(duration) - markernames = [1, 2] - - # Setup trial list - image_type = np.random.binomial(1, 0.5, n_trials) - trials = DataFrame(dict(image_type=image_type, timestamp=np.zeros(n_trials))) - - def load_image(fn): - return visual.ImageStim(win=mywin, image=fn) - - # start the EEG stream, will delay 5 seconds to let signal settle - - # Setup graphics - mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True) - - faces = list(map(load_image, glob(os.path.join(FACE_HOUSE, "faces", "*_3.jpg")))) - houses = list(map(load_image, glob(os.path.join(FACE_HOUSE, "houses", "*.3.jpg")))) - stim = [houses, faces] - # Show the instructions screen - show_instructions(duration) - - if eeg: - if save_fn is None: # If no save_fn passed, generate a new unnamed save file - random_id = random.randint(1000,10000) - save_fn = generate_save_fn(eeg.device_name, "visual_n170", random_id, random_id, "unnamed") - print( - f"No path for a save file was passed to the experiment. Saving data to {save_fn}" - ) - eeg.start(save_fn, duration=record_duration + 5) - - # Start EEG Stream, wait for signal to settle, and then pull timestamp for start point - start = time() - - # Iterate through the events - for ii, trial in trials.iterrows(): - # Inter trial interval - core.wait(iti + np.random.rand() * jitter) - - # Select and display image - label = trials["image_type"].iloc[ii] - image = choice(faces if label == 1 else houses) + # 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) + + def load_stimulus(self): + + # Loading Images from the folder + load_image = lambda fn: visual.ImageStim(win=self.window, image=fn) + + # Setting up images for the stimulus + self.faces = list(map(load_image, glob(os.path.join(FACE_HOUSE, "faces", "*_3.jpg")))) + self.houses = list(map(load_image, glob(os.path.join(FACE_HOUSE, "houses", "*.3.jpg")))) + + # Return the list of images as a stimulus object + return [self.houses, self.faces] + + def present_stimulus(self, idx : int, trial): + + # Get the label of the trial + label = self.trials["parameter"].iloc[idx] + # Get the image to be presented + image = choice(self.faces if label == 1 else self.houses) + # Draw the image image.draw() - # Push sample - if eeg: + # Pushing the sample to the EEG + if self.eeg: timestamp = time() - if eeg.backend == "muselsl": - marker = [markernames[label]] + if self.eeg.backend == "muselsl": + marker = [self.markernames[label]] else: - marker = markernames[label] - eeg.push_sample(marker=marker, timestamp=timestamp) - - mywin.flip() - - # offset - core.wait(soa) - mywin.flip() - if len(event.getKeys()) > 0 or (time() - start) > record_duration: - break - - event.clearEvents() - - # Cleanup - if eeg: - eeg.stop() - - mywin.close() - - -def show_instructions(duration): - - instruction_text = """ - Welcome to the N170 experiment! - - Stay still, focus on the centre of the screen, and try not to blink. - - This block will run for %s seconds. - - Press spacebar to continue. - - """ - instruction_text = instruction_text % duration - - # graphics - mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True) - - mywin.mouseVisible = False - - # Instructions - text = visual.TextStim(win=mywin, text=instruction_text, color=[-1, -1, -1]) - text.draw() - mywin.flip() - event.waitKeys(keyList="space") - - mywin.mouseVisible = True - mywin.close() + marker = self.markernames[label] + self.eeg.push_sample(marker=marker, timestamp=timestamp) + + self.window.flip() \ No newline at end of file diff --git a/eegnb/experiments/visual_p300/p300.py b/eegnb/experiments/visual_p300/p300.py index b8176205..33f6f3e2 100644 --- a/eegnb/experiments/visual_p300/p300.py +++ b/eegnb/experiments/visual_p300/p300.py @@ -1,112 +1,51 @@ + +""" eeg-notebooks/eegnb/experiments/visual_p300/p300.py """ + 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 eegnb import generate_save_fn from eegnb.stimuli import CAT_DOG +from eegnb.experiments import Experiment +from eegnb.devices.eeg import EEG -__title__ = "Visual P300" - - -def present(duration=120, eeg=None, save_fn=None): - n_trials = 2010 - iti = 0.4 - soa = 0.3 - jitter = 0.2 - record_duration = np.float32(duration) - markernames = [1, 2] - - # Setup trial list - image_type = np.random.binomial(1, 0.5, n_trials) - trials = DataFrame(dict(image_type=image_type, timestamp=np.zeros(n_trials))) - - def load_image(fn): - return visual.ImageStim(win=mywin, image=fn) - - # Setup graphics - mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True) - - targets = list(map(load_image, glob(os.path.join(CAT_DOG, "target-*.jpg")))) - nontargets = list(map(load_image, glob(os.path.join(CAT_DOG, "nontarget-*.jpg")))) - stim = [nontargets, targets] - - # Show instructions - show_instructions(duration=duration) - - # start the EEG stream, will delay 5 seconds to let signal settle - if eeg: - if save_fn is None: # If no save_fn passed, generate a new unnamed save file - save_fn = generate_save_fn(eeg.device_name, "visual_p300", "unnamed") - print( - f"No path for a save file was passed to the experiment. Saving data to {save_fn}" - ) - eeg.start(save_fn, duration=record_duration) - - # Iterate through the events - start = time() - for ii, trial in trials.iterrows(): - # Inter trial interval - core.wait(iti + np.random.rand() * jitter) - - # Select and display image - label = trials["image_type"].iloc[ii] - image = choice(targets if label == 1 else nontargets) +class VisualP300(Experiment.BaseExperiment): + + def __init__(self, duration=120, eeg: EEG=None, save_fn=None, + n_trials = 2010, iti = 0.4, soa = 0.3, jitter = 0.2): + + exp_name = "Visual P300" + super().__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter) + + def load_stimulus(self): + + load_image = lambda fn: visual.ImageStim(win=self.window, image=fn) + + self.targets = list(map(load_image, glob(os.path.join(CAT_DOG, "target-*.jpg")))) + self.nontargets = list(map(load_image, glob(os.path.join(CAT_DOG, "nontarget-*.jpg")))) + + return [self.nontargets, self.targets] + + def present_stimulus(self, idx:int, trial): + + label = self.trials["parameter"].iloc[idx] + image = choice(self.targets if label == 1 else self.nontargets) image.draw() # Push sample - if eeg: + if self.eeg: timestamp = time() - if eeg.backend == "muselsl": - marker = [markernames[label]] + if self.eeg.backend == "muselsl": + marker = [self.markernames[label]] else: - marker = markernames[label] - eeg.push_sample(marker=marker, timestamp=timestamp) - - mywin.flip() - - # offset - core.wait(soa) - mywin.flip() - if len(event.getKeys()) > 0 or (time() - start) > record_duration: - break - - event.clearEvents() - - # Cleanup - if eeg: - eeg.stop() - mywin.close() - - -def show_instructions(duration): - - instruction_text = """ - Welcome to the P300 experiment! - - Stay still, focus on the centre of the screen, and try not to blink. - - This block will run for %s seconds. - - Press spacebar to continue. - - """ - instruction_text = instruction_text % duration - - # graphics - mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True) - - mywin.mouseVisible = False - - # Instructions - text = visual.TextStim(win=mywin, text=instruction_text, color=[-1, -1, -1]) - text.draw() - mywin.flip() - event.waitKeys(keyList="space") + marker = self.markernames[label] + self.eeg.push_sample(marker=marker, timestamp=timestamp) - mywin.mouseVisible = True - mywin.close() + self.window.flip() \ No newline at end of file diff --git a/eegnb/experiments/visual_ssvep/ssvep.py b/eegnb/experiments/visual_ssvep/ssvep.py index 8e6aa765..b30baffd 100644 --- a/eegnb/experiments/visual_ssvep/ssvep.py +++ b/eegnb/experiments/visual_ssvep/ssvep.py @@ -1,3 +1,5 @@ + +from eegnb.experiments import Experiment import os from time import time from glob import glob @@ -7,199 +9,105 @@ from pandas import DataFrame from psychopy import visual, core, event -from eegnb import generate_save_fn -__title__ = "Visual SSVEP" - - -def present(duration=120, eeg=None, save_fn=None): - n_trials = 2010 - iti = 0.5 - soa = 3.0 - jitter = 0.2 - record_duration = np.float32(duration) - markernames = [1, 2] - - # Setup trial list - stim_freq = np.random.binomial(1, 0.5, n_trials) - trials = DataFrame(dict(stim_freq=stim_freq, timestamp=np.zeros(n_trials))) - - # Set up graphics - mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True) - grating = visual.GratingStim(win=mywin, mask="circle", size=80, sf=0.2) - grating_neg = visual.GratingStim( - win=mywin, mask="circle", size=80, sf=0.2, phase=0.5 - ) - fixation = visual.GratingStim( - win=mywin, size=0.2, pos=[0, 0], sf=0.2, color=[1, 0, 0], autoDraw=True - ) - - # Generate the possible ssvep frequencies based on monitor refresh rate - def get_possible_ssvep_freqs(frame_rate, stim_type="single"): - """Get possible SSVEP stimulation frequencies. - Utility function that returns the possible SSVEP stimulation - frequencies and on/off pattern based on screen refresh rate. - Args: - frame_rate (float): screen frame rate, in Hz - Keyword Args: - stim_type (str): type of stimulation - 'single'-> single graphic stimulus (the displayed object - appears and disappears in the background.) - 'reversal' -> pattern reversal stimulus (the displayed object - appears and is replaced by its opposite.) - Returns: - (dict): keys are stimulation frequencies (in Hz), and values are - lists of tuples, where each tuple is the number of (on, off) - periods of one stimulation cycle - For more info on stimulation patterns, see Section 2 of: - Danhua Zhu, Jordi Bieger, Gary Garcia Molina, and Ronald M. Aarts, - "A Survey of Stimulation Methods Used in SSVEP-Based BCIs," - Computational Intelligence and Neuroscience, vol. 2010, 12 pages, - 2010. - """ - - max_period_nb = int(frame_rate / 6) - periods = np.arange(max_period_nb) + 1 - - if stim_type == "single": - freqs = dict() - for p1 in periods: - for p2 in periods: - f = frame_rate / (p1 + p2) - try: - freqs[f].append((p1, p2)) - except: - freqs[f] = [(p1, p2)] - elif stim_type == "reversal": - freqs = {frame_rate / p: [(p, p)] for p in periods[::-1]} - - return freqs - - def init_flicker_stim(frame_rate, cycle, soa): - """Initialize flickering stimulus. - Get parameters for a flickering stimulus, based on the screen refresh - rate and the desired stimulation cycle. - Args: - frame_rate (float): screen frame rate, in Hz - cycle (tuple or int): if tuple (on, off), represents the number of - 'on' periods and 'off' periods in one flickering cycle. This - supposes a "single graphic" stimulus, where the displayed object - appears and disappears in the background. - If int, represents the number of total periods in one cycle. - This supposes a "pattern reversal" stimulus, where the - displayed object appears and is replaced by its opposite. - soa (float): stimulus duration, in s - Returns: - (dict): dictionary with keys - 'cycle' -> tuple of (on, off) periods in a cycle - 'freq' -> stimulus frequency - 'n_cycles' -> number of cycles in one stimulus trial - """ - if isinstance(cycle, tuple): - stim_freq = frame_rate / sum(cycle) - n_cycles = int(soa * stim_freq) - else: - stim_freq = frame_rate / cycle - cycle = (cycle, cycle) - n_cycles = int(soa * stim_freq) / 2 - - return {"cycle": cycle, "freq": stim_freq, "n_cycles": n_cycles} - - # Set up stimuli - frame_rate = np.round(mywin.getActualFrameRate()) # Frame rate, in Hz - freqs = get_possible_ssvep_freqs(frame_rate, stim_type="reversal") - stim_patterns = [ - init_flicker_stim(frame_rate, 2, soa), - init_flicker_stim(frame_rate, 3, soa), - ] - - print( - ( - "Flickering frequencies (Hz): {}\n".format( - [stim_patterns[0]["freq"], stim_patterns[1]["freq"]] - ) - ) - ) +from eegnb.devices.eeg import EEG +from eegnb import generate_save_fn - # Show the instructions screen - show_instructions(duration) - # start the EEG stream, will delay 5 seconds to let signal settle - if eeg: - if save_fn is None: # If no save_fn passed, generate a new unnamed save file - save_fn = generate_save_fn(eeg.device_name, "visual_ssvep", "unnamed") - print( - f"No path for a save file was passed to the experiment. Saving data to {save_fn}" +class VisualSSVEP(Experiment.BaseExperiment): + + def __init__(self, duration=120, eeg: EEG=None, save_fn=None, n_trials = 2010, iti = 0.5, soa = 3.0, jitter = 0.2): + + exp_name = "Visual SSVEP" + super().__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter) + + def load_stimulus(self): + + self.grating = visual.GratingStim(win=self.window, mask="circle", size=80, sf=0.2) + + 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) + + # Generate the possible ssvep frequencies based on monitor refresh rate + def get_possible_ssvep_freqs(frame_rate, stim_type="single"): + + max_period_nb = int(frame_rate / 6) + periods = np.arange(max_period_nb) + 1 + + if stim_type == "single": + freqs = dict() + for p1 in periods: + for p2 in periods: + f = frame_rate / (p1 + p2) + try: + freqs[f].append((p1, p2)) + except: + freqs[f] = [(p1, p2)] + + elif stim_type == "reversal": + freqs = {frame_rate / p: [(p, p)] for p in periods[::-1]} + + return freqs + + def init_flicker_stim(frame_rate, cycle, soa): + + if isinstance(cycle, tuple): + stim_freq = frame_rate / sum(cycle) + n_cycles = int(soa * stim_freq) + + else: + stim_freq = frame_rate / cycle + cycle = (cycle, cycle) + n_cycles = int(soa * stim_freq) / 2 + + return {"cycle": cycle, "freq": stim_freq, "n_cycles": n_cycles} + + # Set up stimuli + frame_rate = np.round(self.window.getActualFrameRate()) # Frame rate, in Hz + freqs = get_possible_ssvep_freqs(frame_rate, stim_type="reversal") + self.stim_patterns = [ + init_flicker_stim(frame_rate, 2, self.soa), + init_flicker_stim(frame_rate, 3, self.soa), + ] + + print( + ( + "Flickering frequencies (Hz): {}\n".format( + [self.stim_patterns[0]["freq"], self.stim_patterns[1]["freq"]] + ) ) - eeg.start(save_fn, duration=record_duration) + ) - # Iterate through trials - start = time() - for ii, trial in trials.iterrows(): - # Intertrial interval - core.wait(iti + np.random.rand() * jitter) + return [ + init_flicker_stim(frame_rate, 2, self.soa), + init_flicker_stim(frame_rate, 3, self.soa), + ] + def present_stimulus(self, idx, trial): + # Select stimulus frequency - ind = trials["stim_freq"].iloc[ii] + ind = self.trials["parameter"].iloc[idx] # Push sample - if eeg: + if self.eeg: timestamp = time() - if eeg.backend == "muselsl": - marker = [markernames[ind]] + if self.eeg.backend == "muselsl": + marker = [self.markernames[ind]] else: - marker = markernames[ind] - eeg.push_sample(marker=marker, timestamp=timestamp) + marker = self.markernames[ind] + self.eeg.push_sample(marker=marker, timestamp=timestamp) # Present flickering stim - for _ in range(int(stim_patterns[ind]["n_cycles"])): - grating.setAutoDraw(True) - for _ in range(int(stim_patterns[ind]["cycle"][0])): - mywin.flip() - grating.setAutoDraw(False) - grating_neg.setAutoDraw(True) - for _ in range(stim_patterns[ind]["cycle"][1]): - mywin.flip() - grating_neg.setAutoDraw(False) - - # offset - mywin.flip() - if len(event.getKeys()) > 0 or (time() - start) > record_duration: - break - event.clearEvents() - - # Cleanup - if eeg: - eeg.stop() - mywin.close() - - -def show_instructions(duration): - - instruction_text = """ - Welcome to the SSVEP experiment! - - Stay still, focus on the centre of the screen, and try not to blink. - - This block will run for %s seconds. - - Press spacebar to continue. - - Warning: This experiment contains flashing lights and may induce a seizure. Discretion is advised. - - """ - instruction_text = instruction_text % duration - - # graphics - mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True) - - mywin.mouseVisible = False - - # Instructions - text = visual.TextStim(win=mywin, text=instruction_text, color=[-1, -1, -1]) - text.draw() - mywin.flip() - event.waitKeys(keyList="space") - - mywin.mouseVisible = True - mywin.close() + for _ in range(int(self.stim_patterns[ind]["n_cycles"])): + self.grating.setAutoDraw(True) + for _ in range(int(self.stim_patterns[ind]["cycle"][0])): + self.window.flip() + self.grating.setAutoDraw(False) + self.grating_neg.setAutoDraw(True) + for _ in range(self.stim_patterns[ind]["cycle"][1]): + self.window.flip() + self.grating_neg.setAutoDraw(False) + pass + + self.window.flip() \ No newline at end of file diff --git a/eegnb/experiments/visual_vep/vep.py b/eegnb/experiments/visual_vep/vep.py index 0da5d8fd..1acd1b11 100644 --- a/eegnb/experiments/visual_vep/vep.py +++ b/eegnb/experiments/visual_vep/vep.py @@ -1,9 +1,21 @@ -import numpy as np -from pandas import DataFrame -from psychopy import visual, core, event from time import time, strftime, gmtime -from optparse import OptionParser from pylsl import StreamInfo, StreamOutlet +from eegnb.experiments.Experiment import Experiment + + +class VisualVEP(Experiment): + + def __init__(self, duration=120, eeg: EEG=None, save_fn=None, + n_trials = 2000, iti = 0.2, soa = 0.2, jitter = 0.1): + + exp_name = "Visual VEP" + super().__init__(exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter) + + def load_stimulus(): + pass + + def present_stimulus(): + pass def present(duration=120):