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?