How to save the multiple figures in a bokeh gridplot into separate png files?

Question:

I have a html file generated by bokeh gridplot containing multiple figures.

My use case is:

  1. I eye-check each figure, zoom-in/out individually.
  2. Then, I want click a button to save all the figures into separate png files. So each png file is for one figure.

Could you please show me how to do it?

Here is an example code to quickly generate a gridplot with 2 figures.

import numpy as np
from bokeh.io import save
from bokeh.layouts import gridplot
from bokeh.plotting import figure

x = np.linspace(0, 4 * np.pi, 100)
y = np.sin(x)

TOOLS = "pan,wheel_zoom,box_zoom,reset,save,box_select"

p1 = figure(title="Legend Example", tools=TOOLS)

p1.circle(x, y, legend_label="sin(x)")
p1.circle(x, 2 * y, legend_label="2*sin(x)", color="orange")
p1.circle(x, 3 * y, legend_label="3*sin(x)", color="green")

p1.legend.title = "Markers"

p2 = figure(title="Another Legend Example", tools=TOOLS)

p2.circle(x, y, legend_label="sin(x)")
p2.line(x, y, legend_label="sin(x)")

p2.line(
    x,
    2 * y,
    legend_label="2*sin(x)",
    line_dash=(4, 4),
    line_color="orange",
    line_width=2,
)

p2.square(x, 3 * y, legend_label="3*sin(x)", fill_color=None, line_color="green")
p2.line(x, 3 * y, legend_label="3*sin(x)", line_color="green")

p2.legend.title = "Lines"
p2.x_range = p1.x_range

save(gridplot([p1, p2], ncols=2, width=400, height=400))

Thanks.

Asked By: aura

||

Answers:

Ok, first of all, I don’t know if it’s intentional or not, but I guess not, but when you move one graphic you move the other one too, so if you already implement the Tap (zoom), even if you zoom only on one, the other one will move.

So with this in mind we have to change this. The easiest way is to create separate range objects for p1 and p2.

p1_xrange = figure().x_range
p1_yrange = figure().y_range
p2_xrange = figure().x_range
p2_yrange = figure().y_range

I think you can also use the deepcopy library but there is no need to use it here.

After that you just apply it to your figure:

p1 = figure(title="Legend Example", tools=TOOLS, x_range=p1_xrange, y_range=p1_yrange)

And the same for p2

p2 = figure(title="Another Legend Example", tools=TOOLS, x_range=p2_xrange, y_range=p2_yrange)

With that fixed, now let’s move to the first point.

  1. ZOOM / TAP

For this we are going to use the CustomJS module of bokeh.models. Basically we need to create an event for each figure when it is touched.

So we create a function that does this zoom:

def tap_callback(p):
    custom_tap_code = CustomJS(args=dict(p=p), code="""
        p.x_range.start = cb_obj.x - (p.x_range.end - p.x_range.start)/4;
        p.x_range.end = cb_obj.x + (p.x_range.end - p.x_range.start)/4;
        p.y_range.start = cb_obj.y - (p.y_range.end - p.y_range.start)/4;
        p.y_range.end = cb_obj.y + (p.y_range.end - p.y_range.start)/4;
    """)
    return custom_tap_code

And then we create the event for both figures:

p1.js_on_event('tap', tap_callback(p1))

p2.js_on_event('tap', tap_callback(p2))

With that you can now zoom as you wish.

  1. EXPORT PNG USING BUTTON

First of all we have to create a button, for this we are using the Button method of bokeh.models. And we’ll do the same as we did for the zoom, we have to create a CustomJS that from JavaScript downloads the images individually (of course we can’t use the export_png function of bokeh, because the code is saved in HTML using JS).

So to download the images a bit more complicated, because bokeh introduce some shadow-root elements and we cannot get the elements as usual, using getElementsByTagName or getElementsByClassName. Instead, we have to use shadowRoot.querySelector (at least that’s the easiest way I’ve found that works for me).

So here is the code for that:

# create the button and attach the callback function to its 'click' event
button = Button(label="Save all figures")
callback = CustomJS(args=dict(p1=p1), code="""

    // Because there are some shadow-root (open), we can't use the normal way to get the canvas element
    let shadow_root1 = document.querySelector( '.bk-GridPlot' );
    let shadow_root2 = shadow_root1.shadowRoot.querySelector('.bk-GridBox');
    let shadow_root3 = shadow_root2.shadowRoot.querySelectorAll('.bk-Figure');
    shadow_root3.forEach((figure) => {
        let shadow_root4 = figure.shadowRoot.querySelector('.bk-Canvas');
        let canvas = shadow_root4.shadowRoot.querySelector('canvas');
        let url = canvas.toDataURL('image/png');
        let downloadLink = document.createElement('a');
        downloadLink.setAttribute('download', 'CanvasAsImage.png');
        downloadLink.setAttribute('href', url);
        downloadLink.click();
    });
""")

button.js_on_click(callback)

And finally if we put it all together this would be the final code:

import numpy as np
from bokeh.io import save
from bokeh.layouts import gridplot
from bokeh.plotting import figure
from bokeh.models import CustomJS, Button

def tap_callback(p):
    custom_tap_code = CustomJS(args=dict(p=p), code="""
        p.x_range.start = cb_obj.x - (p.x_range.end - p.x_range.start)/4;
        p.x_range.end = cb_obj.x + (p.x_range.end - p.x_range.start)/4;
        p.y_range.start = cb_obj.y - (p.y_range.end - p.y_range.start)/4;
        p.y_range.end = cb_obj.y + (p.y_range.end - p.y_range.start)/4;
    """)
    return custom_tap_code


x = np.linspace(0, 4 * np.pi, 100)
y = np.sin(x)

TOOLS = "pan,wheel_zoom,box_zoom,reset,save,box_select"

# create separate range objects for p1 and p2
p1_xrange = figure().x_range
p1_yrange = figure().y_range
p2_xrange = figure().x_range
p2_yrange = figure().y_range

p1 = figure(title="Legend Example", tools=TOOLS, x_range=p1_xrange, y_range=p1_yrange)
p1.name = "p1"

p1.circle(x, y, legend_label="sin(x)")
p1.circle(x, 2 * y, legend_label="2*sin(x)", color="orange")
p1.circle(x, 3 * y, legend_label="3*sin(x)", color="green")

p1.legend.title = "Markers"

p1.js_on_event('tap', tap_callback(p1))


p2 = figure(title="Another Legend Example", tools=TOOLS, x_range=p2_xrange, y_range=p2_yrange)
p2.name = "p2"

p2.circle(x, y, legend_label="sin(x)")
p2.line(x, y, legend_label="sin(x)")

p2.line(
    x,
    2 * y,
    legend_label="2*sin(x)",
    line_dash=(4, 4),
    line_color="orange",
    line_width=2,
)

p2.square(x, 3 * y, legend_label="3*sin(x)", fill_color=None, line_color="green")
p2.line(x, 3 * y, legend_label="3*sin(x)", line_color="green")

p2.legend.title = "Lines"

p2.js_on_event('tap', tap_callback(p2))

# create the button and attach the callback function to its 'click' event
button = Button(label="Save all figures")
callback = CustomJS(args=dict(p1=p1), code="""

    // Because there are some shadow-root (open), we can't use the normal way to get the canvas element
    let shadow_root1 = document.querySelector( '.bk-GridPlot' );
    let shadow_root2 = shadow_root1.shadowRoot.querySelector('.bk-GridBox');
    let shadow_root3 = shadow_root2.shadowRoot.querySelectorAll('.bk-Figure');
    shadow_root3.forEach((figure) => {
        let shadow_root4 = figure.shadowRoot.querySelector('.bk-Canvas');
        let canvas = shadow_root4.shadowRoot.querySelector('canvas');
        let url = canvas.toDataURL('image/png');
        let downloadLink = document.createElement('a');
        downloadLink.setAttribute('download', 'CanvasAsImage.png');
        downloadLink.setAttribute('href', url);
        downloadLink.click();
    });
""")

button.js_on_click(callback)



save(gridplot([p1, p2, button], ncols=3, width=400, height=400))

I know this is probably not the best way to do this, but at least it’s the one that worked for me. I hope it works for you too.

Answered By: WhoKnowsMe
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.