keras之父弗朗西斯科肖莱在他的书中提到了CNN的三种常用可视化方法, 同样的算法原理在李宏毅深度学习教程的ExplainableML单元也有提及, 本博客分别使用keras和MXNet(gluon)框架实现了这三种可视化算法, keras实现参考了肖莱书中的代码做了一定修改, gluon版为相同算法的不同框架实现, 后续会补上Pytorch框架的实现, gluon和Pytorch作为动态图框架在可视化上有先天的便利.
github的jupyternotebook地址: link https://github.com/TomMao23/CNN–Visualization
第一种可视化方法是 可视化卷积神经网络的中间输出 , 即可视化特征图: 这种方法有助于理解卷积神经网络连续的层如何对输入进行变换,也有助于初步了解卷积神经网络浅层每个过滤器的含义, 缺点是对于较深层的特征图即使可视化出来也难以理解, 对增强深层模型的可解释性没有帮助.
第二种方法 可视化卷积神经网络的滤波器 : 固定网络参数, 随机初始化输入图像, 用单个滤波器(卷积核)的输出对输入图像的梯度, 做梯队上升法更新输入图像, 最大化单个滤波器的输出均值, 从而求得 此滤波器的最大响应图像. 这种方法利于人观察到神经网络从浅入深的学到的层级特征, 对深层滤波器可视化可以得到复杂轮廓和接近人概念中的图像. (需要注意的是可视化某个滤波器的最大响应图实际上指的是从输入到这个卷积核输出这条通路, 不仅仅是这个卷积核)
类似鸟的最大响应输入图
第三种方法可视化图像中 类激活的热力图: 对于输出的某个类别, 求该类别输出对最后一个卷基层输出特征图的梯度, 对每个通道梯度求均值得到与特征图通道数相同的权重向量描述每个通道的重要程度. 之后每个通道特征图乘以权重后按通道求平均得到"热力图", 可以用双线性插值resize到与图像相同大小. 热力图有助于让人理解神经网络主要根据哪些像素将图片中对象判断为"猫"等其他对象. 谷歌EfficientNet论文中便使用的了这种方法来解释为什么EfficientNet表现更好(热力图聚焦图中对象相关区域得到更多细节)
注: 如果后续过程出现了cudnn和显存问题可尝试文件头加上以下代码解决tensorflow后端显存占用的bug
#解决tensorflow后端显存占用的bug
from tensorflow.compat.v1 import ConfigProto
from tensorflow.compat.v1 import InteractiveSession
config = ConfigProto()
config.gpu_options.allow_growth = True
session = InteractiveSession(config=config)
首先加载模块和模型, 编写辅助函数把图像转为网络可接受的张量. 本文以VGG16为例做可视化, ResNet等模型的可视化方法与其大同小异
#加载我们的可视化对象VGG16预训练网络, 前两种方法只需要卷积层, 第三种方法热力图需要使用到全连接层, 所以在这里直接加载所有层
from keras.applications import VGG16
from keras import backend as K
import cv2
import numpy as np
import matplotlib.pyplot as plt
model = VGG16(weights='imagenet', include_top=True)
#编写辅助函数对输入网络的图像预处理: 原书使用image模块, 这里改成更常用的opencv
#BGR通道转RGB, 本应该做归一化或标准化但keras的预训练模型实际上接受的输入是归一化前的故注释, 加入批量维度, 注: keras(TensorFlow后端)使用通道在后格式即BHWC不用再改通道维度位置
img_path = "00000001_020.jpg"
img = cv2.imread(img_path)
def image_to_tensor(img):
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (224, 224))
#tensor = img / 255.
tensor = np.expand_dims(img, axis=0)
return tensor
tensor = image_to_tensor(img)
#对比一下形状
print(img.shape)
print(tensor.shape)
#可视化原图, 注意OpenCV默认BGR格式, matplotlib可视化需要转RGB
plt.imshow(img[:, :, ::-1])
输入图像:
利用keras的函数式API构建用于可视化的模型. 实际上是重新创建了一个单输入多输出网络, 结构参数输入同VGG16, 输出为需要可视化特征图的层. 本例我们可视化VGG五个Block的第一个卷基层, 对应模型层下标为[1,4,7,11,15]. activations为一个列表, 5个元素对应了5个层的输出特征图
#使用keras函数式API, 实际上是重新创建了一个单输入多输出网络, 结构参数输入同VGG16, 输出为这几个层
from keras import models
layer_outputs = []
for i, layer in enumerate(model.layers):
if i in [1,4,7,11,15]:
print(layer.name)
layer_outputs.append(layer.output)
activation_model = models.Model(inputs=model.input, outputs=layer_outputs)
#activations为一个列表, 5个元素对应了5个层的输出特征图
activations = activation_model.predict(tensor)
编写把张量转化为用于显示的图像的辅助函数, 尝试可视化block2的第一个卷基层第5个卷积核
#用matplotlib画出每层特征图
#先编写显示的辅助函数
#1.输出张量为(Batch, Hight, Width, Chanels)的格式, 需要去掉批量维度
#2.输出为float32类型, 要显示应该配合可视化工具一般转为uint8
#3.输出的范围较为随机, 为可视化方便采用减均值除以标准差标准化为均值为0的正态分布, 之后乘以64放大范围, 加128把均值移动到128, 对于转uint8溢出部分采用截断处理
def tensor_to_image(tensor):
tensor = tensor[0]
tensor -= tensor.mean()
tensor /= tensor.std()
tensor *= 64
tensor += 128
img = np.clip(tensor, 0, 255).astype('uint8')
return img
# 选其中一张特征图测试可视化
t1 = activations[1][:, :, :, 5] #block2的第一个卷基层第5个卷积核的输出
plt.imshow(tensor_to_image(t1), cmap='viridis')
#可视化第一个Block第一个卷基层所有特征图, 64通道64张图
# 把特征图通道移到第一维, 方便遍历
featuremaps = np.transpose(activations[0], [3, 0, 1, 2])
plt.figure(figsize=(40, 10))
for i, featuremap in enumerate(featuremaps):
plt.subplot(4, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
#可视化第二个Block第一个卷基层所有特征图, 128通道128张图
featuremaps = np.transpose(activations[1], [3, 0, 1, 2])
plt.figure(figsize=(20, 10))
for i, featuremap in enumerate(featuremaps):
plt.subplot(8, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
#可视化第三个Block第一个卷基层所有特征图, 256通道256张图
featuremaps = np.transpose(activations[2], [3, 0, 1, 2])
plt.figure(figsize=(20, 20))
for i, featuremap in enumerate(featuremaps):
plt.subplot(16, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
#可视化第四个Block第一个卷基层所有特征图, 512通道512张图
featuremaps = np.transpose(activations[3], [3, 0, 1, 2])
plt.figure(figsize=(20, 40))
for i, featuremap in enumerate(featuremaps):
plt.subplot(32, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
#可视化第五个Block第一个卷基层所有特征图, 512通道512张图
featuremaps = np.transpose(activations[4], [3, 0, 1, 2])
plt.figure(figsize=(20, 40))
for i, featuremap in enumerate(featuremaps):
plt.subplot(32, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
首先编写张量转能可视化图像的辅助函数, 与之前略有不同, 标准化到标准正态分布, 放缩截断转uint8, 同样是为了便于显示
得到最大响应图的函数, 原理同本文开头, 用梯度上升法求使单个滤波器输出最大的输入图像, 即最大响应图
# 将张量变为图像的辅助函数, 与之前的辅助函数稍有不同
def deprocess_image(x):
x -= x.mean()
x /= (x.std() + 1e-5)
x *= 0.1
x += 0.5
x = np.clip(x, 0, 1)
x *= 255
x = np.clip(x, 0, 255).astype('uint8')
return x
# 用于得到神经网络中该卷积核最大响应的输入图
def generate_pattern(layer_name, filter_index, size=224):
#得到该卷积核的输出
layer_output = model.get_layer(layer_name).output
#输出的平局值就是loss(但这里是用梯度上升法最大化loss)
loss = K.mean(layer_output[:, :, :, filter_index])
#求梯度, 梯度标准化使更新平稳
grads = K.gradients(loss, model.input)[0]
grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)
iterate = K.function([model.input], [loss, grads])
#上述在构建计算图并不是立即计算, 而是将计算图放入了iterate函数调用时执行
#初始化输入图
input_img_data = np.random.random((1, size, size, 3)) * 20 + 128.
#更新幅度
step = 1.
#循环40次, 梯度上升法更新输入图, 求使卷积核响应最大的输入图得到识别模式
for i in range(40):
loss_value, grads_value = iterate([input_img_data])
#print(loss_value)
input_img_data += grads_value * step
img = input_img_data[0]
return deprocess_image(img)
分别可视化浅层和深层的滤波器最大响应输入图, 明显观察到浅层学到的是简单特征, 深层学到了复杂纹理
# 可视化第三个Block第一个卷基层第一个卷积核的最大响应图查看结果
plt.imshow(generate_pattern(layer_names[2], 0))
# 可视化第5个Block第一个卷基层第一个卷积核的最大响应图
plt.imshow(generate_pattern(layer_names[4], 0))
可视化由浅入深这5层的前16个卷积核的最大响应图
#这里仅可视化每层前16个卷积核的最大响应图
layer_name = layer_names[0]
plt.figure(figsize=(10, 10))
for i in range(16):
plt.subplot(4, 4, i+1)
p = generate_pattern(layer_name, i)
plt.imshow(p)
#这里仅可视化每层前16个卷积核的最大响应图
layer_name = layer_names[1]
plt.figure(figsize=(10, 10))
for i in range(16):
plt.subplot(4, 4, i+1)
plt.imshow(generate_pattern(layer_name, i))
#这里仅可视化每层前16个卷积核的最大响应图
layer_name = layer_names[2]
plt.figure(figsize=(10, 10))
for i in range(16):
plt.subplot(4, 4, i+1)
plt.imshow(generate_pattern(layer_name, i))
#这里仅可视化每层前16个卷积核的最大响应图
layer_name = layer_names[3]
plt.figure(figsize=(10, 10))
for i in range(16):
plt.subplot(4, 4, i+1)
plt.imshow(generate_pattern(layer_name, i))
#这里仅可视化每层前16个卷积核的最大响应图
layer_name = layer_names[4]
plt.figure(figsize=(10, 10))
for i in range(16):
plt.subplot(4, 4, i+1)
plt.imshow(generate_pattern(layer_name, i))
热力图是针对某个类别输出的, 换句话说我们想知道网络为什么判对/错, 就要把对应最大置信度类别的输出单独拿出来, 对最后一个卷基层的输出特征图求梯度, 把梯度均值作为表述特征图各个通道重要性的权重加权平均, resize到原图得到最后的热力图
原图在imagenet预训练的VGG16输出的置信度最大类别为’Egyptian_cat’, 序号285
from keras.applications.vgg16 import preprocess_input, decode_predictions
img = cv2.imread(img_path)
#注意vgg16
#输出前三预测类别, 最大类别的序号
tensor = image_to_tensor(img)
preds = model.predict(tensor)
print('Predicted:', decode_predictions(preds, top=3)[0])
print(np.argmax(preds[0]))
用keras后端函数构建计算图求梯度得到热力图
#选定最大置信度类别的输出
class_output = model.output[:, 285]
#最后一层卷积层
last_conv_layer = model.get_layer('block5_conv3')
#把该类别输出对于最后一个卷基层的梯度的均值作为该特征图的重要程度
grads = K.gradients(class_output, last_conv_layer.output)[0]
pooled_grads = K.mean(grads, axis=(0, 1, 2))
iterate = K.function([model.input],
[pooled_grads, last_conv_layer.output[0]])
pooled_grads_value, conv_layer_output_value = iterate([tensor])
#特征图乘以重要程度的权重后平均得到热力图
for i in range(512):
conv_layer_output_value[:, :, i] *= pooled_grads_value[i]
heatmap = np.mean(conv_layer_output_value, axis=-1)
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)
双线性插值与原图相同大小并叠加
img = cv2.imread(img_path)
heatmap = cv2.resize(heatmap, (img.shape[1], img.shape[0]))
heatmap = np.uint8(255 * heatmap)
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)
superimposed_img = heatmap * 0.3 + img
superimposed_img = np.clip(superimposed_img, 0, 255).astype('uint8')
plt.imshow(superimposed_img[:, :, ::-1]) #显示要转rgb
#cv2.imwrite('heat_map.jpg', superimposed_img)
叠加效果, 可以明显看出神经网络通过猫耳朵眼睛的特征预测出’Egyptian_cat’类别
最终热力图的样子
Gluon和Pytorch是动态图框架, 在可视化上具有先天的灵活性优势, 需要注意MXNet框架的默认格式是BCHW, 即通道在前; Tensorflow是BHWC, 即通道在后, 在算法的实现上细节易出错
导入模块和预训练模型, MXNet预训练的VGG16中把激活层Activation作为单独的一层(keras中也可以这样, 但是没有这么做), 之后三种可视化算法操作对象都是5个Block后第一个卷积层后的激活层输出
from mxnet import gluon
import mxnet.gluon.nn as nn
from mxnet import autograd, nd
import cv2
import matplotlib.pyplot as plt
import numpy as np
#加载VGG16预训练模型
net = gluon.model_zoo.vision.vgg16(pretrained=True)
#查看VGG16结构, gluon中把Activation也作为了单独的一层
print(net.features)
同样构建预处理辅助函数, 注意除了通道维度不同外, keras官方预训练模型的输入没有做标准化/归一化, MXNet所有官方预训练模型都使用imagenet的均值标准差做标准化预处理
#构造辅助函数做预处理, 注意mxnet中为通道在前格式即BCHW, 输入时要对通道维度调整,
#其预训练模型采用减均值除方差的标准化预处理(均值标准差使用imagenet数据集的[0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
#mxnet使用专有数据类型nd.array
input_image = cv2.imread("00000005_017.jpg")
def image_to_tensor(img):
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (224, 224))
tensor = nd.array(img)
rgb_mean = nd.array([0.485, 0.456, 0.406])
rgb_std = nd.array([0.229, 0.224, 0.225])
tensor = (tensor.astype('float32') / 255 - rgb_mean) / rgb_std
tensor = nd.transpose(tensor, [2, 0, 1])
tensor = nd.expand_dims(tensor, 0)
return tensor
tensor = image_to_tensor(input_image)
查看输入图, BGR显示时要转RGB
import matplotlib.pyplot as plt
plt.imshow(input_image[:,:,::-1])
用类API构建模型同之前的多输出模型得到特征图(keras也有类API但功能和灵活度不及Gluon)
可视化层的下标为[1, 6, 11, 18, 25], 把5个Block的第一个卷积层加入输出中, 由于激活层被单独作为一层, 这里实际上加入的是这些卷积层后的relu层
#构建用于可视化的模型, 结构参数同VGG16, 输出改为5个block第一个卷积层的
class Visible_VGG16(nn.Block):
def __init__(self, **kwargs):
super(Visible_VGG16, self).__init__(**kwargs)
self.VGG16 = gluon.model_zoo.vision.vgg16(pretrained=True)
def forward(self, x):
results = []
for i, layer in enumerate(self.VGG16.features):
x = layer(x)
if i in [1, 6, 11, 18, 25]: #把5个Block的第一个卷积层加入输出中,由于激活层被单独作为一层, 这里实际上加入的是这些卷积层后的relu层
results.append(x)
return results
visible_vgg16 = Visible_VGG16()
#得到特征图列表
activations = visible_vgg16(tensor)
编写张量转图像辅助后处理函数, 可视化单个特征图查看效果
#mxnet使用自家的数据结构mxnet.ndarray, 比起keras转化成可视化图像时多一步转numpy的步骤
#同时, mxnet是通道在前的格式BCHW, tensorflow后端的keras是通道在后的格式BHWC, 提取和调整维度时需要注意
def tensor_to_image(tensor):
tensor = tensor[0].asnumpy()
tensor -= tensor.mean()
tensor /= tensor.std()
tensor *= 64
tensor += 128
img = np.clip(tensor, 0, 255).astype('uint8')
return img
# 选其中一张特征图测试可视化
t1 = activations[1][:, 4, :, :] #block2的第一个卷基层第5个卷积核的输出
plt.imshow(tensor_to_image(t1), cmap='viridis')
#可视化第一个Block第一个卷基层所有特征图, 64通道64张图
# 把特征图通道移到第一维, 方便遍历, 注意格式与keras不同
featuremaps = np.transpose(activations[0], [1, 0, 2, 3])
plt.figure(figsize=(40, 10))
for i, featuremap in enumerate(featuremaps):
plt.subplot(4, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
#可视化第二个Block第一个卷基层所有特征图, 128通道128张图
featuremaps = np.transpose(activations[1], [1, 0, 2, 3])
plt.figure(figsize=(20, 10))
for i, featuremap in enumerate(featuremaps):
plt.subplot(8, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
#可视化第三个Block第一个卷基层所有特征图, 256通道256张图
featuremaps = np.transpose(activations[2], [1, 0, 2, 3])
plt.figure(figsize=(20, 20))
for i, featuremap in enumerate(featuremaps):
plt.subplot(16, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
#可视化第四个Block第一个卷基层所有特征图, 512通道512张图
featuremaps = np.transpose(activations[3], [1, 0, 2, 3])
plt.figure(figsize=(20, 40))
for i, featuremap in enumerate(featuremaps):
plt.subplot(32, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
#可视化第五个Block第一个卷基层所有特征图, 512通道512张图
featuremaps = np.transpose(activations[4], [1, 0, 2, 3])
plt.figure(figsize=(20, 40))
for i, featuremap in enumerate(featuremaps):
plt.subplot(32, 16, i+1)
plt.imshow(tensor_to_image(featuremap))
编写辅助后处理函数, 对每个滤波器构建一个到这层为止的VGG16子模型, 使用方便的自动求导接口对输入图梯度上升得到最大响应对应的输入, Pytorch和Gluon的自动求导在便利性和性能上超过keras的backend接口
# 将张量变为图像的辅助函数, 与之前的辅助函数稍有不同
def deprocess_image(x):
x = x[0].asnumpy()
x = np.transpose(x, [1, 2, 0])
x -= x.mean()
x /= (x.std() + 1e-8)
x *= 0.1
x += 0.5
x = np.clip(x, 0, 1)
x *= 255
x = np.clip(x, 0, 255).astype('uint8')
return x
#与keras的函数原理相同但接口风格有别
def generate_pattern(layer_index, filter_index, size=150):
#构建到目标卷积核所在层为止的子模型
model = nn.Sequential()
for i, layer in enumerate(net.features):
model.add(layer)
if i == layer_index:
break
#初始化输入图, gluon模型接受的图像预处理风格与keras不同, 此处初始化为标准正态分布
inputs = nd.random.normal(shape=(1, 3, size, size))
#开辟空间允许记录inputs张量的梯度
inputs.attach_grad()
#40轮梯度上升法求最大响应图
epochs = 40
for i in range(epochs):
#记录梯度
with autograd.record():
output = model(inputs)[0, filter_index, :, :] #注意通道维的位置
loss = output.mean()
#反向传播更新参数
loss.backward()
grad = inputs.grad
grad /= (nd.sqrt(nd.mean(nd.square(grad)))+1e-8) # 标准化
inputs += grad
return deprocess_image(inputs)
可视化这五层前32个滤波器的最大响应输入图
#第一个Block, 这里仅可视化第一层前32个卷积核的最大响应图
#所有可视化的五个层序号为以下列表, 实际使用的是这些卷积层下一个激活层
layers = [1, 6, 11, 18, 25]
layer_index = layers[0]
plt.figure(figsize=(20, 20))
for i in range(16):
plt.subplot(4, 4, i+1)
p = generate_pattern(layer_index, i)
plt.imshow(p)
#第二个Block, 这里仅可视化第一层前32个卷积核的最大响应图
layer_index = layers[1]
plt.figure(figsize=(15, 30))
for i in range(32):
plt.subplot(8, 4, i+1)
plt.imshow(generate_pattern(layer_index, i))
#第三个Block, 这里仅可视化第一层层前32个卷积核的最大响应图
layer_index = layers[2]
plt.figure(figsize=(15, 30))
for i in range(32):
plt.subplot(8, 4, i+1)
plt.imshow(generate_pattern(layer_index, i))
#第四个Block, 这里仅可视化第一层前16个卷积核的最大响应图
layer_index = layers[3]
plt.figure(figsize=(15, 30))
for i in range(32):
plt.subplot(8, 4, i+1)
plt.imshow(generate_pattern(layer_index, i))
#第五个Block, 这里仅可视化第一层前32个卷积核的最大响应图
layer_index = layers[4]
plt.figure(figsize=(15, 30))
for i in range(32):
plt.subplot(8, 4, i+1)
plt.imshow(generate_pattern(layer_index, i))
与keras的结果相同, 最大置信度类别是’Egyptian_cat’序号285
input_image = cv2.imread("00000005_017.jpg")
tensor = image_to_tensor(input_image)
preds = net(tensor)
print("最大类别序号为: ", nd.argmax(preds[0]))
求取最大置信度类别对最后一个卷积层特征图的梯度的均值作为该特征图的重要程度
一个思路是把VGG16拆分, 构建成两个模型, 第一个为从头到最后一个卷基层, 第二个为最后一个卷基层之后
把预处理的张量输入第一个模型输出最后一个卷积层的特征图, 结果输入第二个模型, 打开自动求导, 计算该特征图梯度可以得到最大类别对其的梯度
最后一个卷基层下标是29(加上了之后的激活层)
#求取最大置信度类别对最后一个卷积层特征图的梯度的均值作为该特征图的重要程度(这个梯度其实就是权重)
#把VGG16拆分, 再构建两个模型, 第一个为从头到最后一个卷基层, 第二个为最后一个卷基层之后
#第一个模型输出最后一个卷积层的特征图, 结果输入第二个模型计算该特征图梯度可以得到最大类别对其的梯度
#最后一个卷基层下标是29(加上了之后的激活层)
m1 = nn.Sequential()
m2 = nn.Sequential()
for i, layer in enumerate(net.features):
if i in [32, 34]:
pass #注意不要dropout层, 不然打开自动求导后模型进入训练模式dropout也会被打开
elif i > 29:
m2.add(layer)
else:
m1.add(layer)
m2.add(net.output)
求热力图, 原理同keras版实现
input_image = cv2.imread("00000005_017.jpg")
tensor = image_to_tensor(input_image)
#第一个模型不用记录梯度
conv_layer_output = m1(tensor)
#为最后一个卷积层的输出特征图申请空间, 并对之后计算记录梯度
conv_layer_output.attach_grad()
with autograd.record():
preds = m2(conv_layer_output)
loss = preds[0, 285]
loss.backward()
conv_layer_output_value = conv_layer_output[0] #除掉批次维度
#梯度平均值作为重要程度的权重
pooled_grads_value = conv_layer_output.grad.mean(axis=(0,2,3))
for i in range(512):
#注意通道维度的位置
conv_layer_output_value[i, :, :] *= pooled_grads_value[i]
#同样注意通道维度的位置
heatmap = np.mean(conv_layer_output_value.asnumpy(), axis=0)
初始热力图, 大小同最后一个卷积层的输出特征图
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)
img = cv2.imread("00000005_017.jpg")
heatmap = cv2.resize(heatmap, (img.shape[1], img.shape[0]))
heatmap = np.uint8(255 * heatmap)
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)
superimposed_img = heatmap * 0.3 + img
superimposed_img = np.clip(superimposed_img, 0, 255).astype('uint8')
plt.imshow(superimposed_img[:, :, ::-1]) #显示要转rgb
#cv2.imwrite('heat_map.jpg', superimposed_img)
plt.imshow(heatmap[:, :, ::-1])