Save Drawing from FreehandDraw Tool with Colab

Hello everyone,

Please take a look at this Google Colab file: https://colab.research.google.com/drive/1Gxzq7gsrCqsFkxZ6VfjeYlsYCbcMRAJk#scrollTo=6t56axKNdBi0&forceEdit=true&sandboxMode=true

The HoloView code is as follows:

import holoviews as hv
from holoviews import opts
from holoviews import streams
%env HV_DOC_HTML=true
hv.extension('bokeh')
path = hv.Path([])
freehand = hv.streams.FreehandDraw(source=path, num_objects=3)

path.opts(
    opts.Path(active_tools=['freehand_draw'], height=400, line_width=10, width=400))

hv.save(path, 'fig1.png', backend='matplotlib')

I am attempting to sketch a prediction of a curve onto an empty x-y coordinate system and save the resulting figure as a png to be called back for later use. However, I do not want to use the “Save” tool, since this sends a download of the png to the “Downloads” folder on my computer.

In the Colab file provided, when hv.save is run, the image that gets saved is the figure but before I have drawn anything on it.

In other words, the line that I am drawing on the output is not saving to the png.

Any help would be great.

Thanks.

Two things, Colab is a non open source fork of Jupyter which does not follow any of the standard Comm APIs, which means we have no way of making many of the interactive features in our projects work. There is no mechanism by which we can communicate changes on the frontend (e.g. freehand draw events) back to Python. So unless Colab follows standard APIs or provides some clear mechanism to do this kind of thing there is no way this can work on Colab.

Secondly the FreehandDraw stream and other streams do not edit objects in place, instead they return a new element. So if you want to save the drawn output you will want to do this:

hv.save(freehand.element, 'fig1.png', backend='matplotlib')

Can we save a Matplotlib fig through a deployed Panel App like Plotly or Holoviews allows us to?

I’ve tried

hv.save(self.row[0].object, 'your_figure_name.png', backend='matplotlib',  bbox_inches='tight')
# and
self.row[0].object.savefig('your_figure_name.png', bbox_inches='tight')

inside a button.on_click callback inside a param.parametrized class that creates a Panel App

Tahnk you for your work Mr. @philippjfr

See this example
https://panel.holoviz.org/reference/widgets/FileDownload.html

The only thing is when I pass the Matplotlib fig it throws a ValueError: Must provide filename if file-like object is provided. error. I can try and create a temporary file and pass it to FileDownload but is there not a way to pass the object itself Mr. @ahuang11 ?

Not entirely sure since I don’t have any minimal code to work on, but I think you can wrap it in BytesIO

Tried following this example

import io
from PIL import Image
import matplotlib.pyplot as plt

plt.figure()
plt.plot([1, 2])
plt.title("test")
buf = io.BytesIO()
plt.savefig(buf, format='png')
buf.seek(0)
im = Image.open(buf)
im.show()
buf.close()

but I still get that the ValueError: Must provide filename if file-like object is provided. error

Hmm, how are you integrating this code into Panel?

I open a dataset on the fly and use it plot using Matplotlib. The idea is to allow to explore and then click download once deployed.

class GeopotentialHeightAndTemperature(param.Parameterized):
    def __init__(self):
        self.row = pn.Column("init")
        self.float_panel = pn.Column(pn.Spacer(height=0))
        self.level_sel = RadioButtonGroup(
            options=LEVELS,
            value=LEVELS[-1],
            button_style="outline",
            button_type="success"
        )
        self.loc_widg = RadioButtonGroup(
            options=DOMAINS,
            value=DOMAINS["Canada"],
            button_style="outline",
            button_type="success",
        )
        self.modelrun_sel = Select(options=generate_model_runs_dict(), button_type="success")
        self.model_sel = RadioButtonGroup(
            options=models,
            orientation="vertical",
            button_style="outline",
            button_type="success",
        )
        self.download_button = Button(icon="download", button_type="success")
        self.load_button = Button(icon="brand-airtable", button_type="success")
        self.ds, self.crs = self.load_data()
        self.forecast_widg = DiscretePlayer(
            name="Time",
            value=self.ds.forecast.values[0],
            options=list(self.ds.forecast.values),
            width=350,
            interval=2000,
        )
        self.file_download = pn.widgets.FileDownload(
            callback=self._download_callback
        )
        self._overlay("init")
        self.forecast_widg.param.watch(self._overlay, "value")
        self.load_button.on_click(self._load_button_callback)
        self.download_button.on_click(self._download_callback)
        
    def _download_callback(self, event: str):
        # hv.save(self.row[0], 'your_figure_name.png', backend='matplotlib',  bbox_inches='tight')
        # self.row[0].object.savefig('your_figure_name.png', bbox_inches='tight')
        # self.row.append(pn.pane.Str("Test"))
        # display(self.row[0].object)
        # print(type(self.row[0]))
        buf = BytesIO()
        self.row[0].object.savefig(buf, 'file.png')
        buf.seek(0)
        return buf

    def _load_button_callback(self, event: str):
        self.ds, self.crs = self.load_data()
        self._overlay("updated dataset")

    def get_model_key(self):
        current_value = self.model_sel.value
        current_key = None
        for key, value in models.items():
            if value == current_value:
                current_key = key
                break
        return current_key

    def get_current_loc_key(self):
        current_value = self.loc_widg.value
        current_key = None
        for key, value in DOMAINS.items():
            if value == current_value:
                current_key = key
                break
        return current_key

    def load_data(self):
        ds_temp = fstd2nc.Buffer(
            sorted(Path(self.model_sel.value).glob(f"{self.modelrun_sel.value}_*")),
            vars=["TT", "GZ"],
            filter=[f"ip1=={int(self.level_sel.value)}"],
            forecast_axis=True,
        ).to_xarray()
        ds_temp = ds_temp.assign_coords(lon=(((ds_temp.lon + 180) % 360) - 180))
        return ds_temp.isel(time=0, pres=0), get_crs(ds_temp)

    def _matplotlib(self):
        """
        Create a Matplotlib plot.
        """
        data = self.ds.sel(
            forecast=self.forecast_widg.value,
            lon=slice(*self.loc_widg.value[:2]),
            lat=slice(*self.loc_widg.value[-2:]),
        ).copy()
        proj = self.crs
        TITLE_EN = f"Temperature (°C) + Geopotential Height {self.level_sel.value}mb:"
        TITLE_FR = (
            f"Température (°C) + Hauteur Geopotentielle {self.level_sel.value}mb:"
        )
        formatted_forecast_hour = str(
            int(self.forecast_widg.value / np.timedelta64(3600, "s"))
        ).zfill(3)

        date_str = f"Init: {self.modelrun_sel.value[:4]}-{self.modelrun_sel.value[4:6]}-{self.modelrun_sel.value[6:8]} {self.modelrun_sel.value[-2:]}Z Forecast Hour:[{formatted_forecast_hour}] valid: TODO Z"

        title = f'{self.get_model_key().replace("_", " ")} - {self.get_current_loc_key()}\n{TITLE_FR}\n{TITLE_EN}\n{date_str}'
        fig, ax = plt.subplots(subplot_kw=dict(projection=self.crs))
        # Map features
        utils.set_limits(ax, self.crs, self.loc_widg.value)

        wind = ax.contourf(
            data.lon,
            data.lat,
            data.TT,
            levels=6,
            cmap="RdBu",
            transform=self.crs,
        )

        pres = ax.contour(
            data.lon,
            data.lat,
            data.GZ,
            colors="black",
            transform=self.crs,
            linewidths=0.8,
        )

        utils.add_features(ax)

        plt.title(title, fontsize=10)
        # Labels on pressure lines
        clabels = ax.clabel(pres, fmt="%1.0f", fontsize=3.5)
        for label in clabels:
            label.set_bbox(dict(facecolor="white", edgecolor="none", pad=0.6))
        fig.colorbar(wind, fraction=0.035)
        plt.close(fig)
        return fig

    def _overlay(self, event: str):
        """
        Overlay method to switch between Holoviews and Matplotlib backends.
        """
        map_plot = pn.pane.Matplotlib(
            self._matplotlib(),
            format="svg",
            tight=True,
            fixed_aspect=True,
            sizing_mode="stretch_both",
            # loading=True,
        )
        if self.row[0] is not None:
            self.row.clear()
        self.row.append(map_plot)


app = GeopotentialHeightAndTemperature()

template = pn.template.MaterialTemplate(
    header_background="#78eb96",
    logo="https://www.canada.ca/etc/designs/canada/wet-boew/assets/sig-blk-en.svg",
    site="CMC",
    title="Dynamic Geopotential Height and Temperature",
    sidebar=[
        app.model_sel,
        app.modelrun_sel,
        app.level_sel,
        app.loc_widg,
        app.load_button,
        app.forecast_widg,
        app.download_button,
        app.file_download
    ],
    main=[app.row],
    sidebar_width=400,
).servable()

I’m not sure that you need , 'file.png'

        buf = BytesIO()
        self.row[0].object.savefig(buf)
        buf.seek(0)
        return buf

Also don’t use on_click; use callback from FileDownload instead.

This is also not detected as a file object. I guess a workaround would be to save it as a temporary file and then delete that temporary file.

I think you need to provide format

Also, this is what I meant by a minimal example; everything copy/pastable and runnable, including imports.

from io import BytesIO
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import panel as pn

fig = plt.figure()
ax = plt.subplot()
x = np.linspace(0, 10, 100)
y = np.sin(x)
ax.plot(x, y)

def save():
    buffer = BytesIO()
    fig.savefig(buffer, format='png')
    buffer.seek(0)
    return buffer

file_download = pn.widgets.FileDownload(
    label='Download Figure',
    filename='sine_wave_plot.png',
    callback=save
)

# Display the Panel with the Matplotlib figure and the FileDownload widget
col = pn.Column(fig, file_download)
col.show()

Sorry I forgot to click Reply, but this does not work either. I guess the only lead would be to somehow get the Canvas generated in the Matplotlib Pane right?

Can you explain what “does not work” mean here? I tested the code I pasted and it works for me.

I was still trying to use the Button widget and the Buffer callback but for one reason or another it simply does not work. I made a MRE below that does work. Now I just need to see how to make the FileDownload widget look a little prettier :slight_smile: thank you again Mr. @ahuang11

from io import BytesIO
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import panel as pn
pn.extension()
class PanelDownloadTest(pn.viewable.Viewer):
    def __init__(self):
        self.row = pn.Column("init")
        self.file_download = pn.widgets.FileDownload(
            label='Download Figure',
            filename='sine_wave_plot.png',
            callback=self.save
        )
        self._overlay('event')
        
    def save(self):
        buffer = BytesIO()
        self.row[0].object.savefig(buffer, format='png')
        buffer.seek(0)
        return buffer


    def _matplotlib(self):
        fig = plt.figure()
        ax = plt.subplot()
        x = np.linspace(0, 10, 100)
        y = np.sin(x)
        ax.plot(x, y)
        return fig

    def _overlay(self, event: str):
        map_plot = pn.pane.Matplotlib(
            self._matplotlib(),
            format="svg",
            tight=True,
            fixed_aspect=True,
            sizing_mode="stretch_both",
        )
        if self.row[0] is not None:
            self.row.clear()
        self.row.append(map_plot)


app = PanelDownloadTest()

ACCENT_COLOR = "#308752"
template = pn.template.FastListTemplate(
    accent_base_color=ACCENT_COLOR,
    header_background=ACCENT_COLOR,
    logo="https://www.canada.ca/etc/designs/canada/wet-boew/assets/sig-blk-en.svg",
    site="CMC",
    title="Panel Download Test",
    sidebar=[
        app.file_download
    ],
    main=[app.row],
    sidebar_width=400,
).servable()

1 Like