1

I'm using Matplotlib to allow the user to find and adjust the lower and upper envelope of a graph in order to normalize it to [0, 1] interval. I follow this answer and also this original matplotlib example but unfortunately I couldn't figure out the solution yet.

Rules:

  • 'd' key deletes a point at the cursor (if there is any point nearby)
  • 'i' key inserts a point at the cursor
  • mouse dragging moves points
  • Closing the figure records the current state of the envelope, so it's kind of an OK button.

I would like to use self.fig.canvas.restore_region(self.background) instead of self.fig.canvas.draw() for more efficient redrawing. The problem is that when I drag a point the original line and point also stays on the plot, because it's interpreted as part of the self.background. I'd like only the self.basedata to be the background, and dynamically change self.lines and self.peakplot. Currently when I move the datapoints, the line and points will be doubled.

Using the safe_draw function in the previously mentioned SO answer doesn't work when I resize the matplotlib window (which is necessary when working with real life datasets.)

This is the code:

from matplotlib import pyplot as plt
import numpy as np

def calc_envelope(x, ind, mode='u'):
    '''https://stackoverflow.com/a/39662343/11751294'''
    x_abs = np.abs(x)
    if mode == 'u':
        loc = np.where(np.diff(np.sign(np.diff(x_abs))) < 0)[0] + 1
    elif mode == 'l':
        loc = np.where(np.diff(np.sign(np.diff(x_abs))) > 0)[0] + 1
    else:
        raise ValueError('mode must be u or l.')
    peak = x_abs[loc]
    envelope = np.interp(ind, loc, peak)
    return envelope, peak, loc


class DraggableEnvelope:

    # this should be pixel distance later, because x and y can be differently scaled.
    epsilon = 2 # max absolute distance to count as a hit

    def __init__(self, x, y, mode='l'):
        self.fig, self.ax = plt.subplots()
        self.x = x
        self.y = y
        self.mode = mode
        if self.mode == 'l':
            self.envelope, self.y_env, loc = calc_envelope(
                self.y, np.arange(len(self.y)), 'l'
                )
            plt.title('Adjust the lower envelope.')
        elif self.mode == 'u':
            self.envelope, self.y_env, loc = calc_envelope(
                self.y, np.arange(len(self.y)), 'u'
                )
            plt.title('Adjust the upper envelope.')
        else:
            raise ValueError('mode must be u or l.')
        self._ind = None # the active point index
        self.basedata, = self.ax.plot(self.x, self.y)
        self.lines, = self.ax.plot(self.x, self.envelope, 'r')
        self.x_env = self.x[loc]
        self.peakplot, = self.ax.plot(self.x_env, self.y_env, 'ko')
        self.fig.canvas.mpl_connect('button_press_event', self.button_press_callback)
        self.fig.canvas.mpl_connect('key_press_event', self.key_press_callback)
        self.fig.canvas.mpl_connect('draw_event', self.draw_callback)
        self.fig.canvas.mpl_connect('button_release_event', self.button_release_callback)
        self.fig.canvas.mpl_connect('motion_notify_event', self.motion_notify_callback)
        plt.grid()
        plt.show()


    def button_release_callback(self, event):
        '''whenever a mouse button is released'''
        if event.button != 1:
            return
        self._ind = None


    def get_ind_under_point(self, event):
        '''Get the index of the selected point within the given epsilon tolerance.'''
        d = np.hypot(self.x_env - event.xdata, self.y_env - event.ydata)
        indseq, = np.nonzero(d == d.min())
        ind = indseq[0]

        if d[ind] >= self.epsilon:
            ind = None
        return ind


    def button_press_callback(self, event):
        '''whenever a mouse button is pressed we get the index'''
        if event.inaxes is None:
            return
        if event.button != 1:
            return
        self._ind = self.get_ind_under_point(event)


    def button_release_callback(self, event):
        '''whenever a mouse button is released'''
        if event.button != 1:
            return
        self._ind = None


    def key_press_callback(self, event):
        '''whenever a key is pressed'''
        if not event.inaxes:
            return
        if event.key == 'd':
            ind = self.get_ind_under_point(event)
            if ind is not None:
                self.x_env = np.delete(self.x_env,
                                         ind)
                self.y_env = np.delete(self.y_env, ind)
                self.interpolate()
                self.peakplot.set_data(self.x_env, self.y_env)
                self.lines.set_data(self.x, self.envelope)
        elif event.key == 'i':
            self.y_env = np.append(self.y_env, event.ydata)
            self.x_env = np.append(self.x_env, event.xdata)
            self.interpolate()
            self.peakplot.set_data(self.x_env, self.y_env)
            self.lines.set_data(self.x, self.envelope)
        if self.peakplot.stale:
            self.fig.canvas.draw_idle()

    def get_data(self):
        if self.mode == 'l':
            return self.y-self.envelope
        elif self.mode == 'u':
            return self.y/self.envelope

    def draw_callback(self, event):
        self.background = self.fig.canvas.copy_from_bbox(self.ax.bbox)
        self.ax.draw_artist(self.peakplot)
        self.ax.draw_artist(self.lines)

    def motion_notify_callback(self, event):
        '''on mouse movement we move the selected point'''
        if self._ind is None:
            return
        if event.inaxes is None:
            return
        if event.button != 1:
            return
        x, y = event.xdata, event.ydata
        self.x_env[self._ind], self.y_env[self._ind] = x, y
        self.interpolate()
        self.peakplot.set_data(self.x_env, self.y_env)
        self.lines.set_data(self.x, self.envelope)

        self.fig.canvas.restore_region(self.background)
        self.ax.draw_artist(self.lines)
        self.ax.draw_artist(self.peakplot)
        self.fig.canvas.blit(self.ax.bbox)
        # self.fig.canvas.draw() <-- redrawing the whole figure slowly

    def interpolate(self):
        idx = np.argsort(self.x_env)
        self.y_env, self.x_env = self.y_env[idx], self.x_env[idx]
        self.envelope = np.interp(self.x, self.x_env, self.y_env)


if __name__ == '__main__':
    # example data
    x = np.arange(0, 100, 0.1)
    y = 4 * np.sin(x) + np.cos(x / 2) + 5
    d = DraggableEnvelope(x, y, 'l')
    yt = d.get_data()
    d2 = DraggableEnvelope(x, yt, 'u')
    y_final = d2.get_data()
    plt.plot(x, y_final)
    plt.title('Final')
    plt.grid()
    plt.show()

How can I solve this issue?

Péter Leéh
  • 2,069
  • 2
  • 10
  • 23

0 Answers0