From 7ab4c594e4605beb204c38d0c202fb86c40c9bc0 Mon Sep 17 00:00:00 2001 From: Christian O'Reilly Date: Fri, 2 Jun 2023 19:41:23 -0400 Subject: [PATCH 1/9] Fixing topomaps. --- pylossless/dash/tests/test_topo_viz.py | 2 +- pylossless/dash/topo_viz.py | 35 ++++++++++++++++++-------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/pylossless/dash/tests/test_topo_viz.py b/pylossless/dash/tests/test_topo_viz.py index 4f96f6e..08cdf4b 100644 --- a/pylossless/dash/tests/test_topo_viz.py +++ b/pylossless/dash/tests/test_topo_viz.py @@ -47,7 +47,7 @@ def test_GridTopoPlot(): offset = 2 nb_topo = 4 plot_data = topo_data.topo_values.iloc[::-1].iloc[offset:offset+nb_topo] - plot_data = list(plot_data.T.to_dict().values()) + plot_data = plot_data.values.tolist() GridTopoPlot(2, 2, raw.get_montage(), plot_data, res=200, width=300, height=300, diff --git a/pylossless/dash/topo_viz.py b/pylossless/dash/topo_viz.py index d89eb4a..73b6f0d 100644 --- a/pylossless/dash/topo_viz.py +++ b/pylossless/dash/topo_viz.py @@ -33,6 +33,14 @@ yaxis.update({"scaleanchor": "x", "scaleratio": 1}) +def pick_montage(montage, ch_names): + digs = montage.remove_fiducials().dig + assert len(digs) == len(montage.ch_names) + digs = [dig for dig, ch_name in zip(digs, montage.ch_names) + if ch_name in ch_names] + return mne.channels.DigMontage(dig=digs, ch_names=ch_names) + + class TopoPlot: # TODO: Fix/finish doc comments for this class. """Representation of a classic EEG topographic map as a plotly figure.""" @@ -151,8 +159,10 @@ def set_data(self, data): self.info = create_info(names, sfreq=256, ch_types="eeg") with warnings.catch_warnings(): warnings.simplefilter("ignore") + # To update self.info with channels positions RawArray(np.zeros((len(names), 1)), self.info, copy=None, verbose=False).set_montage(self.montage) + assert np.all(np.array(names) == np.array(self.info.ch_names)) self.set_head_pos_contours() # TODO: Finish/fix docstring @@ -304,9 +314,10 @@ def __init__(self, rows=1, cols=1, montage="standard_1020", See mne.channels.make_standard_montage(), and mne.channels.get_builtin_montages() for more information on making montage objects in MNE. - data : mne.preprocessing.ICA | None - The data to use for the topoplots. Can be an instance of - mne.preprocessing.ICA. + data : list | None + The data to use for the topoplots. Should be a list of + dictionaries, one per topomap. The dictionaries should + have the channel names as keys. figure : plotly.graph_objects.Figure | None Figure to use (if not None) for plotting. color : str @@ -572,10 +583,13 @@ def initialize_layout(self, slider_val=None, show_sensors=True): # The indexing with ch_names is to ensure the order # of the channels are compatible between plot_data and the montage - ch_names = [ch_name for ch_name in self.montage.ch_names - if ch_name in self.data.topo_values.columns] - plot_data = self.data.topo_values.loc[titles, ch_names] - plot_data = list(plot_data.T.to_dict().values()) + montage = pick_montage(self.montage, self.data.topo_values.columns) + ch_names = montage.ch_names + assert len(ch_names) == len(self.data.topo_values.columns) + assert (np.sum(np.in1d(ch_names, self.data.topo_values.columns)) + == len(ch_names)) + plot_data = [OrderedDict(self.data.topo_values.loc[title, ch_names]) + for title in titles] if len(plot_data) < self.nb_sel_topo: nb_missing_topo = self.nb_sel_topo-len(plot_data) @@ -583,7 +597,7 @@ def initialize_layout(self, slider_val=None, show_sensors=True): [None]*nb_missing_topo)) self.figure = GridTopoPlot(rows=self.rows, cols=self.cols, - montage=self.montage, data=plot_data, + montage=montage, data=plot_data, color=colors, res=self.res, height=self.height, @@ -732,13 +746,14 @@ def init_vars(self, montage, ica, ic_labels): if not montage or not ica: return None - data = TopoData([dict(zip(montage.ch_names, component)) + data = TopoData([dict(zip(ica.ch_names, component)) for component in ica.get_components().T]) + data.topo_values.index = ica._ica_names + if ic_labels: self.head_contours_color = {comp: ic_label_cmap[label] for comp, label in ic_labels.items()} - data.topo_values.index = list(ic_labels.keys()) return data def load_recording(self, montage, ica, ic_labels): From 3dd59887b088db1c4a001145a4f9600a0ca541e5 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Sun, 17 Sep 2023 11:29:45 -0400 Subject: [PATCH 2/9] FIX: Make topomap cmaps look like MNE These last couple fixes will make our topomaps look like MNEs. It mainly had to do with some logic to handle the cmap and vmin/vmax --- pylossless/dash/topo_viz.py | 14 +++++++++++++- pylossless/dash/utils.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 pylossless/dash/utils.py diff --git a/pylossless/dash/topo_viz.py b/pylossless/dash/topo_viz.py index a277db4..28a48f4 100644 --- a/pylossless/dash/topo_viz.py +++ b/pylossless/dash/topo_viz.py @@ -287,12 +287,24 @@ def plot_topo(self, **kwargs): ------- A plotly.graph_objects.Figure object. """ + from .utils import _setup_vmin_vmax + if self.__data is None: return + data = np.array(list(self.__data.values())) + norm = min(np.array(data)) >= 0 + vmin, vmax = _setup_vmin_vmax(data, None, None, norm) + if self.cmap is None: + cmap = "Reds" if norm else "RdBu_r" + else: + cmap = self.cmap + heatmap_trace = go.Heatmap( showscale=self.colorbar, - colorscale=self.cmap, + colorscale=cmap, + zmin=vmin, + zmax=vmax, **self.get_heatmap_data(**kwargs) ) diff --git a/pylossless/dash/utils.py b/pylossless/dash/utils.py new file mode 100644 index 0000000..58f93e7 --- /dev/null +++ b/pylossless/dash/utils.py @@ -0,0 +1,28 @@ +import numpy as np + + +def _setup_vmin_vmax(data, vmin, vmax, norm=False): + """Handle vmin and vmax parameters for visualizing topomaps. + + This is a simplified copy of mne.viz.utils._setup_vmin_vmax. + https://github.com/mne-tools/mne-python/blob/main/mne/viz/utils.py + + Notes + ----- + For the normal use-case (when `vmin` and `vmax` are None), the parameter + `norm` drives the computation. When norm=False, data is supposed to come + from a mag and the output tuple (vmin, vmax) is symmetric range + (-x, x) where x is the max(abs(data)). When norm=True (a.k.a. data is the + L2 norm of a gradiometer pair) the output tuple corresponds to (0, x). + + in the MNE version vmin and vmax can be callables that drive the operation, + but for the sake of simplicity this was not copied over. + """ + should_warn = False + if vmax is None and vmin is None: + vmax = np.abs(data).max() + vmin = 0.0 if norm else -vmax + if vmin == 0 and np.min(data) < 0: + should_warn = True + + return vmin, vmax From e41d9a39791eec7dbbc866fbc5635a80a191edd2 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Sun, 17 Sep 2023 11:36:48 -0400 Subject: [PATCH 3/9] FIX: unused variable in my new code --- pylossless/dash/utils.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pylossless/dash/utils.py b/pylossless/dash/utils.py index 58f93e7..7d57e6b 100644 --- a/pylossless/dash/utils.py +++ b/pylossless/dash/utils.py @@ -18,11 +18,7 @@ def _setup_vmin_vmax(data, vmin, vmax, norm=False): in the MNE version vmin and vmax can be callables that drive the operation, but for the sake of simplicity this was not copied over. """ - should_warn = False if vmax is None and vmin is None: vmax = np.abs(data).max() vmin = 0.0 if norm else -vmax - if vmin == 0 and np.min(data) < 0: - should_warn = True - return vmin, vmax From c9499d447306a325579eb89b4cb1c34fc439823b Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Sun, 17 Sep 2023 11:38:25 -0400 Subject: [PATCH 4/9] DOC: add docstring to a function added in this PR --- pylossless/dash/topo_viz.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pylossless/dash/topo_viz.py b/pylossless/dash/topo_viz.py index 28a48f4..ed802d7 100644 --- a/pylossless/dash/topo_viz.py +++ b/pylossless/dash/topo_viz.py @@ -34,6 +34,7 @@ def pick_montage(montage, ch_names): + """Pick a subset of channels from a montage.""" digs = montage.remove_fiducials().dig assert len(digs) == len(montage.ch_names) digs = [dig for dig, ch_name in zip(digs, montage.ch_names) if ch_name in ch_names] From 44a340715e2cfeea185e57ce775b451f9de99d16 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Sun, 17 Sep 2023 12:37:03 -0400 Subject: [PATCH 5/9] FIX: FORGOT to initiate cmap as None We should initiate the cmap as None instead of "Red_bl" (sic), so that our code logic can handle the color scheme based on the IC values just like MNE does --- pylossless/dash/topo_viz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylossless/dash/topo_viz.py b/pylossless/dash/topo_viz.py index ed802d7..778c54e 100644 --- a/pylossless/dash/topo_viz.py +++ b/pylossless/dash/topo_viz.py @@ -55,7 +55,7 @@ def __init__( res=64, width=None, height=None, - cmap="RdBu_r", + cmap=None, show_sensors=True, colorbar=False, ): From 266caf2fb1a19072c0575ea7f689dc0f2dbc6b7f Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 18 Sep 2023 08:36:56 -0400 Subject: [PATCH 6/9] FIX, TST: revert change in test_GridTopoPlot --- pylossless/dash/tests/test_topo_viz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylossless/dash/tests/test_topo_viz.py b/pylossless/dash/tests/test_topo_viz.py index 1cf4562..237f5f2 100644 --- a/pylossless/dash/tests/test_topo_viz.py +++ b/pylossless/dash/tests/test_topo_viz.py @@ -52,7 +52,7 @@ def test_GridTopoPlot(): offset = 2 nb_topo = 4 plot_data = topo_data.topo_values.iloc[::-1].iloc[offset : offset + nb_topo] - plot_data = plot_data.values.tolist() + plot_data = list(plot_data.T.to_dict().values()) GridTopoPlot( 2, From 1013ef0e9406a14393a0fdb43fd767dd2f70173b Mon Sep 17 00:00:00 2001 From: Christian O'Reilly Date: Thu, 26 Oct 2023 18:23:51 -0400 Subject: [PATCH 7/9] Update pylossless/dash/topo_viz.py Co-authored-by: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> --- pylossless/dash/topo_viz.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pylossless/dash/topo_viz.py b/pylossless/dash/topo_viz.py index 778c54e..d03f00a 100644 --- a/pylossless/dash/topo_viz.py +++ b/pylossless/dash/topo_viz.py @@ -36,7 +36,6 @@ def pick_montage(montage, ch_names): """Pick a subset of channels from a montage.""" digs = montage.remove_fiducials().dig - assert len(digs) == len(montage.ch_names) digs = [dig for dig, ch_name in zip(digs, montage.ch_names) if ch_name in ch_names] return mne.channels.DigMontage(dig=digs, ch_names=ch_names) From 6896d732dd5503906dc06ede8cf99c4436de6af1 Mon Sep 17 00:00:00 2001 From: Christian O'Reilly Date: Thu, 26 Oct 2023 18:25:01 -0400 Subject: [PATCH 8/9] Update pylossless/dash/topo_viz.py Co-authored-by: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> --- pylossless/dash/topo_viz.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pylossless/dash/topo_viz.py b/pylossless/dash/topo_viz.py index d03f00a..3cf1af2 100644 --- a/pylossless/dash/topo_viz.py +++ b/pylossless/dash/topo_viz.py @@ -660,7 +660,6 @@ def initialize_layout(self, slider_val=None, show_sensors=True): montage = pick_montage(self.montage, self.data.topo_values.columns) ch_names = montage.ch_names assert len(ch_names) == len(self.data.topo_values.columns) - assert np.sum(np.in1d(ch_names, self.data.topo_values.columns)) == len(ch_names) plot_data = [ OrderedDict(self.data.topo_values.loc[title, ch_names]) for title in titles ] From f264469116455b0e1135e1b558be9cd167cbd84b Mon Sep 17 00:00:00 2001 From: Christian O'Reilly Date: Thu, 26 Oct 2023 18:25:17 -0400 Subject: [PATCH 9/9] Update pylossless/dash/topo_viz.py Co-authored-by: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> --- pylossless/dash/topo_viz.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pylossless/dash/topo_viz.py b/pylossless/dash/topo_viz.py index 3cf1af2..ca0c9cc 100644 --- a/pylossless/dash/topo_viz.py +++ b/pylossless/dash/topo_viz.py @@ -659,7 +659,6 @@ def initialize_layout(self, slider_val=None, show_sensors=True): # of the channels are compatible between plot_data and the montage montage = pick_montage(self.montage, self.data.topo_values.columns) ch_names = montage.ch_names - assert len(ch_names) == len(self.data.topo_values.columns) plot_data = [ OrderedDict(self.data.topo_values.loc[title, ch_names]) for title in titles ]