Matplotlib - Poly Editor



Poly Editor is short for Polygon Editor is an application that allows users to interactively edit and manipulate vertices of a polygon in a graphical environment.

In the context of Matplotlib, a Poly Editor typically refers to a cross-GUI application that allows users to interactively modify polygons displayed on a canvas. This application provides features such as adding, deleting, and moving vertices of a polygon, as well as adjusting its shape and position using mouse clicks and keybindings.

This tutorial will demonstrate how to create a polygon editor using Matplotlib's event handling capabilities.

Creating the Polygon Interactor Class

To create the Poly Editor, define a Python class called PolygonInteractor, which handles interactions with the polygon vertices. This class implements event handling methods to respond to user interactions −

  • on_draw − Handles the drawing of the polygon and its vertices.

  • on_button_press − Responds to mouse button presses to select vertices.

  • on_button_release − Handles mouse button releases.

  • on_key_press − Handles key presses to toggle vertex markers(using the 't' key), delete vertices(using the ‘d’ key), or insert new vertices(using the 'i' key).

  • on_mouse_move − Handles mouse movements to drag vertices and update the polygon.

Below is the implementation of the PolygonInteractor class −

class PolygonInteractor:

   showverts = True
   epsilon = 3

   def __init__(self, ax, poly):
      if poly.figure is None:
         raise RuntimeError('You must first add the polygon to a figure '
            'or canvas before defining the interactor')
      self.ax = ax
      canvas = poly.figure.canvas
      self.poly = poly

      x, y = zip(*self.poly.xy)
      self.line = Line2D(x, y,
         marker='o', markerfacecolor='r',
         animated=True)
      self.ax.add_line(self.line)

      self.cid = self.poly.add_callback(self.poly_changed)
      self._ind = None  # the active vert

      canvas.mpl_connect('draw_event', self.on_draw)
      canvas.mpl_connect('button_press_event', self.on_button_press)
      canvas.mpl_connect('key_press_event', self.on_key_press)
      canvas.mpl_connect('button_release_event', self.on_button_release)
      canvas.mpl_connect('motion_notify_event', self.on_mouse_move)
      self.canvas = canvas

   def on_draw(self, event):
      self.background = self.canvas.copy_from_bbox(self.ax.bbox)
      self.ax.draw_artist(self.poly)
      self.ax.draw_artist(self.line)
       

   def poly_changed(self, poly):
      vis = self.line.get_visible()
      Artist.update_from(self.line, poly)
      self.line.set_visible(vis)  # don't use the poly visibility state

   def get_ind_under_point(self, event):
      xy = np.asarray(self.poly.xy)
      xyt = self.poly.get_transform().transform(xy)
      xt, yt = xyt[:, 0], xyt[:, 1]
      d = np.hypot(xt - event.x, yt - event.y)
      indseq, = np.nonzero(d == d.min())
      ind = indseq[0]

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

         return ind

   def on_button_press(self, event):
       
      if not self.showverts:
         return
      if event.inaxes is None:
         return
      if event.button != 1:
         return
      self._ind = self.get_ind_under_point(event)

   def on_button_release(self, event):
       
      if not self.showverts:
         return
      if event.button != 1:
         return
      self._ind = None

   def on_key_press(self, event):
       
      if not event.inaxes:
         return
      if event.key == 't':
            self.showverts = not self.showverts
            self.line.set_visible(self.showverts)
            if not self.showverts:
               self._ind = None
      elif event.key == 'd':
         ind = self.get_ind_under_point(event)
         if ind is not None:
            self.poly.xy = np.delete(self.poly.xy,
               ind, axis=0)
            self.line.set_data(zip(*self.poly.xy))
      elif event.key == 'i':
         xys = self.poly.get_transform().transform(self.poly.xy)
         p = event.x, event.y  # display coords
         for i in range(len(xys) - 1):
            s0 = xys[i]
            s1 = xys[i + 1]
            d = dist_point_to_segment(p, s0, s1)
            if d <= self.epsilon:
               self.poly.xy = np.insert(
                  self.poly.xy, i+1,
                  [event.xdata, event.ydata],
                  axis=0)
               self.line.set_data(zip(*self.poly.xy))
               break
      if self.line.stale:
         self.canvas.draw_idle()

   def on_mouse_move(self, event):
      if not self.showverts:
         return
      if self._ind is None:
         return
      if event.inaxes is None:
         return
      if event.button != 1:
         return
      x, y = event.xdata, event.ydata

      self.poly.xy[self._ind] = x, y
      if self._ind == 0:
         self.poly.xy[-1] = x, y
      elif self._ind == len(self.poly.xy) - 1:
         self.poly.xy[0] = x, y
      self.line.set_data(zip(*self.poly.xy))

      self.canvas.restore_region(self.background)
      self.ax.draw_artist(self.poly)
      self.ax.draw_artist(self.line)
      self.canvas.blit(self.ax.bbox)

Defining Utility Function

Define a utility function dist_point_to_segment to calculate the distance between a point and a line segment. This function is used to determine which vertex is closest to the mouse cursor during interaction.

def dist_point_to_segment(p, s0, s1):
   s01 = s1 - s0
   s0p = p - s0
   if (s01 == 0).all():
      return np.hypot(*s0p)

   p1 = s0 + np.clip((s0p @ s01) / (s01 @ s01), 0, 1) * s01
   return np.hypot(*(p - p1))

Initializing the Polygon Editor

To initialize the polygon editor, we need to create an instance of the PolygonInteractor class and pass it the axis object and the polygon object:

if __name__ == '__main__':
   import matplotlib.pyplot as plt

   from matplotlib.patches import Polygon

   theta = np.arange(0, 2*np.pi, 0.2)
   r = 1.5

   xs = r * np.cos(theta)
   ys = r * np.sin(theta)

   poly = Polygon(np.column_stack([xs, ys]), animated=True)

   fig, ax = plt.subplots()
   ax.add_patch(poly)
   p = PolygonInteractor(ax, poly)

   ax.set_title('Click and drag a point to move it')
   ax.set_xlim((-2, 2))
   ax.set_ylim((-2, 2))
   plt.show()

Running the Poly Editor

By executing the complete code provided below, we will get a Matplotlib window displaying a plot with a polygon. We can interact with the polygon by clicking and dragging its vertices, toggling vertex markers by pressing the ‘t’ key, pressing the 'd' key to delete vertices, and pressing the 'i' key to insert new vertices.

Example

import matplotlib.pyplot as plt
import numpy as np

from matplotlib.backend_bases import MouseButton
from matplotlib.patches import PathPatch
from matplotlib.path import Path

class PathInteractor:

    showverts = True
    # max pixel distance to count as a vertex hit
    epsilon = 5  

    def __init__(self, pathpatch):
        # Initialization and event connections
        self.ax = pathpatch.axes
        canvas = self.ax.figure.canvas
        self.pathpatch = pathpatch
        self.pathpatch.set_animated(True)

        x, y = zip(*self.pathpatch.get_path().vertices)

        self.line, = ax.plot(
            x, y, marker='o', markerfacecolor='r', animated=True)

        self._ind = None  # the active vertex

        canvas.mpl_connect('draw_event', self.on_draw)
        canvas.mpl_connect('button_press_event', self.on_button_press)
        canvas.mpl_connect('key_press_event', self.on_key_press)
        canvas.mpl_connect('button_release_event', self.on_button_release)
        canvas.mpl_connect('motion_notify_event', self.on_mouse_move)
        self.canvas = canvas

    def get_ind_under_point(self, event):
        # Return the index of the point closest to the event position or None
        xy = self.pathpatch.get_path().vertices
        xyt = self.pathpatch.get_transform().transform(xy)  # to display coords
        xt, yt = xyt[:, 0], xyt[:, 1]
        d = np.sqrt((xt - event.x)**2 + (yt - event.y)**2)
        ind = d.argmin()
        return ind if d[ind] < self.epsilon else None

    def on_draw(self, event):
        # Callback for draws.
        self.background = self.canvas.copy_from_bbox(self.ax.bbox)
        self.ax.draw_artist(self.pathpatch)
        self.ax.draw_artist(self.line)
        self.canvas.blit(self.ax.bbox)

    def on_button_press(self, event):
        # Callback for mouse button presses 
        if (event.inaxes is None
                or event.button != MouseButton.LEFT
                or not self.showverts):
            return
        self._ind = self.get_ind_under_point(event)

    def on_button_release(self, event):
        # Callback for mouse button releases 
        if (event.button != MouseButton.LEFT
                or not self.showverts):
            return
        self._ind = None

    def on_key_press(self, event):
        # Callback for key presses 
        if not event.inaxes:
            return
        if event.key == 't':
            self.showverts = not self.showverts
            self.line.set_visible(self.showverts)
            if not self.showverts:
                self._ind = None
        self.canvas.draw()

    def on_mouse_move(self, event):
        # Callback for mouse movements 
        if (self._ind is None
                or event.inaxes is None
                or event.button != MouseButton.LEFT
                or not self.showverts):
            return

        vertices = self.pathpatch.get_path().vertices

        vertices[self._ind] = event.xdata, event.ydata
        self.line.set_data(zip(*vertices))

        self.canvas.restore_region(self.background)
        self.ax.draw_artist(self.pathpatch)
        self.ax.draw_artist(self.line)
        self.canvas.blit(self.ax.bbox)

fig, ax = plt.subplots()

pathdata = [
   (Path.MOVETO, (1.58, -2.57)),
   (Path.CURVE4, (0.35, -1.1)),
   (Path.CURVE4, (-1.75, 2.0)),
   (Path.CURVE4, (0.375, 2.0)),
   (Path.LINETO, (0.85, 1.15)),
   (Path.CURVE4, (2.2, 3.2)),
   (Path.CURVE4, (3, 0.05)),
   (Path.CURVE4, (2.0, -0.5)),
   (Path.CLOSEPOLY, (1.58, -2.57)),
]

codes, verts = zip(*pathdata)
path = Path(verts, codes)
patch = PathPatch(
   path, facecolor='green', edgecolor='yellow', alpha=0.5)
ax.add_patch(patch)

interactor = PathInteractor(patch)
ax.set_title('drag vertices to update path')
ax.set_xlim(-3, 4)
ax.set_ylim(-3, 4)

plt.show()

Output

On executing the above code we will get the following output −

poly_editor

Watch the video below to observe the works of this application −

poly_editor gif
Advertisements