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?