I have recently been learning holoviews by creating an interactive sonar data viewer that I plan to open-source. It kind-of works, but performs really slowly for interactivity.
Is someone willing to point me in the best direction to look to improve the performance of this code as I am fairly new to holoviews and not sure the best avenue to follow to improve it?
I think I need to use a Link somehow instead of Streams in order to use javascript to interactively modify the graph colour map based on time and draw an arrow based on user interaction of the key, but can’t seem to figure out what building blocks I will need to get this going. I assume I will need some custom javascript on a RangeTool. But looking for any feedback from people with a better understanding of holoviews to avoid wasting too much time heading in the wrong direction. Can you please point me to any relevant reference code / tutorials?
Also any code review / feedback you can give me will help.
Thanks,
Brendon.
A standalone example of code that achieves roughly what I want but has some rough edges and performs really slowly is attached below. The main interactivity is selecting a range in the smaller key image:
# Setup logging early so we can log time to import etc
import logging
import sys
logger = logging.getLogger('main')
if __name__ == '__main__':
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(asctime)s : %(message)s')
import numpy.random
import datashader.geo
import holoviews.operation.datashader
import holoviews
import holoviews.streams
import time
import math
import xarray
import random
import numpy
WIDTH = 400
HEIGHT = 300
KEY_HEIGHT = 100
# Configure the default renderer
holoviews.extension('bokeh')
renderer = holoviews.renderer('bokeh').instance(mode='server')
#-----------------------------------------
# Dataset
#-----------------------------------------
def GetData():
DATA_POINTS = 2000
MAX_DEPTH = 100
SONAR_BINS = MAX_DEPTH
# @todo tiles resizes axis to make tile images not be stretched, we need the bounds of the tiles to use for graph calc
# Defined longitude/latitude values in WGS84 format
lon_min, lat_min = (150.858, -34.494)
lon_max, lat_max = (150.873, -34.484)
# Convert to northings/eastings in meters required to match the Tiles for overlay
# and we will work in these units as it is easier than converting all the time
left, bottom = datashader.geo.lnglat_to_meters(lon_min, lat_min)
right, top = datashader.geo.lnglat_to_meters(lon_max, lat_max)
# Real data comes from a sonar log file, this is just made up to make the example standalone
time_arr = range (0, DATA_POINTS)
longitude_arr = []
latitude_arr = []
heading_arr = []
speed_arr = []
bottom_depth_arr = []
sonar_data_arr = numpy.random.randint(0, 5, (DATA_POINTS, SONAR_BINS))
last_x = 0
last_y = 0
last_calc_heading = None
for i in range(0, len(time_arr)):
ratio = i / float(len(time_arr))
# Standalone data will create a linear depth change from 0 - 20m
bottom_depth = ratio * MAX_DEPTH
bottom_depth_arr.append(bottom_depth)
sonar_data_arr[i][int(bottom_depth)] = 200 + random.randint(0, 55)
# Lets create a sinsoid path on the map for this standalone data example
x_ratio = ratio
x = left + ((right - left) / 4) + (x_ratio * (right - left) / 2)
longitude_arr.append(x)
y_ratio = (math.sin(ratio * 2 * math.pi) + 1) / 2.0
y = bottom + ((top - bottom) / 4) + (y_ratio * (top - bottom) / 2)
latitude_arr.append(y)
calc_heading = math.pi / 2.0
if last_calc_heading is not None:
# From wikipedia: https://en.wikipedia.org/wiki/Polar_coordinate_system#Converting_between_polar_and_Cartesian_coordinates
dx = x - last_x
dy = y - last_y
heading_r = math.sqrt((dx * dx) + (dy * dy))
if heading_r == 0: calc_heading = last_calc_heading
elif y >= 0: calc_heading = math.acos(dx / math.pi)
else: calc_heading = -1 * math.acos(dx / math.pi)
last_x = x
last_y = y
last_calc_heading = calc_heading
heading_error = (random.randint(0, 100) - 50) / 200.0
heading = calc_heading + heading_error
heading_arr.append(heading)
speed = random.randint(0, 100)
speed_arr.append(speed)
channel = xarray.Dataset({
'bottom_depth': (['time'], bottom_depth_arr, {'units': 'depth meters'}),
'longitude': (['time'], longitude_arr),
'latitude': (['time'], latitude_arr),
'heading': (['time'], heading_arr),
'speed': (['time'], speed_arr),
'amplitudes': (['time', 'depth'], sonar_data_arr, {'units': 'amplitude'}),
},
coords={
'time': (['time'], time_arr),
'depth': (['depth'], range(0,MAX_DEPTH)),
},
attrs={
'graph_longitude_min': left,
'graph_longitude_max': right,
'graph_latitude_min': bottom,
'graph_latitude_max': top,
})
return channel
def CreateTiles(channel):
tiles = holoviews.Tiles('https://maps.wikimedia.org/osm-intl/{Z}/{X}/{Y}@2x.png', name="Wikipedia")
tiles = tiles.opts(width=WIDTH, height=HEIGHT)
# Adjust the framing of the tile to show the frame and not the entire world
# From: https://examples.pyviz.org/nyc_taxi/nyc_taxi.html
x_dim = holoviews.Dimension('x', label='Longitude', range=(channel.attrs['graph_longitude_min'], channel.attrs['graph_longitude_max']))
y_dim = holoviews.Dimension('y', label='Latitude', range=(channel.attrs['graph_latitude_min'], channel.attrs['graph_latitude_max']))
tiles = tiles.redim(x=x_dim, y=y_dim)
# @todo We should export the tile extents as they dont match exactly the channel.attrs['graph_latitude_max'] min/max lat/lon with the values in all cases
return tiles
def CreateSonarImage(channel):
# Create the pre-rasterized low res preview image used as the selection tool for data to show
# @todo Need a better way instead of transposing dimensions
amplitudes = channel.amplitudes.transpose('depth', 'time')
large_image = holoviews.Image((channel.time, channel.depth, amplitudes), datatype=['grid'], kdims=['time', 'depth'], vdims=['amplitude'])
return large_image
def CreateSonarDetail(channel, sonar_image):
# Create a detailed image showing sonar data at user selected zoom
detail = holoviews.operation.datashader.datashade(sonar_image, cmap=datashader.colors.viridis)
detail = detail.opts(width=WIDTH, height=HEIGHT, xaxis=None, yaxis=None, invert_yaxis=True,
tools=['hover', 'xpan', 'xwheel_zoom', 'crosshair', 'xbox_zoom', 'undo', 'redo', 'save']
)
return detail
def CreateSonarKey(channel, sonar_image):
key = holoviews.operation.datashader.rasterize(sonar_image, width=WIDTH, height=KEY_HEIGHT, precompute=True)
key = key.opts(width=WIDTH, height=KEY_HEIGHT, cmap='viridis', logz=False, invert_yaxis=True,
default_tools=[],
tools=['xbox_select', 'undo', 'redo', 'save']
)
return key
def CreatePath(channel, x_range=None):
# @todo Surely we can use xarray properly here somehow without creating a new path_points array
path_points = []
for i in range(0, len(channel.time)):
path_points.append((
channel.longitude[i].values.item(),
channel.latitude[i].values.item(),
# @todo Ideally we would use some kind of javascript to change the colour pallette of all paths based on the associated time instead of re-creating the whole Path below
channel.time[i].values.item(),
# @todo Ideally we would be able to use heading and speed data here in javascript to draw an arrow at a selected point instead of redrawing everything below
channel.heading[i].values.item(),
channel.speed[i].values.item()
))
path = holoviews.Path(
path_points,
kdims=[
holoviews.Dimension('easting'),
holoviews.Dimension('northing')],
vdims=['time', 'heading', 'speed'])
if x_range is not None:
levels = [0,
x_range[0],
x_range[1],
len(channel.time)]
colors = ['#5ebaff', '#ff0000', '#5ebaff']
path = path.opts(color='time', color_levels=levels, cmap=colors)
return path
def CreateArrow(channel, x_range=None):
# @todo Ideally I would like an 'arrow' type marker and use a single Point, but this is a workaround using a Graph
max_speed = channel.speed.max().item()
selected_index = 0
if x_range is not None:
selected_index = int((x_range[0] + x_range[1]) / 2)
logger.info('xrange min:%s, max:%s selected_index:%s', x_range[0], x_range[1], selected_index)
if selected_index >= len(channel.time):
logger.warning('Bad si: %s expected <: %s', selected_index, len(channel.time))
selected_index = len(channel.time) - 1
x = channel.longitude[selected_index].values.item()
y = channel.latitude[selected_index].values.item()
heading = channel.heading[selected_index].values.item()
speed = channel.speed[selected_index].values.item()
x_dist = channel.attrs['graph_longitude_max'] - channel.attrs['graph_longitude_min']
y_dist = channel.attrs['graph_latitude_max'] - channel.attrs['graph_latitude_min']
logger.info('dist x:%s, y:%s', x_dist, y_dist)
# Draw arrow from x,y using polar coords heading and speed for size
# Issue is the size will be diff on x,y axis as they cover different range
speed_percentage = speed * 100 / max_speed
logger.info('speed_percentage:%s speed: %s, heading: %s', speed_percentage, speed, heading)
# Ratio of whole graph range (x_dist, y_dist)
min_arrow_ratio = 0.05
max_arrow_ratio = 0.5
logger.info('arrow_ratio min:%s, max:%s', min_arrow_ratio, max_arrow_ratio)
# Need to account for pixels size of graph as well
y_mult = float(HEIGHT) / float(WIDTH)
avail_x_dist = (x_dist * max_arrow_ratio * speed_percentage / 100) - (x_dist * min_arrow_ratio * speed_percentage / 100)
avail_y_dist = (y_dist * max_arrow_ratio * speed_percentage / 100 * y_mult) - (y_dist * min_arrow_ratio * speed_percentage / 100 * y_mult)
logger.info('avail_dist x:%s, y:%s', avail_x_dist, avail_y_dist)
x_ratio = math.cos(heading)
y_ratio = math.sin(heading)
logger.info('vector ratio x:%s, y:%s', x_ratio, y_ratio)
x_size = x_ratio * avail_x_dist
y_size = y_ratio * avail_y_dist
logger.info('size x:%s, y:%s', x_size, y_size)
x = [x, x-x_size, channel.attrs['graph_longitude_min'], channel.attrs['graph_longitude_max']]
y = [y, y-y_size, channel.attrs['graph_latitude_min'], channel.attrs['graph_latitude_max']]
node_indexes = [0,1,2,3]
nodes = holoviews.Nodes((x, y, node_indexes, node_indexes))
source,target = [1],[0]
arrow = holoviews.Graph(((source, target, ), nodes, ))
arrow = arrow.opts(directed=True, node_size=0, arrowhead_length=min_arrow_ratio)
arrow = arrow.opts(width=WIDTH, height=HEIGHT)
return arrow
def Main():
def OnKeyChangeGenerateMap(x_range):
path = CreatePath(channel, x_range)
arrow = CreateArrow(channel, x_range)
return holoviews.Overlay([tiles, path, arrow])
channel = GetData()
tiles = CreateTiles(channel)
sonar_image = CreateSonarImage(channel)
sonar_detail = CreateSonarDetail(channel, sonar_image)
sonar_key = CreateSonarKey(channel, sonar_image)
# Create a DynamicMap able to display path + heading and speed on the map
range_stream = holoviews.streams.RangeX(source=sonar_detail)
map = holoviews.DynamicMap(OnKeyChangeGenerateMap, streams=[range_stream])
# Declare a RangeToolLink between the x-axes of the two plots
detail_link = holoviews.plotting.links.RangeToolLink(sonar_key, sonar_detail, axes=['x'])
graph = holoviews.Layout([sonar_detail, map, sonar_key]).cols(2).opts(holoviews.opts.Layout(shared_axes=False))
# Display interactive
from bokeh.server.server import Server
from tornado.ioloop import IOLoop
app = renderer.app(graph)
server = Server({'/': app}, port=0)
server.start()
server.show('/')
loop = IOLoop.current()
loop.start()
if __name__ == '__main__':
Main()