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

calibration trigger refactor and cleanup #149

Merged
merged 7 commits into from
Jun 10, 2021
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,8 @@ clean:
bci-gui:
python bcipy/gui/BCInterface.py

viewer:
python bcipy/gui/viewer/data_viewer.py --file $(filepath)

run-with-defaults:
bcipy
51 changes: 34 additions & 17 deletions bcipy/display/rsvp/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ def __init__(

# Trigger handling
self.first_run = True
self.first_stim_time = None
self.trigger_type = trigger_type
self.trigger_callback = TriggerCallback()
self.marker_writer = marker_writer or NullMarkerWriter()
Expand Down Expand Up @@ -326,25 +327,14 @@ def do_inquiry(self) -> List[float]:
timing = []

if self.first_run:
# play a inquiry start sound to help orient triggers
first_stim_timing = _calibration_trigger(
self.experiment_clock,
trigger_type=self.trigger_type, display=self.window,
on_trigger=self.marker_writer.push_marker)

timing.append(first_stim_timing)

self.first_stim_time = first_stim_timing[-1]
self.first_run = False
timing = self._trigger_pulse(timing)

# generate a inquiry (list of stimuli with meta information)
inquiry = self._generate_inquiry()

# do the inquiry
for idx in range(len(inquiry)):

self.is_first_stim = (idx == 0)

# set a static period to do all our stim setting.
# will warn if ISI value is violated.
self.staticPeriod.name = 'Stimulus Draw Period'
Expand All @@ -357,14 +347,15 @@ def do_inquiry(self) -> List[float]:
inquiry[idx]['sti_label'])
self.window.callOnFlip(self.marker_writer.push_marker, inquiry[idx]['sti_label'])

# If this is the start of an inquiry and a callback registered for first_stim_callback evoke it
if idx == 0 and callable(self.first_stim_callback):
self.first_stim_callback(inquiry[idx]['sti'])

# Draw stimulus for n frames
inquiry[idx]['sti'].draw()
self.draw_static()
self.window.flip()
core.wait((inquiry[idx]['time_to_present'] - 1) / self.refresh_rate)
core.wait(inquiry[idx]['time_to_present'])

# End static period
self.staticPeriod.complete()
Expand All @@ -383,6 +374,30 @@ def do_inquiry(self) -> List[float]:

return timing

def _trigger_pulse(self, timing: List[str]) -> List[str]:
"""Trigger Pulse.

This method uses a marker writer and calibration trigger to determine any functional
offsets needed for operation with this display. By setting the first_stim_time and searching for the
same stimuli output to the marker stream, the offsets between these proceses can be reconciled at the
beginning of an experiment. If drift is detected in your experiment, more frequent pulses and offset
correction may be required.
"""
calibration_time = _calibration_trigger(
self.experiment_clock,
trigger_type=self.trigger_type,
display=self.window,
on_trigger=self.marker_writer.push_marker)

timing.append(calibration_time)

# set the first stim time if not present and first_run to False
if not self.first_stim_time:
self.first_stim_time = calibration_time[-1]
self.first_run = False

return timing

def preview_inquiry(self) -> Tuple[List[float], bool]:
"""Preview Inquiry.

Expand All @@ -396,6 +411,9 @@ def preview_inquiry(self) -> Tuple[List[float], bool]:
"""
# construct the timing to return and generate the content for preview
timing = []
if self.first_run:
timing = self._trigger_pulse(timing)

content = self._generate_inquiry_preview()

# define the trigger callbacks. Here we use the trigger_callback to return immediately and a marker_writer
Expand Down Expand Up @@ -473,8 +491,7 @@ def _generate_inquiry(self):
for idx in range(len(self.stimuli_inquiry)):
current_stim = {}

# turn ms timing into frames! Much more accurate!
current_stim['time_to_present'] = int(self.stimuli_timing[idx] * self.refresh_rate)
current_stim['time_to_present'] = self.stimuli_timing[idx]

# check if stimulus needs to use a non-default size
if self.size_list_sti:
Expand Down Expand Up @@ -556,12 +573,12 @@ def wait_screen(self, message, color):
try:
wait_logo = visual.ImageStim(
self.window,
image='bcipy/static/images/gui_images/bci_cas_logo.png',
image='bcipy/static/images/gui/bci_cas_logo.png',
pos=(0, .5),
mask=None,
ori=0.0)
wait_logo.size = resize_image(
'bcipy/static/images/gui_images/bci_cas_logo.png',
'bcipy/static/images/gui/bci_cas_logo.png',
self.window.size, 1)
wait_logo.draw()

Expand Down
4 changes: 4 additions & 0 deletions bcipy/display/tests/rsvp/test_rsvp_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def test_preview_inquiry_evoked_press_to_accept_pressed(self):
when(stim_mock).draw().thenReturn()
when(self.rsvp).draw_static().thenReturn()
when(self.rsvp.window).flip().thenReturn()
when(self.rsvp)._trigger_pulse(any()).thenReturn([])

# skip the core wait for testing
when(psychopy.core).wait(self.preview_inquiry.preview_inquiry_isi).thenReturn()
Expand All @@ -203,6 +204,7 @@ def test_preview_inquiry_evoked_press_to_skip_pressed(self):
when(stim_mock).draw().thenReturn()
when(self.rsvp).draw_static().thenReturn()
when(self.rsvp.window).flip().thenReturn()
when(self.rsvp)._trigger_pulse(any()).thenReturn([])

# skip the core wait for testing
when(psychopy.core).wait(self.preview_inquiry.preview_inquiry_isi).thenReturn()
Expand All @@ -224,6 +226,7 @@ def test_preview_inquiry_evoked_press_to_accept_not_pressed(self):
when(stim_mock).draw().thenReturn()
when(self.rsvp).draw_static().thenReturn()
when(self.rsvp.window).flip().thenReturn()
when(self.rsvp)._trigger_pulse(any()).thenReturn([])

# skip the core wait for testing
when(psychopy.core).wait(self.preview_inquiry.preview_inquiry_isi).thenReturn()
Expand All @@ -245,6 +248,7 @@ def test_preview_inquiry_evoked_press_to_skip_not_pressed(self):
when(stim_mock).draw().thenReturn()
when(self.rsvp).draw_static().thenReturn()
when(self.rsvp.window).flip().thenReturn()
when(self.rsvp)._trigger_pulse(any()).thenReturn([])

# skip the core wait for testing
when(psychopy.core).wait(self.preview_inquiry.preview_inquiry_isi).thenReturn()
Expand Down
4 changes: 2 additions & 2 deletions bcipy/gui/BCInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,9 @@ def build_images(self) -> None:
Build add images needed for the UI. In this case, the OHSU and NEU logos.
"""
self.add_image(
path='bcipy/static/images/gui_images/ohsu.png', position=[self.padding, 0], size=100)
path='bcipy/static/images/gui/ohsu.png', position=[self.padding, 0], size=100)
self.add_image(
path='bcipy/static/images/gui_images/neu.png', position=[self.width - self.padding - 110, 0], size=100)
path='bcipy/static/images/gui/neu.png', position=[self.width - self.padding - 110, 0], size=100)

def build_assets(self) -> None:
"""Build Assets.
Expand Down
2 changes: 1 addition & 1 deletion bcipy/gui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ experiments/FieldRegistry.py | GUI for creating new fields for experiment data c
experiments/ExperimentField.py | GUI for collecting a registered experiment's field data.


The folder 'bcipy/static/images/gui_images' contains images for the GUI.
The folder 'bcipy/static/images/gui' contains images for the GUI.
Parameters loaded by BCInterface parameter definition form can be found in 'bcipy/parameters/parameters.json'.

To run the GUI, do so from the root, as follows:
Expand Down
2 changes: 1 addition & 1 deletion bcipy/gui/gui_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,7 @@ def start_app() -> None:
# ex.throw_alert_message(title='title', message='test', okay_to_exit=True)
ex.get_filename_dialog()
ex.add_button(message='Test Button', position=[200, 300], size=[100, 100], id=1)
# ex.add_image(path='../static/images/gui_images/bci_cas_logo.png', position=[50, 50], size=200)
# ex.add_image(path='../static/images/gui/bci_cas_logo.png', position=[50, 50], size=200)
# ex.add_static_textbox(
# text='Test static text',
# background_color='black',
Expand Down
2 changes: 0 additions & 2 deletions bcipy/gui/viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ In addition to its use during an experiment, the Signal Viewer can be run from t
-f FILE, --file FILE path to the data file
-s SECONDS, --seconds SECONDS
seconds to display
-d DOWNSAMPLE, --downsample DOWNSAMPLE
downsample factor
-r REFRESH, --refresh REFRESH
refresh rate in ms

Expand Down
16 changes: 8 additions & 8 deletions bcipy/gui/viewer/data_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,6 @@ def __init__(self,

self.data_source = data_source

self.refresh_rate = refresh
self.samples_per_second = device_info.fs
self.records_per_refresh = int(
(self.refresh_rate / 1000) * self.samples_per_second)

self.channels = device_info.channels
self.removed_channels = ['TRG', 'timestamp']
self.data_indices = self.init_data_indices()
Expand All @@ -64,6 +59,11 @@ def __init__(self,
# Default filter
self.filter = Downsample(self.downsample_factor)

self.refresh_rate = refresh
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lawhead I addressed all comments I intended in this PR. I added this after testing everything and running into an issue with downsample in the data viewer. Can you look this part over to make sure it looks ok?

Changed:

  • Output of Downsample when fs is not defined
  • samples per second uses the downsample rate
  • changed default (not sure how this impacts everything though)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the samples_per_second is not correct. This value is used to get the raw data before the filters are applied so it doesn't seem like it would retrieve enough data.

self.samples_per_second = device_info.fs / self.downsample_factor
self.records_per_refresh = int(
(self.refresh_rate / 1000) * self.samples_per_second)

self.autoscale = True
self.y_min = -y_scale
self.y_max = y_scale
Expand Down Expand Up @@ -322,7 +322,7 @@ def cursor_x(self):

def init_data(self):
"""Initialize the data."""
channel_data = self.filter(self.current_data())
channel_data, _ = self.filter(self.current_data())

for i, _channel in enumerate(self.data_indices):
data = channel_data[i].tolist()
Expand Down Expand Up @@ -351,7 +351,7 @@ def update_view(self, _evt):
"""Called by the timer on refresh. Updates the buffer with the latest
data and refreshes the plots. This is called on every tick."""
self.update_buffer()
channel_data = self.filter(self.current_data())
channel_data, _ = self.filter(self.current_data())

# plot each channel
for i, _channel in enumerate(self.data_indices):
Expand Down Expand Up @@ -501,7 +501,7 @@ def main(data_file: str,
parser.add_argument('-r',
'--refresh',
help='refresh rate in ms',
default=500,
default=300,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the refresh rate of the viewer, not the EEG device. It was set to refresh the display every 500 ms (half second). Was this choppy? The downside to a more frequent refresh is more performance overhead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wasn't buggy at 300 but I will revert since it is configurable!

type=int)
parser.add_argument('-y', '--yscale', help='yscale', default=150, type=int)
parser.add_argument('-m',
Expand Down
52 changes: 26 additions & 26 deletions bcipy/helpers/stimuli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
from psychopy import core

# Prevents pillow from filling the console with debug info
logging.getLogger("PIL").setLevel(logging.WARNING)
logging.getLogger('PIL').setLevel(logging.WARNING)
DEFAULT_FIXATION_PATH = 'bcipy/static/images/main/PLUS.png'


class InquirySchedule(NamedTuple):
Expand Down Expand Up @@ -59,13 +60,10 @@ def rsvp_inq_generator(query: list,

# Init some lists to construct our stimuli with
samples, times, colors = [], [], []
for idx_num in range(stim_number):
for _ in range(stim_number):

# append a fixation cross. if not text, append path to image fixation
if is_txt:
sample = ['+']
else:
sample = ['bcipy/static/images/bci_main_images/PLUS.png']
sample = [get_fixation(is_txt)]

# construct the sample from the query
sample += [i for i in query]
Expand Down Expand Up @@ -168,13 +166,10 @@ def best_case_rsvp_inq_gen(alp: list,

# Init some lists to construct our stimuli with
samples, times, colors = [], [], []
for idx_num in range(stim_number):
for _ in range(stim_number):

# append a fixation cross. if not text, append path to image fixation
if is_txt:
sample = ['+']
else:
sample = ['bcipy/static/images/bci_main_images/PLUS.png']
sample = [get_fixation(is_txt)]

# construct the sample from the query
sample += [i for i in query]
Expand Down Expand Up @@ -217,13 +212,13 @@ def random_rsvp_calibration_inq_gen(alp,
len_alp = len(alp)

samples, times, colors = [], [], []
for idx_num in range(stim_number):
for _ in range(stim_number):
idx = np.random.permutation(np.array(list(range(len_alp))))
rand_smp = (idx[0:stim_length])
if not is_txt:
sample = [
alp[rand_smp[0]],
'bcipy/static/images/bci_main_images/PLUS.png']
get_fixation(is_txt=False)]
else:
sample = [alp[rand_smp[0]], '+']
rand_smp = np.random.permutation(rand_smp)
Expand Down Expand Up @@ -268,12 +263,9 @@ def target_rsvp_inquiry_generator(alp,
# initialize our arrays
samples, times, colors = [], [], []
rand_smp = random.sample(range(len_alp), stim_length)
if is_txt:
sample = ['+']
else:
sample = ['bcipy/static/images/bci_main_images/PLUS.png']
target_letter = parameters['path_to_presentation_images'] + \
target_letter + '.png'
sample = [get_fixation(is_txt)]
target_letter = parameters['path_to_presentation_images'] + \
target_letter + '.png'
sample += [alp[i] for i in rand_smp]

# if the target isn't in the array, replace it with some random index that
Expand Down Expand Up @@ -419,7 +411,7 @@ def generate_icon_match_images(
image_array[target_image_numbers[inquiry]])
# Add PLUS.png to image array TODO: get this from parameters file
return_array[inquiry].append(
'bcipy/static/images/bci_main_images/PLUS.png')
get_fixation(is_txt=False))

# Add target image to inquiry, if it is not already there
if not target_image_numbers[inquiry] in random_number_array[
Expand Down Expand Up @@ -507,13 +499,10 @@ def play_sound(sound_file_path: str,

# if timing is wanted, get trigger timing for this sound stimuli
if track_timing:
timing.append(trigger_name)
timing.append(experiment_clock.getTime())

# if there is a timing callback for sound, evoke it with the timing
# list
# if there is a timing callback for sound, evoke it
if sound_callback is not None:
sound_callback(timing)
sound_callback(experiment_clock, trigger_name)
timing.append([trigger_name, experiment_clock.getTime()])

# play our loaded sound and wait for some time before it's finished
# NOTE: there is a measurable delay for calling sd.play. (~ 0.1 seconds;
Expand Down Expand Up @@ -544,3 +533,14 @@ def soundfiles(directory: str) -> Iterator[str]:
if not directory.endswith(sep):
directory += sep
return itertools.cycle(glob.glob(directory + '*.wav'))


def get_fixation(is_txt: bool) -> str:
"""Get Fixation.

Return the correct stimulus fixation given the type (text or image).
"""
if is_txt:
return '+'
else:
return DEFAULT_FIXATION_PATH
4 changes: 2 additions & 2 deletions bcipy/helpers/tests/resources/mock_session/parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -445,10 +445,10 @@
"type": "str"
},
"alert_sounds_path": {
"value": "bcipy/static/sounds/rsvp_sounds/",
"value": "bcipy/static/sounds/rsvp/",
"section": "bci_config",
"readableName": "Alert sounds directory",
"helpTip": "Specifies the location of audio files to be presented with visual stimuli during the Alert Tone Calibration Task. This must be a directory ending with /. Default: bcipy/static/sounds/rsvp_sounds/",
"helpTip": "Specifies the location of audio files to be presented with visual stimuli during the Alert Tone Calibration Task. This must be a directory ending with /. Default: bcipy/static/sounds/rsvp/",
"recommended_values": "",
"type": "directorypath"
},
Expand Down
Loading