CAM系列(一)之CAM(原理讲解和PyTorch代码实现)

本文首发自【简书】作者【西北小生_】的博客,转载请私聊作者!


图1 CAM实现示意图

一、什么是CAM?

CAM的全称是Class Activation MappingClass Activation Map,即类激活映射类激活图

论文《Learning Deep Features for Discriminative Localization》发现了CNN分类模型的一个有趣的现象:
CNN的最后一层卷积输出的特征图,对其通道进行加权叠加后,其激活值(ReLU激活后的非零值)所在的区域,即为图像中的物体所在区域。而将这一叠加后的单通道特征图覆盖到输入图像上,即可高亮图像中物体所在位置区域。如图1中的输入图像和输出图像所示。

该文章作者将实现这一现象的方法命名为类激活映射,并将特征图叠加在原始输入图像上生成的新图片命名为类激活图

二、CAM有什么用?

CAM一般有两种用途:

  • 可视化模型特征图,以便观察模型是通过图像中的哪些区域特征来区分物体类别的;
  • 利用卷积神经网络分类模型进行弱监督的图像目标定位

第一种用途是最直接的用途,根据CAM高亮的图像区域,可以直观地解释CNN是如何区分不同类别的物体的。

对于第二种用途,一般的目标定位方法,都需要专门对图像中的物体位置区域进行标注,并将标注信息作为图像标签的一部分,然后通过训练带标签的图像和专门的目标定位模型才能实现定位,是一种强监督的方法。而CAM方法不需要物体在图像中的位置信息,仅仅依靠图像整体的类别标签训练分类模型,即可找到图像中物体所在的大致位置并高亮之,因此可以作为一种弱监督的目标定位方法。

三、CAM原理

图2 输出结构示意图

如图2所示,CNN最后一层卷积层输出的特征图是三维的:[C, H, W ],设特征图的第k个通道可表示为f_k(x,y),其中x,y分别是宽和高维度上的索引。若最后一个卷积层连接一个全局平均池化层,然后再由一个全连接层输出分类结果,则由最后一个卷积层的输出特征图到输出层中的第c个类别的置信分数(未进行Softmax映射前)的计算过程可表示为:
S_c=\sum_{k}w_{k}^{c} \sum_{x,y}f_{k}(x,y)=\sum_{x,y} \sum_{k} w_{k}^{c} f_{k}(x,y) \tag{1}
其中\sum_{x,y}f_{k}(x,y)为全局平均池化(省略了除以元素总数),由于只对空间上到宽和高两个维度求和,结果就是这两个维度坍塌,只剩通道维度保持不变,即计算结果为C个数值,每个值代表着该通道上所有值的平均值。w_{k}^{c}表示全连接输出层中第c类对应的C个权重中的第k个:即全连接层的权重矩阵W[N_o,C]维的(N_o即输出类别数,C是最后一层卷积层的输出通道数),那么第c类对应的权重w^c就应该是W[c,:]w^c有着C个权重参数,对应着每个输入值(即全局平均池化的结果),w^c_k就是这C个权重参数中的第k个数。

\sum_{k}w_{k}^{c} \sum_{x,y}f_{k}(x,y)表示特征图的每个输出通道首先被平均为一个值,C个通道得到C个值,然后这些值再被加权相加得到一个数,这个数就是第c类的置信分数,表征着输入图像的类别是c的可能性大小。

\sum_{x,y} \sum_{k} w_{k}^{c} f_{k}(x,y)表示首先对特征图的每个通道进行加权求和(\sum_{k} w_{k}^{c} f_{k}(x,y)),得到一个二维的特征图(通道维坍塌),然后再对这个二维特征图求平均值,得到第c类的置信分数。

由公式(1)的推导可知,先对特征图进行全局平均池化,再进行加权求和得到类别的置信分数,等价于先对特征图进行通道维度的加权求和,再进行全局平均池化。

经过这一等价变换,就突显了特征图通道加权和\sum_{k} w_{k}^{c} f_{k}(x,y)的重要性了:一方面,特征图的通道加权和直接编码了类别信息;另一方面,也是最重要的,特征图的通道加权和是二维的,还保留着图像的空间位置信息。我们可以通过可视化方法观察到图像中的相对位置信息与CNN编码的类别信息的关系。

这里的特征图的通道加权之和\sum_{k} w_{k}^{c} f_{k}(x,y)就叫做类别激活图。

四、CAM的PyTorch实现

本文以PyTorch自带的ResNet-18为例,分步骤讲解并用代码实现CAM的整个流程和细节。

1.准备工作

首先导入需要用到的包:

import math
import torch
from torch import Tensor
from torch import nn
import torch.nn.functional as F
from typing import Optional, List
import torchvision.transforms as transforms
from PIL import Image
import torchvision.models as models
from torch import Tensor
from matplotlib import cm
from torchvision.transforms.functional import to_pil_image

定义输入图片路径,和保存输出的类激活图的路径:

img_path = '/home/dell/img/1.JPEG'     # 输入图片的路径
save_path = '/home/dell/cam/CAM1.png'    # 类激活图保存路径

定义输入图片预处理方式。由于本文用的输入图片来自ILSVRC-2012验证集,因此采用PyTorch官方文档提供的ImageNet验证集处理流程:

preprocess = transforms.Compose([transforms.Resize(256),
                                transforms.CenterCrop(224),
                                transforms.ToTensor(),
                                transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
2.获取CNN最后一层卷积层的输出特征图

本文选用的CNN模型是PyTorch自带的ResNet-18,首先导入预训练模型:

net = models.resnet18(pretrained=True).cuda()   # 导入模型

由于特征图是模型前向传播时的中间变量,不能直接从模型中获取,需要用到PyTorch提供的hook工具,补课请参考我的这两篇博客:hook1hook2

通过输出模型(print(net))我们就能看到ResNet-18输出最后一层特征图的层为net.layer4(或者net.layer4[1]、net.layer4[1].bn2都可)。我们用hook工具注册这一层,以便获得它的输出特征图:

feature_map = []     # 建立列表容器,用于盛放输出特征图

def forward_hook(module, inp, outp):     # 定义hook
    feature_map.append(outp)    # 把输出装入字典feature_map

net.layer4.register_forward_hook(forward_hook)    # 对net.layer4这一层注册前向传播

做好了hook的定义和注册工作,现在只需要对输入图片进行预处理,然后执行一次模型前向传播即可获得CNN最后一层卷积层的输出特征图:

orign_img = Image.open(img_path).convert('RGB')    # 打开图片并转换为RGB模型
img = preprocess(orign_img)     # 图片预处理
img = torch.unsqueeze(img, 0)     # 增加batch维度 [1, 3, 224, 224]

with torch.no_grad():
    out = net(img.cuda())     # 前向传播

这时我们想要的特征图已经装在列表feature_map中了。我们输出尺寸来验证一下:

In [10]: print(feature_map[0].size())
torch.Size([1, 512, 7, 7])
3.获取权重

CAM使用的权重是全连接输出层中,对应这张图像所属类别的权重。文字表述可能存在歧义或不清楚,直接看本文最上面的图中全连接层被着色的连接。可以看到,每个连接对应一个权重值,左边和特征图的每个通道(全局平均池化后)一一连接,右边全都连接着输出类别所对应的那个神经元。

由于我也不知道这张图的类别标签,这里假设模型对这张图像分类正确,我们来获得其输出类别所对应的权重:

cls = torch.argmax(out).item()    # 获取预测类别编码
weights = net._modules.get('fc').weight.data[cls,:]    # 获取类别对应的权重
4.对特征图的通道进行加权叠加,获得CAM
cam = (weights.view(*weights.shape, 1, 1) * feature_map[0].squeeze(0)).sum(0)

这里的代码比较简单,扩充权重的维度([512, ]\rightarrow[512, 1, 1])是为了使之在通道上与特征图相乘;去除特征图的batch维([1, 512, 7, 7]\rightarrow[512, 7, 7])是为了使其维度和weights扩充后的维度相同以相乘。最后在第一维(通道维)上相加求和,得到一个7\times 7的类激活图。

5.对CAM进行ReLU激活和归一化

这一步有两个细节需要注意:

  • 上步得到的类激活图像素值分布杂乱,要想确定目标位置,须先进行ReLU激活,将正值保留,负值置零。像素值正值所在的(一个或多个)区域即为目标定位区域。
  • 上步获得的激活图还只是一个普通矩阵,需要变换成图像规格,将其值归一化到[0,1]之间。

我们首先定义归一化函数:

def _normalize(cams: Tensor) -> Tensor:
        """CAM normalization"""
        cams.sub_(cams.flatten(start_dim=-2).min(-1).values.unsqueeze(-1).unsqueeze(-1))
        cams.div_(cams.flatten(start_dim=-2).max(-1).values.unsqueeze(-1).unsqueeze(-1))

        return cams

然后对类激活图执行ReLU激活和归一化,并利用PyTorch的 to_pil_image函数将其转换为PIL格式以便下步处理:

cam = _normalize(F.relu(cam, inplace=True)).cpu()
mask = to_pil_image(cam.detach().numpy(), mode='F')

将类激活图转换成PIL格式是为了方便下一步和输入图像融合,因为本例中我们选用的PIL库将输入图像打开,选用PIL库也是因为PyTorch处理图像时默认的图像格式是PIL格式的。

6.将类激活图覆盖到输入图像上,实现目标定位

这一步也有很多细节需要注意:

  • 上步得到的类激活图只有7\times 7的尺寸,想要将其覆盖在输入图像上显示,就需将其用插值的方法扩大到和输入图像相同大小。
  • 我们的目的是用类激活图中被激活(非零值)的位置区域,来高亮原始图像中相应的位置区域,这一高亮的方法就是将激活图变换为热力图的形式:值越大的像素颜色越红,值越小的像素颜色越蓝
  • 如果直接将热力图覆盖到原始输入图像上,会遮蔽图像中的内容导致不容易观察,因此需要设置两个图像融合的比例(透明度),即在两种图像融合在一起时,将原始输入图像的像素值权重设置大一些,而把热力图的像素值权重设置小一些,这样就会使生成图像中原始输入图像的内容更加清晰,易于观察。(mixup方法同理)
  • 两种图像融合后的像素值会超出图像规格像素值的范围[0,1],因此还需要将其转换为图像规格。

我们将两个图像交叠融合的过程封装成了函数:

def overlay_mask(img: Image.Image, mask: Image.Image, colormap: str = 'jet', alpha: float = 0.6) -> Image.Image:
    """Overlay a colormapped mask on a background image

    Args:
        img: background image
        mask: mask to be overlayed in grayscale
        colormap: colormap to be applied on the mask
        alpha: transparency of the background image

    Returns:
        overlayed image
    """

    if not isinstance(img, Image.Image) or not isinstance(mask, Image.Image):
        raise TypeError('img and mask arguments need to be PIL.Image')

    if not isinstance(alpha, float) or alpha < 0 or alpha >= 1:
        raise ValueError('alpha argument is expected to be of type float between 0 and 1')

    cmap = cm.get_cmap(colormap)    
    # Resize mask and apply colormap
    overlay = mask.resize(img.size, resample=Image.BICUBIC)
    overlay = (255 * cmap(np.asarray(overlay) ** 2)[:, :, 1:]).astype(np.uint8)
    # Overlay the image with the mask
    overlayed_img = Image.fromarray((alpha * np.asarray(img) + (1 - alpha) * overlay).astype(np.uint8))

    return overlayed_img

接下来就是激动人心的时刻了!!!将类激活图作为掩码,以一定的比例覆盖到原始输入图像上,生成类激活图:

result = overlay_mask(orign_img, mask) 

这里的变量result已经是有着PIL图片格式的类激活图了,我们可以通过:

result.show()

可视化输出,也可以通过:

result.save(save_path)

将图片保存在本地查看。我们在这里展示一下输入图像和输出定位图像的对比:


(左)输入图像;(右)定位图像
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容