在工具提示中显示 Numpy 图像#
在本教程中,你将学习如何在任何 Altair 图表中使用工具提示显示存储为 Numpy 数组的图像。
首先,我们创建一些示例图像数组,其中包含不同大小和形状(圆形和方形)的斑点(对象)。我们测量斑点的面积,以便在图表中获得用于比较的定量测量值。
import numpy as np
import pandas as pd
from scipy import ndimage as ndi
rng = np.random.default_rng([ord(c) for c in 'altair'])
n_rows = 200
def create_blobs(blob_shape, img_width=96, n_dim=2, sizes=[0.05, 0.1, 0.15]):
"""Helper function to create blobs in the images"""
shape = tuple([img_width] * n_dim)
mask = np.zeros(shape)
points = (img_width * rng.random(n_dim)).astype(int)
mask[tuple(indices for indices in points)] = 1
if blob_shape == 'circle':
im = ndi.gaussian_filter(mask, sigma=rng.choice(sizes) * img_width)
elif blob_shape == 'square':
im = ndi.uniform_filter(mask, size=rng.choice(sizes) * img_width, mode='constant') * rng.normal(4, size=(img_width, img_width))
return im / im.max()
df = pd.DataFrame({
'image1': [create_blobs('circle') for _ in range(n_rows)],
'image2': [create_blobs('square', sizes=[0.3, 0.4, 0.5]) for _ in range(n_rows)],
'group': rng.choice(['a', 'b', 'c'], size=n_rows)
})
# Compute the area as the proportion of pixels above a threshold
df[['image1_area', 'image2_area']] = df[['image1', 'image2']].map(lambda x: (x > 0.4).mean())
df
image1 image2 group image1_area image2_area
0 [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... b 0.030599 0.070855
1 [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... b 0.134006 0.167752
2 [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... a 0.014865 0.195421
3 [[0.8408188559277083, 0.8434385804894682, 0.84... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... c 0.097222 0.208550
4 [[0.0, 0.0, 0.00030006935893488147, 0.00039595... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... b 0.072266 0.043294
.. ... ... ... ... ...
195 [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... a 0.014865 0.100260
196 [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... a 0.040148 0.070747
197 [[0.020642835524827916, 0.021450917851981367, ... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... b 0.122830 0.135091
198 [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,... a 0.064779 0.088542
199 [[0.0, 2.078157310309435e-06, 2.74224551900630... [[0.5236619475835835, 0.446206634299134, 0.398... b 0.130317 0.145942
[200 rows x 5 columns]
接下来,我们定义一个函数,将 Numpy 数组转换为base64 编码的字符串。这是工具提示识别数据是图像形式并正确渲染所必需的步骤。
from io import BytesIO
from PIL import Image, ImageDraw
import base64
def create_tooltip_image(df_row):
"""Concatenate, rescale, and convert images to base64 strings."""
# Concatenate images to show together in the tooltip
# This can be skipped if only one image is to be displayed
img_gap = np.ones([df_row['image1'].shape[0], 10]) # 10 px white gap between imgs
img_arr = np.concatenate(
[
df_row['image1'],
img_gap,
df_row['image2']
],
axis=1
)
# Create a PIL image from the array.
# Multiplying by 255 and recasting as uint8 for the images to occupy the entire supported instensity space from 0-255
img = Image.fromarray((255 * img_arr).astype('uint8'))
# Optional: Burn in labels as pixels in the images. Can be helpful to keep track of which image is which
ImageDraw.Draw(img).text((3, 0), 'im1', fill=255)
ImageDraw.Draw(img).text((3 + df_row['image1'].shape[1] + img_gap.shape[1], 0), 'im2', fill=255)
# Convert to base64 encoded image string that can be displayed in the tooltip
buffered = BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
# The column with the base64 image string must be called "image" in order for it to trigger the image rendering in the tooltip
df['image'] = df[['image1', 'image2']].apply(create_tooltip_image, axis=1)
# Dropping the image arrays since they are large an no longer needed
df_plot = df.drop(columns=['image1', 'image2'])
df_plot
group image1_area image2_area image
0 b 0.030599 0.070855 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
1 b 0.134006 0.167752 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
2 a 0.014865 0.195421 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
3 c 0.097222 0.208550 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
4 b 0.072266 0.043294 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
.. ... ... ... ...
195 a 0.014865 0.100260 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
196 a 0.040148 0.070747 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
197 b 0.122830 0.135091 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
198 a 0.064779 0.088542 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
199 b 0.130317 0.145942 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
[200 rows x 4 columns]
现在我们准备创建图表,当鼠标悬停在点上时,图表将显示图像作为工具提示。我们可以看到,正如预期的那样,大的白色斑点对应于较高的面积测量值。
import altair as alt
# The random() function is used to jitter points in the x-direction
alt.Chart(df_plot, width=alt.Step(40)).mark_circle(xOffset=alt.expr('random() * 16 - 8')).encode(
x='group',
y=alt.Y(alt.repeat(), type='quantitative'),
tooltip=['image'],
color='group',
).repeat(
['image1_area', 'image2_area']
).resolve_scale(
y='shared'
).properties(
title='Comparison of blob areas'
)
请注意,当将图像作为图表数据的一部分包含在内时,图表大小通常会增加数倍。上面图表的大小在不包含图像的情况下是 19 Kb,但添加图像后是 760 Kb。尽管这增加了 20 倍的大小,但 base64 编码仍然相当节省存储空间;如果我们将图像以原始 Numpy 数组格式包含在内,图表大小将达到 35Mb!
如果我们想更有趣、更精巧一些,我们可以一次显示一个图表,并根据下拉选择器动态更新 y 轴显示的内容以及图像工具提示中显示的内容。我们首先定义一个工具提示,它只包含单个图像,而不是将两个图像连接在一起。
def create_tooltip_image(img_arr):
"""Rescale and convert an image to a base64 string."""
# print(img_arr)
# Create a PIL image from the array.
# Multiplying by 255 and recasting as uint8 for the images to occupy the entire supported instensity space from 0-255
img = Image.fromarray((255 * img_arr).astype('uint8'))
# Convert to base64 encoded image string that can be displayed in the tooltip
buffered = BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
# The column with the base64 image string must be called "image" in order for it to trigger the image rendering in the tooltip
df[['image1_base64', 'image2_base64']] = df[['image1', 'image2']].map(create_tooltip_image)
# Dropping the image arrays since they are large an no longer needed
# Also drop the previous tooltip image for clarity
df_plot = df.drop(columns=['image1', 'image2', 'image'])
df_plot
group image1_area image2_area image1_base64 image2_base64
0 b 0.030599 0.070855 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
1 b 0.134006 0.167752 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
2 a 0.014865 0.195421 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
3 c 0.097222 0.208550 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
4 b 0.072266 0.043294 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
.. ... ... ... ... ...
195 a 0.014865 0.100260 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
196 a 0.040148 0.070747 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
197 b 0.122830 0.135091 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
198 a 0.064779 0.088542 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
199 b 0.130317 0.145942 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
[200 rows x 5 columns]
在我们的图表中,我们需要使用一个转换(transform)来根据下拉菜单中的选择动态更新 y 轴列和工具提示列。代码中的注释更详细地解释了此图表规范中的每一行做什么。
metric_dropdown = alt.binding_select(
options=['image1_area', 'image2_area'],
name='Image metric '
)
metric_param = alt.param(
value='image1_area',
bind=metric_dropdown
)
alt.hconcat(
# This first chart is the axis title and is only needed because
# Vega-Lite does not yet support passing an expression directly to the axis title
alt.Chart().mark_text(angle=270, dx=-150, fontWeight='bold').encode(
alt.TextValue(alt.expr(f'{metric_param.name}'))
),
alt.Chart(df_plot, width=alt.Step(40)).mark_circle(xOffset=alt.expr('random() * 16 - 8')).encode(
x='group',
y=alt.Y('image_area:Q').title(''),
tooltip=['image:N'],
color='group',
).properties(
title='Area of blobs'
).transform_calculate(
# This first line updates the image_area which is used for the y axis
# to correspond to the selected string in the dropdown
image_area=f'datum[{metric_param.name}]',
# Since altair needs the tooltip field to be called `image`, we need to dynamically
# change what's in the `image` field depending on the selection in the dropdown
# This is further complicated by the fact that the string in the dropdown is not
# an exact match for the column holding the image data so we need
# to replace part of the name to match to match the corresponding base 64 image field
image=f'datum[replace({metric_param.name}, "_area", "_base64")]',
)
).add_params(
metric_param
)