Beispiel #1
0
class MyOpenGLView(ManualClusteringView):
    """All OpenGL views derive from ManualClusteringView."""
    def __init__(self, templates=None):
        """
        Typically, the constructor takes as arguments *functions* that take as input
        one or several cluster ids, and return as many Bunch instances which contain
        the data as NumPy arrays. Many such functions are defined in the TemplateController.
        """

        super(MyOpenGLView, self).__init__()
        """
        The View instance contains a special `canvas` object which is a `̀PlotCanvas` instance.
        This class derives from `BaseCanvas` which itself derives from the PyQt5 `QOpenGLWindow`.
        The canvas represents a rectangular black window where you can draw geometric objects
        with OpenGL.

        phy uses the notion of **Layout** that lets you organize graphical elements in different
        subplots. These subplots can be organized in several ways:

        * Grid layout: a `(n_rows, n_cols)` grid of subplots (example: FeatureView).
        * Boxed layout: boxes arbitrarily located (example: WaveformView, using the
          probe geometry)
        * Stacked layout: one column with `n_boxes` subplots (example: TraceView,
          one row per channel)

        In this example, we use the stacked layout, with one subplot per cluster. This number
        will change at each cluster selection, depending on the number of selected clusters.
        But initially, we just use 1 subplot.

        """
        self.canvas.set_layout('stacked', n_plots=1)

        self.templates = templates
        """
        phy uses the notion of **Visual**. This is a graphical element that is represented with
        a single type of graphical element. phy provides many visuals:

        * PlotVisual (plots)
        * ScatterVisual (points with a given marker type and different colors and sizes)
        * LineVisual (for lines segments)
        * HistogramVisual
        * PolygonVisual
        * TextVisual
        * ImageVisual

        Each visual comes with a single OpenGL program, which is defined by a vertex shader
        and a fragment shader. These are programs written in a C-like language called GLSL.
        A visual also comes with a primitive type, which can be points, line segments, or
        triangles. This is all a GPU is able to render, but the position and the color of
        these primitives can be entirely customized in the shaders.

        The vertex shader acts on data arrays represented as NumPy arrays.

        These low-level details are hidden by the visuals abstraction, so it is unlikely that
        you'll ever need to write your own visual.

        In ManualClusteringViews, you typically define one or several visuals. For example
        if you need to add text, you would add `self.text_visual = TextVisual()`.

        """
        self.visual = PlotVisual()
        """
        For internal reasons, you need to add all visuals (empty for now) directly to the
        canvas, in the view's constructor. Later, we will use the `visual.set_data()` method
        to update the visual's data and display something in the figure.

        """
        self.canvas.add_visual(self.visual)

    def on_select(self, cluster_ids=(), **kwargs):
        """
        The main method to implement in ManualClusteringView is `on_select()`, called whenever
        new clusters are selected.

        *Note*: `cluster_ids` contains the clusters selected in the cluster view, followed
        by clusters selected in the similarity view.

        """
        """
        This method should always start with these few lines of code.
        """
        self.cluster_ids = cluster_ids
        if not cluster_ids:
            return
        """
        We update the number of boxes in the stacked layout, which is the number of
        selected clusters.
        """
        self.canvas.stacked.n_boxes = len(cluster_ids)
        """
        We obtain the template data.
        """
        bunchs = {
            cluster_id: self.templates(cluster_id).data
            for cluster_id in cluster_ids
        }
        """
        For performance reasons, it is best to use as few visuals as possible. In this example,
        we want 1 waveform template per subplot. We will use a single visual covering all
        subplots at once. This is the key to achieve good performance with OpenGL in Python.
        However, this comes with the drawback that the programming interface is more complicated.

        In principle, we would have to concatenate all data (x and y coordinates) of all subplots
        to pass it to `self.visual.set_data()` in order to draw all subplots at once. But this
        is tedious.

        phy uses the notion of **batch**: for each subplot, we set *partial data* for the subplot
        which just prepares the data for concatenation *after* we're done with looping through
        all clusters. The concatenation happens in the special call
        `self.canvas.update_visual(self.visual)`.

        We need to call `visual.reset_batch()` before constructing a batch.

        """
        self.visual.reset_batch()
        """
        We iterate through all selected clusters.
        """
        for idx, cluster_id in enumerate(cluster_ids):
            bunch = bunchs[cluster_id]
            """
            In this example, we just keep the peak channel. Note that `bunch.template` is a
            2D array `(n_samples, n_channels)` where `n_channels` in the number of "best"
            channels for the cluster. The channels are sorted by decreasing template amplitude,
            so the first one is the peak channel. The channel ids can be found in
            `bunch.channel_ids`.
            """
            y = bunch.template[:, 0]
            """
            We decide to use, on the x axis, values ranging from -1 to 1. This is the
            standard viewport in OpenGL and phy.
            """
            x = np.linspace(-1., 1., len(y))
            """
            phy requires you to specify explicitly the x and y range of the plots.
            The `data_bounds` variable is a `(xmin, ymin, xmax, ymax)` tuple representing the
            lower-left and upper-right corners of a rectangle. By default, the data bounds
            of the entire view is (-1, -1, 1, 1), also called normalized device coordinates.
            Eventually, OpenGL uses this coordinate system for display, but phy provides
            a transform system to convert from different coordinate systems, both on the CPU
            and the GPU.

            Here, the x range is (-1, 1), and the y range is (m, M) where m and M are
            respectively the min and max of the template.
            """
            m, M = y.min(), y.max()
            data_bounds = (-1, m, +1, M)
            """
            This function gives the color of the i-th selected cluster. This is a 4-tuple with
            values between 0 and 1 for RGBA: red, green, blue, alpha channel (transparency,
            1 by default).
            """
            color = selected_cluster_color(idx)
            """
            The plot visual takes as input the x and y coordinates of the points, the color,
            and the data bounds.
            There is also a special keyword argument `box_index` which is the subplot index.
            In the stacked layout, this is just an integer identifying the subplot index, from
            top to bottom. Note that in the grid view, the box index is a pair (row, col).
            """
            self.visual.add_batch_data(x=x,
                                       y=y,
                                       color=color,
                                       data_bounds=data_bounds,
                                       box_index=idx)
        """
        After the loop, this special call automatically builds the data to upload to the GPU
        by concatenating the partial data set in `add_batch_data()`.
        """
        self.canvas.update_visual(self.visual)
        """
        After updating the data on the GPU, we need to refresh the canvas.
        """
        self.canvas.update()
Beispiel #2
0
class TemplateView(ScalingMixin, BaseColorView, BaseGlobalView,
                   ManualClusteringView):
    """This view shows all template waveforms of all clusters in a large grid of shape
    `(n_channels, n_clusters)`.

    Constructor
    -----------

    templates : function
        Maps `cluster_ids` to a list of `[Bunch(template, channel_ids)]` where `template` is
        an `(n_samples, n_channels)` array, and `channel_ids` specifies the channels of the
        `template` array (sparse format).
    channel_ids : array-like
        The list of all channel ids.
    channel_labels : list
        Labels of all shown channels. By default, this is just the channel ids.
    cluster_ids : array-like
        The list of all clusters to show initially.

    """
    _default_position = 'right'
    _scaling = 1.

    default_shortcuts = {
        'change_template_size': 'ctrl+wheel',
        'switch_color_scheme': 'shift+wheel',
        'decrease': 'ctrl+alt+-',
        'increase': 'ctrl+alt++',
        'select_cluster': 'ctrl+click',
        'select_more': 'shift+click',
    }

    def __init__(self,
                 templates=None,
                 channel_ids=None,
                 channel_labels=None,
                 cluster_ids=None,
                 **kwargs):
        super(TemplateView, self).__init__(**kwargs)
        self.state_attrs += ()
        self.local_state_attrs += ('scaling', )

        # Full list of channels.
        self.channel_ids = channel_ids
        self.n_channels = len(channel_ids)

        # Channel labels.
        self.channel_labels = (channel_labels
                               if channel_labels is not None else
                               ['%d' % ch for ch in range(self.n_channels)])
        assert len(self.channel_labels) == self.n_channels
        # TODO: show channel and cluster labels

        # Full list of clusters.
        if cluster_ids is not None:
            self.set_cluster_ids(cluster_ids)

        self.canvas.set_layout('grid', has_clip=False)
        self.canvas.enable_axes()
        self.templates = templates

        self.visual = PlotVisual()
        self.canvas.add_visual(self.visual)
        self._cluster_box_index = {
        }  # dict {cluster_id: box_index} used to quickly reorder

        self.select_visual = PlotVisual()
        self.canvas.add_visual(self.select_visual)

    # Internal plot functions
    # -------------------------------------------------------------------------

    def _get_data_bounds(self, bunchs):
        """Get the data bounds."""
        m = np.median([b.template.min() for b in bunchs])
        M = np.median([b.template.max() for b in bunchs])
        M = max(abs(m), abs(M))
        return [-1, -M, +1, M]

    def _get_box_index(self, bunch):
        """Get the box_index array for a cluster."""
        # Generate the box index (channel_idx, cluster_idx) per vertex.
        n_samples, nc = bunch.template.shape
        box_index = _index_of(bunch.channel_ids, self.channel_ids)
        box_index = np.repeat(box_index, n_samples)
        box_index = np.c_[box_index.reshape((-1, 1)),
                          bunch.cluster_idx * np.ones(
                              (n_samples * len(bunch.channel_ids), 1))]
        assert box_index.shape == (len(bunch.channel_ids) * n_samples, 2)
        assert box_index.size == bunch.template.size * 2
        return box_index

    def _plot_cluster(self, bunch, color=None):
        """Plot one cluster."""
        wave = bunch.template  # shape: (n_samples, n_channels)
        channel_ids_loc = bunch.channel_ids
        n_channels_loc = len(channel_ids_loc)

        n_samples, nc = wave.shape
        assert nc == n_channels_loc

        # Find the x coordinates.
        t = get_linear_x(n_channels_loc, n_samples)

        color = color or self.cluster_colors[bunch.cluster_rel]
        assert len(color) == 4

        box_index = self._get_box_index(bunch)

        return Bunch(x=t,
                     y=wave.T,
                     color=color,
                     box_index=box_index,
                     data_bounds=self.data_bounds)

    def set_cluster_ids(self, cluster_ids):
        """Update the cluster ids when their identity or order has changed."""
        if cluster_ids is None or not len(cluster_ids):
            return
        self.all_cluster_ids = np.array(cluster_ids, dtype=np.int32)
        # Permutation of the clusters.
        self.cluster_idxs = np.argsort(self.all_cluster_ids)
        self.sorted_cluster_ids = self.all_cluster_ids[self.cluster_idxs]
        # Cluster colors, ordered by cluster id.
        self.cluster_colors = self.get_cluster_colors(self.sorted_cluster_ids,
                                                      alpha=.75)

    def get_clusters_data(self, load_all=None):
        """Return all templates data."""
        bunchs = self.templates(self.all_cluster_ids)
        out = []
        for cluster_rel, cluster_idx, cluster_id in self._iter_clusters():
            b = bunchs[cluster_id]
            b.cluster_rel = cluster_rel
            b.cluster_idx = cluster_idx
            b.cluster_id = cluster_id
            out.append(b)
        return out

    # Main methods
    # -------------------------------------------------------------------------

    def update_cluster_sort(self, cluster_ids):
        """Update the order of the clusters."""
        if not self._cluster_box_index:  # pragma: no cover
            return self.plot()
        # Only the order of the cluster_ids is supposed to change here.
        # We just have to update box_index instead of replotting everything.
        assert len(cluster_ids) == len(self.all_cluster_ids)
        # Update the cluster ids, in the new order.
        self.all_cluster_ids = np.array(cluster_ids, dtype=np.int32)
        # Update the permutation of the clusters.
        self.cluster_idxs = np.argsort(self.all_cluster_ids)
        box_index = []
        for cluster_rel, cluster_idx in enumerate(self.cluster_idxs):
            cluster_id = self.all_cluster_ids[cluster_idx]
            clu_box_index = self._cluster_box_index[cluster_id]
            clu_box_index[:, 1] = cluster_idx
            box_index.append(clu_box_index)
        box_index = np.concatenate(box_index, axis=0)
        self.visual.set_box_index(box_index)
        self.canvas.update()

    def update_color(self):
        """Update the color of the clusters, taking the selected clusters into account."""
        # This method is only used when the view has been plotted at least once,
        # such that self._cluster_box_index has been filled.
        if not self._cluster_box_index:
            return self.plot()
        # The call to set_cluster_ids() update the cluster_colors array.
        self.set_cluster_ids(self.all_cluster_ids)
        # Selected cluster colors.
        cluster_colors = self.cluster_colors
        selected_clusters = self.cluster_ids
        if selected_clusters is not None:
            cluster_colors = _add_selected_clusters_colors(
                selected_clusters, self.sorted_cluster_ids, cluster_colors)
        # Number of vertices per cluster = number of vertices per signal
        n_vertices_clu = [
            len(self._cluster_box_index[cluster_id])
            for cluster_id in self.sorted_cluster_ids
        ]
        # The argument passed to set_color() must have 1 row per vertex.
        self.visual.set_color(np.repeat(cluster_colors, n_vertices_clu,
                                        axis=0))
        self.canvas.update()

    @property
    def status(self):
        return 'Color scheme: %s' % self.color_schemes.current

    def plot(self, **kwargs):
        """Make the template plot."""

        # Retrieve the waveform data.
        bunchs = self.get_clusters_data()
        if not bunchs:
            return
        n_clusters = len(self.all_cluster_ids)
        self.canvas.grid.shape = (self.n_channels, n_clusters)

        self.visual.reset_batch()
        # Go through all clusters, ordered by cluster id.
        self.data_bounds = self._get_data_bounds(bunchs)
        for bunch in bunchs:
            data = self._plot_cluster(bunch)
            self._cluster_box_index[bunch.cluster_id] = data.box_index
            self.visual.add_batch_data(**data)
        self.canvas.update_visual(self.visual)
        self._apply_scaling()
        self.canvas.axes.reset_data_bounds((0, 0, n_clusters, self.n_channels))
        self.canvas.update()

    def on_select(self, *args, **kwargs):
        super(TemplateView, self).on_select(*args, **kwargs)
        self.update_color()

    # Scaling
    # -------------------------------------------------------------------------

    def _set_scaling_value(self, value):
        self._scaling = value
        self._apply_scaling()

    def _apply_scaling(self):
        sx, sy = self.canvas.layout.scaling
        self.canvas.layout.scaling = (sx, self._scaling)

    @property
    def scaling(self):
        """Return the grid scaling."""
        return self._scaling

    @scaling.setter
    def scaling(self, value):
        self._scaling = value

    # Interactivity
    # -------------------------------------------------------------------------

    def on_mouse_click(self, e):
        """Select a cluster by clicking on its template waveform."""
        if 'Control' not in e.modifiers:
            return
        b = e.button
        # Get mouse position in NDC.
        (channel_idx, cluster_rel), _ = self.canvas.grid.box_map(e.pos)
        cluster_id = self.all_cluster_ids[cluster_rel]
        logger.debug("Click on cluster %d with button %s.", cluster_id, b)
        if 'Shift' in e.modifiers:
            emit('select_more', self, [cluster_id])
        else:
            emit('request_select', self, [cluster_id])
Beispiel #3
0
class HistogramView(ScalingMixin, ManualClusteringView):
    """This view displays a histogram for every selected cluster, along with a possible plot
    and some text. To be overriden.

    Constructor
    -----------

    cluster_stat : function
        Maps `cluster_id` to `Bunch(data (1D array), plot (1D array), text)`.

    """

    _default_position = 'right'
    cluster_ids = ()

    # Number of bins in the histogram.
    n_bins = 100

    # Minimum value on the x axis (determines the range of the histogram)
    # If None, then `data.min()` is used.
    x_min = None

    # Maximum value on the x axis (determines the range of the histogram)
    # If None, then `data.max()` is used.
    x_max = None

    # Unit of the bin in the set_bin_size, set_x_min, set_x_max actions.
    bin_unit = 's'  # s (seconds) or ms (milliseconds)

    # The snippet to update this view are `hn` to change the number of bins, and `hm` to
    # change the maximum value on the x axis. The character `h` can be customized by child classes.
    alias_char = 'h'

    default_shortcuts = {
        'change_window_size': 'ctrl+wheel',
    }

    default_snippets = {
        'set_n_bins': '%sn' % alias_char,
        'set_bin_size (%s)' % bin_unit: '%sb' % alias_char,
        'set_x_min (%s)' % bin_unit: '%smin' % alias_char,
        'set_x_max (%s)' % bin_unit: '%smax' % alias_char,
    }

    _state_attrs = ('n_bins', 'x_min', 'x_max')
    _local_state_attrs = ()

    def __init__(self, cluster_stat=None):
        super(HistogramView, self).__init__()
        self.state_attrs += self._state_attrs
        self.local_state_attrs += self._local_state_attrs
        self.canvas.set_layout(layout='stacked', n_plots=1)
        self.canvas.enable_axes()

        self.cluster_stat = cluster_stat

        self.visual = HistogramVisual()
        self.canvas.add_visual(self.visual)

        self.plot_visual = PlotVisual()
        self.canvas.add_visual(self.plot_visual)

        self.text_visual = TextVisual(color=(1., 1., 1., 1.))
        self.canvas.add_visual(self.text_visual)

    def _plot_cluster(self, bunch):
        assert bunch
        n_bins = self.n_bins
        assert n_bins >= 0

        # Update the visual's data.
        self.visual.add_batch_data(
            hist=bunch.histogram, ylim=bunch.ylim, color=bunch.color, box_index=bunch.index)

        # Plot.
        plot = bunch.get('plot', None)
        if plot is not None:
            x = np.linspace(self.x_min, self.x_max, len(plot))
            self.plot_visual.add_batch_data(
                x=x, y=plot, color=(1, 1, 1, 1), data_bounds=self.data_bounds,
                box_index=bunch.index,
            )

        text = bunch.get('text', None)
        if not text:
            return
        # Support multiline text.
        text = text.splitlines()
        n = len(text)
        self.text_visual.add_batch_data(
            text=text, pos=[(-1, .8)] * n, anchor=[(1, -1 - 2 * i) for i in range(n)],
            box_index=bunch.index,
        )

    def get_clusters_data(self, load_all=None):
        bunchs = []
        for i, cluster_id in enumerate(self.cluster_ids):
            bunch = self.cluster_stat(cluster_id)
            if not bunch.data.size:
                continue
            bmin, bmax = bunch.data.min(), bunch.data.max()
            # Update self.x_max if it was not set before.
            self.x_min = self.x_min or bunch.get('x_min', None) or bmin
            self.x_max = self.x_max or bunch.get('x_max', None) or bmax
            self.x_min = min(self.x_min, self.x_max)
            assert self.x_min is not None
            assert self.x_max is not None
            assert self.x_min <= self.x_max

            # Compute the histogram.
            bunch.histogram = _compute_histogram(
                bunch.data, x_min=self.x_min, x_max=self.x_max, n_bins=self.n_bins)
            bunch.ylim = bunch.histogram.max()

            bunch.color = selected_cluster_color(i)
            bunch.index = i
            bunch.cluster_id = cluster_id
            bunchs.append(bunch)
        return bunchs

    def _get_data_bounds(self, bunchs):
        # Get the axes data bounds (the last subplot's extended n_cluster times on the y axis).
        ylim = max(bunch.ylim for bunch in bunchs) if bunchs else 1
        return (self.x_min, 0, self.x_max, ylim * len(self.cluster_ids))

    def plot(self, **kwargs):
        """Update the view with the selected clusters."""
        bunchs = self.get_clusters_data()
        self.data_bounds = self._get_data_bounds(bunchs)

        self.canvas.stacked.n_boxes = len(self.cluster_ids)

        self.visual.reset_batch()
        self.plot_visual.reset_batch()
        self.text_visual.reset_batch()
        for bunch in bunchs:
            self._plot_cluster(bunch)
        self.canvas.update_visual(self.visual)
        self.canvas.update_visual(self.plot_visual)
        self.canvas.update_visual(self.text_visual)

        self._update_axes()
        self.canvas.update()
        self.update_status()

    def attach(self, gui):
        """Attach the view to the GUI."""
        super(HistogramView, self).attach(gui)

        self.actions.add(
            self.set_n_bins, alias=self.alias_char + 'n',
            prompt=True, prompt_default=lambda: self.n_bins)
        self.actions.add(
            self.set_bin_size, alias=self.alias_char + 'b',
            prompt=True, prompt_default=lambda: self.bin_size)
        self.actions.add(
            self.set_x_min, alias=self.alias_char + 'min',
            prompt=True, prompt_default=lambda: self.x_min)
        self.actions.add(
            self.set_x_max, alias=self.alias_char + 'max',
            prompt=True, prompt_default=lambda: self.x_max)
        self.actions.separator()

    @property
    def status(self):
        f = 1 if self.bin_unit == 's' else 1000
        return '[{:.1f}{u}, {:.1f}{u:s}]'.format(
            (self.x_min or 0) * f, (self.x_max or 0) * f, u=self.bin_unit)

    # Histogram parameters
    # -------------------------------------------------------------------------

    def _get_scaling_value(self):
        return self.x_max

    def _set_scaling_value(self, value):
        if self.bin_unit == 'ms':
            value *= 1000
        self.set_x_max(value)

    def set_n_bins(self, n_bins):
        """Set the number of bins in the histogram."""
        self.n_bins = n_bins
        logger.debug("Change number of bins to %d for %s.", n_bins, self.__class__.__name__)
        self.plot()

    @property
    def bin_size(self):
        """Return the bin size (in seconds or milliseconds depending on `self.bin_unit`)."""
        bs = (self.x_max - self.x_min) / self.n_bins
        if self.bin_unit == 'ms':
            bs *= 1000
        return bs

    def set_bin_size(self, bin_size):
        """Set the bin size in the histogram."""
        assert bin_size > 0
        if self.bin_unit == 'ms':
            bin_size /= 1000
        self.n_bins = np.round((self.x_max - self.x_min) / bin_size)
        logger.debug("Change number of bins to %d for %s.", self.n_bins, self.__class__.__name__)
        self.plot()

    def set_x_min(self, x_min):
        """Set the minimum value on the x axis for the histogram."""
        if self.bin_unit == 'ms':
            x_min /= 1000
        x_min = min(x_min, self.x_max)
        if x_min == self.x_max:
            return
        self.x_min = x_min
        logger.debug("Change x min to %s for %s.", x_min, self.__class__.__name__)
        self.plot()

    def set_x_max(self, x_max):
        """Set the maximum value on the x axis for the histogram."""
        if self.bin_unit == 'ms':
            x_max /= 1000
        x_max = max(x_max, self.x_min)
        if x_max == self.x_min:
            return
        self.x_max = x_max
        logger.debug("Change x max to %s for %s.", x_max, self.__class__.__name__)
        self.plot()
class WaveformClusteringView(LassoMixin,ManualClusteringView):

    default_shortcuts = {
        'next_channel': 'f',
        'previous_channel': 'r',
    }

    def __init__(self, model=None):

        super(WaveformClusteringView, self).__init__()
        self.canvas.enable_axes()
        self.canvas.enable_lasso()
        
        self.text_visual = TextVisual()
        self.canvas.add_visual(self.text_visual, exclude_origins=(self.canvas.panzoom,))

        self.model = model

        self.gain = 0.195
        self.Fs = 30  # kHz

        self.visual = PlotVisual()

        self.canvas.add_visual(self.visual)
        self.canvas.panzoom.zoom = self.canvas.panzoom._default_zoom = (.97, .95)
        self.canvas.panzoom.pan = self.canvas.panzoom._default_pan = (-.01, 0)
        self.cluster_ids = None
        self.cluster_id = None
        self.channel_ids = None
        self.wavefs = None
        self.current_channel_idx = None
    
    def on_select(self, cluster_ids=(), **kwargs):
        self.cluster_ids = cluster_ids
        if not cluster_ids:
            return
        
        if self.cluster_id != cluster_ids[0]:
            self.cluster_id = cluster_ids[0]

            self.channel_ids = self.model.get_cluster_channels(self.cluster_id)
            self.spike_ids = self.model.get_cluster_spikes(self.cluster_id)
            self.wavefs = self.model.get_waveforms(self.spike_ids,channel_ids=self.channel_ids)

            self.setChannelIdx(0)
        

    def plotWaveforms(self):

        Nspk,Ntime,Nchan = self.wavefs.shape

        self.visual.reset_batch()
        self.text_visual.reset_batch()

        x = np.tile(np.linspace(-Ntime/2/self.Fs, Ntime/2/self.Fs, Ntime), (Nspk, 1))

        M=np.max(np.abs(self.wavefs[:,:,self.current_channel_idx]))
        #print(M*self.gain)
        if M*self.gain<100:
            M = 10*np.ceil(M*self.gain/10)
        elif M*self.gain<1000:
            M = 100*np.ceil(M*self.gain/100)
        else:
            M = 1000*np.floor(M*self.gain/1000)
        self.data_bounds = (x[0][0], -M, x[0][-1], M)

        colorwavef = selected_cluster_color(0)
        colormedian = selected_cluster_color(3)#(1,156/256,0,.5)#selected_cluster_color(1)
        colorstd = (0,1,0,1)#selected_cluster_color(2)
        colorqtl = (1,1,0,1)

        if Nspk>100:
            medianCl = np.median(self.wavefs[:,:,self.current_channel_idx],axis=0)
            stdCl = np.std(self.wavefs[:,:,self.current_channel_idx],axis=0)
            q1 = np.quantile(self.wavefs[:,:,self.current_channel_idx],.01,axis=0,interpolation='higher')
            q9 = np.quantile(self.wavefs[:,:,self.current_channel_idx],.99,axis=0,interpolation='lower')

        self.visual.add_batch_data(
                x=x, y=self.gain*self.wavefs[:,:,self.current_channel_idx], color=colorwavef, data_bounds=self.data_bounds, box_index=0)

        #stats
        if Nspk>100:
            x1 = x[0]
            self.visual.add_batch_data(
                    x=x1, y=self.gain*medianCl, color=colormedian, data_bounds=self.data_bounds, box_index=0)
            self.visual.add_batch_data(
                    x=x1, y=self.gain*(medianCl+3*stdCl), color=colorstd, data_bounds=self.data_bounds, box_index=0)
            self.visual.add_batch_data(
                    x=x1, y=self.gain*(medianCl-3*stdCl), color=colorstd, data_bounds=self.data_bounds, box_index=0)
            self.visual.add_batch_data(
                    x=x1, y=self.gain*q1, color=colorqtl, data_bounds=self.data_bounds, box_index=0)
            self.visual.add_batch_data(
                    x=x1, y=self.gain*q9, color=colorqtl, data_bounds=self.data_bounds, box_index=0)

        #axes
        self.text_visual.add_batch_data(
                pos=[.9, .98],
                text='[uV]',
                anchor=[-1, -1],
                box_index=0,
            )
        
        self.text_visual.add_batch_data(
                pos=[-1, -.95],
                text='[ms]',
                anchor=[1, 1],
                box_index=0,
            )

        label = 'Ch {a}'.format(a=self.channel_ids[self.current_channel_idx])
        self.text_visual.add_batch_data(
                pos=[-.98, .98],
                text=str(label),
                anchor=[1, -1],
                box_index=0,
            )
        self.canvas.update_visual(self.visual)
        self.canvas.update_visual(self.text_visual)
        self.canvas.axes.reset_data_bounds(self.data_bounds)

        self.canvas.update()
    
    def setChannel(self,channel_id):
        self.channel_ids
        itemindex = np.where(self.channel_ids==channel_id)[0]
        if len(itemindex):
            self.setChannelIdx(itemindex[0])

    def setChannelIdx(self,channel_idx):
        self.current_channel_idx = channel_idx
        self.plotWaveforms()

    def setNextChannelIdx(self):
        if self.current_channel_idx == len(self.channel_ids)-1:
            return
        self.setChannelIdx(self.current_channel_idx+1)

    def setPrevChannelIdx(self):
        if self.current_channel_idx == 0:
            return
        self.setChannelIdx(self.current_channel_idx-1)

    def on_request_split(self, sender=None):
        """Return the spikes enclosed by the lasso."""
        if (self.canvas.lasso.count < 3 or not len(self.cluster_ids)):  # pragma: no cover
            return np.array([], dtype=np.int64)

        pos = []
        spike_ids = []

        Ntime = self.wavefs.shape[1]
        x = np.linspace(-Ntime/2/self.Fs, Ntime/2/self.Fs, Ntime)

        for idx,spike in enumerate(self.spike_ids):
            points = np.c_[x,self.gain*self.wavefs[idx,:,self.current_channel_idx]]
            pos.append(points)
            spike_ids.append([spike]*len(x))

        if not pos:  # pragma: no cover
            logger.warning("Empty lasso.")
            return np.array([])
        pos = np.vstack(pos)
        pos = range_transform([self.data_bounds], [NDC], pos)
        spike_ids = np.concatenate(spike_ids)

        # Find lassoed spikes.
        ind = self.canvas.lasso.in_polygon(pos)
        self.canvas.lasso.clear()

        # Return all spikes not lassoed, so the selected cluster is still the same we are working on
        spikes_to_remove = np.unique(spike_ids[ind])
        keepspikes=np.isin(self.spike_ids,spikes_to_remove,assume_unique=True,invert=True)
        A=self.spike_ids[self.spike_ids != spikes_to_remove]
        if len(A)>0:
            return self.spike_ids[keepspikes]
        else:
            return np.array([], dtype=np.int64)
Beispiel #5
0
class WaveformView(ScalingMixin, ManualClusteringView):
    """This view shows the waveforms of the selected clusters, on relevant channels,
    following the probe geometry.

    Constructor
    -----------

    waveforms : dict of functions
        Every function maps a cluster id to a Bunch with the following attributes:

        * `data` : a 3D array `(n_spikes, n_samples, n_channels_loc)`
        * `channel_ids` : the channel ids corresponding to the third dimension in `data`
        * `channel_labels` : a list of channel labels for every channel in `channel_ids`
        * `channel_positions` : a 2D array with the coordinates of the channels on the probe
        * `masks` : a 2D array `(n_spikes, n_channels)` with the waveforms masks
        * `alpha` : the alpha transparency channel

        The keys of the dictionary are called **waveform types**. The `next_waveforms_type`
        action cycles through all available waveform types. The key `waveforms` is mandatory.
    waveform_type : str
        Default key of the waveforms dictionary to plot initially.

    """

    _default_position = 'right'
    cluster_ids = ()

    default_shortcuts = {
        'toggle_waveform_overlap': 'o',
        'toggle_show_labels': 'ctrl+l',
        'next_waveforms_type': 'w',
        'toggle_mean_waveforms': 'm',

        # Box scaling.
        'widen': 'ctrl+right',
        'narrow': 'ctrl+left',
        'increase': 'ctrl+up',
        'decrease': 'ctrl+down',
        'change_box_size': 'ctrl+wheel',

        # Probe scaling.
        'extend_horizontally': 'shift+right',
        'shrink_horizontally': 'shift+left',
        'extend_vertically': 'shift+up',
        'shrink_vertically': 'shift+down',
    }
    default_snippets = {
        'change_n_spikes_waveforms': 'wn',
    }

    def __init__(self, waveforms=None, waveforms_type=None, **kwargs):
        self._overlap = False
        self.do_show_labels = True
        self.channel_ids = None
        self.filtered_tags = ()

        # Initialize the view.
        super(WaveformView, self).__init__(**kwargs)
        self.state_attrs += ('waveforms_type', 'overlap', 'do_show_labels')
        self.local_state_attrs += ('box_scaling', 'probe_scaling')

        # Box and probe scaling.
        self.canvas.set_layout('boxed', box_bounds=[[-1, -1, +1, +1]])
        self.canvas.enable_axes()

        self._box_scaling = (1., 1.)
        self._probe_scaling = (1., 1.)

        self.box_pos = np.array(self.canvas.boxed.box_pos)
        self.box_size = np.array(self.canvas.boxed.box_size)
        self._update_boxes()

        # Ensure waveforms is a dictionary, even if there is a single waveforms type.
        waveforms = waveforms if isinstance(waveforms, dict) else {
            'waveforms': waveforms
        }
        assert waveforms
        self.waveforms = waveforms
        self.waveforms_types = list(waveforms.keys())
        # Current waveforms type.
        self.waveforms_type = waveforms_type or self.waveforms_types[0]
        assert self.waveforms_type in waveforms
        assert 'waveforms' in waveforms

        self.text_visual = TextVisual()
        self.canvas.add_visual(self.text_visual)

        self.waveform_visual = PlotVisual()
        self.canvas.add_visual(self.waveform_visual)

    # Internal methods
    # -------------------------------------------------------------------------

    def _get_data_bounds(self, bunchs):
        m = min(_min(b.data) for b in bunchs)
        M = max(_max(b.data) for b in bunchs)
        return [-1, m, +1, M]

    def get_clusters_data(self):
        bunchs = [
            self.waveforms[self.waveforms_type](cluster_id)
            for cluster_id in self.cluster_ids
        ]
        clu_offsets = _get_clu_offsets(bunchs)
        n_clu = max(clu_offsets) + 1
        # Offset depending on the overlap.
        for i, (bunch, offset) in enumerate(zip(bunchs, clu_offsets)):
            bunch.index = i
            bunch.offset = offset
            bunch.n_clu = n_clu
            bunch.color = selected_cluster_color(i, bunch.get('alpha', .75))
        return bunchs

    def _plot_cluster(self, bunch):
        wave = bunch.data
        if wave is None or not wave.size:
            return
        channel_ids_loc = bunch.channel_ids

        n_channels = len(channel_ids_loc)
        masks = bunch.get('masks', np.ones((wave.shape[0], n_channels)))
        # By default, this is 0, 1, 2 for the first 3 clusters.
        # But it can be customized when displaying several sets
        # of waveforms per cluster.

        n_spikes_clu, n_samples = wave.shape[:2]
        assert wave.shape[2] == n_channels
        assert masks.shape == (n_spikes_clu, n_channels)

        # Find the x coordinates.
        t = get_linear_x(n_spikes_clu * n_channels, n_samples)
        t = _overlap_transform(t,
                               offset=bunch.offset,
                               n=bunch.n_clu,
                               overlap=self.overlap)
        # HACK: on the GPU, we get the actual masks with fract(masks)
        # since we add the relative cluster index. We need to ensure
        # that the masks is never 1.0, otherwise it is interpreted as
        # 0.
        masks *= .99999
        # NOTE: we add the cluster index which is used for the
        # computation of the depth on the GPU.
        masks += bunch.index

        # Generate the box index (one number per channel).
        box_index = _index_of(channel_ids_loc, self.channel_ids)
        box_index = np.repeat(box_index, n_samples)
        box_index = np.tile(box_index, n_spikes_clu)
        assert box_index.shape == (n_spikes_clu * n_channels * n_samples, )

        # Generate the waveform array.
        wave = np.transpose(wave, (0, 2, 1))
        wave = wave.reshape((n_spikes_clu * n_channels, n_samples))

        self.waveform_visual.add_batch_data(x=t,
                                            y=wave,
                                            color=bunch.color,
                                            masks=masks,
                                            box_index=box_index,
                                            data_bounds=self.data_bounds)

    def _plot_labels(self, channel_ids, n_clusters, channel_labels):
        # Add channel labels.
        if not self.do_show_labels:
            return
        self.text_visual.reset_batch()
        for i, ch in enumerate(channel_ids):
            label = channel_labels[ch]
            self.text_visual.add_batch_data(
                pos=[-1, 0],
                text=str(label),
                anchor=[-1.25, 0],
                box_index=i,
            )
        self.canvas.update_visual(self.text_visual)

    def plot(self, **kwargs):
        """Update the view with the current cluster selection."""
        if not self.cluster_ids:
            return
        bunchs = self.get_clusters_data()

        # All channel ids appearing in all selected clusters.
        channel_ids = sorted(set(_flatten([d.channel_ids for d in bunchs])))
        self.channel_ids = channel_ids

        # Channel labels.
        channel_labels = {}
        for d in bunchs:
            chl = d.get('channel_labels', ['%d' % ch for ch in d.channel_ids])
            channel_labels.update({
                channel_id: chl[i]
                for i, channel_id in enumerate(d.channel_ids)
            })

        # Update the box bounds as a function of the selected channels.
        if channel_ids:
            self.canvas.boxed.box_bounds = _get_box_bounds(bunchs, channel_ids)
        self.box_pos = np.array(self.canvas.boxed.box_pos)
        self.box_size = np.array(self.canvas.boxed.box_size)
        self._update_boxes()

        self.data_bounds = self._get_data_bounds(bunchs)

        self.waveform_visual.reset_batch()
        for bunch in bunchs:
            self._plot_cluster(bunch)
        self.canvas.update_visual(self.waveform_visual)

        self._plot_labels(channel_ids, len(self.cluster_ids), channel_labels)
        self._update_axes(bunchs)
        self.canvas.update()

    def _update_axes(self, bunchs):
        """Update the axes."""
        # Update the axes data bounds.
        _, m, _, M = self.data_bounds
        # Waveform duration, scaled by overlap factor if needed.
        wave_dur = bunchs[0].get('waveform_duration', 1.)
        wave_dur /= .5 * (1 + _overlap_transform(
            1, n=len(self.cluster_ids), overlap=self.overlap))
        x1, y1 = range_transform(self.canvas.boxed.box_bounds[0], NDC,
                                 [wave_dur, M - m])
        axes_data_bounds = (0, 0, x1, y1)
        self.canvas.axes.reset_data_bounds(axes_data_bounds, do_update=True)

    def attach(self, gui):
        """Attach the view to the GUI."""
        super(WaveformView, self).attach(gui)

        self.actions.add(self.toggle_waveform_overlap,
                         checkable=True,
                         checked=self.overlap)
        self.actions.add(self.toggle_show_labels,
                         checkable=True,
                         checked=self.do_show_labels)
        self.actions.add(self.next_waveforms_type)
        self.actions.add(self.toggle_mean_waveforms, checkable=True)
        self.actions.separator()

        # Box scaling.
        self.actions.add(self.widen)
        self.actions.add(self.narrow)
        self.actions.separator()

        # Probe scaling.
        self.actions.add(self.extend_horizontally)
        self.actions.add(self.shrink_horizontally)
        self.actions.separator()

        self.actions.add(self.extend_vertically)
        self.actions.add(self.shrink_vertically)
        self.actions.separator()

    @property
    def boxed(self):
        """Layout instance."""
        return self.canvas.boxed

    # Overlap
    # -------------------------------------------------------------------------

    @property
    def overlap(self):
        """Whether to overlap the waveforms belonging to different clusters."""
        return self._overlap

    @overlap.setter
    def overlap(self, value):
        self._overlap = value
        self.plot()

    def toggle_waveform_overlap(self, checked):
        """Toggle the overlap of the waveforms."""
        self.overlap = checked

    # Box scaling
    # -------------------------------------------------------------------------

    def _update_boxes(self):
        self.canvas.boxed.update_boxes(self.box_pos * self.probe_scaling,
                                       self.box_size)

    def _apply_box_scaling(self):
        self.canvas.layout.scaling = self._box_scaling

    @property
    def box_scaling(self):
        """Scaling of the channel boxes."""
        return self._box_scaling

    @box_scaling.setter
    def box_scaling(self, value):
        assert len(value) == 2
        self._box_scaling = value
        self._apply_box_scaling()

    def widen(self):
        """Increase the horizontal scaling of the waveforms."""
        w, h = self._box_scaling
        self._box_scaling = (w * self._scaling_param_increment, h)
        self._apply_box_scaling()

    def narrow(self):
        """Decrease the horizontal scaling of the waveforms."""
        w, h = self._box_scaling
        self._box_scaling = (w / self._scaling_param_increment, h)
        self._apply_box_scaling()

    def _get_scaling_value(self):
        return self._box_scaling[1]

    def _set_scaling_value(self, value):
        w, h = self._box_scaling
        self.box_scaling = (w, value)
        self._update_boxes()

    # Probe scaling
    # -------------------------------------------------------------------------

    @property
    def probe_scaling(self):
        """Scaling of the entire probe."""
        return self._probe_scaling

    @probe_scaling.setter
    def probe_scaling(self, value):
        assert len(value) == 2
        self._probe_scaling = value
        self._update_boxes()

    def extend_horizontally(self):
        """Increase the horizontal scaling of the probe."""
        w, h = self._probe_scaling
        self._probe_scaling = (w * self._scaling_param_increment, h)
        self._update_boxes()

    def shrink_horizontally(self):
        """Decrease the horizontal scaling of the waveforms."""
        w, h = self._probe_scaling
        self._probe_scaling = (w / self._scaling_param_increment, h)
        self._update_boxes()

    def extend_vertically(self):
        """Increase the vertical scaling of the waveforms."""
        w, h = self._probe_scaling
        self._probe_scaling = (w, h * self._scaling_param_increment)
        self._update_boxes()

    def shrink_vertically(self):
        """Decrease the vertical scaling of the waveforms."""
        w, h = self._probe_scaling
        self._probe_scaling = (w, h / self._scaling_param_increment)
        self._update_boxes()

    # Navigation
    # -------------------------------------------------------------------------

    def toggle_show_labels(self, checked):
        """Whether to show the channel ids or not."""
        self.do_show_labels = checked
        self.text_visual.show() if checked else self.text_visual.hide()
        self.canvas.update()

    def on_mouse_click(self, e):
        """Select a channel by clicking on a box in the waveform view."""
        b = e.button
        nums = tuple('%d' % i for i in range(10))
        if 'Control' in e.modifiers or e.key in nums:
            key = int(e.key) if e.key in nums else None
            # Get mouse position in NDC.
            channel_idx, _ = self.canvas.boxed.box_map(e.pos)
            channel_id = self.channel_ids[channel_idx]
            logger.debug("Click on channel_id %d with key %s and button %s.",
                         channel_id, key, b)
            emit('channel_click',
                 self,
                 channel_id=channel_id,
                 key=key,
                 button=b)

    def next_waveforms_type(self):
        """Switch to the next waveforms type."""
        i = self.waveforms_types.index(self.waveforms_type)
        n = len(self.waveforms_types)
        self.waveforms_type = self.waveforms_types[(i + 1) % n]
        logger.debug("Switch to waveforms type %s.", self.waveforms_type)
        self.plot()

    def toggle_mean_waveforms(self, checked):
        """Switch to the `mean_waveforms` type, if it is available."""
        if self.waveforms_type == 'mean_waveforms':
            self.waveforms_type = 'waveforms'
            self.plot()
        elif 'mean_waveforms' in self.waveforms_types:
            self.waveforms_type = 'mean_waveforms'
            self.plot()
Beispiel #6
0
class TraceView(ScalingMixin, BaseColorView, ManualClusteringView):
    """This view shows the raw traces along with spike waveforms.

    Constructor
    -----------

    traces : function
        Maps a time interval `(t0, t1)` to a `Bunch(data, color, waveforms)` where
        * `data` is an `(n_samples, n_channels)` array
        * `waveforms` is a list of bunchs with the following attributes:
            * `data`
            * `color`
            * `channel_ids`
            * `start_time`
            * `spike_id`
            * `spike_cluster`

    spike_times : function
        Teturns the list of relevant spike times.
    sample_rate : float
    duration : float
    n_channels : int
    channel_positions : array-like
        Positions of the channels, used for displaying the channels in the right y order
    channel_labels : list
        Labels of all shown channels. By default, this is just the channel ids.

    """
    _default_position = 'left'
    auto_update = True
    auto_scale = True
    interval_duration = .25  # default duration of the interval
    shift_amount = .1
    scaling_coeff_x = 1.25
    trace_quantile = .01  # quantile for auto-scaling
    default_trace_color = (.5, .5, .5, 1)
    trace_color_0 = (.353, .161, .443)
    trace_color_1 = (.133, .404, .396)
    default_shortcuts = {
        'change_trace_size': 'ctrl+wheel',
        'switch_color_scheme': 'shift+wheel',
        'navigate': 'alt+wheel',
        'decrease': 'alt+down',
        'increase': 'alt+up',
        'go_left': 'alt+left',
        'go_right': 'alt+right',
        'jump_left': 'shift+alt+left',
        'jump_right': 'shift+alt+right',
        'go_to_start': 'alt+home',
        'go_to_end': 'alt+end',
        'go_to': 'alt+t',
        'go_to_next_spike': 'alt+pgdown',
        'go_to_previous_spike': 'alt+pgup',
        'narrow': 'alt++',
        'select_spike': 'ctrl+click',
        'select_channel_pcA': 'shift+left click',
        'select_channel_pcB': 'shift+right click',
        'switch_origin': 'alt+o',
        'toggle_highlighted_spikes': 'alt+s',
        'toggle_show_labels': 'alt+l',
        'widen': 'alt+-',
    }
    default_snippets = {
        'go_to': 'tg',
        'shift': 'ts',
    }

    def __init__(
            self, traces=None, sample_rate=None, spike_times=None, duration=None,
            n_channels=None, channel_positions=None, channel_labels=None, **kwargs):

        self.do_show_labels = True
        self.show_all_spikes = False

        self.get_spike_times = spike_times

        # Sample rate.
        assert sample_rate > 0
        self.sample_rate = float(sample_rate)
        self.dt = 1. / self.sample_rate

        # Traces and spikes.
        assert hasattr(traces, '__call__')
        self.traces = traces
        # self.waveforms = None

        assert duration >= 0
        self.duration = duration

        assert n_channels >= 0
        self.n_channels = n_channels

        # Channel y ranking.
        self.channel_positions = (
            channel_positions if channel_positions is not None else
            np.c_[np.zeros(n_channels), np.arange(n_channels)])
        # channel_y_ranks[i] is the position of channel #i in the trace view.
        self.channel_y_ranks = np.argsort(np.argsort(self.channel_positions[:, 1]))
        assert self.channel_y_ranks.shape == (n_channels,)

        # Channel labels.
        self.channel_labels = (
            channel_labels if channel_labels is not None else
            ['%d' % ch for ch in range(n_channels)])
        assert len(self.channel_labels) == n_channels

        # Initialize the view.
        super(TraceView, self).__init__(**kwargs)
        self.state_attrs += ('origin', 'do_show_labels', 'show_all_spikes', 'auto_scale')
        self.local_state_attrs += ('interval', 'scaling',)

        # Visuals.
        self._create_visuals()

        # Initial interval.
        self._interval = None
        self.go_to(duration / 2.)

        self._waveform_times = []
        self.canvas.panzoom.set_constrain_bounds((-1, -2, +1, +2))

    def _create_visuals(self):
        self.canvas.set_layout('stacked', n_plots=self.n_channels)
        self.canvas.enable_axes(show_y=False)

        self.trace_visual = UniformPlotVisual()
        # Gradient of color for the traces.
        if self.trace_color_0 and self.trace_color_1:
            self.trace_visual.inserter.insert_frag(
                'gl_FragColor.rgb = mix(vec3%s, vec3%s, (v_signal_index / %d));' % (
                    self.trace_color_0, self.trace_color_1, self.n_channels), 'end')
        self.canvas.add_visual(self.trace_visual)

        self.waveform_visual = PlotVisual()
        self.canvas.add_visual(self.waveform_visual)

        self.text_visual = TextVisual()
        _fix_coordinate_in_visual(self.text_visual, 'x')
        self.text_visual.inserter.add_varying(
            'float', 'v_discard',
            'float((n_boxes >= 50 * u_zoom.y) && '
            '(mod(int(a_box_index), int(n_boxes / (50 * u_zoom.y))) >= 1))')
        self.text_visual.inserter.insert_frag('if (v_discard > 0) discard;', 'end')
        self.canvas.add_visual(self.text_visual)

    @property
    def stacked(self):
        return self.canvas.stacked

    # Internal methods
    # -------------------------------------------------------------------------

    def _plot_traces(self, traces, color=None):
        traces = traces.T
        n_samples = traces.shape[1]
        n_ch = self.n_channels
        assert traces.shape == (n_ch, n_samples)
        color = color or self.default_trace_color

        t = self._interval[0] + np.arange(n_samples) * self.dt
        t = np.tile(t, (n_ch, 1))

        box_index = self.channel_y_ranks
        box_index = np.repeat(box_index[:, np.newaxis], n_samples, axis=1)

        assert t.shape == (n_ch, n_samples)
        assert traces.shape == (n_ch, n_samples)
        assert box_index.shape == (n_ch, n_samples)

        self.trace_visual.color = color
        self.canvas.update_visual(
            self.trace_visual,
            t, traces,
            data_bounds=self.data_bounds,
            box_index=box_index.ravel(),
        )

    def _plot_spike(self, bunch):
        # The spike time corresponds to the first sample of the waveform.
        n_samples, n_channels = bunch.data.shape
        assert len(bunch.channel_ids) == n_channels

        # Generate the x coordinates of the waveform.
        t = bunch.start_time + self.dt * np.arange(n_samples)
        t = np.tile(t, (n_channels, 1))  # (n_unmasked_channels, n_samples)

        # Determine the spike color.
        i = bunch.select_index
        c = bunch.spike_cluster
        cs = self.color_schemes.get()
        color = selected_cluster_color(i, alpha=1) if i is not None else cs.get(c, alpha=1)

        # We could tweak the color of each spike waveform depending on the template amplitude
        # on each of its best channels.
        # channel_amps = bunch.get('channel_amps', None)
        # if channel_amps is not None:
        #     color = np.tile(color, (n_channels, 1))
        #     assert color.shape == (n_channels, 4)
        #     color[:, 3] = channel_amps

        # The box index depends on the channel.
        box_index = self.channel_y_ranks[bunch.channel_ids]
        box_index = np.repeat(box_index[:, np.newaxis], n_samples, axis=0)
        self.waveform_visual.add_batch_data(
            box_index=box_index,
            x=t, y=bunch.data.T, color=color,
            data_bounds=self.data_bounds,
        )

    def _plot_waveforms(self, waveforms, **kwargs):
        """Plot the waveforms."""
        # waveforms = self.waveforms
        assert isinstance(waveforms, list)
        if waveforms:
            self.waveform_visual.show()
            self.waveform_visual.reset_batch()
            for w in waveforms:
                self._plot_spike(w)
                self._waveform_times.append(
                    (w.start_time, w.spike_id, w.spike_cluster, w.get('channel_ids', None)))
            self.canvas.update_visual(self.waveform_visual)
        else:  # pragma: no cover
            self.waveform_visual.hide()

    def _plot_labels(self, traces):
        self.text_visual.reset_batch()
        for ch in range(self.n_channels):
            bi = self.channel_y_ranks[ch]
            ch_label = self.channel_labels[ch]
            self.text_visual.add_batch_data(
                pos=[self.data_bounds[0], 0],
                text=ch_label,
                anchor=[+1., 0],
                data_bounds=self.data_bounds,
                box_index=bi,
            )
        self.canvas.update_visual(self.text_visual)

    # Public methods
    # -------------------------------------------------------------------------

    def _restrict_interval(self, interval):
        start, end = interval
        # Round the times to full samples to avoid subsampling shifts
        # in the traces.
        start = int(round(start * self.sample_rate)) / self.sample_rate
        end = int(round(end * self.sample_rate)) / self.sample_rate
        # Restrict the interval to the boundaries of the traces.
        if start < 0:
            end += (-start)
            start = 0
        elif end >= self.duration:
            start -= (end - self.duration)
            end = self.duration
        start = np.clip(start, 0, end)
        end = np.clip(end, start, self.duration)
        assert 0 <= start < end <= self.duration
        return start, end

    def plot(self, update_traces=True, update_waveforms=True):
        if update_waveforms:
            # Load the traces in the interval.
            traces = self.traces(self._interval)

        if update_traces:
            logger.log(5, "Redraw the entire trace view.")
            start, end = self._interval

            # Find the data bounds.
            if self.auto_scale or getattr(self, 'data_bounds', NDC) == NDC:
                ymin = np.quantile(traces.data, self.trace_quantile)
                ymax = np.quantile(traces.data, 1. - self.trace_quantile)
            else:
                ymin, ymax = self.data_bounds[1], self.data_bounds[3]
            self.data_bounds = (start, ymin, end, ymax)

            # Used for spike click.
            self._waveform_times = []

            # Plot the traces.
            self._plot_traces(
                traces.data, color=traces.get('color', None))

            # Plot the labels.
            if self.do_show_labels:
                self._plot_labels(traces.data)

        if update_waveforms:
            self._plot_waveforms(traces.get('waveforms', []))

        self._update_axes()
        self.canvas.update()

    def set_interval(self, interval=None):
        """Display the traces and spikes in a given interval."""
        if interval is None:
            interval = self._interval
        interval = self._restrict_interval(interval)

        if interval != self._interval:
            logger.log(5, "Redraw the entire trace view.")
            self._interval = interval
            emit('is_busy', self, True)
            self.plot(update_traces=True, update_waveforms=True)
            emit('is_busy', self, False)
            emit('time_range_selected', self, interval)
            self.update_status()
        else:
            self.plot(update_traces=False, update_waveforms=True)

    def on_select(self, cluster_ids=None, **kwargs):
        self.cluster_ids = cluster_ids
        if not cluster_ids:
            return
        # Make sure we call again self.traces() when the cluster selection changes.
        self.set_interval()

    def attach(self, gui):
        """Attach the view to the GUI."""
        super(TraceView, self).attach(gui)

        self.actions.add(self.toggle_show_labels, checkable=True, checked=self.do_show_labels)
        self.actions.add(
            self.toggle_highlighted_spikes, checkable=True, checked=self.show_all_spikes)
        self.actions.add(self.toggle_auto_scale, checkable=True, checked=self.auto_scale)
        self.actions.add(self.switch_origin)
        self.actions.separator()

        self.actions.add(
            self.go_to, prompt=True, prompt_default=lambda: str(self.time))
        self.actions.separator()

        self.actions.add(self.go_to_start)
        self.actions.add(self.go_to_end)
        self.actions.separator()

        self.actions.add(self.shift, prompt=True)
        self.actions.add(self.go_right)
        self.actions.add(self.go_left)
        self.actions.add(self.jump_right)
        self.actions.add(self.jump_left)
        self.actions.separator()

        self.actions.add(self.widen)
        self.actions.add(self.narrow)
        self.actions.separator()

        self.actions.add(self.go_to_next_spike)
        self.actions.add(self.go_to_previous_spike)
        self.actions.separator()

        self.set_interval()

    @property
    def status(self):
        a, b = self._interval
        return '[{:.2f}s - {:.2f}s]. Color scheme: {}.'.format(a, b, self.color_scheme)

    # Origin
    # -------------------------------------------------------------------------

    @property
    def origin(self):
        """Whether to show the channels from top to bottom (`top` option, the default), or from
        bottom to top (`bottom`)."""
        return getattr(self.canvas.layout, 'origin', Stacked._origin)

    @origin.setter
    def origin(self, value):
        if value is None:
            return
        if self.canvas.layout:
            self.canvas.layout.origin = value
        else:  # pragma: no cover
            logger.warning(
                "Could not set origin to %s because the layout instance was not initialized yet.",
                value)

    def switch_origin(self):
        """Switch between top and bottom origin for the channels."""
        self.origin = 'bottom' if self.origin == 'top' else 'top'

    # Navigation
    # -------------------------------------------------------------------------

    @property
    def time(self):
        """Time at the center of the window."""
        return sum(self._interval) * .5

    @property
    def interval(self):
        """Interval as `(tmin, tmax)`."""
        return self._interval

    @interval.setter
    def interval(self, value):
        self.set_interval(value)

    @property
    def half_duration(self):
        """Half of the duration of the current interval."""
        if self._interval is not None:
            a, b = self._interval
            return (b - a) * .5
        else:
            return self.interval_duration * .5

    def go_to(self, time):
        """Go to a specific time (in seconds)."""
        half_dur = self.half_duration
        self.set_interval((time - half_dur, time + half_dur))

    def shift(self, delay):
        """Shift the interval by a given delay (in seconds)."""
        self.go_to(self.time + delay)

    def go_to_start(self):
        """Go to the start of the recording."""
        self.go_to(0)

    def go_to_end(self):
        """Go to end of the recording."""
        self.go_to(self.duration)

    def go_right(self):
        """Go to right."""
        start, end = self._interval
        delay = (end - start) * .1
        self.shift(delay)

    def go_left(self):
        """Go to left."""
        start, end = self._interval
        delay = (end - start) * .1
        self.shift(-delay)

    def jump_right(self):
        """Jump to right."""
        delay = self.duration * .1
        self.shift(delay)

    def jump_left(self):
        """Jump to left."""
        delay = self.duration * .1
        self.shift(-delay)

    def _jump_to_spike(self, delta=+1):
        """Jump to next or previous spike from the selected clusters."""
        spike_times = self.get_spike_times()
        if spike_times is not None and len(spike_times):
            ind = np.searchsorted(spike_times, self.time)
            n = len(spike_times)
            self.go_to(spike_times[(ind + delta) % n])

    def go_to_next_spike(self, ):
        """Jump to the next spike from the first selected cluster."""
        self._jump_to_spike(+1)

    def go_to_previous_spike(self, ):
        """Jump to the previous spike from the first selected cluster."""
        self._jump_to_spike(-1)

    def toggle_highlighted_spikes(self, checked):
        """Toggle between showing all spikes or selected spikes."""
        self.show_all_spikes = checked
        self.set_interval()

    def widen(self):
        """Increase the interval size."""
        t, h = self.time, self.half_duration
        h *= self.scaling_coeff_x
        self.set_interval((t - h, t + h))

    def narrow(self):
        """Decrease the interval size."""
        t, h = self.time, self.half_duration
        h /= self.scaling_coeff_x
        self.set_interval((t - h, t + h))

    # Misc
    # -------------------------------------------------------------------------

    def toggle_show_labels(self, checked):
        """Toggle the display of the channel ids."""
        logger.debug("Set show labels to %s.", checked)
        self.do_show_labels = checked
        self.text_visual.toggle()
        self.canvas.update()

    def toggle_auto_scale(self, checked):
        """Toggle automatic scaling of the traces."""
        logger.debug("Set auto scale to %s.", checked)
        self.auto_scale = checked

    def update_color(self):
        """Update the view when the color scheme changes."""
        self.plot(update_traces=False, update_waveforms=True)

    # Scaling
    # -------------------------------------------------------------------------

    @property
    def scaling(self):
        """Scaling of the channel boxes."""
        return self.stacked._box_scaling[1]

    @scaling.setter
    def scaling(self, value):
        self.stacked._box_scaling = (self.stacked._box_scaling[0], value)

    def _get_scaling_value(self):
        return self.scaling

    def _set_scaling_value(self, value):
        self.scaling = value
        self.stacked.update()

    # Spike selection
    # -------------------------------------------------------------------------

    def on_mouse_click(self, e):
        """Select a cluster by clicking on a spike."""
        if 'Control' in e.modifiers:
            # Get mouse position in NDC.
            box_id, _ = self.canvas.stacked.box_map(e.pos)
            channel_id = np.nonzero(self.channel_y_ranks == box_id)[0]
            # Find the spike and cluster closest to the mouse.
            db = self.data_bounds
            # Get the information about the displayed spikes.
            wt = [(t, s, c, ch) for t, s, c, ch in self._waveform_times if channel_id in ch]
            if not wt:
                return
            # Get the time coordinate of the mouse position.
            mouse_pos = self.canvas.panzoom.window_to_ndc(e.pos)
            mouse_time = Range(NDC, db).apply(mouse_pos)[0][0]
            # Get the closest spike id.
            times, spike_ids, spike_clusters, channel_ids = zip(*wt)
            i = np.argmin(np.abs(np.array(times) - mouse_time))
            # Raise the select_spike event.
            spike_id = spike_ids[i]
            cluster_id = spike_clusters[i]
            emit('select_spike', self, channel_id=channel_id,
                 spike_id=spike_id, cluster_id=cluster_id)

        if 'Shift' in e.modifiers:
            # Get mouse position in NDC.
            box_id, _ = self.canvas.stacked.box_map(e.pos)
            channel_id = int(np.nonzero(self.channel_y_ranks == box_id)[0][0])
            emit('select_channel', self, channel_id=channel_id, button=e.button)

    def on_mouse_wheel(self, e):  # pragma: no cover
        """Scroll through the data with alt+wheel."""
        super(TraceView, self).on_mouse_wheel(e)
        if e.modifiers == ('Alt',):
            start, end = self._interval
            delay = e.delta * (end - start) * .1
            self.shift(-delay)
Beispiel #7
0
class TraceView(ScalingMixin, ManualClusteringView):
    """This view shows the raw traces along with spike waveforms.

    Constructor
    -----------

    traces : function
        Maps a time interval `(t0, t1)` to a `Bunch(data, color, waveforms)` where
        * `data` is an `(n_samples, n_channels)` array
        * `waveforms` is a list of bunchs with the following attributes:
            * `data`
            * `color`
            * `channel_ids`
            * `start_time`
            * `spike_id`
            * `spike_cluster`

    spike_times : function
        Teturns the list of relevant spike times.
    sample_rate : float
    duration : float
    n_channels : int
    channel_vertical_order : array-like
        Permutation of the channels. This 1D array gives the channel id of all channels from
        top to bottom (or conversely, depending on `origin=top|bottom`).
    channel_labels : list
        Labels of all shown channels. By default, this is just the channel ids.

    """
    _default_position = 'left'
    auto_update = True
    auto_scale = True
    interval_duration = .25  # default duration of the interval
    shift_amount = .1
    scaling_coeff_x = 1.25
    trace_quantile = .01  # quantile for auto-scaling
    default_trace_color = (.5, .5, .5, 1)
    default_shortcuts = {
        'change_trace_size': 'ctrl+wheel',
        'decrease': 'alt+down',
        'increase': 'alt+up',
        'go_left': 'alt+left',
        'go_right': 'alt+right',
        'go_to_start': 'alt+home',
        'go_to_end': 'alt+end',
        'go_to': 'alt+t',
        'go_to_next_spike': 'alt+pgdown',
        'go_to_previous_spike': 'alt+pgup',
        'narrow': 'alt++',
        'select_spike': 'ctrl+click',
        'switch_origin': 'alt+o',
        'toggle_highlighted_spikes': 'alt+s',
        'toggle_show_labels': 'alt+l',
        'widen': 'alt+-',
    }
    default_snippets = {
        'go_to': 'tg',
        'shift': 'ts',
    }

    def __init__(
            self, traces=None, sample_rate=None, spike_times=None, duration=None, n_channels=None,
            channel_vertical_order=None, channel_labels=None, **kwargs):

        self.do_show_labels = True
        self.show_all_spikes = False
        self._scaling = 1.

        self.get_spike_times = spike_times

        # Sample rate.
        assert sample_rate > 0
        self.sample_rate = float(sample_rate)
        self.dt = 1. / self.sample_rate

        # Traces and spikes.
        assert hasattr(traces, '__call__')
        self.traces = traces
        self.waveforms = None

        assert duration >= 0
        self.duration = duration

        assert n_channels >= 0
        self.n_channels = n_channels

        # Channel permutation.
        self._channel_perm = (
            np.arange(n_channels) if channel_vertical_order is None else channel_vertical_order)
        assert self._channel_perm.shape == (n_channels,)
        self._channel_perm = np.argsort(self._channel_perm)

        # Channel labels.
        self.channel_labels = (
            channel_labels if channel_labels is not None else
            ['%d' % ch for ch in range(n_channels)])
        assert len(self.channel_labels) == n_channels

        # Box and probe scaling.
        self._origin = None

        # Initialize the view.
        super(TraceView, self).__init__(**kwargs)
        self.state_attrs += ('origin', 'do_show_labels', 'show_all_spikes', 'auto_scale')
        self.local_state_attrs += ('interval', 'scaling',)

        self.canvas.set_layout('stacked', origin=self.origin, n_plots=self.n_channels)
        self.canvas.enable_axes(show_y=False)

        # Visuals.
        self.trace_visual = UniformPlotVisual()
        self.canvas.add_visual(self.trace_visual)

        self.waveform_visual = PlotVisual()
        self.canvas.add_visual(self.waveform_visual)

        self.text_visual = TextVisual()
        _fix_coordinate_in_visual(self.text_visual, 'x')
        self.canvas.add_visual(self.text_visual)

        # Make a copy of the initial box pos and size. We'll apply the scaling
        # to these quantities.
        self.box_size = np.array(self.canvas.stacked.box_size)

        # Initial interval.
        self._interval = None
        self.go_to(duration / 2.)

        self._waveform_times = []

    @property
    def stacked(self):
        return self.canvas.stacked

    def _permute_channels(self, x, inv=False):
        cp = self._channel_perm
        cp = np.argsort(cp)
        return cp[x]

    # Internal methods
    # -------------------------------------------------------------------------

    def _plot_traces(self, traces, color=None):
        traces = traces.T
        n_samples = traces.shape[1]
        n_ch = self.n_channels
        assert traces.shape == (n_ch, n_samples)
        color = color or self.default_trace_color

        t = self._interval[0] + np.arange(n_samples) * self.dt
        t = np.tile(t, (n_ch, 1))

        box_index = self._permute_channels(np.arange(n_ch))
        box_index = np.repeat(box_index[:, np.newaxis], n_samples, axis=1)

        assert t.shape == (n_ch, n_samples)
        assert traces.shape == (n_ch, n_samples)
        assert box_index.shape == (n_ch, n_samples)

        self.trace_visual.color = color
        self.canvas.update_visual(
            self.trace_visual,
            t, traces,
            data_bounds=self.data_bounds,
            box_index=box_index.ravel(),
        )

    def _plot_spike(self, bunch):
        # The spike time corresponds to the first sample of the waveform.
        n_samples, n_channels = bunch.data.shape
        assert len(bunch.channel_ids) == n_channels

        # Generate the x coordinates of the waveform.
        t = bunch.start_time + self.dt * np.arange(n_samples)
        t = np.tile(t, (n_channels, 1))  # (n_unmasked_channels, n_samples)

        # The box index depends on the channel.
        box_index = self._permute_channels(bunch.channel_ids)
        box_index = np.repeat(box_index[:, np.newaxis], n_samples, axis=0)
        self.waveform_visual.add_batch_data(
            box_index=box_index,
            x=t, y=bunch.data.T, color=bunch.color,
            data_bounds=self.data_bounds,
        )

    def _plot_labels(self, traces):
        self.text_visual.reset_batch()
        for ch in range(self.n_channels):
            bi = self._permute_channels(ch)
            ch_label = self.channel_labels[ch]
            self.text_visual.add_batch_data(
                pos=[self.data_bounds[0], 0],
                text=ch_label,
                anchor=[+1., 0],
                data_bounds=self.data_bounds,
                box_index=bi,
            )
        self.canvas.update_visual(self.text_visual)

    # Public methods
    # -------------------------------------------------------------------------

    def _restrict_interval(self, interval):
        start, end = interval
        # Round the times to full samples to avoid subsampling shifts
        # in the traces.
        start = int(round(start * self.sample_rate)) / self.sample_rate
        end = int(round(end * self.sample_rate)) / self.sample_rate
        # Restrict the interval to the boundaries of the traces.
        if start < 0:
            end += (-start)
            start = 0
        elif end >= self.duration:
            start -= (end - self.duration)
            end = self.duration
        start = np.clip(start, 0, end)
        end = np.clip(end, start, self.duration)
        assert 0 <= start < end <= self.duration
        return start, end

    def set_interval(self, interval=None, change_status=True):
        """Display the traces and spikes in a given interval."""
        if interval is None:
            interval = self._interval
        interval = self._restrict_interval(interval)

        # Load the traces.
        traces = self.traces(interval)
        self.waveforms = traces.get('waveforms', [])

        if interval != self._interval:
            logger.debug("Redraw the entire trace view.")
            self._interval = interval
            start, end = interval

            # Set the status message.
            if change_status:
                self.set_status('Interval: {:.3f} s - {:.3f} s'.format(start, end))

            # Find the data bounds.
            if self.auto_scale or getattr(self, 'data_bounds', NDC) == NDC:
                ymin = np.quantile(traces.data, self.trace_quantile)
                ymax = np.quantile(traces.data, 1. - self.trace_quantile)
            else:
                ymin, ymax = self.data_bounds[1], self.data_bounds[3]
            self.data_bounds = (start, ymin, end, ymax)

            # Used for spike click.
            self._waveform_times = []

            # Plot the traces.
            self._plot_traces(
                traces.data, color=traces.get('color', None))

            # Plot the labels.
            if self.do_show_labels:
                self._plot_labels(traces.data)

        # Plot the waveforms.
        self.plot()

    def on_select(self, cluster_ids=None, **kwargs):
        self.cluster_ids = cluster_ids
        if not cluster_ids:
            return
        # Make sure we call again self.traces() when the cluster selection changes.
        self.set_interval()

    def plot(self, **kwargs):
        """Plot the waveforms."""
        waveforms = self.waveforms
        assert isinstance(waveforms, list)
        if waveforms:
            self.waveform_visual.show()
            self.waveform_visual.reset_batch()
            for w in waveforms:
                self._plot_spike(w)
                self._waveform_times.append(
                    (w.start_time, w.spike_id, w.spike_cluster, w.get('channel_ids', None)))
            self.canvas.update_visual(self.waveform_visual)
        else:  # pragma: no cover
            self.waveform_visual.hide()

        self._update_axes()
        self.canvas.update()

    def attach(self, gui):
        """Attach the view to the GUI."""
        super(TraceView, self).attach(gui)

        self.actions.add(self.toggle_show_labels, checkable=True, checked=self.do_show_labels)
        self.actions.add(
            self.toggle_highlighted_spikes, checkable=True, checked=self.show_all_spikes)
        self.actions.add(self.toggle_auto_scale, checkable=True, checked=self.auto_scale)
        self.actions.add(self.switch_origin)
        self.actions.separator()

        self.actions.add(
            self.go_to, prompt=True, prompt_default=lambda: str(self.time))
        self.actions.separator()

        self.actions.add(self.go_to_start)
        self.actions.add(self.go_to_end)
        self.actions.separator()

        self.actions.add(self.shift, prompt=True)
        self.actions.add(self.go_right)
        self.actions.add(self.go_left)
        self.actions.separator()

        self.actions.add(self.widen)
        self.actions.add(self.narrow)
        self.actions.separator()

        self.actions.add(self.go_to_next_spike)
        self.actions.add(self.go_to_previous_spike)
        self.actions.separator()

        self.set_interval()

    # Origin
    # -------------------------------------------------------------------------

    @property
    def origin(self):
        """Whether to show the channels from top to bottom (`top` option, the default), or from
        bottom to top (`bottom`)."""
        return self._origin

    @origin.setter
    def origin(self, value):
        self._origin = value
        if self.canvas.layout:
            self.canvas.layout.origin = value

    def switch_origin(self):
        """Switch between top and bottom origin for the channels."""
        self.origin = 'top' if self._origin in ('bottom', None) else 'bottom'

    # Navigation
    # -------------------------------------------------------------------------

    @property
    def time(self):
        """Time at the center of the window."""
        return sum(self._interval) * .5

    @property
    def interval(self):
        """Interval as `(tmin, tmax)`."""
        return self._interval

    @interval.setter
    def interval(self, value):
        self.set_interval(value)

    @property
    def half_duration(self):
        """Half of the duration of the current interval."""
        if self._interval is not None:
            a, b = self._interval
            return (b - a) * .5
        else:
            return self.interval_duration * .5

    def go_to(self, time):
        """Go to a specific time (in seconds)."""
        half_dur = self.half_duration
        self.set_interval((time - half_dur, time + half_dur))

    def shift(self, delay):
        """Shift the interval by a given delay (in seconds)."""
        self.go_to(self.time + delay)

    def go_to_start(self):
        """Go to the start of the recording."""
        self.go_to(0)

    def go_to_end(self):
        """Go to end of the recording."""
        self.go_to(self.duration)

    def go_right(self):
        """Go to right."""
        start, end = self._interval
        delay = (end - start) * .1
        self.shift(delay)

    def go_left(self):
        """Go to left."""
        start, end = self._interval
        delay = (end - start) * .1
        self.shift(-delay)

    def _jump_to_spike(self, delta=+1):
        """Jump to next or previous spike from the selected clusters."""
        spike_times = self.get_spike_times()
        if spike_times is not None and len(spike_times):
            ind = np.searchsorted(spike_times, self.time)
            n = len(spike_times)
            self.go_to(spike_times[(ind + delta) % n])

    def go_to_next_spike(self, ):
        """Jump to the next spike from the first selected cluster."""
        self._jump_to_spike(+1)

    def go_to_previous_spike(self, ):
        """Jump to the previous spike from the first selected cluster."""
        self._jump_to_spike(-1)

    def toggle_highlighted_spikes(self, checked):
        """Toggle between showing all spikes or selected spikes."""
        self.show_all_spikes = checked
        self.set_interval()

    def widen(self):
        """Increase the interval size."""
        t, h = self.time, self.half_duration
        h *= self.scaling_coeff_x
        self.set_interval((t - h, t + h))

    def narrow(self):
        """Decrease the interval size."""
        t, h = self.time, self.half_duration
        h /= self.scaling_coeff_x
        self.set_interval((t - h, t + h))

    # Misc
    # -------------------------------------------------------------------------

    def toggle_show_labels(self, checked):
        """Toggle the display of the channel ids."""
        logger.debug("Set show labels to %s.", checked)
        self.do_show_labels = checked
        self.set_interval()

    def toggle_auto_scale(self, checked):
        """Toggle automatic scaling of the traces."""
        logger.debug("Set auto scale to %s.", checked)
        self.auto_scale = checked

    # Scaling
    # -------------------------------------------------------------------------

    def _apply_scaling(self):
        self.canvas.layout.scaling = (self.canvas.layout.scaling[0], self._scaling)

    @property
    def scaling(self):
        """Scaling of the channel boxes."""
        return self._scaling

    @scaling.setter
    def scaling(self, value):
        self._scaling = value
        self._apply_scaling()

    def _get_scaling_value(self):
        return self.scaling

    def _set_scaling_value(self, value):
        self.scaling = value

    # Spike selection
    # -------------------------------------------------------------------------

    def on_mouse_click(self, e):
        """Select a cluster by clicking on a spike."""
        if 'Control' in e.modifiers:
            # Get mouse position in NDC.
            box_id, _ = self.canvas.stacked.box_map(e.pos)
            channel_id = self._permute_channels(box_id, inv=True)
            # Find the spike and cluster closest to the mouse.
            db = self.data_bounds
            # Get the information about the displayed spikes.
            wt = [(t, s, c, ch) for t, s, c, ch in self._waveform_times if channel_id in ch]
            if not wt:
                return
            # Get the time coordinate of the mouse position.
            mouse_pos = self.canvas.panzoom.window_to_ndc(e.pos)
            mouse_time = Range(NDC, db).apply(mouse_pos)[0][0]
            # Get the closest spike id.
            times, spike_ids, spike_clusters, channel_ids = zip(*wt)
            i = np.argmin(np.abs(np.array(times) - mouse_time))
            # Raise the spike_click event.
            spike_id = spike_ids[i]
            cluster_id = spike_clusters[i]
            emit('spike_click', self, channel_id=channel_id,
                 spike_id=spike_id, cluster_id=cluster_id)