pythonnumpymatplotlibjupyter-notebookipywidgets

Capturing Matplotlib coordinates with mouse clicks using ipywidgets in Jupyter Notebook


Short question

I want to capture coordinates by clicking different locations with a mouse on a Matplotlib figure inside a Jupyter Notebook. I want to use ipywidgets without using any Matplotlib magic command (like %matplotlib ipympl) to switch the backend and without using extra packages apart from Matplotlib, ipywidgets and Numpy.

Detailed explanation

I know how to achieve this using the ipympl package and the corresponding Jupyter magic command %matplotlib ipympl to switch the backend from inline to ipympl (see HERE).

After installing ipympl, e.g. with conda install ipympl, and switching to the ipympl backend, one can follow this procedure to capture mouse click coordinates in Matplotlib.

import matplotlib.pyplot as plt

# Function to store mouse-click coordinates
def onclick(event):
    x, y = event.xdata, event.ydata
    plt.plot(x, y, 'ro')
    xy.append((x, y))

# %%
# Start Matplotlib interactive mode
%matplotlib ipympl  

plt.plot([0, 1])
xy = []     # Initializes coordinates
plt.connect('button_press_event', onclick)

enter image description here

However, I find this switching back and forth between inline and ipympl backend quite confusing in a Notebook.

An alternative for interactive Matplotlib plotting in Jupyter Notebook is to use the ipywidgets package. For example, with the interact command one can easily create sliders for Matplotlib plots, without the need to switcxh backend. (see HERE).

from ipywidgets import interact
import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 2 * np.pi)

def update(w=1.0):
    plt.plot(np.sin(w * x))
    plt.show()

interact(update);

enter image description here

However, I have not found a way to use the ipywidgets package to capture (x,y) coordinates from mouse clicks, equivalent to my above example using ipympl.


Solution

  • Short answer

    Capturing mouse clicks on a non-interactive Matplotlib figure is not possible ā€“ that's what the interactive backends are for. If you want to avoid switching back and forth between non-interactive and interactive backends, maybe try the reverse approach: Rather than trying to get interactivity from non-interactive plots, use an interactive backend by default, and disable interactivity where it is not necessary.

    Detailed answer

    What Matplotlib says

    Regarding interactivity, Matplotlib's documentation explicitly states (emphasis by me):

    To get interactive figures in the 'classic' notebook or Jupyter lab, use the ipympl backend (must be installed separately) which uses the ipywidget framework.

    And further down:

    The default backend in notebooks, the inline backend, is not [interactive]. backend_inline renders the figure once and inserts a static image into the notebook when the cell is executed. Because the images are static, they cannot be panned / zoomed, take user input, or be updated from other cells.

    I guess that should make the situation pretty clear.

    Interactivity with ipywidgets

    As you noted, you can interact with (static) Matplotlib figures using ipywidgets. What happens there, however, is the following: The widgets (e.g. the slider that you show) are interactive, while the figure is still not. So "interactivity" in this context means interacting with a widget that then triggers the re-rendering of a static image. This use case and setup is fundamentally different from trying to interactively capture inputs from a static image.

    Proposed approach

    What I would suggest is:

    1. Install ipympl, as it is meant to be used for your purpose.
    2. If you want to avoid switching back and forth between backends, set your interactive backend once for your notebook, and disable interactive features in plots where you don't need them. Following Matplotlib's "comprehensive ipympl example", the display() function can be used for this purpose.

    Altogether, this could look as follows in code:

    %matplotlib widget
    # Alternatively: %matplotlib ipympl
    import matplotlib.pyplot as plt
    import numpy as np
    
    # Provide some dummy data
    x = np.linspace(-10, 10, num=10000)
    y1 = x ** 2
    y2 = x ** 3
    
    # Plot `y1` in an interactive plot
    def on_click(event):
        plt.plot(event.xdata, event.ydata, "ro")
    
    plt.connect("button_press_event", on_click)
    plt.plot(x, y1)
    
    # Plot `y2` in a 'static' plot
    with plt.ioff():
        plt.figure()  # Create new figure for 2nd plot
        plt.plot(x, y2)
        display(plt.gcf())  # Display without interactivity
    

    The resulting notebook would look as follows: screenshot of resulting notebook

    Semi-off-topic: "interactive mode"

    You might have noticed that display() is used in connection with ioff() for the static figure here. And although ioff() is documented as the function to, quote, disable interactive mode, it is not the one that is responsible for disabling click capturing etc. here. In this context, "interactive" refers to yet another concept, which is explained with the isinteractive() function; namely,

    ā€¦ whether plots are updated after every plotting command. The interactive mode is mainly useful if you build plots from the command line and want to see the effect of each command while you are building the figure.

    In the given example, we don't want plotting commands to have immediate effects on the output, because this would mean that already the figure() and plot() calls would render the figure (with all its interaction capabilities in our original sense!), rather than only rendering it (as a static image) when we call display(). Moreover, we would get two outputs of our figure: one (interactive) plot because of the figure() and plot() calls, one (static) plot because of the display() call. To suppress the first one, we use an ioff() context.