LIGHT DARK
homeresearchpublicationspostsaboutCV
METHODS, PYTHON

How to make interactive figures

Jul 24, 2024 Moritz Lürig Jul 24, 2024 Moritz Lürig

Background

Background

Interpreting phenotypic variation presented through scientific figures is often challenging because the traits of interest are hidden behind data points. Specifically, in scatterplots or biplots of Principal Component Analysis (PCA), which can be very high-dimensional, the visual impression often remains abstract. Using pictograms in addition to data points, or adding interactive elements, can be a powerful way to increase the communicative value of a figure (especially if your study organism is as charismatic as these Junonia butterflies below). Interactive figures that show organisms are becoming increasingly feasible with the use of computer vision (automated extraction of meaningful information from images) to extract not only the phenotypic information but the relevant regions of interest (ROIs) themselves. In this post, I will show how to implement both approaches in Python, using the matplotlib and bokeh library

Pictogram-based figure

In this approach the goal is to plot the pictograms directly into the plot panel, which can be useful if you want to see all the variation in your dataset at once, so relationships of interest become visible, or if interactive figures are not an option (e.g., in publications). This is fairly straightforward using matplotlib’s offsetbox module. Below is an example that uses the ROIs from a scan image to demonstrate how pigmentation increases with body size in isopods — see the result below, where the pictograms are plotted at their centroid on top of the data point:

Reproduce the figure with the following gist and by downloading the isopod dataset and scripts.

## load modules
import os
import cv2
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.offsetbox import AnnotationBbox, OffsetImage
## set wd
os.chdir(r"D:\workspace\figures\interactive_figures")
## load data (see gist interactive_figure_data_collection.py for how it was collected)
df_data = pd.read_csv(r"data\isopods_traits.csv")
## add regression line (optional)
fit = np.polyfit(df_data["log_length"], df_data["pigmentation"], 1, full=True)
slope, intercept = fit[0][0], fit[0][1]
line_x = np.linspace(df_data["log_length"].min(), df_data["log_length"].max(), 100)
line_y = slope * line_x + intercept
## create scatter plot
fig, ax = plt.subplots(figsize=(5, 5))
plt.tight_layout(pad=3)
ax.set_xlabel("Length (mm, log)")
ax.set_ylabel("Pigmentation (0-1)")
scatter = ax.scatter(df_data['log_length'], df_data['pigmentation'], s=5)
ax.plot(line_x, line_y, color='red', linewidth=2)
## add pictograms
for idx, row in df_data.iterrows():
if os.path.isfile(row["filepath"]):
image = cv2.imread(row["filepath"], cv2.IMREAD_UNCHANGED)
image = cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA)
ab = AnnotationBbox(OffsetImage(image, zoom=0.2),
(row["log_length"], row["pigmentation"]), frameon=False)
ax.add_artist(ab)
## show / save
fig.show()
figure_path = r"figures\figure_isopods_pictograms.jpg"
fig.savefig(figure_path, dpi=500)
view raw isopods_figure_pictograms.py hosted with ❤ by GitHub

Interactive figures

The solution for producing interactive figures is a bit more involved (but not too much). Here we use bokeh, an extremely powerful library for static and interactive plots in Python. bokeh allows you to add all sorts of tools (e.g. box or lasso selection), but the most important one here is the creation of a custom java-based hover tool. This tool displays trait information and pictograms that have been extracted from the same scan image as above in a panel to the right when hovering over points on the scatter plot. The final layout, which combines the scatter plot and image display, is rendered and saved as an HTML file - give it a try by hovering over the points:

Bokeh Plot

Reproduce the figure with the following gist and by downloading the isopod dataset and scripts.

## load modules
import os
import numpy as np
import pandas as pd
from bokeh.io import save
from bokeh import plotting, models, layouts
## load data (see https://www.luerig.net/posts/interactive-figures/ for how it was collected)
os.chdir(r"D:\workspace\figures\interactive_figures")
## load data (see gist below for how it was collected)
df_data = pd.read_csv(r"data/isopods_traits.csv")
## add url filepath (you can use local files, this is just for my online demo)
url_base = "https://raw.githubusercontent.com/mluerig/website-assets-static/" + \
"main/luerig.net/posts/2024-07-24-interactive-figures/rois_isopods/"
df_data["filepath"] = f"{url_base}/isopod_" + \
df_data["contour_idx"].apply(str).str.zfill(3) + ".png"
## Specify the output file
figure_path = r"figures\figure_isopods_interactive.html"
plotting.output_file(figure_path)
## Convert to ColumnDataSource
ds_points = models.ColumnDataSource(data=dict(df_data))
## Add hover tool with tooltips
hover = models.HoverTool(
tooltips=[
("contour_idx", "@contour_idx"),
("log_length", "@log_length"),
("pigmentation", "@pigmentation"),
]
)
## JavaScript callback for displaying images on hover - it's java script that's wrapping html
## https://docs.bokeh.org/en/latest/docs/user_guide/interaction/js_callbacks.html#customjs-callbacks
div = models.Div(text="")
hover.callback = models.CustomJS(args=dict(div=div, ds=ds_points), code="""
const hit_test_result = cb_data.index;
const indices = hit_test_result.indices;
if (indices.length > 0) {
div.text = `<img
src="${ds.data['filepath'][indices[0]]}"
style="float: left; margin: 0px 15px 15px 0px; min-width: 100%;"
/>`;
}
""")
## Assemble the figure
p = plotting.figure(
tools=["pan", "reset", "wheel_zoom", "box_select", "lasso_select", "tap", hover],
active_scroll="wheel_zoom", output_backend="webgl",
width=500, height=500,
x_axis_label="Length (mm, log)", y_axis_label="Pigmentation (0-1)"
)
## Add scatter plot
p.scatter(
x='log_length', y='pigmentation',
source=ds_points,
size=10
)
## Add regression line
fit = np.polyfit(df_data["log_length"], df_data["pigmentation"], 1)
slope, intercept = fit[0], fit[1]
line_x = np.linspace(df_data["log_length"].min(), df_data["log_length"].max(), 100)
line_y = slope * line_x + intercept
p.line(line_x, line_y, line_width=2, color='red')
## Layout the figure and div side by side
layout = layouts.column(layouts.row(p, div))
## Render and save HTML
save(layout)
view raw isopods_figure_interactive.py hosted with ❤ by GitHub
Previous
Computer vision Forum in Lund (Sep. 18th - 22nd 2023)
Next
A recorded presentation of my work with butterflies
© Moritz Lürig 2025