Repository: mne-tools/mne-python

Issue:
BUG: Plotting TFR with non-linear frequency spacing
I've computed a TFR decomposition with logarithmically spaced frequencies. Calling `plot` on the resulting AverageTFR objects shows plots with wrong frequency labels.

Digging a bit deeper I found that plotting is done with a call to matplotlib's imshow function (in `mne.viz.topo._imshow_tfr`)  and then using the `extent` keyword to put appropriate labels onto the plot. However, these labels only work for uniform linearly spaced labels. 

The following example illustrates this point:
```
x, y = logspace(log10(4), log10(150)), logspace(log10(4), log10(150))
Z = dot(x[:, newaxis],(y[newaxis,:]*0)+1)
extent = (x.min(), x.max(), y.min(), y.max())
subplot(1,2,1)
imshow(Z, origin='lower', cmap='gray', extent=extent, aspect='auto')
title('imshow')
subplot(1,2,2)
pcolormesh(x, y, Z, cmap='gray')
ylim([y.min(), y.max()])
xlim([x.min(), x.max()])
title('pcolor')
```
![log_spacing](https://cloud.githubusercontent.com/assets/623604/21183762/b79696bc-c209-11e6-92c2-fa864d738b60.png)

`pcolor` correctly displays the spacing between points on the x and y-axis, but imshow does not. The imshow plot gives the impression that values for 80 on the y-axis are low (black), while they actually are quite high (gray). 

One way to fix this would be to use `pcolormesh` instead of `imshow` for plotting. The only tricky thing would be to get the edges right that are needed for `pcolormesh`. I have a test implementation of a `_pcolormesh_tfr` that I could share. But I suspect fixing this behavior would need changes to a all of the plotting functions in AverageTFR - insofar I'd be interested in your opinion on how to best fix this issue.


Discussion:
I had this issue before. The problem is that theoretically you can have arbitrary spacing in your freqs of interest. Simplest solution would be to allow log y axis (or detect this automatically). Another simple solution is to just change the y tick labels (this is what I have been doing so far). The y extent is still linear then however and adding things onto the axis (bounding boxes highlighting ROI for example) might give unexpected results.
indeed this needs a fix.

PR or at least a gist welcome

thx

> The problem is that theoretically you can have arbitrary spacing in your freqs of interest. 

That isn't really a problem if we use a `pcolor` variant like `pcolormesh` because it will take that spacing into account.

Yes please open a PR for the `pcolormesh` version if you're up for it
@Eric89GXL ok, I actually never used `pcolormesh`. Can you still have interpolation then?
You can do `shading='gouraud'` which might be the same
Actually according to the `mpl` folks there is a `NonUniformImage` class we can use.

@mmagnuski do you have time to try it in the next couple of days? It would be good to get this in for 0.14. If not, I can probably do it.
Yes, I can work on this this week. I looked into the mpl example of NonUniformImage and it is perfect for this indeed. 

Resolving patch:
diff --git a/mne/time_frequency/tfr.py b/mne/time_frequency/tfr.py
--- a/mne/time_frequency/tfr.py
+++ b/mne/time_frequency/tfr.py
@@ -884,11 +884,11 @@ def __init__(self, info, data, times, freqs, nave, comment=None,
         self.method = method
 
     @verbose
-    def plot(self, picks, baseline=None, mode='mean', tmin=None,
-             tmax=None, fmin=None, fmax=None, vmin=None, vmax=None,
-             cmap='RdBu_r', dB=False, colorbar=True, show=True,
-             title=None, axes=None, layout=None, verbose=None):
-        """Plot TFRs in a topography with images.
+    def plot(self, picks, baseline=None, mode='mean', tmin=None, tmax=None,
+             fmin=None, fmax=None, vmin=None, vmax=None, cmap='RdBu_r',
+             dB=False, colorbar=True, show=True, title=None, axes=None,
+             layout=None, yscale='auto', verbose=None):
+        """Plot TFRs as a two-dimensional image(s).
 
         Parameters
         ----------
@@ -961,6 +961,10 @@ def plot(self, picks, baseline=None, mode='mean', tmin=None,
             Layout instance specifying sensor positions. Used for interactive
             plotting of topographies on rectangle selection. If possible, the
             correct layout is inferred from the data.
+        yscale : 'auto' (default) | 'linear' | 'log'
+            The scale of y (frequency) axis. 'linear' gives linear y axis,
+            'log' leads to log-spaced y axis and 'auto' detects if frequencies
+            are log-spaced and only then sets the y axis to 'log'.
         verbose : bool, str, int, or None
             If not None, override default verbose level (see :func:`mne.verbose`).
 
@@ -1004,7 +1008,7 @@ def plot(self, picks, baseline=None, mode='mean', tmin=None,
             _imshow_tfr(ax, 0, tmin, tmax, vmin, vmax, onselect_callback,
                         ylim=None, tfr=data[idx: idx + 1], freq=freqs,
                         x_label='Time (ms)', y_label='Frequency (Hz)',
-                        colorbar=colorbar, picker=False, cmap=cmap)
+                        colorbar=colorbar, cmap=cmap, yscale=yscale)
             if title:
                 fig.suptitle(title)
 
@@ -1058,7 +1062,7 @@ def plot_topo(self, picks=None, baseline=None, mode='mean', tmin=None,
                   layout=None, cmap='RdBu_r', title=None, dB=False,
                   colorbar=True, layout_scale=0.945, show=True,
                   border='none', fig_facecolor='k', fig_background=None,
-                  font_color='w'):
+                  font_color='w', yscale='auto'):
         """Plot TFRs in a topography with images.
 
         Parameters
@@ -1127,6 +1131,10 @@ def plot_topo(self, picks=None, baseline=None, mode='mean', tmin=None,
             `matplotlib.pyplot.imshow`. Defaults to None.
         font_color: str | obj
             The color of tick labels in the colorbar. Defaults to white.
+        yscale : 'auto' (default) | 'linear' | 'log'
+            The scale of y (frequency) axis. 'linear' gives linear y axis,
+            'log' leads to log-spaced y axis and 'auto' detects if frequencies
+            are log-spaced and only then sets the y axis to 'log'.
 
         Returns
         -------
@@ -1153,7 +1161,7 @@ def plot_topo(self, picks=None, baseline=None, mode='mean', tmin=None,
         onselect_callback = partial(self._onselect, baseline=baseline,
                                     mode=mode, layout=layout)
 
-        click_fun = partial(_imshow_tfr, tfr=data, freq=freqs,
+        click_fun = partial(_imshow_tfr, tfr=data, freq=freqs, yscale=yscale,
                             cmap=(cmap, True), onselect=onselect_callback)
         imshow = partial(_imshow_tfr_unified, tfr=data, freq=freqs, cmap=cmap,
                          onselect=onselect_callback)
diff --git a/mne/viz/topo.py b/mne/viz/topo.py
--- a/mne/viz/topo.py
+++ b/mne/viz/topo.py
@@ -247,28 +247,73 @@ def _check_vlim(vlim):
 
 
 def _imshow_tfr(ax, ch_idx, tmin, tmax, vmin, vmax, onselect, ylim=None,
-                tfr=None, freq=None, vline=None, x_label=None, y_label=None,
-                colorbar=False, picker=True, cmap=('RdBu_r', True), title=None,
-                hline=None):
-    """Show time-freq map on topo."""
-    import matplotlib.pyplot as plt
+                tfr=None, freq=None, x_label=None, y_label=None,
+                colorbar=False, cmap=('RdBu_r', True), yscale='auto'):
+    """Show time-frequency map as two-dimensional image."""
+    from matplotlib import pyplot as plt, ticker
     from matplotlib.widgets import RectangleSelector
 
-    extent = (tmin, tmax, freq[0], freq[-1])
-    cmap, interactive_cmap = cmap
+    if yscale not in ['auto', 'linear', 'log']:
+        raise ValueError("yscale should be either 'auto', 'linear', or 'log'"
+                         ", got {}".format(yscale))
 
-    img = ax.imshow(tfr[ch_idx], extent=extent, aspect="auto", origin="lower",
-                    vmin=vmin, vmax=vmax, picker=picker, cmap=cmap)
-    if isinstance(ax, plt.Axes):
-        if x_label is not None:
-            ax.set_xlabel(x_label)
-        if y_label is not None:
-            ax.set_ylabel(y_label)
+    cmap, interactive_cmap = cmap
+    times = np.linspace(tmin, tmax, num=tfr[ch_idx].shape[1])
+
+    # test yscale
+    if yscale == 'log' and not freq[0] > 0:
+        raise ValueError('Using log scale for frequency axis requires all your'
+                         ' frequencies to be positive (you cannot include'
+                         ' the DC component (0 Hz) in the TFR).')
+    if len(freq) < 2 or freq[0] == 0:
+        yscale = 'linear'
+    elif yscale != 'linear':
+        ratio = freq[1:] / freq[:-1]
+    if yscale == 'auto':
+        if freq[0] > 0 and np.allclose(ratio, ratio[0]):
+            yscale = 'log'
+        else:
+            yscale = 'linear'
+
+    # compute bounds between time samples
+    time_diff = np.diff(times) / 2. if len(times) > 1 else [0.0005]
+    time_lims = np.concatenate([[times[0] - time_diff[0]], times[:-1] +
+                               time_diff, [times[-1] + time_diff[-1]]])
+
+    # the same for frequency - depending on whether yscale is log
+    if yscale == 'linear':
+        freq_diff = np.diff(freq) / 2. if len(freq) > 1 else [0.5]
+        freq_lims = np.concatenate([[freq[0] - freq_diff[0]], freq[:-1] +
+                                   freq_diff, [freq[-1] + freq_diff[-1]]])
     else:
-        if x_label is not None:
-            plt.xlabel(x_label)
-        if y_label is not None:
-            plt.ylabel(y_label)
+        log_freqs = np.concatenate([[freq[0] / ratio[0]], freq,
+                                   [freq[-1] * ratio[0]]])
+        freq_lims = np.sqrt(log_freqs[:-1] * log_freqs[1:])
+
+    # construct a time-frequency bounds grid
+    time_mesh, freq_mesh = np.meshgrid(time_lims, freq_lims)
+
+    img = ax.pcolormesh(time_mesh, freq_mesh, tfr[ch_idx], cmap=cmap,
+                        vmin=vmin, vmax=vmax)
+
+    # limits, yscale and yticks
+    ax.set_xlim(time_lims[0], time_lims[-1])
+    if ylim is None:
+        ylim = (freq_lims[0], freq_lims[-1])
+    ax.set_ylim(ylim)
+
+    if yscale == 'log':
+        ax.set_yscale('log')
+        ax.get_yaxis().set_major_formatter(ticker.ScalarFormatter())
+
+    tick_vals = freq[np.unique(np.linspace(
+        0, len(freq) - 1, 12).round().astype('int'))]
+    ax.set_yticks(tick_vals)
+
+    if x_label is not None:
+        ax.set_xlabel(x_label)
+    if y_label is not None:
+        ax.set_ylabel(y_label)
     if colorbar:
         if isinstance(colorbar, DraggableColorbar):
             cbar = colorbar.cbar  # this happens with multiaxes case
@@ -276,10 +321,6 @@ def _imshow_tfr(ax, ch_idx, tmin, tmax, vmin, vmax, onselect, ylim=None,
             cbar = plt.colorbar(mappable=img)
         if interactive_cmap:
             ax.CB = DraggableColorbar(cbar, img)
-    if title:
-        plt.title(title)
-    if not isinstance(ax, plt.Axes):
-        ax = plt.gca()
     ax.RS = RectangleSelector(ax, onselect=onselect)  # reference must be kept
 
 
diff --git a/tutorials/plot_sensors_time_frequency.py b/tutorials/plot_sensors_time_frequency.py
--- a/tutorials/plot_sensors_time_frequency.py
+++ b/tutorials/plot_sensors_time_frequency.py
@@ -87,7 +87,8 @@
 # but you can also use :func:`mne.time_frequency.tfr_multitaper`
 # or :func:`mne.time_frequency.tfr_stockwell`.
 
-freqs = np.arange(6, 30, 3)  # define frequencies of interest
+# define frequencies of interest (log-spaced)
+freqs = np.logspace(*np.log10([6, 35]), num=8)
 n_cycles = freqs / 2.  # different number of cycle per frequency
 power, itc = tfr_morlet(epochs, freqs=freqs, n_cycles=n_cycles, use_fft=True,
                         return_itc=True, decim=3, n_jobs=1)


Test patch:
diff --git a/mne/viz/tests/test_topo.py b/mne/viz/tests/test_topo.py
--- a/mne/viz/tests/test_topo.py
+++ b/mne/viz/tests/test_topo.py
@@ -21,7 +21,8 @@
 from mne.viz import (plot_topo_image_epochs, _get_presser,
                      mne_analyze_colormap, plot_evoked_topo)
 from mne.viz.utils import _fake_click
-from mne.viz.topo import _plot_update_evoked_topo_proj, iter_topography
+from mne.viz.topo import (_plot_update_evoked_topo_proj, iter_topography,
+                          _imshow_tfr)
 
 # Set our plotters to test mode
 import matplotlib
@@ -144,14 +145,54 @@ def test_plot_topo_image_epochs():
 
 def test_plot_tfr_topo():
     """Test plotting of TFR data."""
+    import matplotlib.pyplot as plt
+
     epochs = _get_epochs()
     n_freqs = 3
     nave = 1
     data = np.random.RandomState(0).randn(len(epochs.ch_names),
                                           n_freqs, len(epochs.times))
     tfr = AverageTFR(epochs.info, data, epochs.times, np.arange(n_freqs), nave)
-    tfr.plot_topo(baseline=(None, 0), mode='ratio', title='Average power',
-                  vmin=0., vmax=14., show=False)
+    fig = tfr.plot_topo(baseline=(None, 0), mode='ratio',
+                        title='Average power', vmin=0., vmax=14.)
+
+    # test opening tfr by clicking
+    num_figures_before = len(plt.get_fignums())
+    _fake_click(fig, fig.axes[-1], (0.08, 0.65))
+    assert_equal(num_figures_before + 1, len(plt.get_fignums()))
+    plt.close('all')
+
     tfr.plot([4], baseline=(None, 0), mode='ratio', show=False, title='foo')
+    assert_raises(ValueError, tfr.plot, [4], yscale='lin', show=False)
+
+    # nonuniform freqs
+    freqs = np.logspace(*np.log10([3, 10]), num=3)
+    tfr = AverageTFR(epochs.info, data, epochs.times, freqs, nave)
+    fig = tfr.plot([4], baseline=(None, 0), mode='mean', vmax=14., show=False)
+    assert_equal(fig.axes[0].get_yaxis().get_scale(), 'log')
+
+    # one timesample
+    tfr = AverageTFR(epochs.info, data[:, :, [0]], epochs.times[[1]],
+                     freqs, nave)
+    tfr.plot([4], baseline=None, vmax=14., show=False, yscale='linear')
+
+    # one freqency bin, log scale required: as it doesn't make sense
+    # to plot log scale for one value, we test whether yscale is set to linear
+    vmin, vmax = 0., 2.
+    fig, ax = plt.subplots()
+    tmin, tmax = epochs.times[0], epochs.times[-1]
+    _imshow_tfr(ax, 3, tmin, tmax, vmin, vmax, None, tfr=data[:, [0], :],
+                freq=freqs[[-1]], x_label=None, y_label=None,
+                colorbar=False, cmap=('RdBu_r', True), yscale='log')
+    fig = plt.gcf()
+    assert_equal(fig.axes[0].get_yaxis().get_scale(), 'linear')
+
+    # ValueError when freq[0] == 0 and yscale == 'log'
+    these_freqs = freqs[:3].copy()
+    these_freqs[0] = 0
+    assert_raises(ValueError, _imshow_tfr, ax, 3, tmin, tmax, vmin, vmax,
+                  None, tfr=data[:, :3, :], freq=these_freqs, x_label=None,
+                  y_label=None, colorbar=False, cmap=('RdBu_r', True),
+                  yscale='log')
 
 run_tests_if_main()