datashader icon indicating copy to clipboard operation
datashader copied to clipboard

How can we save multiple panels to one image file?

Open slowkow opened this issue 5 years ago • 8 comments

Could I please ask how we can save an image with multiple panels?

For example, I can see a nice multi-panel image in the documentation below:

https://datashader.org/getting_started/Pipeline.html

Pipeline — Datashader 0 9 0 documentation - Google Chrome 2019-12-13_11-47-57

How can we convert a tf.Images object into a PNG or SVG file?

slowkow avatar Dec 13 '19 16:12 slowkow

There's no obvious way that I know of. Each individual plot (i.e. tf.Image object) is easily converted to a PNG using the _repr_png_() method, but tf.Images() uses HTML to render the collection of images, labeling each one. Your browser is what stores the font information and the ability to lay out the images, so you'd have to convince your browser to render a PNG from the result. It is possible to use PhantomJS and Selenium to render such a collection of images into an internal frame buffer and save that, but it's not trivial, and beyond the scope of what Datashader is trying to do. It's also beyond Datashader's scope to try to render the text itself. So I don't think there is any straightforward way to export a collection of labeled images like this.

jbednar avatar Dec 14 '19 04:12 jbednar

Should be able to stick it inside a Panel and then use the save method.

import panel as pn
pn.panel(images).save('images.png')

philippjfr avatar Dec 14 '19 05:12 philippjfr

Good point! That's a very convenient way to invoke PhantomJS and Selenium and render it in a way that doesn't add those unrelated tools to Datashader itself. Great suggestion!

jbednar avatar Dec 14 '19 14:12 jbednar

@philippjfr Your suggestion is not working for me.

The images.png file is written, and it is empty.

Here are the details on the images.png file:

$ file images.png
images.png: PNG image data, 1366 x 18, 8-bit/color RGBA, non-interlaced

$ ll images.png
-rw-r--r-- 1 kamil staff 175 Dec 17 09:19 images.png

Here is the script for creating the file. It should create a figure somewhat similar to the one shown in my previous comment.

#!/usr/bin/env python

import pandas as pd
import numpy as np
from collections import OrderedDict as odict
import datashader as ds
import datashader.transfer_functions as tf
import panel as pn

num = 10000
np.random.seed(1)

dists = {
    cat: pd.DataFrame(
        odict(
            [
                ("x", np.random.normal(x, s, num)),
                ("y", np.random.normal(y, s, num)),
                ("val", val),
                ("cat", cat),
            ]
        )
    )
    for x, y, s, val, cat in [
        (2, 2, 0.03, 10, "d1"),
        (2, -2, 0.10, 20, "d2"),
        (-2, -2, 0.50, 30, "d3"),
        (-2, 2, 1.00, 40, "d4"),
        (0, 0, 3.00, 50, "d5"),
    ]
}

df = pd.concat(dists, ignore_index=True)
df["cat"] = df["cat"].astype("category")

canvas = ds.Canvas(plot_width=300, plot_height=300,
                   x_range=(-8,8), y_range=(-8,8),
                   x_axis_type='linear', y_axis_type='linear')

imgs = tf.Images(
    tf.shade(   canvas.points(df,'x','y', ds.count()),     name="count()"),
    tf.shade(   canvas.points(df,'x','y', ds.any()),       name="any()"),
    tf.shade(   canvas.points(df,'x','y', ds.mean('y')),   name="mean('y')"),
    tf.shade(50-canvas.points(df,'x','y', ds.mean('val')), name="50- mean('val')")
)

pn.panel(imgs).save('images.png')

slowkow avatar Dec 17 '19 14:12 slowkow

Yeah, sadly doesn't seem to work, I suspect the selenium driver is taking the screenshot before the images are rendered.

philippjfr avatar Dec 17 '19 14:12 philippjfr

There's probably a way to make it work with Selenium, but I don't know how to get it to wait or how to ensure the images are rendered first.

Meanwhile, people have made multi-image Datashader layouts as PNG using PIL; see https://github.com/russss/datavis/blob/master/ofcom-rail-signal/Ofcom%20Train%20Mobile%20Signal.ipynb . A _repr_png_ method could be added to tf.Images using such a technique, but it would need to be configurable for font size and color for it to be useful, so it would be a good bit of work.

That's not work I'd do personally, because for my own purposes I'd simply use HoloViews with the Matplotlib backend to lay out and render the images, as that already allows composition of multiple Datashader images with control over the font sizes and colors. The HoloViews code would be something like this untested code (which would need options added to hide axes, etc.):

import holoviews as hv
from holoviews.operation.datashader import datashade
hv.extension("matplotlib")
p1=datashade(hv.Points(df1))
p2=datashade(hv.Points(df2))
p3=datashade(hv.Points(df3))
hv.save(p1+p2+p3,"file.png")

But that's a different API to learn, so I do think that having Datashader rendering multi-image layouts would be useful to people.

jbednar avatar Dec 24 '19 14:12 jbednar

I believe you are referring to this code:

images = [draw_text(make_plot(gsm).to_pil(), "GSM 2G", 80, 100), 
          draw_text(make_plot(umts).to_pil(), "UMTS 3G", 80, 100),
          draw_text(make_plot(lte).to_pil(), "LTE 4G", 80, 100)]
output = tile_images(images)
width, height = output.size
# Draw title and footer text
draw_text(
    output,
    "Mobile Signal Levels on UK Rail Lines 2018-2019",
    width//2, 20, size=36, face="Bold"
)
draw_text(
    output,
    "Russ Garrett (@russss). Ofcom Open Data, Open Government License v3.0",
    width - 320, height - 40, size=16, color=(180,180,180)
)
output.save('coverage_by_technology.png')

Which in turn depends on these functions:

from PIL import Image, ImageFont, ImageDraw


def draw_text(img, text, x, y, size=30, face="Regular", color=(230, 230, 230)):
    """ Helper to draw text labels using PIL. """
    # This font path assumes you're using a Mac with Open Sans installed.
    fnt = ImageFont.truetype(f"~/Library/Fonts/OpenSans-{face}.ttf", size)
    d = ImageDraw.Draw(img)
    w, h = d.textsize(text, fnt)
    x -= w // 2
    h -= h // 2
    d.text((x, y), text, font=fnt, fill=color)
    return img


def tile_images(images):
    # Tile the three images side by side
    widths, heights = zip(*(i.size for i in images))
    width = sum(widths)
    height = max(heights)
    output = Image.new("RGB", (width, height))
    x_offset = 0
    for im in images:
        output.paste(im, (x_offset, 0))
        x_offset += im.size[0]

    return output

Thanks, I think this should be a workable solution.

slowkow avatar Dec 24 '19 16:12 slowkow

Right. It shouldn't be difficult to get that to work on any particular system, but it will need a little effort to write something that could be in Datashader itself as tf.Images._repr_png_, due to things like hardcoding the font path.

jbednar avatar Dec 24 '19 16:12 jbednar