From 4efd76822072cc35282ade87cb93f19f07821602 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Fri, 10 Aug 2018 12:39:18 -0400 Subject: [PATCH 01/34] Fix delimiter in output file of epoch encoder example Since Excel assumes commas are the delimiter when the file extension is CSV, the use of tabs as the delimiter in the epoch encoder example results in output files that are read incorrectly by Excel. This could be resolved by either changing the file extension to TSV or by using commas with the extension CSV. This change does the latter. --- .gitignore | 3 ++- examples/epoch_encoder.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 90ec626..98add4c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ doc/_build/* dist/* ephyviewer.egg-info/* ephyviewer/tests/*.avi -examples/*.avi \ No newline at end of file +examples/*.avi +examples/*.csv diff --git a/examples/epoch_encoder.py b/examples/epoch_encoder.py index 66558f0..d994d3b 100644 --- a/examples/epoch_encoder.py +++ b/examples/epoch_encoder.py @@ -24,7 +24,7 @@ def __init__(self, output_filename, possible_labels): if os.path.exists(self.filename): # if file already exists load previous epoch - df = pd.read_csv(self.filename, index_col=None, sep='\t') + df = pd.read_csv(self.filename, index_col=None) times = df['time'].values durations = df['duration'].values labels = df['label'].values @@ -59,7 +59,7 @@ def save(self): df['time'] = self.all[0]['time'] df['duration'] = self.all[0]['duration'] df['label'] = self.all[0]['label'] - df.to_csv(self.filename, index=False, sep='\t') + df.to_csv(self.filename, index=False) From 47b061a33f0424ba28d26c6c5725a2196dd62520 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Fri, 10 Aug 2018 13:48:08 -0400 Subject: [PATCH 02/34] Fixed spelling of CsvEpochSource --- examples/epoch_encoder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/epoch_encoder.py b/examples/epoch_encoder.py index d994d3b..5946270 100644 --- a/examples/epoch_encoder.py +++ b/examples/epoch_encoder.py @@ -16,7 +16,7 @@ import pandas as pd -class CvsEpochSource(WritableEpochSource): +class CsvEpochSource(WritableEpochSource): def __init__(self, output_filename, possible_labels): self.output_filename = output_filename self.filename = output_filename @@ -67,7 +67,7 @@ def save(self): possible_labels = ['euphoric', 'nervous', 'hungry', 'triumphant'] filename = 'example_dev_mood_encoder.csv' -source_epoch = CvsEpochSource(filename, possible_labels) +source_epoch = CsvEpochSource(filename, possible_labels) From 4f4c2015052bdc2fd79508e2ccf6eb76d23ae29e Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Fri, 10 Aug 2018 14:04:42 -0400 Subject: [PATCH 03/34] Move CsvEpochSource from example into main code CsvEpochSource was originally implemented for the purpose of demonstrating how to create a subclass of WritableEpochSource. However, if `'name': 'animal_state'` is removed, it's fully general and could be very useful to anyone interested in saving epochs using CSVs. Consequently, it's a great candidate for adoption into the main code. This change migrates the implementation of CsvEpochSource to the main code so that users do not need to implement this subclass themselves. They are still invited to create alternative implementations of subclasses of WritableEpochSource that use different file types. Dependence of ephyviewer on pandas is avoided by requiring pandas only when CsvEpochSource is instantiated. --- ephyviewer/datasource/epochs.py | 52 +++++++++++++++++++++++++++++ examples/epoch_encoder.py | 58 ++++----------------------------- 2 files changed, 58 insertions(+), 52 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 7825c6e..d964b40 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -1,10 +1,17 @@ # -*- coding: utf-8 -*- #~ from __future__ import (unicode_literals, print_function, division, absolute_import) +import os import numpy as np import matplotlib.cm import matplotlib.colors +try: + import pandas as pd + HAVE_PANDAS = True +except ImportError: + HAVE_PANDAS = False + from .sourcebase import BaseDataSource from .events import BaseEventAndEpoch @@ -229,8 +236,53 @@ def save(self): +class CsvEpochSource(WritableEpochSource): + def __init__(self, output_filename, possible_labels): + assert HAVE_PANDAS, 'Pandas is not installed' + self.output_filename = output_filename + self.filename = output_filename + if os.path.exists(self.filename): + # if file already exists load previous epoch + df = pd.read_csv(self.filename, index_col=None) + times = df['time'].values + durations = df['duration'].values + labels = df['label'].values + + # fix due to rounding errors with CSV for some epoch + # time[i]+duration[i]>time[i+1] + # which to lead errors in GUI + # so make a patch here + mask1 = (times[:-1]+durations[:-1])>times[1:] + mask2 = (times[:-1]+durations[:-1])<(times[1:]+1e-9) + mask = mask1 & mask2 + errors, = np.nonzero(mask) + durations[errors] = times[errors+1] - times[errors] + # end fix + + epoch = {'time': times, + 'duration':durations, + 'label':labels, + 'name': ''} + else: + # if file NOT exists take empty. + s = max([len(l) for l in possible_labels]) + epoch = {'time': np.array([], dtype='float64'), + 'duration':np.array([], dtype='float64'), + 'label': np.array([], dtype='U'+str(s)), + 'name': ''} + + WritableEpochSource.__init__(self, epoch, possible_labels) + + def save(self): + df = pd.DataFrame() + df['time'] = self.all[0]['time'] + df['duration'] = self.all[0]['duration'] + df['label'] = self.all[0]['label'] + df.to_csv(self.filename, index=False) + + def insert_item(arr, ind, value): diff --git a/examples/epoch_encoder.py b/examples/epoch_encoder.py index 5946270..d441f0e 100644 --- a/examples/epoch_encoder.py +++ b/examples/epoch_encoder.py @@ -3,63 +3,17 @@ which can be used with key short cuts to encode levels or with the mouse defining limits. -The main trick is that the source must subclasse because the write method -is not writen, so it let flexibility for the ouput format. +ephyviewer makes available a CsvEpochSource class, which inherits from +WritableEpochSource. If you would like to customize reading and writing epochs +to files, you can write your own subclass of WritableEpochSource that +implements the __init__() (for reading) and save() (for writing) methods. -Here an example of epoch encode that same file with simple csv. +Here is an example of an epoch encoder that uses CsvEpochSource. """ -import os -from ephyviewer import mkQApp, MainViewer, TraceViewer, WritableEpochSource, EpochEncoder +from ephyviewer import mkQApp, MainViewer, TraceViewer, CsvEpochSource, EpochEncoder import numpy as np -import pandas as pd - - -class CsvEpochSource(WritableEpochSource): - def __init__(self, output_filename, possible_labels): - self.output_filename = output_filename - self.filename = output_filename - - - if os.path.exists(self.filename): - # if file already exists load previous epoch - df = pd.read_csv(self.filename, index_col=None) - times = df['time'].values - durations = df['duration'].values - labels = df['label'].values - - # fix due to rounding errors with CSV for some epoch - # time[i]+duration[i]>time[i+1] - # which to lead errors in GUI - # so make a patch here - mask1 = (times[:-1]+durations[:-1])>times[1:] - mask2 = (times[:-1]+durations[:-1])<(times[1:]+1e-9) - mask = mask1 & mask2 - errors, = np.nonzero(mask) - durations[errors] = times[errors+1] - times[errors] - # end fix - - epoch = {'time': times, - 'duration':durations, - 'label':labels, - 'name': 'animal_state'} - else: - # if file NOT exists take empty. - s = max([len(l) for l in possible_labels]) - epoch = {'time': np.array([], dtype='float64'), - 'duration':np.array([], dtype='float64'), - 'label': np.array([], dtype='U'+str(s)), - 'name': 'animal_state'} - - WritableEpochSource.__init__(self, epoch, possible_labels) - - def save(self): - df = pd.DataFrame() - df['time'] = self.all[0]['time'] - df['duration'] = self.all[0]['duration'] - df['label'] = self.all[0]['label'] - df.to_csv(self.filename, index=False) From afae788df7f63f0bbeb08f93a394e244cc699cd2 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Fri, 10 Aug 2018 14:17:26 -0400 Subject: [PATCH 04/34] Add missing color_labels param to CsvEpochSource constructor --- ephyviewer/datasource/epochs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index d964b40..adc75fe 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -237,7 +237,7 @@ def save(self): class CsvEpochSource(WritableEpochSource): - def __init__(self, output_filename, possible_labels): + def __init__(self, output_filename, possible_labels, color_labels=None): assert HAVE_PANDAS, 'Pandas is not installed' self.output_filename = output_filename @@ -273,7 +273,7 @@ def __init__(self, output_filename, possible_labels): 'label': np.array([], dtype='U'+str(s)), 'name': ''} - WritableEpochSource.__init__(self, epoch, possible_labels) + WritableEpochSource.__init__(self, epoch, possible_labels, color_labels) def save(self): df = pd.DataFrame() From 6339817f4b0c5e0c406e3a7065c773a1f1f654f8 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Fri, 10 Aug 2018 14:24:28 -0400 Subject: [PATCH 05/34] Added channel_name param to CsvEpochSource constructor Accessible via inherited method get_channel_name(). --- ephyviewer/datasource/epochs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index adc75fe..b494278 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -237,7 +237,7 @@ def save(self): class CsvEpochSource(WritableEpochSource): - def __init__(self, output_filename, possible_labels, color_labels=None): + def __init__(self, output_filename, possible_labels, color_labels=None, channel_name=''): assert HAVE_PANDAS, 'Pandas is not installed' self.output_filename = output_filename @@ -264,14 +264,14 @@ def __init__(self, output_filename, possible_labels, color_labels=None): epoch = {'time': times, 'duration':durations, 'label':labels, - 'name': ''} + 'name': channel_name} else: # if file NOT exists take empty. s = max([len(l) for l in possible_labels]) epoch = {'time': np.array([], dtype='float64'), 'duration':np.array([], dtype='float64'), 'label': np.array([], dtype='U'+str(s)), - 'name': ''} + 'name': channel_name} WritableEpochSource.__init__(self, epoch, possible_labels, color_labels) From 1f0a0bfc336cb40f83b7313bde7febf4d886c4b4 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Sun, 12 Aug 2018 12:19:47 -0400 Subject: [PATCH 06/34] Raise NotImplementedError for abstract method WritableEpochSource.save() --- ephyviewer/datasource/epochs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index b494278..4df2b02 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -233,6 +233,7 @@ def fill_blank(self, method='from_left'): def save(self): print('WritableEpochSource.save') + raise NotImplementedError() From 2b4adf7c38e83d4121dc5aefbb6c85c8e3e110a5 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Sun, 12 Aug 2018 12:38:18 -0400 Subject: [PATCH 07/34] Removed unneeded variable in CsvEpochSource --- ephyviewer/datasource/epochs.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 4df2b02..51293f2 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -238,11 +238,10 @@ def save(self): class CsvEpochSource(WritableEpochSource): - def __init__(self, output_filename, possible_labels, color_labels=None, channel_name=''): + def __init__(self, filename, possible_labels, color_labels=None, channel_name=''): assert HAVE_PANDAS, 'Pandas is not installed' - self.output_filename = output_filename - self.filename = output_filename + self.filename = filename if os.path.exists(self.filename): # if file already exists load previous epoch From 60315a6fe88511569d10286fc2b33fe7784366d8 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Mon, 13 Aug 2018 12:07:26 -0400 Subject: [PATCH 08/34] Created CsvEpochSource.load() method --- ephyviewer/datasource/epochs.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 51293f2..32e0b95 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -242,7 +242,13 @@ def __init__(self, filename, possible_labels, color_labels=None, channel_name='' assert HAVE_PANDAS, 'Pandas is not installed' self.filename = filename + self.possible_labels = possible_labels + self.channel_name = channel_name + + epoch = self.load() + WritableEpochSource.__init__(self, epoch, self.possible_labels, color_labels) + def load(self): if os.path.exists(self.filename): # if file already exists load previous epoch df = pd.read_csv(self.filename, index_col=None) @@ -264,16 +270,16 @@ def __init__(self, filename, possible_labels, color_labels=None, channel_name='' epoch = {'time': times, 'duration':durations, 'label':labels, - 'name': channel_name} + 'name': self.channel_name} else: # if file NOT exists take empty. - s = max([len(l) for l in possible_labels]) + s = max([len(l) for l in self.possible_labels]) epoch = {'time': np.array([], dtype='float64'), 'duration':np.array([], dtype='float64'), 'label': np.array([], dtype='U'+str(s)), - 'name': channel_name} - - WritableEpochSource.__init__(self, epoch, possible_labels, color_labels) + 'name': self.channel_name} + + return epoch def save(self): df = pd.DataFrame() From 4fcc2f5efa30d3d898e13f4dfc58cb825d5294c3 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Mon, 13 Aug 2018 13:24:51 -0400 Subject: [PATCH 09/34] Created WritableEpochSource.load() method This change allows epoch=None when creating a WritableEpochSource object. In this case, the new load() method is called to build an empty dictionary containing the appropriate keys and data types. Like the save() method, load() can be overridden in subclasses of WritableEpochSource to load epoch data from arbitrary sources. CsvEpochSource implements an example of this. --- ephyviewer/datasource/epochs.py | 73 ++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 32e0b95..f78d41d 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -61,7 +61,14 @@ class WritableEpochSource(InMemoryEpochSource): epoch is dict { 'time':np.array, 'duration':np.array, 'label':np.array, 'name':' ''} """ - def __init__(self, epoch, possible_labels, color_labels=None): + def __init__(self, epoch=None, possible_labels=[], color_labels=None, channel_name=''): + + self.possible_labels = possible_labels + self.channel_name = channel_name + + if epoch is None: + epoch = self.load() + InMemoryEpochSource.__init__(self, all_epochs=[epoch]) #~ self._t_stop = max([ np.max(e['time']+e['duration']) for e in self.all if len(e['time'])>0]) @@ -73,11 +80,10 @@ def __init__(self, epoch, possible_labels, color_labels=None): assert self.all[0]['duration'].dtype.kind=='f' assert np.all((self.times[:-1]+self.durations[:-1])<=self.times[1:]) - assert np.all(np.in1d(epoch['label'], possible_labels)) - self.possible_labels = possible_labels + assert np.all(np.in1d(epoch['label'], self.possible_labels)) if color_labels is None: - n = len(possible_labels) + n = len(self.possible_labels) cmap = matplotlib.cm.get_cmap('Dark2' , n) color_labels = [ matplotlib.colors.ColorConverter().to_rgb(cmap(i)) for i in range(n)] color_labels = (np.array(color_labels)*255).astype(int) @@ -231,6 +237,29 @@ def fill_blank(self, method='from_left'): self._clean_and_set(ep_times, ep_durations, ep_labels) + def load(self): + """ + Returns a dictionary containing the data for an epoch. + + Derived subclasses of WritableEpochSource override this method to + implement loading a file or importing data from objects in memory. The + superclass implementation WritableEpochSource.load() creates an empty + dictionary with the correct keys and types. It can be called from the + subclass implementation using super().load() if, for example, the file + to be loaded does not exist. + + The method returns a dictionary containing the loaded data in this form: + + { 'time': np.array, 'duration': np.array, 'label': np.array, 'name': string } + """ + + s = max([len(l) for l in self.possible_labels]) + epoch = {'time': np.array([], dtype='float64'), + 'duration': np.array([], dtype='float64'), + 'label': np.array([], dtype='U'+str(s)), + 'name': self.channel_name} + return epoch + def save(self): print('WritableEpochSource.save') raise NotImplementedError() @@ -242,15 +271,24 @@ def __init__(self, filename, possible_labels, color_labels=None, channel_name='' assert HAVE_PANDAS, 'Pandas is not installed' self.filename = filename - self.possible_labels = possible_labels - self.channel_name = channel_name - epoch = self.load() - WritableEpochSource.__init__(self, epoch, self.possible_labels, color_labels) + WritableEpochSource.__init__(self, epoch=None, possible_labels=possible_labels, color_labels=color_labels, channel_name=channel_name) def load(self): + """ + Returns a dictionary containing the data for an epoch. + + Data is loaded from the CSV file if it exists; otherwise the superclass + implementation in WritableEpochSource.load() is called to create an + empty dictionary with the correct keys and types. + + The method returns a dictionary containing the loaded data in this form: + + { 'time': np.array, 'duration': np.array, 'label': np.array, 'name': string } + """ + if os.path.exists(self.filename): - # if file already exists load previous epoch + # if file already exists, load previous epoch df = pd.read_csv(self.filename, index_col=None) times = df['time'].values durations = df['duration'].values @@ -267,17 +305,14 @@ def load(self): durations[errors] = times[errors+1] - times[errors] # end fix - epoch = {'time': times, - 'duration':durations, - 'label':labels, - 'name': self.channel_name} + epoch = {'time': times, + 'duration': durations, + 'label': labels, + 'name': self.channel_name} else: - # if file NOT exists take empty. - s = max([len(l) for l in self.possible_labels]) - epoch = {'time': np.array([], dtype='float64'), - 'duration':np.array([], dtype='float64'), - 'label': np.array([], dtype='U'+str(s)), - 'name': self.channel_name} + # if file does NOT already exist, use superclass method for creating + # an empty dictionary + epoch = super().load() return epoch From 532743e647aeb2d0ebd21e098ac29b914479a295 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Mon, 13 Aug 2018 13:56:35 -0400 Subject: [PATCH 10/34] Added test for empty WritableEpochSource --- ephyviewer/tests/test_epochencoder.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ephyviewer/tests/test_epochencoder.py b/ephyviewer/tests/test_epochencoder.py index 597ad3f..bfadb03 100644 --- a/ephyviewer/tests/test_epochencoder.py +++ b/ephyviewer/tests/test_epochencoder.py @@ -41,7 +41,24 @@ def test_EpochEncoder_settings(): win.show() app.exec_() + + +def test_EpochEncoder_empty(): + possible_labels = ['AAA', 'BBB', 'CCC', 'DDD'] + + source = WritableEpochSource(epoch=None, possible_labels=possible_labels) + source._t_stop = 10 # set to positive value so navigation has a finite range + + app = ephyviewer.mkQApp() + view = ephyviewer.EpochEncoder(source=source, name='Epoch encoder') + + win = ephyviewer.MainViewer(show_step=False, show_global_xsize=True, debug=False) + win.add_view(view) + win.show() + + app.exec_() if __name__=='__main__': test_EpochEncoder() #~ test_EpochEncoder_settings() + test_EpochEncoder_empty() From 6aadbd5440bd9056d59dde3866970bf9a3d3f8a5 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Fri, 24 Aug 2018 16:32:58 -0400 Subject: [PATCH 11/34] Updated epoch encoder example --- doc/examples.rst | 10 ++++++++++ doc/img/epoch_encoder_example.png | Bin 0 -> 41713 bytes examples/epoch_encoder.py | 10 +++++----- 3 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 doc/img/epoch_encoder_example.png diff --git a/doc/examples.rst b/doc/examples.rst index f3a6047..36a15f0 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -62,6 +62,16 @@ Epoch viewer .. literalinclude:: ../examples/event_epoch_viewer.py +Epoch encoder +------------- + +.. image:: img/epoch_encoder_example.png + +:download:`epoch_encoder.py <../examples/epoch_encoder.py>` + +.. literalinclude:: ../examples/epoch_encoder.py + + mixed viewer ------------ diff --git a/doc/img/epoch_encoder_example.png b/doc/img/epoch_encoder_example.png new file mode 100644 index 0000000000000000000000000000000000000000..6fe4e33e4313deb65542367322d1ddf16dedabb9 GIT binary patch literal 41713 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­^3B{;WGllv1dlWBObx151#8|=0ONo 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 Date: Wed, 29 Aug 2018 19:25:12 -0400 Subject: [PATCH 12/34] Limit EpochEncoder and WritableEpochSource precision to microseconds (1) `WritableEpochSource._clean_and_set` now discards epochs with duration shorter than 1 microsecond. (2) `CsvEpochSource.save` now rounds time and duration to the nearest microsecond before writing to file. (3) `EpochEncoder.refresh_table` now rounds start, stop, and duration to the nearest microsecond before displaying in the data table. These changes (1) prevent the creation of spurious epochs resulting from floating point arithmetic. They also improve display of floating point numbers in both (2) saved files and (3) the EpochEncoder's data table. --- ephyviewer/datasource/epochs.py | 6 +++--- ephyviewer/epochencoder.py | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index f78d41d..f28ed75 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -105,7 +105,7 @@ def color_by_label(self, label): def _clean_and_set(self,ep_times, ep_durations, ep_labels): - keep = ep_durations>0. + keep = ep_durations >= 1e-6 # discard epochs shorter than 1 microsecond ep_times = ep_times[keep] ep_durations = ep_durations[keep] ep_labels = ep_labels[keep] @@ -318,8 +318,8 @@ def load(self): def save(self): df = pd.DataFrame() - df['time'] = self.all[0]['time'] - df['duration'] = self.all[0]['duration'] + df['time'] = np.round(self.all[0]['time'], 6) # round to nearest microsecond + df['duration'] = np.round(self.all[0]['duration'], 6) # round to nearest microsecond df['label'] = self.all[0]['label'] df.to_csv(self.filename, index=False) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 0486225..9c31ca9 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -449,7 +449,12 @@ def refresh_table(self): self.table_widget.setHorizontalHeaderLabels(['start', 'stop', 'duration', 'label']) for r in range(times.size): - values = [times[r], times[r]+durations[r], durations[r], labels[r]] + values = [ + np.round(times[r], 6), # round to nearest microsecond + np.round(times[r]+durations[r], 6), # round to nearest microsecond + np.round(durations[r], 6), # round to nearest microsecond + labels[r], + ] for c, value in enumerate(values): item = QT.QTableWidgetItem('{}'.format(value)) item.setFlags(QT.ItemIsSelectable|QT.ItemIsEnabled) From 9094410c1eb42a89252858a3b039a9994aa46d02 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Wed, 29 Aug 2018 19:31:38 -0400 Subject: [PATCH 13/34] Use numbers for default EpochEncoder shortcuts --- ephyviewer/epochencoder.py | 4 +--- examples/epoch_encoder.py | 17 +---------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 9c31ca9..a5eeae4 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -93,7 +93,7 @@ def make_params(self): self.params.param('xsize').setLimits((0, np.inf)) - keys = 'azertyuiop' + keys = '1234567890' all = [] self.shortcuts = OrderedDict() for i, label in enumerate(self.source.possible_labels): @@ -473,5 +473,3 @@ def on_seek_table(self): self.time_changed.emit(self.t) - - diff --git a/examples/epoch_encoder.py b/examples/epoch_encoder.py index 28948d4..51a96ee 100644 --- a/examples/epoch_encoder.py +++ b/examples/epoch_encoder.py @@ -54,20 +54,5 @@ app.exec_() -# press 'a', 'z', 'e', 'r' to encode state. +# press '1', '2', '3', '4' to encode state. # or press 'show/hide range' and 'apply' - - - - - - - - - - - - - - - From 54cfda63f9d348b2097b71772931d3abb1859406 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Wed, 29 Aug 2018 20:24:42 -0400 Subject: [PATCH 14/34] Moved EpochEncoder global options into EpochEncoder_ParamController --- ephyviewer/epochencoder.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index a5eeae4..803433b 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -38,17 +38,15 @@ def __init__(self, parent=None, viewer=None, with_visible=True, with_color=True) self.v1 = QT.QVBoxLayout() h.addLayout(self.v1) + self.tree_params = pg.parametertree.ParameterTree() + self.tree_params.setParameters(self.viewer.params, showTop=True) + self.tree_params.header().hide() + self.v1.addWidget(self.tree_params, stretch = 1) + self.tree_label_params = pg.parametertree.ParameterTree() self.tree_label_params.setParameters(self.viewer.by_label_params, showTop=True) self.tree_label_params.header().hide() - self.v1.addWidget(self.tree_label_params) - - #~ self.v1 = QT.QVBoxLayout() - #~ h.addLayout(self.v1) - #~ self.tree_params = pg.parametertree.ParameterTree() - #~ self.tree_params.setParameters(self.viewer.params, showTop=True) - #~ self.tree_params.header().hide() - #~ self.v1.addWidget(self.tree_params) + self.v1.addWidget(self.tree_label_params, stretch = 3) @@ -144,9 +142,8 @@ def set_layout(self): g = QT.QGridLayout() h.addLayout(g) - but = QT.PushButton('Colors and keys') + but = QT.PushButton('Options') g.addWidget(but, 0, 0) - but.clicked.connect(self.show_params_controller) but = QT.PushButton('Merge neighbors') @@ -201,11 +198,6 @@ def set_layout(self): self.but_del_region.clicked.connect(self.delete_region) - self.tree_params = pg.parametertree.ParameterTree() - self.tree_params.setParameters(self.params, showTop=True) - self.tree_params.header().hide() - h.addWidget(self.tree_params) - self.table_widget = QT.QTableWidget() h.addWidget(self.table_widget) self.table_widget.itemSelectionChanged.connect(self.on_seek_table) From c90727ce01a3eb6c95862ce1282abd1ea9983bd0 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Thu, 30 Aug 2018 12:00:43 -0400 Subject: [PATCH 15/34] Update data table after merging neighbors in EpochEncoder --- ephyviewer/epochencoder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 803433b..6659cc8 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -362,6 +362,7 @@ def on_shortcut(self): def on_merge_neighbors(self): self.source.merge_neighbors() self.refresh() + self.refresh_table() def on_fill_blank(self): params = [{'name': 'method', 'type': 'list', 'value':'from_left', 'values' : ['from_left', 'from_right', 'from_nearest']}] From 63a4f27d0d5e6ea3cab376c93efbc82572f8e58f Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Thu, 30 Aug 2018 17:43:20 -0400 Subject: [PATCH 16/34] Remove most assumptions of non-overlapping epochs in WritableEpochSource Algorithms in `InMemoryEpochSource.get_chunk_by_time`, `WritableEpochSource.add_epoch`, `WritableEpochSource.delete_in_between`, and `WritableEpochSource.merge_neighbors` implicitly assumed that epochs never overlap. This commit rewrites them to allow for the possibility of overlapping epochs. Added sorting to `WritableEpochSource._clean_and_set`, since these modifications were simplest when not trying to maintain temporal ordering during operations (i.e., use of `np.append` instead of `insert_item`). Removed redundant epoch deletion code from `add_epoch`. Added calls to `delete_in_between` before each `add_epoch` call in EpochEncoder so the behavior remains the same. `WritableEpochSource.fill_blank` was not modified and still implicitly assumes that epochs do not overlap. --- ephyviewer/datasource/epochs.py | 139 +++++++++++++------------------- ephyviewer/epochencoder.py | 9 +++ 2 files changed, 63 insertions(+), 85 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index f28ed75..6f81dcd 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -39,17 +39,12 @@ def get_chunk_by_time(self, chan=0, t_start=None, t_stop=None): ep_durations = self.all[chan]['duration'] ep_labels = self.all[chan]['label'] - #~ keep1 = (ep_times>=t_start) & (ep_times=t_start) & (ep_times+ep_duqrationst_stop) # overlap - #~ keep = keep1| keep2 | keep3 + keep1 = (ep_times>=t_start) & (ep_times=t_start) & (ep_times+ep_durationst_stop) # epochs that span the range + keep = keep1 | keep2 | keep3 - #~ return ep_times[keep], ep_durations[keep], ep_labels[keep] - - i1 = np.searchsorted(ep_times, t_start, side='left') - i2 = np.searchsorted(ep_times+ep_durations, t_stop, side='left') - sl = slice(max(0, i1-1), i2+1) - return ep_times[sl], ep_durations[sl], ep_labels[sl] + return ep_times[keep], ep_durations[keep], ep_labels[keep] @@ -102,115 +97,89 @@ def get_chunk_by_time(self, chan=0, t_start=None, t_stop=None): def color_by_label(self, label): return self.label_to_color[label] - - + def _clean_and_set(self,ep_times, ep_durations, ep_labels): - keep = ep_durations >= 1e-6 # discard epochs shorter than 1 microsecond + + # remove bad epochs + keep = ep_durations >= 1e-6 # discard epochs shorter than 1 microsecond or with negative duration ep_times = ep_times[keep] ep_durations = ep_durations[keep] ep_labels = ep_labels[keep] + # sort epochs by start time + ordering = np.argsort(ep_times) + ep_times = ep_times[ordering] + ep_durations = ep_durations[ordering] + ep_labels = ep_labels[ordering] + + # set epochs for the WritableEpochSource self.all[0]['time'] = ep_times self.all[0]['duration'] = ep_durations self.all[0]['label'] = ep_labels - def add_epoch(self, t1, duration, label): - ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] - t2 = t1+duration - - ind = np.searchsorted(ep_times, t1, side='left') - - - ep_times = insert_item(ep_times, ind, t1) - ep_durations = insert_item(ep_durations, ind, duration) - ep_labels = insert_item(ep_labels, ind, label) - - #previous - prev = ind-1 - - #if the previsous ends after the new ones then add the other part - if ind>0: - t2_prev = ep_times[prev]+ep_durations[prev] - if (t2_prev)>t2: - ep_times = insert_item(ep_times, ind+1, t2) - ep_durations = insert_item(ep_durations, ind+1, t2_prev - t2) - ep_labels = insert_item(ep_labels, ind+1, ep_labels[prev]) - #short prev durations - while prev>=0: - if (ep_times[prev]+ep_durations[prev])>ep_times[ind]: - ep_durations[prev] = ep_times[ind] - ep_times[prev] - else: - break - prev = prev-1 - - #nexts - next = ind+1 - while next0: - ep_times[next] += delta - ep_durations[next] -= delta - else: - break - next = next + 1 + ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] + ep_times = np.append(ep_times, t1) + ep_durations = np.append(ep_durations, duration) + ep_labels = np.append(ep_labels, label) self._clean_and_set(ep_times, ep_durations, ep_labels) def delete_in_between(self, t1, t2): - #~ print('delete_in_between', t1, t2) - ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] - ep_stops = ep_times+ep_durations + ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] + ep_stops = ep_times + ep_durations - i1 = np.searchsorted(ep_times, t1, side='left') - i2 = np.searchsorted(ep_times+ep_durations, t2, side='right') - #~ print 'i1, i2', i1, i2 - #~ print() - for i in range(i1-1, i2+1): - #~ print i - if i1<0: continue - if i>=ep_durations.size: continue + for i in range(len(ep_times)): + # if epoch starts and ends inside range, delete it if ep_times[i]>=t1 and ep_stops[i]t2: - #~ print 'c' ep_durations[i] = ep_stops[i] - t2 ep_times[i] = t2 + + # if epoch starts before and ends after range, + # truncate the first part and add a new epoch for the end part elif ep_times[i]<=t1 and ep_stops[i]>=t2: - #~ print 'd' ep_durations[i] = t1 - ep_times[i] - # and insert one - ep_times = insert_item(ep_times, i, t2) - ep_durations = insert_item(ep_durations, i, ep_stops[i]-t2) - ep_labels = insert_item(ep_labels,i, ep_labels[i]) - break - + ep_times = np.append(ep_times, t2) + ep_durations = np.append(ep_durations, ep_stops[i]-t2) + ep_labels = np.append(ep_labels, ep_labels[i]) self._clean_and_set(ep_times, ep_durations, ep_labels) - - - - + def merge_neighbors(self): - ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] - mask = ((ep_times[:-1] + ep_durations[:-1])==ep_times[1:]) & (ep_labels[:-1]==ep_labels[1:]) - inds, = np.nonzero(mask) + ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] + ep_stops = ep_times + ep_durations - for ind in inds: - ep_times[ind+1] = ep_times[ind] - ep_durations[ind+1] = ep_durations[ind] + ep_durations[ind+1] - ep_durations[ind] = -1 + for label in self.possible_labels: + inds, = np.nonzero(ep_labels == label) + for i in range(len(inds)-1): + + # if two sequentially adjacent epochs with the same label + # overlap or have less than 1 microsecond separation, merge them + if ep_times[inds[i+1]] - ep_stops[inds[i]] < 1e-6: + + # stretch the second epoch to cover the range of both epochs + ep_times[inds[i+1]] = min(ep_times[inds[i]], ep_times[inds[i+1]]) + ep_stops[inds[i+1]] = max(ep_stops[inds[i]], ep_stops[inds[i+1]]) + ep_durations[inds[i+1]] = ep_stops[inds[i+1]] - ep_times[inds[i+1]] + + # delete the first epoch + ep_durations[inds[i]] = -1 # non-positive duration flags this epoch for clean up self._clean_and_set(ep_times, ep_durations, ep_labels) + def fill_blank(self, method='from_left'): ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 6659cc8..45c899b 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -352,6 +352,10 @@ def on_shortcut(self): #~ duration = self.spin_step.value() duration = self.params['new_epoch_step'] + # delete existing epochs in the region where the new epoch will be inserted + self.source.delete_in_between(self.t, self.t + duration) + + # create the new epoch self.source.add_epoch(self.t, duration, label) self.t += duration @@ -399,6 +403,11 @@ def apply_region(self): t = rgn[0] duration = rgn[1] - rgn[0] label = str(self.combo_labels.currentText()) + + # delete existing epochs in the region where the new epoch will be inserted + self.source.delete_in_between(rgn[0], rgn[1]) + + # create the new epoch self.source.add_epoch(t, duration, label) self.refresh() From 9ca91ffb845f1b2372f1dc614c73b60396c6a486 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Thu, 30 Aug 2018 17:56:46 -0400 Subject: [PATCH 17/34] Allow overlapping epochs with EpochEncoder Removed assertion in `WritableEpochSource.__init__` that epochs do not overlap. Removed code in `CsvEpochSource.load` for working around inadvertent overlap caused by floating point arithmetic problems. Added `remove_old_when_inserting_new` boolean parameter to EpochEncoder. When True (default), existing epochs are deleted when new epochs are created using shortcut keys or the region selector (this was the old behavior). When False, existing epochs are not deleted, resulting in overlapping epochs. --- ephyviewer/datasource/epochs.py | 22 +++------------------- ephyviewer/epochencoder.py | 9 ++++++--- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 6f81dcd..a4b71b4 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -73,7 +73,6 @@ def __init__(self, epoch=None, possible_labels=[], color_labels=None, channel_na assert self.all[0]['time'].dtype.kind=='f' assert self.all[0]['duration'].dtype.kind=='f' - assert np.all((self.times[:-1]+self.durations[:-1])<=self.times[1:]) assert np.all(np.in1d(epoch['label'], self.possible_labels)) @@ -259,24 +258,9 @@ def load(self): if os.path.exists(self.filename): # if file already exists, load previous epoch df = pd.read_csv(self.filename, index_col=None) - times = df['time'].values - durations = df['duration'].values - labels = df['label'].values - - # fix due to rounding errors with CSV for some epoch - # time[i]+duration[i]>time[i+1] - # which to lead errors in GUI - # so make a patch here - mask1 = (times[:-1]+durations[:-1])>times[1:] - mask2 = (times[:-1]+durations[:-1])<(times[1:]+1e-9) - mask = mask1 & mask2 - errors, = np.nonzero(mask) - durations[errors] = times[errors+1] - times[errors] - # end fix - - epoch = {'time': times, - 'duration': durations, - 'label': labels, + epoch = {'time': df['time'].values, + 'duration': df['duration'].values, + 'label': df['label'].values, 'name': self.channel_name} else: # if file does NOT already exist, use superclass method for creating diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 45c899b..270f740 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -20,6 +20,7 @@ {'name': 'xsize', 'type': 'float', 'value': 3., 'step': 0.1}, {'name': 'background_color', 'type': 'color', 'value': 'k'}, {'name': 'new_epoch_step', 'type': 'float', 'value': .1, 'step': 0.1, 'limits':(0,np.inf)}, + {'name': 'remove_old_when_inserting_new', 'type': 'bool', 'value': True}, {'name': 'view_mode', 'type': 'list', 'value':'stacked', 'values' : ['stacked', 'flat']}, #~ {'name': 'display_labels', 'type': 'bool', 'value': True}, @@ -31,7 +32,7 @@ class EpochEncoder_ParamController(Base_ParamController): def __init__(self, parent=None, viewer=None, with_visible=True, with_color=True): Base_ParamController.__init__(self, parent=parent, viewer=viewer) - self.resize(400, 600) + self.resize(400, 700) h = QT.QHBoxLayout() self.mainlayout.addLayout(h) @@ -353,7 +354,8 @@ def on_shortcut(self): duration = self.params['new_epoch_step'] # delete existing epochs in the region where the new epoch will be inserted - self.source.delete_in_between(self.t, self.t + duration) + if self.params['remove_old_when_inserting_new']: + self.source.delete_in_between(self.t, self.t + duration) # create the new epoch self.source.add_epoch(self.t, duration, label) @@ -405,7 +407,8 @@ def apply_region(self): label = str(self.combo_labels.currentText()) # delete existing epochs in the region where the new epoch will be inserted - self.source.delete_in_between(rgn[0], rgn[1]) + if self.params['remove_old_when_inserting_new']: + self.source.delete_in_between(rgn[0], rgn[1]) # create the new epoch self.source.add_epoch(t, duration, label) From 26540271e13b2758eef74ccc3f489cee323dcf2f Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Thu, 30 Aug 2018 18:12:23 -0400 Subject: [PATCH 18/34] Add modifier key for EpochEncoder shortcut keys What the EpochEncoder does about existing epochs when a new one is created (deletes them or not) is determined by the `remove_old_when_inserting_new` parameter. With this commit, the behavior can be temporarily switched by holding the Shift key when pressing a shortcut key. If `remove_old_when_inserting_new` is True, pressing a shortcut key without the modifier key will delete epochs that overlap with the new epoch. Holding Shift will prevent this deletion and allow overlapping. The logic is inverted if `remove_old_when_inserting_new` is False (i.e., Shift can be used to force deletion). --- ephyviewer/epochencoder.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 270f740..eca1bb4 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -96,6 +96,7 @@ def make_params(self): all = [] self.shortcuts = OrderedDict() for i, label in enumerate(self.source.possible_labels): + # get string for shortcut key key = keys[i] if i Date: Sat, 1 Sep 2018 16:18:07 -0400 Subject: [PATCH 19/34] Add EpochEncoder buttons for controlling epoch insertion mode Added new "Epoch insertion mode" button group to EpochEncoder GUI, containing radio buttons for "Mutually exclusive" and "Overlapping". Renamed global option `remove_old_when_inserting_new` to `exclusive_mode`. --- ephyviewer/epochencoder.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index eca1bb4..dce2000 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -20,7 +20,7 @@ {'name': 'xsize', 'type': 'float', 'value': 3., 'step': 0.1}, {'name': 'background_color', 'type': 'color', 'value': 'k'}, {'name': 'new_epoch_step', 'type': 'float', 'value': .1, 'step': 0.1, 'limits':(0,np.inf)}, - {'name': 'remove_old_when_inserting_new', 'type': 'bool', 'value': True}, + {'name': 'exclusive_mode', 'type': 'bool', 'value': True}, {'name': 'view_mode', 'type': 'list', 'value':'stacked', 'values' : ['stacked', 'flat']}, #~ {'name': 'display_labels', 'type': 'bool', 'value': True}, @@ -162,8 +162,28 @@ def set_layout(self): g.addWidget(but, 2, 0) but.clicked.connect(self.on_fill_blank) + # Epoch insertion mode box + + group_box = QT.QGroupBox('Epoch insertion mode') + group_box.setToolTip('Hold Shift when using shortcut keys to temporarily switch modes') + group_box_layout = QT.QVBoxLayout() + group_box.setLayout(group_box_layout) + g.addWidget(group_box, 3, 0, 2, 1) + + # Epoch insertion mode buttons + + self.btn_insertion_mode_exclusive = QT.QRadioButton('Mutually exclusive') + self.btn_insertion_mode_overlapping = QT.QRadioButton('Overlapping') + group_box_layout.addWidget(self.btn_insertion_mode_exclusive) + group_box_layout.addWidget(self.btn_insertion_mode_overlapping) + self.btn_insertion_mode_exclusive.toggled.connect(self.params.param('exclusive_mode').setValue) + if self.params['exclusive_mode']: + self.btn_insertion_mode_exclusive.setChecked(True) + else: + self.btn_insertion_mode_overlapping.setChecked(True) + but = QT.PushButton('Save') - g.addWidget(but, 4, 0) + g.addWidget(but, 5, 0) but.clicked.connect(self.on_save) @@ -258,6 +278,10 @@ def show_params_controller(self): self.params_controller.show() def on_param_change(self): + if self.params['exclusive_mode']: + self.btn_insertion_mode_exclusive.setChecked(True) + else: + self.btn_insertion_mode_overlapping.setChecked(True) self.refresh() def on_change_keys(self, refresh=True): @@ -365,7 +389,7 @@ def on_shortcut(self): duration = self.params['new_epoch_step'] # delete existing epochs in the region where the new epoch will be inserted - if (self.params['remove_old_when_inserting_new'] and not modifier_used) or (not self.params['remove_old_when_inserting_new'] and modifier_used): + if (self.params['exclusive_mode'] and not modifier_used) or (not self.params['exclusive_mode'] and modifier_used): self.source.delete_in_between(self.t, self.t + duration) # create the new epoch @@ -418,7 +442,7 @@ def apply_region(self): label = str(self.combo_labels.currentText()) # delete existing epochs in the region where the new epoch will be inserted - if self.params['remove_old_when_inserting_new']: + if self.params['exclusive_mode']: self.source.delete_in_between(rgn[0], rgn[1]) # create the new epoch From 3ff4cb861a58ead23ba3d122536b1c2a21a343d2 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Sat, 1 Sep 2018 18:00:30 -0400 Subject: [PATCH 20/34] Add editable labels to EpochEncoder data table Plain text labels for epochs provided in the EpochEncoder's data table are replaced with drop-down menus that allow the user to change the label of an existing epoch. --- ephyviewer/datasource/epochs.py | 7 +++++++ ephyviewer/epochencoder.py | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index a4b71b4..23edce6 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -125,6 +125,13 @@ def add_epoch(self, t1, duration, label): self._clean_and_set(ep_times, ep_durations, ep_labels) + def change_labels(self, new_labels): + + ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] + assert(ep_labels.size == new_labels.size) + ep_labels = new_labels + self._clean_and_set(ep_times, ep_durations, ep_labels) + def delete_in_between(self, t1, t2): ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index dce2000..d6162ee 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -489,17 +489,20 @@ def refresh_table(self): self.table_widget.setHorizontalHeaderLabels(['start', 'stop', 'duration', 'label']) for r in range(times.size): - values = [ - np.round(times[r], 6), # round to nearest microsecond - np.round(times[r]+durations[r], 6), # round to nearest microsecond - np.round(durations[r], 6), # round to nearest microsecond - labels[r], - ] + # start, stop, duration + values = np.round([times[r], times[r]+durations[r], durations[r]], 6) # round to nearest microsecond for c, value in enumerate(values): item = QT.QTableWidgetItem('{}'.format(value)) item.setFlags(QT.ItemIsSelectable|QT.ItemIsEnabled) self.table_widget.setItem(r, c, item) + # label + combo_labels = QT.QComboBox() + combo_labels.addItems(self.source.possible_labels) + combo_labels.setCurrentText(labels[r]) + combo_labels.currentIndexChanged.connect(self.on_change_label) + self.table_widget.setCellWidget(r, 3, combo_labels) + def on_seek_table(self): if self.table_widget.rowCount()==0: return @@ -512,4 +515,10 @@ def on_seek_table(self): self.refresh() self.time_changed.emit(self.t) + def on_change_label(self): + labels = [] + for r in range(self.table_widget.rowCount()): + labels.append(self.table_widget.cellWidget(r, 3).currentText()) + self.source.change_labels(np.asarray(labels)) + self.refresh() From b993212409d958bec5a6c27fdfd1252636d9dda3 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Sat, 1 Sep 2018 18:47:54 -0400 Subject: [PATCH 21/34] Fix for incorrect time jump after inserting epoch with EpochEncoder After using a drop-down menu to change an existing epoch's label and then pressing a shortcut key to insert a new one, the time would jump back to the first epoch, rather than forward one step. This was caused by `on_seek_table` triggering when the table was cleared by `refresh_table`. Interacting with a drop-down menu in the data table before this apparently caused row selection to trigger for the first row in an unexpected way, even if the drop-down menu interacted with was not in the first row. --- ephyviewer/epochencoder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index d6162ee..0302fbb 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -481,7 +481,9 @@ def set_limit2(self): self.spin_limit2.setValue(self.t) def refresh_table(self): + self.table_widget.blockSignals(True) self.table_widget.clear() + self.table_widget.blockSignals(False) #~ ev = self.source.all_events[ind] times, durations, labels = self.source.get_chunk(chan=0, i_start=None, i_stop=None) self.table_widget.setColumnCount(4) From e01f1ba37970702a572caeba11b3ce348e51b56e Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Sat, 1 Sep 2018 21:24:10 -0400 Subject: [PATCH 22/34] Click rectangle in EpochEncoder to find epoch in data table Added a new feature that makes it easier to find epochs in the EpochEncoder's data table. When an epoch's rectangle is clicked in the plot, the corresponding row in the data table is automatically selected. More specifically, the label drop-down menu is selected, which allows the up and down arrow keys to be used to change the label quickly. Time is also moved to the start of the epoch. --- ephyviewer/datasource/epochs.py | 6 ++++-- ephyviewer/epochencoder.py | 23 ++++++++++++++++++----- ephyviewer/epochviewer.py | 19 +++++++++++++++---- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 23edce6..79563e4 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -32,19 +32,21 @@ def get_chunk(self, chan=0, i_start=None, i_stop=None): ep_times = self.all[chan]['time'][i_start:i_stop] ep_durations = self.all[chan]['duration'][i_start:i_stop] ep_labels = self.all[chan]['label'][i_start:i_stop] - return ep_times, ep_durations, ep_labels + ep_ids = np.arange(len(ep_times))[i_start:i_stop] + return ep_times, ep_durations, ep_labels, ep_ids def get_chunk_by_time(self, chan=0, t_start=None, t_stop=None): ep_times = self.all[chan]['time'] ep_durations = self.all[chan]['duration'] ep_labels = self.all[chan]['label'] + ep_ids = np.arange(len(ep_times)) keep1 = (ep_times>=t_start) & (ep_times=t_start) & (ep_times+ep_durationst_stop) # epochs that span the range keep = keep1 | keep2 | keep3 - return ep_times[keep], ep_durations[keep], ep_labels[keep] + return ep_times[keep], ep_durations[keep], ep_labels[keep], ep_ids[keep] diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 0302fbb..c38aac8 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -338,7 +338,7 @@ def on_data_ready(self, t_start, t_stop, visibles, data): self.graphicsview.setBackground(self.params['background_color']) - times, durations, labels = data[0] + times, durations, labels, ids = data[0] #~ print(data) n = len(self.source.possible_labels) @@ -350,8 +350,8 @@ def on_data_ready(self, t_start, t_stop, visibles, data): ypos = n - ind - 1 else: ypos = 0 - item = RectItem([times[i], ypos,durations[i], .9], border=color, fill=color) - item = RectItem([times[i], ypos,durations[i], .9], border='#FFFFFF', fill=color) + item = RectItem([times[i], ypos,durations[i], .9], border='#FFFFFF', fill=color, id=ids[i]) + item.clicked.connect(self.on_rect_clicked) item.setPos(times[i], ypos) self.plot.addItem(item) self.rect_items.append(item) @@ -485,7 +485,7 @@ def refresh_table(self): self.table_widget.clear() self.table_widget.blockSignals(False) #~ ev = self.source.all_events[ind] - times, durations, labels = self.source.get_chunk(chan=0, i_start=None, i_stop=None) + times, durations, labels, _ = self.source.get_chunk(chan=0, i_start=None, i_stop=None) self.table_widget.setColumnCount(4) self.table_widget.setRowCount(times.size) self.table_widget.setHorizontalHeaderLabels(['start', 'stop', 'duration', 'label']) @@ -505,6 +505,19 @@ def refresh_table(self): combo_labels.currentIndexChanged.connect(self.on_change_label) self.table_widget.setCellWidget(r, 3, combo_labels) + def on_rect_clicked(self, id): + + # select the epoch in the data table + self.table_widget.blockSignals(True) + self.table_widget.setCurrentCell(id, 3) # select the label combo box + self.table_widget.blockSignals(False) + + # set time to epoch start + t, _, _, _= self.source.get_chunk(chan=0, i_start=id, i_stop=id+1) + self.t = t[0] + self.refresh() + self.time_changed.emit(self.t) + def on_seek_table(self): if self.table_widget.rowCount()==0: return @@ -512,7 +525,7 @@ def on_seek_table(self): if len(selected_ind)==0: return i = selected_ind[0].row() - t, _, _= self.source.get_chunk(chan=0, i_start=i, i_stop=i+1) + t, _, _, _= self.source.get_chunk(chan=0, i_start=i, i_stop=i+1) self.t = t[0] self.refresh() self.time_changed.emit(self.t) diff --git a/ephyviewer/epochviewer.py b/ephyviewer/epochviewer.py index 7044790..60d9264 100644 --- a/ephyviewer/epochviewer.py +++ b/ephyviewer/epochviewer.py @@ -35,11 +35,15 @@ class EpochViewer_ParamController(Base_MultiChannel_ParamController): class RectItem(pg.GraphicsWidget): - def __init__(self, rect, border = 'r', fill = 'g'): + + clicked = QT.pyqtSignal(int) + + def __init__(self, rect, border = 'r', fill = 'g', id = -1): pg.GraphicsWidget.__init__(self) self.rect = rect self.border= border self.fill= fill + self.id = id def boundingRect(self): return QT.QRectF(0, 0, self.rect[2], self.rect[3]) @@ -49,6 +53,13 @@ def paint(self, p, *args): p.setBrush(pg.mkBrush(self.fill)) p.drawRect(self.boundingRect()) + def mousePressEvent(self, event): + event.accept() + + def mouseReleaseEvent(self, event): + event.accept() + self.clicked.emit(self.id) + class DataGrabber(QT.QObject): data_ready = QT.pyqtSignal(float, float, object, object) @@ -60,8 +71,8 @@ def __init__(self, source, parent=None): def on_request_data(self, t_start, t_stop, visibles): data = {} for e, chan in enumerate(visibles): - times, durations, labels = self.source.get_chunk_by_time(chan=chan, t_start=t_start, t_stop=t_stop) - data[chan] = (times, durations, labels) + times, durations, labels, ids = self.source.get_chunk_by_time(chan=chan, t_start=t_start, t_stop=t_stop) + data[chan] = (times, durations, labels, ids) self.data_ready.emit(t_start, t_stop, visibles, data) @@ -123,7 +134,7 @@ def on_data_ready(self, t_start, t_stop, visibles, data): self.graphicsview.setBackground(self.params['background_color']) for e, chan in enumerate(visibles): - times, durations, labels = data[chan] + times, durations, labels, _ = data[chan] color = self.by_channel_params.children()[e].param('color').value() color2 = QT.QColor(color) From 294d52c5e37c0737a790a4afe021b2559ce74357 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Sat, 1 Sep 2018 21:40:23 -0400 Subject: [PATCH 23/34] Fix for failure to delete epochs in EpochEncoder Epochs would fail to delete if they started within the selected region and ended exactly at its right boundary. --- ephyviewer/datasource/epochs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 79563e4..24b6058 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -142,11 +142,11 @@ def delete_in_between(self, t1, t2): for i in range(len(ep_times)): # if epoch starts and ends inside range, delete it - if ep_times[i]>=t1 and ep_stops[i]=t1 and ep_stops[i]<=t2: ep_durations[i] = -1 # non-positive duration flags this epoch for clean up # if epoch starts before and ends inside range, truncate it - elif ep_times[i] Date: Sun, 2 Sep 2018 10:49:18 -0400 Subject: [PATCH 24/34] Click rectangle to match region in EpochEncoder In addition to selecting the corresponding row in the data table and changing the time, clicking a rectangle in the EpochEncoder plot will now update the region selection to match the epoch for easier duplication or deletion. --- ephyviewer/epochencoder.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index c38aac8..1362195 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -512,8 +512,9 @@ def on_rect_clicked(self, id): self.table_widget.setCurrentCell(id, 3) # select the label combo box self.table_widget.blockSignals(False) - # set time to epoch start - t, _, _, _= self.source.get_chunk(chan=0, i_start=id, i_stop=id+1) + # set region to epoch start and stop and set time to epoch start + t, dur, _, _= self.source.get_chunk(chan=0, i_start=id, i_stop=id+1) + self.region.setRegion((t[0], t[0]+dur[0])) self.t = t[0] self.refresh() self.time_changed.emit(self.t) From 14ba5dc0509ed0a62e8f24bbfd770efffe908174 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Sun, 2 Sep 2018 11:18:49 -0400 Subject: [PATCH 25/34] Double-click rectangle to match region in EpochEncoder Changed region match feature to double-click instead of single-click since changing region might not always be desired. --- ephyviewer/epochencoder.py | 12 +++++++++--- ephyviewer/epochviewer.py | 7 +++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 1362195..91bf5aa 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -352,6 +352,7 @@ def on_data_ready(self, t_start, t_stop, visibles, data): ypos = 0 item = RectItem([times[i], ypos,durations[i], .9], border='#FFFFFF', fill=color, id=ids[i]) item.clicked.connect(self.on_rect_clicked) + item.doubleclicked.connect(self.on_rect_doubleclicked) item.setPos(times[i], ypos) self.plot.addItem(item) self.rect_items.append(item) @@ -512,13 +513,18 @@ def on_rect_clicked(self, id): self.table_widget.setCurrentCell(id, 3) # select the label combo box self.table_widget.blockSignals(False) - # set region to epoch start and stop and set time to epoch start - t, dur, _, _= self.source.get_chunk(chan=0, i_start=id, i_stop=id+1) - self.region.setRegion((t[0], t[0]+dur[0])) + # set time to epoch start + t, _, _, _= self.source.get_chunk(chan=0, i_start=id, i_stop=id+1) self.t = t[0] self.refresh() self.time_changed.emit(self.t) + def on_rect_doubleclicked(self, id): + + # set region to epoch start and stop + t, dur, _, _= self.source.get_chunk(chan=0, i_start=id, i_stop=id+1) + self.region.setRegion((t[0], t[0]+dur[0])) + def on_seek_table(self): if self.table_widget.rowCount()==0: return diff --git a/ephyviewer/epochviewer.py b/ephyviewer/epochviewer.py index 60d9264..6606294 100644 --- a/ephyviewer/epochviewer.py +++ b/ephyviewer/epochviewer.py @@ -37,6 +37,7 @@ class EpochViewer_ParamController(Base_MultiChannel_ParamController): class RectItem(pg.GraphicsWidget): clicked = QT.pyqtSignal(int) + doubleclicked = QT.pyqtSignal(int) def __init__(self, rect, border = 'r', fill = 'g', id = -1): pg.GraphicsWidget.__init__(self) @@ -60,6 +61,10 @@ def mouseReleaseEvent(self, event): event.accept() self.clicked.emit(self.id) + def mouseDoubleClickEvent(self, event): + event.accept() + self.doubleclicked.emit(self.id) + class DataGrabber(QT.QObject): data_ready = QT.pyqtSignal(float, float, object, object) @@ -159,5 +164,3 @@ def on_data_ready(self, t_start, t_stop, visibles, data): self.vline.setPos(self.t) self.plot.setXRange( t_start, t_stop, padding = 0.0) self.plot.setYRange( 0, visibles.size) - - From c793a22611fc4e4b3e593a9cb8cd6e9330fbd244 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Sun, 2 Sep 2018 22:31:52 -0400 Subject: [PATCH 26/34] Unique, unchanging ids for epochs in WritableEpochSource ids (really, indices) added to epoch rectangles in EpochEncoder with commit e01f1ba were buggy. They assumed epoch ordering remained in sync for both the plot and data table, but this was not always the case when epoch labels are changed. Unchanging, unique identifiers that allow for unambiguous matching of rectangles to data table rows are needed. With this commit, id is now distinct from index and is used to track epochs even as indices change due to insertion, deletion, and reordering of epochs. This fixes known bugs where rectangle ids were not in sync with table rows. However, I suspect more work is needed to make the reverse mapping (from table rows to epochs) more robust. The interface to WritableEpochSource was also cleaned up, so that data can be accessed more simply and consistently using new class properties. --- ephyviewer/datasource/epochs.py | 117 ++++++++++++++++++++------------ ephyviewer/epochencoder.py | 38 +++++++---- 2 files changed, 96 insertions(+), 59 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 24b6058..fdcc009 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -27,19 +27,24 @@ def __init__(self, all_epochs=[]): s = [ np.max(e['time']+e['duration']) for e in self.all if len(e['time'])>0] self._t_stop = max(s) if len(s)>0 else 0 + # assign each epoch a fixed, unique integer id + self.next_id = 0 + for chan in self.all: + chan['id'] = np.arange(self.next_id, self.next_id + len(chan['time'])) + self.next_id += len(chan['time']) def get_chunk(self, chan=0, i_start=None, i_stop=None): ep_times = self.all[chan]['time'][i_start:i_stop] ep_durations = self.all[chan]['duration'][i_start:i_stop] ep_labels = self.all[chan]['label'][i_start:i_stop] - ep_ids = np.arange(len(ep_times))[i_start:i_stop] + ep_ids = self.all[chan]['id'][i_start:i_stop] return ep_times, ep_durations, ep_labels, ep_ids def get_chunk_by_time(self, chan=0, t_start=None, t_stop=None): ep_times = self.all[chan]['time'] ep_durations = self.all[chan]['duration'] ep_labels = self.all[chan]['label'] - ep_ids = np.arange(len(ep_times)) + ep_ids = self.all[chan]['id'] keep1 = (ep_times>=t_start) & (ep_times=t_start) & (ep_times+ep_durations0]) - - self.times = self.all[0]['time'] - self.durations = self.all[0]['duration'] - assert self.all[0]['time'].dtype.kind=='f' assert self.all[0]['duration'].dtype.kind=='f' - assert np.all(np.in1d(epoch['label'], self.possible_labels)) + # put the epochs into a canonical order after loading + self._clean_and_set(self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'], self.all[0]['id']) + if color_labels is None: n = len(self.possible_labels) cmap = matplotlib.cm.get_cmap('Dark2' , n) @@ -87,6 +89,46 @@ def __init__(self, epoch=None, possible_labels=[], color_labels=None, channel_na self.color_labels = color_labels self.label_to_color = dict(zip(self.possible_labels, self.color_labels)) + @property + def ep_times(self): + return self.all[0]['time'] + + @ep_times.setter + def ep_times(self, arr): + self.all[0]['time'] = arr + + @property + def ep_durations(self): + return self.all[0]['duration'] + + @ep_durations.setter + def ep_durations(self, arr): + self.all[0]['duration'] = arr + + @property + def ep_labels(self): + return self.all[0]['label'] + + @ep_labels.setter + def ep_labels(self, arr): + self.all[0]['label'] = arr + + @property + def ep_ids(self): + return self.all[0]['id'] + + @ep_ids.setter + def ep_ids(self, arr): + self.all[0]['id'] = arr + + @property + def ep_stops(self): + return self.ep_times + self.ep_durations + + @property + def id_to_ind(self): + return dict((id,ind) for ind,id in enumerate(self.ep_ids)) + def get_chunk(self, chan=0, i_start=None, i_stop=None): assert chan==0 return InMemoryEpochSource. get_chunk(self, chan=chan, i_start=i_start, i_stop=i_stop) @@ -99,45 +141,42 @@ def get_chunk_by_time(self, chan=0, t_start=None, t_stop=None): def color_by_label(self, label): return self.label_to_color[label] - def _clean_and_set(self,ep_times, ep_durations, ep_labels): + def _clean_and_set(self, ep_times, ep_durations, ep_labels, ep_ids): # remove bad epochs keep = ep_durations >= 1e-6 # discard epochs shorter than 1 microsecond or with negative duration ep_times = ep_times[keep] ep_durations = ep_durations[keep] ep_labels = ep_labels[keep] + ep_ids = ep_ids[keep] # sort epochs by start time ordering = np.argsort(ep_times) ep_times = ep_times[ordering] ep_durations = ep_durations[ordering] ep_labels = ep_labels[ordering] + ep_ids = ep_ids[ordering] # set epochs for the WritableEpochSource - self.all[0]['time'] = ep_times - self.all[0]['duration'] = ep_durations - self.all[0]['label'] = ep_labels + self.ep_times = ep_times + self.ep_durations = ep_durations + self.ep_labels = ep_labels + self.ep_ids = ep_ids def add_epoch(self, t1, duration, label): - ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] + ep_times, ep_durations, ep_labels, ep_ids = self.ep_times, self.ep_durations, self.ep_labels, self.ep_ids ep_times = np.append(ep_times, t1) ep_durations = np.append(ep_durations, duration) ep_labels = np.append(ep_labels, label) + ep_ids = np.append(ep_ids, self.next_id) + self.next_id += 1 - self._clean_and_set(ep_times, ep_durations, ep_labels) - - def change_labels(self, new_labels): - - ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] - assert(ep_labels.size == new_labels.size) - ep_labels = new_labels - self._clean_and_set(ep_times, ep_durations, ep_labels) + self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) def delete_in_between(self, t1, t2): - ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] - ep_stops = ep_times + ep_durations + ep_times, ep_durations, ep_stops, ep_labels, ep_ids = self.ep_times, self.ep_durations, self.ep_stops, self.ep_labels, self.ep_ids for i in range(len(ep_times)): @@ -161,13 +200,14 @@ def delete_in_between(self, t1, t2): ep_times = np.append(ep_times, t2) ep_durations = np.append(ep_durations, ep_stops[i]-t2) ep_labels = np.append(ep_labels, ep_labels[i]) + ep_ids = np.append(ep_ids, self.next_id) + self.next_id += 1 - self._clean_and_set(ep_times, ep_durations, ep_labels) + self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) def merge_neighbors(self): - ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] - ep_stops = ep_times + ep_durations + ep_times, ep_durations, ep_stops, ep_labels, ep_ids = self.ep_times, self.ep_durations, self.ep_stops, self.ep_labels, self.ep_ids for label in self.possible_labels: inds, = np.nonzero(ep_labels == label) @@ -185,11 +225,12 @@ def merge_neighbors(self): # delete the first epoch ep_durations[inds[i]] = -1 # non-positive duration flags this epoch for clean up - self._clean_and_set(ep_times, ep_durations, ep_labels) + self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) def fill_blank(self, method='from_left'): - ep_times, ep_durations, ep_labels = self.all[0]['time'], self.all[0]['duration'], self.all[0]['label'] + + ep_times, ep_durations, ep_labels, ep_ids = self.ep_times, self.ep_durations, self.ep_labels, self.ep_ids mask = ((ep_times[:-1] + ep_durations[:-1]) Date: Tue, 4 Sep 2018 10:12:33 -0400 Subject: [PATCH 27/34] WritableEpochSource.next_id for internal use only --- ephyviewer/datasource/epochs.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index fdcc009..d7c9339 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -28,10 +28,10 @@ def __init__(self, all_epochs=[]): self._t_stop = max(s) if len(s)>0 else 0 # assign each epoch a fixed, unique integer id - self.next_id = 0 + self._next_id = 0 for chan in self.all: - chan['id'] = np.arange(self.next_id, self.next_id + len(chan['time'])) - self.next_id += len(chan['time']) + chan['id'] = np.arange(self._next_id, self._next_id + len(chan['time'])) + self._next_id += len(chan['time']) def get_chunk(self, chan=0, i_start=None, i_stop=None): ep_times = self.all[chan]['time'][i_start:i_stop] @@ -169,8 +169,8 @@ def add_epoch(self, t1, duration, label): ep_times = np.append(ep_times, t1) ep_durations = np.append(ep_durations, duration) ep_labels = np.append(ep_labels, label) - ep_ids = np.append(ep_ids, self.next_id) - self.next_id += 1 + ep_ids = np.append(ep_ids, self._next_id) + self._next_id += 1 self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) @@ -200,8 +200,8 @@ def delete_in_between(self, t1, t2): ep_times = np.append(ep_times, t2) ep_durations = np.append(ep_durations, ep_stops[i]-t2) ep_labels = np.append(ep_labels, ep_labels[i]) - ep_ids = np.append(ep_ids, self.next_id) - self.next_id += 1 + ep_ids = np.append(ep_ids, self._next_id) + self._next_id += 1 self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) From 157c4d64b9bae38369405c09e4d0045422e540aa Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Tue, 4 Sep 2018 10:55:07 -0400 Subject: [PATCH 28/34] Delete, duplicate, split individual epochs with EpochEncoder Added `delete_epoch` and `split_epoch` methods to WritableEpochSource. Added buttons to EpochEncoder for deleting, duplicating, or splitting at current time any epoch currently selected in the data table. Removed jumping to start of epoch when clicking on rectangle since this makes splitting multiple epochs at the same time easier (also makes double-clicking to set region easier). --- ephyviewer/datasource/epochs.py | 25 ++++++++++++++ ephyviewer/epochencoder.py | 61 ++++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index d7c9339..5145cfa 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -174,6 +174,16 @@ def add_epoch(self, t1, duration, label): self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) + def delete_epoch(self, ind): + + ep_times, ep_durations, ep_labels, ep_ids = self.ep_times, self.ep_durations, self.ep_labels, self.ep_ids + ep_times = np.delete(ep_times, ind) + ep_durations = np.delete(ep_durations, ind) + ep_labels = np.delete(ep_labels, ind) + ep_ids = np.delete(ep_ids, ind) + + self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) + def delete_in_between(self, t1, t2): ep_times, ep_durations, ep_stops, ep_labels, ep_ids = self.ep_times, self.ep_durations, self.ep_stops, self.ep_labels, self.ep_ids @@ -227,6 +237,21 @@ def merge_neighbors(self): self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) + def split_epoch(self, ind, t_split): + + ep_times, ep_durations, ep_stops, ep_labels, ep_ids = self.ep_times, self.ep_durations, self.ep_stops, self.ep_labels, self.ep_ids + + if t_split <= ep_times[ind] or ep_stops[ind] <= t_split: + return + + ep_durations[ind] = t_split - ep_times[ind] + ep_times = np.append(ep_times, t_split) + ep_durations = np.append(ep_durations, ep_stops[ind]-t_split) + ep_labels = np.append(ep_labels, ep_labels[ind]) + ep_ids = np.append(ep_ids, self._next_id) + self._next_id += 1 + + self._clean_and_set(ep_times, ep_durations, ep_labels, ep_ids) def fill_blank(self, method='from_left'): diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 221642f..6248879 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -226,6 +226,28 @@ def set_layout(self): self.but_del_region.clicked.connect(self.delete_region) + # Table operations box + + group_box = QT.QGroupBox('Table operations') + group_box_layout = QT.QVBoxLayout() + group_box.setLayout(group_box_layout) + g.addWidget(group_box, 0, 2, 3, 1) + + # Table operations buttons + + but = QT.PushButton('Delete') + group_box_layout.addWidget(but) + but.clicked.connect(self.delete_selected_epoch) + + but = QT.PushButton('Duplicate') + group_box_layout.addWidget(but) + but.clicked.connect(self.duplicate_selected_epoch) + + but = QT.PushButton('Split') + group_box_layout.addWidget(but) + but.clicked.connect(self.split_selected_epoch) + + self.table_widget = QT.QTableWidget() h.addWidget(self.table_widget) self.table_widget.itemSelectionChanged.connect(self.on_seek_table) @@ -518,11 +540,6 @@ def on_rect_clicked(self, id): self.table_widget.setCurrentCell(ind, 3) # select the label combo box self.table_widget.blockSignals(False) - # set time to epoch start - self.t = self.source.ep_times[ind] - self.refresh() - self.time_changed.emit(self.t) - def on_rect_doubleclicked(self, id): # get index corresponding to epoch id @@ -552,3 +569,37 @@ def on_change_label(self, id, new_label): self.refresh() # refresh_table is not called to avoid deselecting table cell + def delete_selected_epoch(self): + if self.table_widget.rowCount()==0: + return + selected_ind = self.table_widget.selectedIndexes() + if len(selected_ind)==0: + return + ind = selected_ind[0].row() + self.source.delete_epoch(ind) + self.refresh() + self.refresh_table() + + def duplicate_selected_epoch(self): + if self.table_widget.rowCount()==0: + return + selected_ind = self.table_widget.selectedIndexes() + if len(selected_ind)==0: + return + ind = selected_ind[0].row() + self.source.add_epoch(self.source.ep_times[ind], self.source.ep_durations[ind], self.source.ep_labels[ind]) + self.refresh() + self.refresh_table() + + def split_selected_epoch(self): + if self.table_widget.rowCount()==0: + return + selected_ind = self.table_widget.selectedIndexes() + if len(selected_ind)==0: + return + ind = selected_ind[0].row() + if self.t <= self.source.ep_times[ind] or self.source.ep_stops[ind] <= self.t: + return + self.source.split_epoch(ind, self.t) + self.refresh() + self.refresh_table() From 6d5987db3f3846f4c6f1cc0287a5f968d0c795dc Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Thu, 6 Sep 2018 19:01:00 -0400 Subject: [PATCH 29/34] Fix for EpochViewer not working with NeoEpochSource Commit e01f1ba added epoch ids to `InMemoryEpochSource` so that the `EpochEncoder` could handle changing epoch order. To accomodate this, `EpochEncoder` and `EpochViewer` were modified to expect 4 lists from the epoch source's `get_chunk*` methods, rather than the former 3 lists. This introduced a bug, since `NeoEpochSource` still provides only 3 lists, making `NeoEpochSource` incompatible with `EpochViewer`. Since epoch ids are only needed by `EpochEncoder` to keep track of epochs in mutable epoch sources, i.e. `WritableEpochSource`, ids should have been added exclusively to `WritableEpochSource`, rather than to its parent class, `InMemoryEpochSource`. This commit fixes the bug by (1) migrating the epoch id code to `WritableEpochSource` and restoring `InMemoryEpochSource` to its state prior to commit e01f1ba, and (2) making `EpochViewer` capable of using any epoch source (whether it provide 3 or 4 data lists). Additionally, this commit safeguards against using immutable epoch sources when a mutable source is needed by asserting that `EpochEncoder`'s `source` is a `WritableEpochSource`. This commit also increases the flexibility of `EventList` by making it capable of using any event or epoch source (whether it provide 2, 3, or 4 data lists). --- ephyviewer/datasource/epochs.py | 38 ++++++++++++++++++++++----------- ephyviewer/epochencoder.py | 3 +++ ephyviewer/epochviewer.py | 11 +++++++--- ephyviewer/eventlist.py | 8 +++++++ 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 5145cfa..878eea6 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -27,31 +27,23 @@ def __init__(self, all_epochs=[]): s = [ np.max(e['time']+e['duration']) for e in self.all if len(e['time'])>0] self._t_stop = max(s) if len(s)>0 else 0 - # assign each epoch a fixed, unique integer id - self._next_id = 0 - for chan in self.all: - chan['id'] = np.arange(self._next_id, self._next_id + len(chan['time'])) - self._next_id += len(chan['time']) - def get_chunk(self, chan=0, i_start=None, i_stop=None): ep_times = self.all[chan]['time'][i_start:i_stop] ep_durations = self.all[chan]['duration'][i_start:i_stop] ep_labels = self.all[chan]['label'][i_start:i_stop] - ep_ids = self.all[chan]['id'][i_start:i_stop] - return ep_times, ep_durations, ep_labels, ep_ids + return ep_times, ep_durations, ep_labels def get_chunk_by_time(self, chan=0, t_start=None, t_stop=None): ep_times = self.all[chan]['time'] ep_durations = self.all[chan]['duration'] ep_labels = self.all[chan]['label'] - ep_ids = self.all[chan]['id'] keep1 = (ep_times>=t_start) & (ep_times=t_start) & (ep_times+ep_durationst_stop) # epochs that span the range keep = keep1 | keep2 | keep3 - return ep_times[keep], ep_durations[keep], ep_labels[keep], ep_ids[keep] + return ep_times[keep], ep_durations[keep], ep_labels[keep] @@ -73,6 +65,12 @@ def __init__(self, epoch=None, possible_labels=[], color_labels=None, channel_na InMemoryEpochSource.__init__(self, all_epochs=[epoch]) + # assign each epoch a fixed, unique integer id + self._next_id = 0 + for chan in self.all: + chan['id'] = np.arange(self._next_id, self._next_id + len(chan['time'])) + self._next_id += len(chan['time']) + assert self.all[0]['time'].dtype.kind=='f' assert self.all[0]['duration'].dtype.kind=='f' assert np.all(np.in1d(epoch['label'], self.possible_labels)) @@ -131,12 +129,26 @@ def id_to_ind(self): def get_chunk(self, chan=0, i_start=None, i_stop=None): assert chan==0 - return InMemoryEpochSource. get_chunk(self, chan=chan, i_start=i_start, i_stop=i_stop) + ep_times = self.all[chan]['time'][i_start:i_stop] + ep_durations = self.all[chan]['duration'][i_start:i_stop] + ep_labels = self.all[chan]['label'][i_start:i_stop] + ep_ids = self.all[chan]['id'][i_start:i_stop] + return ep_times, ep_durations, ep_labels, ep_ids + def get_chunk_by_time(self, chan=0, t_start=None, t_stop=None): - #~ print(chan) assert chan==0 - return InMemoryEpochSource.get_chunk_by_time(self, chan=chan, t_start=t_start, t_stop=t_stop) + ep_times = self.all[chan]['time'] + ep_durations = self.all[chan]['duration'] + ep_labels = self.all[chan]['label'] + ep_ids = self.all[chan]['id'] + + keep1 = (ep_times>=t_start) & (ep_times=t_start) & (ep_times+ep_durationst_stop) # epochs that span the range + keep = keep1 | keep2 | keep3 + + return ep_times[keep], ep_durations[keep], ep_labels[keep], ep_ids[keep] def color_by_label(self, label): return self.label_to_color[label] diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 6248879..d960507 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -14,6 +14,7 @@ from .base import ViewerBase, Base_ParamController, MyViewBox, same_param_tree from .epochviewer import RectItem, DataGrabber +from .datasource import WritableEpochSource default_params = [ @@ -59,6 +60,8 @@ class EpochEncoder(ViewerBase): def __init__(self, **kargs): ViewerBase.__init__(self, **kargs) + assert isinstance(self.source, WritableEpochSource) + self.make_params() self.set_layout() self.make_param_controller() diff --git a/ephyviewer/epochviewer.py b/ephyviewer/epochviewer.py index 6606294..a3e334a 100644 --- a/ephyviewer/epochviewer.py +++ b/ephyviewer/epochviewer.py @@ -76,8 +76,7 @@ def __init__(self, source, parent=None): def on_request_data(self, t_start, t_stop, visibles): data = {} for e, chan in enumerate(visibles): - times, durations, labels, ids = self.source.get_chunk_by_time(chan=chan, t_start=t_start, t_stop=t_stop) - data[chan] = (times, durations, labels, ids) + data[chan] = self.source.get_chunk_by_time(chan=chan, t_start=t_start, t_stop=t_stop) self.data_ready.emit(t_start, t_stop, visibles, data) @@ -139,7 +138,13 @@ def on_data_ready(self, t_start, t_stop, visibles, data): self.graphicsview.setBackground(self.params['background_color']) for e, chan in enumerate(visibles): - times, durations, labels, _ = data[chan] + + if len(data[chan])==3: + times, durations, labels = data[chan] + elif len(data[chan])==4: + times, durations, labels, _ = data[chan] + else: + raise ValueError("data has unexpected dimensions") color = self.by_channel_params.children()[e].param('color').value() color2 = QT.QColor(color) diff --git a/ephyviewer/eventlist.py b/ephyviewer/eventlist.py index f229f58..a476924 100644 --- a/ephyviewer/eventlist.py +++ b/ephyviewer/eventlist.py @@ -51,6 +51,10 @@ def refresh_list(self, ind): times, labels = data elif len(data)==3: times, _, labels = data + elif len(data)==4: + times, _, labels, _ = data + else: + raise ValueError("data has unexpected dimensions") for i in range(times.size): if labels is None: @@ -69,6 +73,10 @@ def select_event(self, i): times, labels = data elif len(data)==3: times, _, labels = data + elif len(data)==4: + times, _, labels, _ = data + else: + raise ValueError("data has unexpected dimensions") if len(times)>0: t = float(times[0]) From 7f397ba3224fde48fa050e1fe74c9f9c0682136e Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Fri, 7 Sep 2018 18:08:17 -0400 Subject: [PATCH 30/34] Double-click rectangle to show and match region in EpochEncoder Double-clicking on a rectangle will now also show the range/region if it is hidden, in addition to changing the region to match the rectangle. Additionally, this commit changes the event handlers for both `RectItem` (rectangles) and `MyViewBox` (background) to use PyQtGraph's `mouseClickEvent` with the `event.double()` flag, instead of Qt's `mousePressEvent` and `mouseDoubleClickEvent`, since this approach handles better the choice of which object should receive the event. --- ephyviewer/base.py | 9 +++++---- ephyviewer/epochencoder.py | 9 ++++++--- ephyviewer/epochviewer.py | 19 +++++++++---------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/ephyviewer/base.py b/ephyviewer/base.py index d1c555f..62b7601 100644 --- a/ephyviewer/base.py +++ b/ephyviewer/base.py @@ -52,10 +52,11 @@ def __init__(self, *args, **kwds): pg.ViewBox.__init__(self, *args, **kwds) self.disableAutoRange() def mouseClickEvent(self, ev): - ev.accept() - def mouseDoubleClickEvent(self, ev): - self.doubleclicked.emit() - ev.accept() + if ev.double(): + ev.accept() + self.doubleclicked.emit() + else: + ev.ignore() def wheelEvent(self, ev): if ev.modifiers() == QT.Qt.ControlModifier: z = 5. if ev.delta()>0 else 1/5. diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index d960507..61da5ca 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -486,13 +486,12 @@ def delete_region(self): self.refresh_table() - def on_range_visibility_changed(self, flag, refresh=True): + def on_range_visibility_changed(self, flag, refresh=True, shift_region=True): enabled = self.but_range.isChecked() #~ print(enabled) for w in (self.spin_limit1, self.spin_limit2, self.combo_labels, self.but_apply_region, self.but_del_region): w.setEnabled(enabled) - - if enabled: + if enabled and shift_region: rgn = self.region.getRegion() rgn = (self.t, self.t + rgn[1] - rgn[0]) self.region.setRegion(rgn) @@ -551,6 +550,10 @@ def on_rect_doubleclicked(self, id): # set region to epoch start and stop self.region.setRegion((self.source.ep_times[ind], self.source.ep_stops[ind])) + # show the region if it isn't already visible + self.but_range.setChecked(True) + self.on_range_visibility_changed(None, shift_region = False) + def on_seek_table(self): if self.table_widget.rowCount()==0: return diff --git a/ephyviewer/epochviewer.py b/ephyviewer/epochviewer.py index a3e334a..f600d22 100644 --- a/ephyviewer/epochviewer.py +++ b/ephyviewer/epochviewer.py @@ -54,16 +54,15 @@ def paint(self, p, *args): p.setBrush(pg.mkBrush(self.fill)) p.drawRect(self.boundingRect()) - def mousePressEvent(self, event): - event.accept() - - def mouseReleaseEvent(self, event): - event.accept() - self.clicked.emit(self.id) - - def mouseDoubleClickEvent(self, event): - event.accept() - self.doubleclicked.emit(self.id) + def mouseClickEvent(self, event): + if event.button()== QT.LeftButton: + event.accept() + if event.double(): + self.doubleclicked.emit(self.id) + else: + self.clicked.emit(self.id) + else: + event.ignore() class DataGrabber(QT.QObject): From 865e29335ed16b9305532366de6ae8f190f7a0e4 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Tue, 11 Sep 2018 17:17:18 -0400 Subject: [PATCH 31/34] Make sure EpochEncoder rectangles do not draw over label text boxes `EpochEncoder`'s `RectItem` rectangles were getting destroyed and recreated in the same z-layer as the `TextItem` labels, causing them to obscure the labels. This commit increases the z-layer for the labels so that they are not obscured by the rectangles. It also ensures the labels are drawn above the region indicator. --- ephyviewer/epochencoder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 61da5ca..5114f72 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -294,6 +294,7 @@ def initialize_plot(self): for i, label in enumerate(self.source.possible_labels): color = self.by_label_params['label'+str(i), 'color'] label_item = pg.TextItem(label, color=color, anchor=(0, 0.5), border=None, fill=pg.mkColor((128,128,128, 120))) + label_item.setZValue(11) self.plot.addItem(label_item) self.label_items.append(label_item) From 03fbd25d2556abdd28377d1365eecc80221d99e4 Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Tue, 30 Oct 2018 19:55:50 -0400 Subject: [PATCH 32/34] Sort before export in CsvEpochSource.save --- ephyviewer/datasource/epochs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index 878eea6..d3c1a3d 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -361,4 +361,5 @@ def save(self): df['time'] = np.round(self.ep_times, 6) # round to nearest microsecond df['duration'] = np.round(self.ep_durations, 6) # round to nearest microsecond df['label'] = self.ep_labels + df.sort_values(['time', 'duration', 'label'], inplace=True) df.to_csv(self.filename, index=False) From 74b7fe9165134a2a861da28c260de68bb13bb14a Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Mon, 8 Apr 2019 16:17:41 -0400 Subject: [PATCH 33/34] Load CsvEpochSource with correct dtypes --- ephyviewer/datasource/epochs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ephyviewer/datasource/epochs.py b/ephyviewer/datasource/epochs.py index d3c1a3d..9a79b7e 100644 --- a/ephyviewer/datasource/epochs.py +++ b/ephyviewer/datasource/epochs.py @@ -344,7 +344,10 @@ def load(self): if os.path.exists(self.filename): # if file already exists, load previous epoch - df = pd.read_csv(self.filename, index_col=None) + df = pd.read_csv(self.filename, index_col=None, dtype={ + 'time': 'float64', + 'duration': 'float64', + 'label': 'U'}) epoch = {'time': df['time'].values, 'duration': df['duration'].values, 'label': df['label'].values, From 169c09d3cbe18247eec7808fba36651547e0471c Mon Sep 17 00:00:00 2001 From: Jeffrey Gill Date: Thu, 16 May 2019 11:36:50 -0400 Subject: [PATCH 34/34] Provide a more informative save prompt for EpochEncoder --- ephyviewer/epochencoder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ephyviewer/epochencoder.py b/ephyviewer/epochencoder.py index 35a52bf..9d85d89 100644 --- a/ephyviewer/epochencoder.py +++ b/ephyviewer/epochencoder.py @@ -266,8 +266,8 @@ def make_param_controller(self): def closeEvent(self, event): - text = 'save ?' - title = 'quit' + text = 'Do you want to save epoch encoder changes before closing?' + title = 'Save?' mb = QT.QMessageBox.question(self, title,text, QT.QMessageBox.Ok , QT.QMessageBox.Discard) if mb==QT.QMessageBox.Ok: