Matplotlib: A bug in interactive zooming tool after updating figure - python

I am stuck again with interactive plotting with matplotlib.
Everything else works like a charm (hovering and clicking of objects in a figure) but if I zoom the shown figure and it will be updated, zooming rectangle will remain in the new figure. Probably I have to reset zooming settings somehow but I couldn't find out the correct method to do it from other StackOverflow questions (clearing the figure is not obviously enough).
I built a toy example to illustrate the problem. Four points are attached to four images and they are plotted to the figure. With interactive-mode by inserting cursor on top of chosen point, it shows related image in a imagebox. After one point is clicked, program waits 2 seconds and updates the view by rotating all the samples 15 degrees.
The problem occurs when current view is zoomed and then its updated. Zoom-to-rectangle will start automatically and after clicking once anywhere in the figure, the rectangle is gone without doing anything. This is shown in below image. I just want to have normal cursor after figure is updated.
Here is the code for the toy example:
import matplotlib.pyplot as plt
import matplotlib as mpl
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
import numpy as np
import copy
def initialize_figure(fignum):
plt.figure(fignum)
plt.clf()
def draw_interactive_figures(new_samples, images):
global new_samples_tmp, images_tmp, offset_image_tmp, image_box_tmp, fig_tmp, x_tmp, y_tmp
initialize_figure(1)
plt.ion()
fig_tmp = plt.gcf()
images_tmp = copy.deepcopy(images)
offset_image_tmp = OffsetImage(images_tmp[0,:,:,:])
image_box_tmp = (40., 40.)
x_tmp = new_samples[:,0]
y_tmp = new_samples[:,1]
new_samples_tmp = copy.deepcopy(new_samples)
update_plot()
fig_tmp.canvas.mpl_connect('motion_notify_event', hover)
fig_tmp.canvas.mpl_connect('button_press_event', click)
plt.show()
fig_tmp.canvas.start_event_loop()
plt.ioff()
def update_plot():
global points_tmp, annotationbox_tmp
ax = plt.gca()
points_tmp = plt.scatter(*new_samples_tmp.T, s=14, c='b', edgecolor='k')
annotationbox_tmp = AnnotationBbox(offset_image_tmp, (0,0), xybox=image_box_tmp, xycoords='data', boxcoords='offset points', pad=0.3, arrowprops=dict(arrowstyle='->'))
ax.add_artist(annotationbox_tmp)
annotationbox_tmp.set_visible(False)
def hover(event):
if points_tmp.contains(event)[0]:
inds = points_tmp.contains(event)[1]['ind']
ind = inds[0]
w,h = fig_tmp.get_size_inches()*fig_tmp.dpi
ws = (event.x > w/2.)*-1 + (event.x <= w/2.)
hs = (event.y > h/2.)*-1 + (event.y <= h/2.)
annotationbox_tmp.xybox = (image_box_tmp[0]*ws, image_box_tmp[1]*hs)
annotationbox_tmp.set_visible(True)
annotationbox_tmp.xy =(x_tmp[ind], y_tmp[ind])
offset_image_tmp.set_data(images_tmp[ind,:,:])
else:
annotationbox_tmp.set_visible(False)
fig_tmp.canvas.draw_idle()
def click(event):
if points_tmp.contains(event)[0]:
inds = points_tmp.contains(event)[1]['ind']
ind = inds[0]
initialize_figure(1)
update_plot()
plt.scatter(x_tmp[ind], y_tmp[ind], s=20, marker='*', c='y')
plt.pause(2)
fig_tmp.canvas.stop_event_loop()
fig_tmp.canvas.draw_idle()
def main():
fig, ax = plt.subplots(1, figsize=(7, 7))
points = np.array([[1,1],[1,-1],[-1,1],[-1,-1]])
zero_layer = np.zeros([28,28])
one_layer = np.ones([28,28])*255
images = np.array([np.array([zero_layer, zero_layer, one_layer]).astype(np.uint8),np.array([zero_layer, one_layer, zero_layer]).astype(np.uint8),np.array([one_layer, zero_layer, zero_layer]).astype(np.uint8),np.array([one_layer, zero_layer, one_layer]).astype(np.uint8)])
images = np.transpose(images, (0,3,2,1))
theta = 0
delta = 15 * (np.pi/180)
rotation_matrix = np.array([[np.cos(theta),-np.sin(theta)],[np.sin(theta),np.cos(theta)]])
while True:
rotated_points = np.matmul(points, rotation_matrix)
draw_interactive_figures(rotated_points, images)
theta += delta
rotation_matrix = np.array([[np.cos(theta),-np.sin(theta)],[np.sin(theta),np.cos(theta)]])
if __name__== "__main__":
main()
Thanks in advance!

I'm providing you with a starting point here. The following is a script that creates a plot and allows you to add new points by clicking on the axes. For each point one may mouse hover and show a respective image.
import matplotlib.pyplot as plt
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
import numpy as np
class MyInteractivePlotter():
def __init__(self):
self.fig, self.ax = plt.subplots()
self.ax.set(xlim=(0,1), ylim=(0,1))
self.points = np.array([[0.5,0.5]]) # will become N x 2 array
self.images = [np.random.rand(10,10)]
self.scatter = self.ax.scatter(*self.points.T)
self.im = OffsetImage(self.images[0], zoom=5)
self.ab = AnnotationBbox(self.im, (0,0), xybox=(50., 50.), xycoords='data',
boxcoords="offset points", pad=0.3,
arrowprops=dict(arrowstyle="->"))
# add it to the axes and make it invisible
self.ax.add_artist(self.ab)
self.ab.set_visible(False)
self.cid = self.fig.canvas.mpl_connect("button_press_event", self.onclick)
self.hid = self.fig.canvas.mpl_connect("motion_notify_event", self.onhover)
def add_point(self):
# Update points (here, we just add a new random point)
self.points = np.concatenate((self.points, np.random.rand(1,2)), axis=0)
# For each points there is an image. (Here, we just add a random one)
self.images.append(np.random.rand(10,10))
# Update the scatter plot to show the new point
self.scatter.set_offsets(self.points)
def onclick(self, event):
self.add_point()
self.fig.canvas.draw_idle()
def onhover(self, event):
# if the mouse is over the scatter points
if self.scatter.contains(event)[0]:
# find out the index within the array from the event
ind, = self.scatter.contains(event)[1]["ind"]
# make annotation box visible
self.ab.set_visible(True)
# place it at the position of the hovered scatter point
self.ab.xy = self.points[ind,:]
# set the image corresponding to that point
self.im.set_data(self.images[ind])
else:
#if the mouse is not over a scatter point
self.ab.set_visible(False)
self.fig.canvas.draw_idle()
m = MyInteractivePlotter()
plt.show()
I would suggest you take this and add your functionality into it. Once you stumble upon a problem you can use it to ask for clarifications.

Related

get_xlim() not returning correct figure limits

Wrote the following matplotlib based class to pan-right the figure using right arrow key. However, if I use the same class after zooming into the figure it does not produce the desired panning of the figure. I have figured out the root cause to be the get_xlim() function not returning the current or updated xlim of the figure. The right arrow key press event shows how get_xlim() seems to revert back to the old xlim values.
import numpy as np
import matplotlib.pyplot as plt
class NavFigure:
ax_tmp = plt.gca()
fig = plt.gcf()
def __init__(self):
NavFigure.fig.canvas.mpl_connect('key_press_event', self.pan_nav)
def pan_nav(self, event):
if event.key == 'right':
print("initial limits:", NavFigure.ax_tmp.get_xlim())
lims = NavFigure.ax_tmp.get_xlim()
adjust = (lims[1] - lims[0]) * 0.9
NavFigure.ax_tmp.set_xlim((lims[0] + adjust, lims[1] + adjust))
plt.draw()
NavFigure.ax_tmp = plt.gca()
print("updated limits:", NavFigure.ax_tmp.get_xlim())
plt.plot(np.random.rand(100))
myfig = NavFigure()

Change color of chart bars based on mouse press event

I am trying to create an interactive bar chart where the bars of the chart change color when the user selects a value (based on mouse click). The selected value displays at the bottom of the chart and the bars are supposed to change color dependent on the probability of the selected value being above or below the mean of the a sample.
I am stuck on the coloring of the bars. When I click on the chart only the first bar changes colors and then does not update with subsequent clicks.
Overall expected result is to allow multiple values to be selected based on mouse click events. Intention is then to draw the horizontal line at the selected value and then recolor the bars based on the probability of the selected value being within the range of the sample mean. This is being run in jupyter.
I am still new to this so certainly appreciate any advice that you may have.
import numpy as np
from scipy import stats
from scipy.stats import norm
import math
import matplotlib.gridspec as gridspec
import matplotlib.pyplot as plt
import ipywidgets as wdg
from matplotlib.cm import ScalarMappable
%matplotlib notebook
###Set up dummy data
np.random.seed(12345)
df = pd.DataFrame([np.random.normal(32000,200000,3650),
np.random.normal(43000,100000,3650),
np.random.normal(43500,140000,3650),
np.random.normal(48000,70000,3650)],
index=[1992,1993,1994,1995])
###Calculate statistics incl confidence interval for the mean. Calculate 97.5% interquantile range of the normal distribution (being 1.96 x standard error)
df = df.T
stats = df.describe(percentiles = [0.025, 0.25, 0.5, 0.75, 0.975])
mean = stats.loc['mean']
onesd_meanerror = df.sem(axis = 0)
error_low = onesd_meanerror*1.96
error_high = onesd_meanerror*1.96
###Setup initial chart and plot bar chart
fig = plt.figure()
ax = fig.add_subplot(111)
x_axis_label = df.columns.values
plt.xticks(x_axis_label)
bars = (ax.bar(x_axis_label, mean, width=0.85, alpha=0.9, align='center',
yerr = (error_low, error_high), error_kw={'capsize': 10, 'elinewidth': 2, 'alpha':1}))
###Create and display textarea widget
txt = wdg.Textarea(
value='',
placeholder='',
description='Y Value:',
disabled=False)
display(txt)
### Formats color bar. Need the scalar mapable to enable use of the color bar.
my_cmap = plt.cm.get_cmap('coolwarm')
sm = ScalarMappable(cmap=my_cmap, norm=plt.Normalize(0,1))
sm.set_array([])
cbar = plt.colorbar(sm)
cbar.set_label('Probability', rotation=270,labelpad=25)
ydataselect = 40000
class ClickChart(object):
def __init__(self, ax):
self.fig=ax.figure
self.ax = ax
self.horiz_line = ax.axhline(y=ydataselect, color='black', linewidth=2)
self.fig.canvas.mpl_connect('button_press_event', self.onclick)
### Event handlers
def onclick(self, event):
self.horiz_line.remove()
self.ypress = event.ydata
self.horiz_line = ax.axhline(y=self.ypress, color='red', linewidth=2)
txt.value = str(event.ydata)
self.color_bar(event)
def color_bar(self, event):
for index, bar in enumerate(bars):
bar.set_color(c=my_cmap(self.calc_prob(index)))
print(index)
def calc_prob(self, index):
global mean, onesd_meanerror
mean = mean.iloc[index]
err = onesd_meanerror.iloc[index]
result = norm.cdf(self.ypress, loc=mean, scale=err)
return result
click = ClickChart(ax)```
You are so close! The problem is you are re-defining mean inside of calc_prob(). Making changes to avoid this will fix the code and give the behavior you want:
def calc_prob(self, index):
global mean, onesd_meanerror
mean2 = mean.iloc[index] # Changed
err = onesd_meanerror.iloc[index]
result = norm.cdf(self.ypress, loc=mean2, scale=err) # Changed
return result

Why can't I add an image on top of my Matplotlib/Cartopy map?

I am working on a project that involves plotting data on a map with Cartopy.
Everything has been working so far, but I have been refactoring the code to make different functions callable by other parts of the program. So to that end, I have one function which adds my background to the map, and another which adds a placemarker at a specified lat/lon. Obviously, I want the placemarker above the background, but I can't seem to make it work.
For the background, I want to be able to use Cartopy stock images or web map tiles. The problem is the same either way, so I am using the Cartopy background for current testing purpose. Here is that function:
def custom_background(self, source_point):
cartmap = self.plot
source_point = source_point.split(" ")
source_point = (float(source_point[0]), float(source_point[1]))
dx = 2.5
dy = 5
pad = 0.5
lon_min, lon_max = source_point[0]-dx, source_point[0]+dx
lat_min, lat_max = source_point[1]-dy, source_point[1]+dy
area = 4*dx*dy
zoom = self.get_zoom(area) ##only relevant when using a map tile
cartmap.set_extent([lat_min-pad, lat_max+pad, lon_min-pad, lon_max+pad])
#~ cartmap.add_image(self.tile, zoom)
cartmap.add_feature(cartopy.feature.LAND, zorder=1)
return cartmap
Here is the placemark function:
def add_point_icon(self, x, y, cartmap):
src_point = np.array(Image.open('icons/icon63.png'))
im = OffsetImage(src_point, zoom=1, alpha=1.0, zorder=3)
ab = AnnotationBbox(im, (x,y), xycoords='data', frameon=False)
cartmap.add_artist(ab)
Both of these are called one after the other like so:
cartmap = self.custom_background(mysrc)
#~ cartmap=self.plot
self.add_point_icon(x1, y1, cartmap)
Results:
If I run the code as it is, this is how the map looks:
If I change it to (i.e. bypassing the function which draws the background):
#~ cartmap = self.custom_background(mysrc)
cartmap=self.plot
self.add_point_icon(x1, y1, cartmap)
Then I get:
Why can't I get the red "plus" sign to show up on top of the map? I've tried setting the "zorder" parameter of the different objects and it doesn't seem to do anything. I'm at a complete loss right now. Any help would be hugely appreciated, thanks.
Edit: perhaps I should also include the lines which create the subplot:
def __init__(self, mylevs):
self.fig, self.header, self.footer, self.plot, self.legend =
self.create_spec()
def create_spec(self):
"""Define layout of figure"""
#left column: header, footer, plt
fig = plt.figure(figsize=(12,10))
layout = 1
if layout == 1: #Default
widths = [8,1]
heights = [2, 10, 3]
column_border = 0.75
pad = 0.1
colorbar_width = 0.05
spec = gridspec.GridSpec(ncols=1, nrows=3, width_ratios = [1], height_ratios=heights, left=0.1, right = column_border)
#right column: colorbar
spec2 = gridspec.GridSpec(ncols=1, nrows=1, width_ratios = [1], height_ratios=[1], left=column_border+pad, right=column_border+pad+colorbar_width)
header = plt.subplot(spec[0,0])
footer = plt.subplot(spec[2,0])
plot = plt.subplot(spec[1,0], projection=cimgt.OSM().crs)
legend = plt.subplot(spec2[0,0])
return fig, header, footer, plot, legend
The problem was that in switching back to the Cartopy stock background from the map tiles, I forgot to switch the projection from ccrs.OSM() back to ccrs.PlateCarree(). In the map tile projection, the placemark was being plotted outside the viewing window.

Add a legend for an animation (of Artists) in matplotlib

I have made an animation from a set of images like this (10 snapshots):
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
import time
infile = open ('out.txt')
frame_counter = 0
N_p = 100
N_step = 10
N_line = N_p*N_step
for s in xrange(N_step):
x, y = [], []
for i in xrange(N_p):
data = infile.readline()
raw = data.split()
x.append(float(raw[0]))
y.append(float(raw[1]))
xnp = np.array(x)
ynp = np.array(y)
fig = plt.figure(0)
ax = fig.add_subplot(111, aspect='equal')
for x, y in zip(xnp, ynp):
cir = Circle(xy = (x, y), radius = 1)
cir.set_facecolor('red')
ax.add_artist(cir)
cir.set_clip_box(ax.bbox)
ax.set_xlim(-10, 150)
ax.set_ylim(-10, 150)
fig.savefig("step.%04d.png" % frame_counter)
ax.remove()
frame_counter +=1
Now I want to add a legend to each image showing the time step.
For doing this I must set legends to each of these 10 images. The problem is that I have tested different things like ax.set_label , cir.set_label, ...
and I get errors like this:
UserWarning: No labelled objects found. Use label='...' kwarg on individual plots
According to this error I must add label to my individual plots, but since this is a plot of Artists, I don't know how I can do this.
If for whatever reason you need a legend, you can show your Circle as the handle and use some text as the label.
ax.legend(handles=[cir], labels=["{}".format(frame_counter)])
If you don't really need a legend, you can just use some text to place inside the axes.
ax.text(.8,.8, "{}".format(frame_counter), transform=ax.transAxes)

Matplotlib/Pyplot: How to zoom subplots together AND x-scroll separately?

I previously asked the question "How to zoom subplots together?", and have been using the excellent answer since then.
I'm now plotting just two sets of time-series data, and I need to continue to zoom as above, but now I need to also pan one plot relative to the other (I'm doing eyeball correlation). The data comes from 2 independent instruments with different start times and different clock settings.
In use, I zoom using the 'Zoom to Rectangle' toolbar button, and I scroll using the "Pan/Zoom" button.
How may I best scroll one plot in X relative to the other? Ideally, I'd also like to capture and display the time difference. I do not need to scroll vertically in Y.
I suspect I may need to stop using the simple "sharex=" "sharey=" method, but am not certain how best to proceed.
Thanks, in advance, to the great StackOverflow community!
-BobC
I hacked the above solution until it did want I think I want.
# File: ScrollTest.py
# coding: ASCII
"""
Interatively zoom plots together, but permit them to scroll independently.
"""
from matplotlib import pyplot
import sys
def _get_limits( ax ):
""" Return X and Y limits for the passed axis as [[xlow,xhigh],[ylow,yhigh]]
"""
return [list(ax.get_xlim()), list(ax.get_ylim())]
def _set_limits( ax, lims ):
""" Set X and Y limits for the passed axis
"""
ax.set_xlim(*(lims[0]))
ax.set_ylim(*(lims[1]))
return
def pre_zoom( fig ):
""" Initialize history used by the re_zoom() event handler.
Call this after plots are configured and before pyplot.show().
"""
global oxy
oxy = [_get_limits(ax) for ax in fig.axes]
# :TODO: Intercept the toolbar Home, Back and Forward buttons.
return
def re_zoom(event):
""" Pyplot event handler to zoom all plots together, but permit them to
scroll independently. Created to support eyeball correlation.
Use with 'motion_notify_event' and 'button_release_event'.
"""
global oxy
for ax in event.canvas.figure.axes:
navmode = ax.get_navigate_mode()
if navmode is not None:
break
scrolling = (event.button == 1) and (navmode == "PAN")
if scrolling: # Update history (independent of event type)
oxy = [_get_limits(ax) for ax in event.canvas.figure.axes]
return
if event.name != 'button_release_event': # Nothing to do!
return
# We have a non-scroll 'button_release_event': Were we zooming?
zooming = (navmode == "ZOOM") or ((event.button == 3) and (navmode == "PAN"))
if not zooming: # Nothing to do!
oxy = [_get_limits(ax) for ax in event.canvas.figure.axes] # To be safe
return
# We were zooming, but did anything change? Check for zoom activity.
changed = None
zoom = [[0.0,0.0],[0.0,0.0]] # Zoom from each end of axis (2 values per axis)
for i, ax in enumerate(event.canvas.figure.axes): # Get the axes
# Find the plot that changed
nxy = _get_limits(ax)
if (oxy[i] != nxy): # This plot has changed
changed = i
# Calculate zoom factors
for j in [0,1]: # Iterate over x and y for each axis
# Indexing: nxy[x/y axis][lo/hi limit]
# oxy[plot #][x/y axis][lo/hi limit]
width = oxy[i][j][1] - oxy[i][j][0]
# Determine new axis scale factors in a way that correctly
# handles simultaneous zoom + scroll: Zoom from each end.
zoom[j] = [(nxy[j][0] - oxy[i][j][0]) / width, # lo-end zoom
(oxy[i][j][1] - nxy[j][1]) / width] # hi-end zoom
break # No need to look at other axes
if changed is not None:
for i, ax in enumerate(event.canvas.figure.axes): # change the scale
if i == changed:
continue
for j in [0,1]:
width = oxy[i][j][1] - oxy[i][j][0]
nxy[j] = [oxy[i][j][0] + (width*zoom[j][0]),
oxy[i][j][1] - (width*zoom[j][1])]
_set_limits(ax, nxy)
event.canvas.draw() # re-draw the canvas (if required)
pre_zoom(event.canvas.figure) # Update history
return
# End re_zoom()
def main(argv):
""" Test/demo code for re_zoom() event handler.
"""
import numpy
x = numpy.linspace(0,100,1000) # Create test data
y = numpy.sin(x)*(1+x)
fig = pyplot.figure() # Create plot
ax1 = pyplot.subplot(211)
ax1.plot(x,y)
ax2 = pyplot.subplot(212)
ax2.plot(x,y)
pre_zoom( fig ) # Prepare plot event handler
pyplot.connect('motion_notify_event', re_zoom) # for right-click pan/zoom
pyplot.connect('button_release_event',re_zoom) # for rectangle-select zoom
pyplot.show() # Show plot and interact with user
# End main()
if __name__ == "__main__":
# Script is being executed from the command line (not imported)
main(sys.argv)
# End of file ScrollTest.py
Ok, here's my stab at it. This works, but there might be a simpler approach. This solution uses some matplotlib event-handling to trigger a new set_xlim() every time it notices the mouse in motion. The trigger event 'motion_notify_event' could be eliminated if dynamic synchronous zooming isn't required.
Bonus: this works for any number of subplots.
from matplotlib import pyplot
import numpy
x = numpy.linspace(0,10,100)
y = numpy.sin(x)*(1+x)
fig = pyplot.figure()
ax1 = pyplot.subplot(121)
ax1.plot(x,y)
ax2 = pyplot.subplot(122)
ax2.plot(x,y)
ax1.old_xlim = ax1.get_xlim() # store old values so changes
ax2.old_xlim = ax2.get_xlim() # can be detected
def re_zoom(event):
zoom = 1.0
for ax in event.canvas.figure.axes: # get the change in scale
nx = ax.get_xlim()
ox = ax.old_xlim
if ox != nx: # of axes that have changed scale
zoom = (nx[1]-nx[0])/(ox[1]-ox[0])
for ax in event.canvas.figure.axes: # change the scale
nx = ax.get_xlim()
ox = ax.old_xlim
if ox == nx: # of axes that need an update
mid = (ox[0] + ox[1])/2.0
dif = zoom*(ox[1] - ox[0])/2.0
nx = (mid - dif, mid + dif)
ax.set_xlim(*nx)
ax.old_xlim = nx
if zoom != 1.0:
event.canvas.draw() # re-draw the canvas (if required)
pyplot.connect('motion_notify_event', re_zoom) # for right-click pan/zoom
pyplot.connect('button_release_event', re_zoom) # for rectangle-select zoom
pyplot.show()

Categories