Drawing Tools

Bokeh's drawing tools are the basis for a wide range of functionality in EarthSim, using the convenient interface provided by HoloViews. They make it simple to build systems for annotating existing data, highlighting regions of interest, and drawing and editing shapes that can be used as input to simulators or other programs. This user guide will give a basic introduction to the drawing tools, including how to access the resulting data from within Python code.

For more detail about the underlying Bokeh tools, see the Bokeh user guide. Note that most of the discussion here is not specific to EarthSim, and applies to any usage of the drawing tools in practice, apart from a few I/O routines imported from earthsim when used below.

In [1]:
import os
import numpy as np
import holoviews as hv
import geoviews as gv
import cartopy.crs as ccrs

from holoviews import opts
from holoviews.streams import PointDraw, PolyEdit, BoxEdit, PolyDraw, FreehandDraw

hv.extension('bokeh')

tiles = gv.tile_sources.Wikipedia.opts(width=900, height=500)

Drawing Points

All drawing tools are added by instantiating a corresponding HoloViews stream, which also syncs the data. Here we will use the PointDraw stream, which allows adding points, dragging points, and deleting points. The num_objects parameter, if set, will limit the number of points that can be drawn, ensuring that when the limit is reached the oldest point is dropped.

Add point: Tap anywhere on the plot; each tap adds one point.

Move point: Tap and drag an existing point, which will be dropped once you let go of the mouse button.

Delete point: Tap a point to select it, then press the Backspace key (sometimes labeled "Delete") while the mouse is within the plot area.

Note that to use the PointDraw tool or any of the other drawing tools, you first need to select the icon for it in the toolbar:

In [2]:
points = gv.Points(np.random.rand(10, 2)*10)
point_stream = PointDraw(source=points, num_objects=10)

(tiles * points).opts(
    opts.Points(tools=['hover'], size=10, color='red'))
Out[2]:

Note that here and in the other examples below, we have provided initial values for the source, just so that there will be objects in the map when this notebook is rendered as a web page or otherwise shared. In practice, the source here and in every case below can be an empty list [] if you don't want any initial values.

Once points are available on the map, we can wrap them in a GeoViews Points object, project them back into longitude and latitude, and then convert the resulting object to a dataframe for use in any Python code:

In [3]:
if point_stream.data:
    display(point_stream.element.dframe())
Longitude Latitude
0 2.148437 1.318063
1 0.751744 7.837387
2 7.811799 2.613693
3 0.178425 7.459082
4 7.366027 9.298548
5 1.831841 5.875540
6 3.325663 0.748956
7 3.089414 1.373333
8 1.279783 9.172827
9 4.824987 6.161201

Of course, the dataframe output above will only contain the points that were present at the time that cell was executed, so the cell will need to be re-run if you add points to the main plot.

Drawing bounding boxes

The BoxEdit stream adds a tool that allows drawing, dragging, and deleting rectangular bounding boxes, once you have selected it in the toolbar:

The num_objects parameter, if set, will limit the number of boxes that can be drawn, causing the oldest box to be dropped when the limit is exceeded.

Add box: Hold shift, then click and drag anywhere on the plot.

Move box: Click and drag an existing box; the box will be dropped once you let go of the mouse button.

Delete box: Tap a box to select it, then press the Backspace (or Delete) key while the mouse is within the plot area.

In [4]:
sample_box = hv.Bounds((-90.99, 32.25, -90.85, 32.37))

box_poly = gv.Polygons([sample_box])
box_stream = BoxEdit(source=box_poly, num_objects=3)
(tiles * box_poly).opts(
    opts.Polygons(fill_alpha=0, line_color='black', selection_fill_color='red'))
Out[4]:

Note that BoxEdit accepts a Polygon element, as there is not yet a vectorized Box type that would let it generate boxes directly, and so we will need to convert the returned polygons into boxes manually:

In [5]:
def bbox(poly):
    "Convert the polygon returned by the BoxEdit stream into a bounding box tuple"
    xs,ys = poly.array().T
    return (xs[0], ys[0], xs[2], ys[2])

if box_stream.element:
    polygons = box_stream.element.split()
    bboxes = [bbox(p) for p in polygons]
    print(bboxes)
[(-90.98999999999101, 32.25000000000758, -90.85000000000898, 32.3699999999924)]

(Of course, boxes will only be printed above if they were drawn on the map before the cell above is executed.)

Polygon Editing

The PolyEdit stream adds a Bokeh tool to the source plot that allows drawing, dragging, and deleting vertices on polygons and making the drawn data available to Python:

The tool supports the following actions:

Show vertices: Double tap an existing patch or multi-line

Add vertex: Double tap an existing vertex to select it, then the tool will draw the next point; to add it tap in a new location. To finish editing and add a point, double tap; otherwise press the ESC key to cancel.

Move vertex: Drag an existing vertex and let go of the mouse button to release it.

Delete vertex: After selecting one or more vertices press Backspace (or Delete) while the mouse cursor is within the plot area.

In [6]:
shapefile = '../data/vicksburg_watershed/watershed_boundary.shp'

mask_poly = gv.Shape.from_shapefile(shapefile)
vertex_stream = PolyEdit(source=mask_poly)

tiles * mask_poly.opts(tools=['box_select'], alpha=0.5, line_width=3)
Out[6]:

Once the shape has been edited, it can be pulled out into its own file for later usage, and displayed separately:

In [7]:
from earthsim.io import save_shapefile
if vertex_stream.data:
    edited_shape_fname = '../data/vicksburg_watershed_edited/watershed_boundary.shp'
    dir_name = os.path.dirname(edited_shape_fname)
    if not os.path.isdir(dir_name): os.makedirs(dir_name)
    save_shapefile(vertex_stream.data, edited_shape_fname, shapefile)
    mask_shape = gv.Shape.from_shapefile(edited_shape_fname)
mask_shape = mask_shape.opts() # Clear options to avoid adding edit tool

mask_shape.opts(width=600, height=400, alpha=0.5)
Out[7]:

Freehand Drawing

The FreehandDraw tool allows drawing polygons or paths (polylines), depending on whether it is given a Path or Polygon source, using simple click and drag actions:

The num_objects parameter, if set, will limit the number of lines/polygons that can be drawn, causing the oldest object to be dropped when the limit is exceeded.

Add patch/multi-line: Click and drag to draw a line or polygon and release mouse to finish drawing

Delete patch/multi-line: Tap a patch/multi-line to select it, then press Backspace/Delete while the mouse is within the plot area.

In [8]:
path = gv.Path([[(0, 52), (-74, 43)]])
freehand_stream = FreehandDraw(source=path, num_objects=3)

tiles * path.opts(line_width=5, color='black')
Out[8]:
In [9]:
freehand_stream.element.data
Out[9]:
[OrderedDict([('Longitude', array([  0., -74.])),
              ('Latitude', array([52., 43.]))])]

Drawing Polygons

The PolyDraw tool allows drawing new polygons or paths (polylines) on a plot, depending on whether it is given a Path or Polygon source:

The num_objects parameter, if set, will limit the number of lines/polygons that can be drawn, causing the oldest object to be dropped when the limit is exceeded. Additionally it is possible to display and snap to existing vertices by enabling the show_vertices parameter.

Add patch/multi-line: Double tap to add the first vertex, then use tap to add each subsequent vertex. To finalize the draw action, double tap to insert the final vertex or press the ESC key to stop drawing.

Move patch/multi-line: Tap and drag an existing patch/polyline; the point will be dropped once you let go of the mouse button.

Delete patch/multi-line: Tap a patch/multi-line to select it, then press Backspace/Delete while the mouse is within the plot area.

In [10]:
sample_poly=dict(
    Longitude = [-90.86, -90.94, -91.0 , -90.92, -91.0 , -90.94],
    Latitude  = [ 32.33,  32.37,  32.34,  32.32,  32.27,  32.25])
sample_path=dict(
    Longitude = [-90.99, -90.90, -90.90, -90.98],
    Latitude  = [ 32.35,  32.34,  32.32,  32.25])

new_polys = gv.Polygons([sample_poly])
new_paths = gv.Path([sample_path])
poly_stream = PolyDraw(source=new_polys, show_vertices=True)
path_stream = PolyDraw(source=new_paths, show_vertices=True)

(tiles * new_polys * new_paths).opts(
    opts.Path(line_width=5, color='black'),
    opts.Polygons(fill_alpha=0.1, line_color='black'))
Out[10]:
In [11]:
path_stream.element.data
Out[11]:
[OrderedDict([('Longitude', array([-90.99, -90.9 , -90.9 , -90.98])),
              ('Latitude', array([32.35, 32.34, 32.32, 32.25]))])]

Notice that the toolbar has two PolyDraw tools here; if you select the first one you'll be able to add Polygons (drawn with thin lines), and if you select the other one you can add Path objects (poly-lines, drawn with a thick line). You'll need to have the appropriate copy of the tool selected if you want to move or delete an object associated with that stream.

Once you have drawn some objects, you can extract the new paths or polygons from the stream (which will be blank unless you have drawn something above when the following cells are executed):

In [12]:
poly_stream.element.geom()
Out[12]:
In [13]:
path_stream.element.geom()
Out[13]:

Here .geom() returns a Shapely geometry with all of the shapes you drew of that type. If you would rather work with each shape separately, you can get them as a list with poly_stream.element.split() or path_stream.element.split().

Drawing and editing a polygon

By adding tools for both polygon drawing and vertex editing on the same HoloViews object, we can both draw and edit polygons in the same plot:

In [14]:
new_polys = gv.Polygons([sample_poly])
poly_stream   = PolyDraw(source=new_polys)
vertex_stream = PolyEdit(source=new_polys)

tiles * new_polys.opts(fill_alpha=0.2, line_color='black')
Out[14]:
In [15]:
poly_stream.data
Out[15]:
{'xs': [array([-90.86, -90.94, -91.  , -90.92, -91.  , -90.94, -90.86])],
 'ys': [array([32.33, 32.37, 32.34, 32.32, 32.27, 32.25, 32.33])]}
In [16]:
poly_stream.element
Out[16]:

The above examples should make it clear how to draw shapes and use the data from within Python. The next set of examples show how to associate data interactively with each point or object added, via Annotators.


Right click to download this notebook from GitHub.