안녕하세요.

 

오늘은 Grad-CAM 논문의 code review를 진행해보려고 합니다.

 

해당 논문의 paper review는 다음 주소에서 확인하실 수 있습니다.

 

https://cumulu-s.tistory.com/40

 

9. Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization - paper review

안녕하세요. 오늘은 CAM을 발전시킨 방법론인 Grad-CAM의 논문을 review 해보겠습니다. 논문 주소: arxiv.org/abs/1610.02391 Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization..

cumulu-s.tistory.com

 

 

 

이번 code review에서는 ImageNet Pre-trained resnet50을 이용해서 Grad-CAM을 만들어보면서 code review를 진행해보겠습니다.

 

물론, resnet50을 받아올 때 pretrained = False로 parameter를 setting 하게 되면 별도의 학습된 weight를 가지고 오지 않으므로, 이를 활용해서 원하시는 custom dataset에 동일한 방법을 사용해 Grad-CAM을 생성하실 수 있습니다.

 

custom dataset에 활용하는 방법은 제 Github에 구현되어 있으니, 확인해주시면 됩니다.

 

다만 제가 이번 code review에서 Pre-trained resnet50을 사용하는 이유는, 본 논문에서 제시되었던 Fig. 1을 직접 구현하기 위함입니다.

 

Fig. 1은 다음과 같습니다.

 

 

그럼 본격적으로 시작해보겠습니다.

 

import torchvision.models as models
import torch.nn as nn
import torch
import os
import cv2
import PIL
import torchvision
import torchvision.transforms as transforms
import datetime
import numpy as np
import torch.nn.functional as F
import matplotlib.pyplot as plt
import matplotlib
from torchvision.utils import make_grid, save_image

device = 'cuda' if torch.cuda.is_available() else 'cpu'

resnet50 = models.resnet50(pretrained = True).to(device)
resnet50.eval()

 

구현하는데 필요한 library를 불러오고, device를 cuda로 설정하였으며 Pre-trained resnet50를 가져옵니다.

 

그리고 Grad-CAM에서는 별도로 학습을 진행하지 않으므로, resnet50.eval()을 선언하여 inference mode로 전환합니다.

 

 

img_dir = 'images'
img_name = 'both.png'
img_path = os.path.join(img_dir, img_name)

pil_img = PIL.Image.open(img_path)
pil_img

 

images라는 directory는 제 Github에 올라와있으며, 이를 통해서 논문에 나왔던 개와 고양이가 같이 있는 사진을 가져옵니다.

 

해당 사진은 다음과 같습니다.

 

 

def normalize(tensor, mean, std):
    if not tensor.ndimension() == 4:
        raise TypeError('tensor should be 4D')

    mean = torch.FloatTensor(mean).view(1, 3, 1, 1).expand_as(tensor).to(tensor.device)
    std = torch.FloatTensor(std).view(1, 3, 1, 1).expand_as(tensor).to(tensor.device)

    return tensor.sub(mean).div(std)


class Normalize(object):
    def __init__(self, mean, std):
        self.mean = mean
        self.std = std

    def __call__(self, tensor):
        return self.do(tensor)
    
    def do(self, tensor):
        return normalize(tensor, self.mean, self.std)
    
    def undo(self, tensor):
        return denormalize(tensor, self.mean, self.std)

    def __repr__(self):
        return self.__class__.__name__ + '(mean={0}, std={1})'.format(self.mean, self.std)

위 사진을 normalization 해야 하므로, 이를 위한 코드를 만들어줍니다.

 

 

 

normalizer = Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
torch_img = torch.from_numpy(np.asarray(pil_img)).permute(2, 0, 1).unsqueeze(0).float().div(255).cuda()
torch_img = F.interpolate(torch_img, size=(224, 224), mode='bilinear', align_corners=False) # (1, 3, 224, 224)
normed_torch_img = normalizer(torch_img) # (1, 3, 224, 224)

 

mean값과 std 값은 ImageNet의 값들이며, 알려져 있는 값들이라 그대로 사용하면 됩니다.

 

pil_img를 numpy로 만들고, permute를 활용해 차원의 위치를 바꿔주고, unsqueeze(0)으로 맨 첫 번째 차원을 하나 늘려주면서 255로 나눠줍니다. 이를 torch.from_numpy를 통해 torch.Tensor로 전환합니다.

 

그리고 우리가 사용할 ImageNet pre-trained resnet50은 input shape가 224x224가 되어야 합니다. 따라서 이를 F.interpolate를 이용해서 224,224가 되도록 width / height를 변경해줍니다. 

 

이를 적용하면 torch_img의 shape는 (1, 3, 224, 224)가 됩니다.

 

앞에서 함수로 만들어뒀던 normalizer를 활용해서 이 이미지를 normalization 해줍니다.

 

 

current_time = datetime.datetime.now() + datetime.timedelta(hours= 9)
current_time = current_time.strftime('%Y-%m-%d-%H:%M')

saved_loc = os.path.join('./', current_time)
if os.path.exists(saved_loc):
    shutil.rmtree(saved_loc)
os.mkdir(saved_loc)

print("결과 저장 위치: ", saved_loc)

제 코드 리뷰를 보신 분들은 아시겠지만, 항상 사용하는 코드들입니다.

 

해당 코드를 돌렸을 때 결과를 저장하기 위해서 돌린 시간을 폴더 이름으로 만들어줍니다.

 

from torch.autograd import Function


class GuidedBackpropReLU(Function):
    @staticmethod
    def forward(self, input_img):
        # input image 기준으로 양수인 부분만 1로 만드는 positive_mask 생성
        positive_mask = (input_img > 0).type_as(input_img)
        
        # torch.addcmul(input, tensor1, tensor2) => output = input + tensor1 x tensor 2
        # input image와 동일한 사이즈의 torch.zeros를 만든 뒤, input image와 positive_mask를 곱해서 output 생성
        output = torch.addcmul(torch.zeros(input_img.size()).type_as(input_img), input_img, positive_mask)
        
        # backward에서 사용될 forward의 input이나 output을 저장
        self.save_for_backward(input_img, output)
        return output

    @staticmethod
    def backward(self, grad_output):
        
        # forward에서 저장된 saved tensor를 불러오기
        input_img, output = self.saved_tensors
        grad_input = None

        # input image 기준으로 양수인 부분만 1로 만드는 positive_mask 생성
        positive_mask_1 = (input_img > 0).type_as(grad_output)
        
        # 모델의 결과가 양수인 부분만 1로 만드는 positive_mask 생성
        positive_mask_2 = (grad_output > 0).type_as(grad_output)
        
        # 먼저 모델의 결과와 positive_mask_1과 곱해주고,
        # 다음으로는 positive_mask_2와 곱해줘서 
        # 모델의 결과가 양수이면서 input image가 양수인 부분만 남도록 만들어줌
        grad_input = torch.addcmul(torch.zeros(input_img.size()).type_as(input_img),
                                   torch.addcmul(torch.zeros(input_img.size()).type_as(input_img), grad_output,
                                                 positive_mask_1), positive_mask_2)
        return grad_input

 

이번에 소개할 내용은 GuidedBackpropagation 입니다. 

 

Grad-CAM 논문에서는 별도로 Guided Backpropagation을 설명하고 있지는 않지만, 논문의 Fig. 1을 만들기 위해서는 알아야 하므로 설명을 해보겠습니다.

 

Guided backpropagation을 설명해주는 많은 글에서 사용하는 그림 자료입니다.

 

Forward pass에서는, input image에서 양수인 부분들은 그대로 출력하고, 음수인 부분은 0으로 처리합니다.

 

ReLU와 동일하다고 할 수 있죠.

 

Backward pass에서, 우리가 살펴볼 내용은 가장 아래쪽에 있는 Guided backpropagation입니다.

 

Guided backpropagation은 gradient가 0보다 크면서, relu의 output이 양수인 부분만 backward로 넘겨줍니다.

 

Guided backpropagation을 보게 되면 gradient가 현재 -2, 3, -1, 6, -3, 1, 2, -1, 3입니다.

 

여기서 먼저 0보다 큰 부분을 남기면 0, 3, 0, 6, 0, 1, 2, 0, 3이 될 것이고,

 

relu의 output이 양수인 부분만 다시 한번 걸러서 남겨주게 되면 0, 0, 0, 6, 0, 0, 0, 0, 3이 됩니다.

 

이와 같은 방식을 코드로 구현한 내용이라고 보시면 되겠습니다.

 

 

 

앞에서 설명드린 대로, Guided Backpropagation은 크게 forward와 backward 두 가지로 나눠서 생각해봐야 합니다.

 

Forward는 ReLU와 동일합니다.

 

input_img가 들어왔을 때, 값이 0보다 큰 경우만 남기기 위해 positive_mask를 생성합니다.

 

예를 들어서, input 값이 [1, -2, 3, -5, 7]이라고 한다면, positive_mask는 [1, 0, 1, 0, 1]이 됩니다.

 

만들어진 positive_mask를 이용해서, torch.addcmul 함수를 이용해 input_img 중에 양수인 부분만 남기도록 만들어줍니다.

 

torch.addcmul은 input와 tensor1, tensor2를 입력으로 받고, input + tensor1 x tensor 2를 결과로 내게 되는데 해당 코드에서는 input은 그냥 torch.zeros로 0으로 채워진 tensor를 만들어주고, input_img와 positive_mask를 곱해서 나온 결과를 output으로 만들어줍니다.

 

그러고 나서 self.save_for_backward 함수를 통해 input과 output을 저장해줍니다.

 

이렇게 저장을 해두면, backward 단에서 이를 가지고 와서 사용할 수 있게 됩니다.

 

 

다음으로는 backward입니다. 

 

먼저 forward에서 저장된 input_img와 output을 self.saved_tensors를 통해서 가져옵니다.

 

backward에서는 앞에서 그림과 함께 설명한 대로, 두 가지 기준을 만족해야 하는데요.

 

1) forward에서 relu output이 0보다 큰 부분 / 2) gradient가 0보다 큰 부분이라는 조건을 만족해야 합니다.

 

따라서 이번에는 positive_mask를 두 개 생성합니다.

 

positive_mask_1은 input_img가 0보다 큰 부분을, positive_mask_2는 gradient가 0보다 큰 부분을 잡습니다.

 

그리고 이전과 동일하게 torch.addcmul 함수를 이용해 이 두 개의 mask를 적용해서 최종 결과를 내게 됩니다.

 

 

 

class GuidedBackpropReLUModel:
    def __init__(self, model, use_cuda):
        self.model = model
        self.model.eval()
        self.cuda = use_cuda
        if self.cuda:
            self.model = model.cuda()

        def recursive_relu_apply(module_top):
            for idx, module in module_top._modules.items():
                recursive_relu_apply(module)
                if module.__class__.__name__ == 'ReLU':
                    module_top._modules[idx] = GuidedBackpropReLU.apply

        # replace ReLU with GuidedBackpropReLU
        recursive_relu_apply(self.model)

    def forward(self, input_img):
        return self.model(input_img)

    def __call__(self, input_img, target_category=None):
        if self.cuda:
            input_img = input_img.cuda()

        input_img = input_img.requires_grad_(True)

        output = self.forward(input_img)

        if target_category is None:
            target_category = np.argmax(output.cpu().data.numpy())

        one_hot = np.zeros((1, output.size()[-1]), dtype=np.float32)
        one_hot[0][target_category] = 1
        one_hot = torch.from_numpy(one_hot).requires_grad_(True)
        if self.cuda:
            one_hot = one_hot.cuda()

        one_hot = torch.sum(one_hot * output)
        # 모델이 예측한 결과값을 기준으로 backward 진행
        one_hot.backward(retain_graph=True)

        # input image의 gradient를 저장
        output = input_img.grad.cpu().data.numpy()
        output = output[0, :, :, :]
        output = output.transpose((1, 2, 0))
        return output

 

다음으로 소개할 내용은 모델을 Guided Backpropagation이 가능한 모델로 바꿔주는 코드입니다.

 

__init__에서 받는 인자들을 보시면 model과 use_cuda를 받게 되고, 우리의 코드에서는 model = resnet50이고 use_cuda는 True입니다.

 

recursive_relu_apply라는 함수를 self.model에 적용하는 것을 확인할 수 있는데, 이는 ReLU를 GuidedBackpropReLU로 바꾸게 됩니다. GuidedBackpropReLU는 방금 위에서 설명드린 코드입니다.

 

 

실제로 이 모델이 불렸을 때 (call 되었을 때) 작동하는 부분도 보겠습니다.

 

인자로는 input_img와 target_category를 받게 됩니다. target_category는 어떤 class를 기준으로 Guided Backprop을 시행할지를 결정하는 것이라고 보시면 되겠습니다.

 

고양이에 대한 그림을 그리고 싶으면 target_category에 cat class에 해당하는 값을 집어넣으면 되고, 개에 대한 그림을 그리고 싶으면 dog class에 해당하는 값을 집어넣으면 됩니다.

 

먼저 input_img의 requires_grad를 True로 지정하고, 이를 가지고 self.forward로 모델을 통과시킵니다.

 

즉 이 작업은 이미지가 주어졌을 때, 이를 기반으로 어떤 class인지 예측을 하는 image classification을 작동시킵니다.

 

그럼 이걸 통해서 예측된 결과가 나오게 되겠죠? 그게 output이라는 변수에 저장됩니다.

 

one_hot은 output 사이즈만큼 np.zeros로 만들어주는 것이고, one_hot[0][target_category] = 1은 말 그대로 target_category에 해당하는 곳만 1로 만들어줍니다.

 

예를 들어서 고양이는 281번인데, target_category를 281로 지정하면 one_hot에서 281번째 위치만 1이 되고 나머지는 0이 됩니다.

 

그리고 이를 torch.from_numpy로 해서 torch Tensor로 바꿔줍니다. 

 

다음으로는 one_hot = torch.sum(one_hot * output)이 되는데, 이는 예측한 값과 one_hot을 곱해 우리가 원하는 class의 예측값만을 빼냅니다. 

 

즉, 고양이를 예로 들자면 고양이가 될 확률을 얼마로 예측했는지를 저장한 것이죠.

 

그리고 이를 가지고 .backward()를 통해서 backward를 진행합니다.

 

output이라는 변수로 input image의 gradient를 저장하고, numpy로 바꾼 다음 transpose를 통해 차원의 위치를 바꿔줍니다.

 

이 작업은 torch에서의 차원 순서 별 의미와 numpy에서의 차원 순서 별 의미가 다르기 때문에 작업합니다.

 

pytorch에서는 224x224 짜리 RGB 이미지를 (3, 224, 224)로 저장하게 되는데, numpy에서는 (224, 224, 3)으로 저장하기 때문이죠.

 

def deprocess_image(img):
    """ see https://github.com/jacobgil/keras-grad-cam/blob/master/grad-cam.py#L65 """
    img = img - np.mean(img)
    img = img / (np.std(img) + 1e-5)
    img = img * 0.1
    img = img + 0.5
    img = np.clip(img, 0, 1)
    return np.uint8(img * 255)

 

해당 코드는 Guided Backprop을 통해서 얻어진 이미지를 처리하는 함수입니다.

 

논문에 나온 사진처럼 만들기 위해 관례적으로 사용하는 처리 방식인 것 같습니다.

 

 

 

# final conv layer name 
finalconv_name = 'layer4'

# activations
feature_blobs = []

# gradients
backward_feature = []

# output으로 나오는 feature를 feature_blobs에 append하도록
def hook_feature(module, input, output):
    feature_blobs.append(output.cpu().data)
    

# Grad-CAM
def backward_hook(module, input, output):
    backward_feature.append(output[0])
    

resnet50._modules.get(finalconv_name).register_forward_hook(hook_feature)
resnet50._modules.get(finalconv_name).register_backward_hook(backward_hook)

finalconv_name을 통해서 어떤 conv layer에서 Grad-CAM을 뽑아낼지 결정합니다.

 

만약 다른 conv의 결과를 가지고 Grad-CAM을 구현하고 싶다면, 이 부분을 변경하시면 됩니다.

 

feature_blobs는 forward 단계에서 얻어지는 feature를 저장하는 곳이고, backward_feature는 backward 단계에서 얻어지는 feature를 저장하는 곳입니다.

 

hook_feature라는 함수를 통해 forward 진행 시 특정한 layer에서 얻어지는 output을 feature_blobs에 저장합니다.

 

backward_hook이라는 함수를 통해 backward 진행 시 특정한 layer에서 얻어지는 output을 backward_feature에 저장합니다.

 

resnet50._modules.get(finalconv_name).register_forward_hook(hook_feature)은 forward 단계에서 hook_feature를 적용하도록 만드는 함수이고, 그 밑은 backward 단계에서 backward_hook을 적용하도록 만드는 함수입니다.

 

 

 

forward라는 것은, input image가 주어졌을 때 conv layer들을 거쳐서 최종 output을 내는 과정을 말합니다.

 

예를 들자면, 어떤 사진이 주어졌을 때, layer들을 거쳐 최종 output으로 해당 이미지가 어떤 종류의 이미지인지를 분류하는 것이죠.

 

 

ResNet 논문에 있는 전체 구조 table을 살펴보자면, 마지막 conv을 거치고 나온 결과는 output size가 7x7이고 channel 수가 512입니다. 

 

따라서, forward를 진행하고 나서 이 feature가 feature_blobs에 저장될 것입니다.

 

 

Backward는 계산한 output을 가지고 backpropagation을 통해 gradient를 계산하는 과정이라고 보시면 됩니다.

 

 

 

논문에서 "gradients via backprop"이라고 표현된 부분이 바로 위의 코드에서 구현된 부분이라고 보시면 되겠습니다.

 

의미적으로 본다면, 해당 Activation map($A^k$)이 어떤 예측값에 영향을 준 정도가 되겠죠?

 

이 또한 마지막 conv을 거치고 나온 결과와 사이즈가 동일하므로, output size가 7x7이고 channel 수가 512가 나옵니다.

 

 

 

# get the softmax weight
params = list(resnet50.parameters())
weight_softmax = np.squeeze(params[-2].cpu().detach().numpy()) # [1000, 512]

# Prediction
logit = resnet50(normed_torch_img)

 

resnet50에 있는 모든 파라미터를 list로 만들어두고, 여기서 마지막에서 두 번째 파라미터만 빼냅니다

 

params[-1]은 1000개로 예측할 때 사용된 bias이고, params[-2]는 weight가 됩니다.

 

이 부분은 모델을 설계할 때 마지막 fc layer에서 bias = False로 지정될 경우 이를 -1로 바꿔줘야 하기 때문에 반드시 모델을 어떻게 설계했는지 확인하셔야 합니다.

 

 

아까 만들어뒀던 normalization을 거친 img를 resnet50에 투입하여 결괏값을 냅니다.

 

이를 logit이라는 변수에 저장합니다.

 

 

 

# ============================= #
# ==== Grad-CAM main lines ==== #
# ============================= #


# Tabby Cat: 281, pug-dog: 254
score = logit[:, 254].squeeze() # 예측값 y^c
score.backward(retain_graph = True) # 예측값 y^c에 대해서 backprop 진행

 

지금부터가 본 논문의 메인 파트가 됩니다.

 

어쩌다 보니 앞의 내용이 너무 길었어서 오히려 본론이 늦게 나온 느낌인데요.

 

코드를 따라가시면서 이해가 최대한 잘 되도록 주석으로 shape를 표기하였으니, 같이 확인하시면서 따라가 주시면 좋을 것 같습니다.

 

logit에서 254번째로 예측한 값을 score라는 변수로 빼냅니다.

 

ImageNet class name을 확인해보니, index 기준 254가 pug-dog이었습니다.

 

따라서 logit[:, 254]로 예측값을 빼낼 수 있습니다.

 

고양이의 경우 281로 바꿔주시면 됩니다.

 

그리고 해당 score를 가지고 backward를 진행하도록 해줍니다.

 

 

activations = feature_blobs[0].to(device) # (1, 512, 7, 7), forward activations
gradients = backward_feature[0] # (1, 512, 7, 7), backward gradients
b, k, u, v = gradients.size()

alpha = gradients.view(b, k, -1).mean(2) # (1, 512, 7*7) => (1, 512), feature map k의 'importance'
weights = alpha.view(b, k, 1, 1) # (1, 512, 1, 1)

 

feature_blobs[0]는 위에서 설명드렸지만, forward pass를 진행하면서 얻게 되는 마지막 conv layer의 feature map이 됩니다. 

 

이는 크기가 (1, 512, 7, 7)이 되고요. (1은 batch size인데 지금은 이미지를 한 장 투입했으므로 1입니다.)

 

이를 activations라는 변수로 저장합니다.

 

backward_feature[0]도 앞에서 설명했듯이 backward pass를 진행하면서 얻게 되는 마지막 conv layer의 feature map의 gradient가 됩니다. 

 

이 또한 activations와 크기는 동일합니다.

 

다음으로는 gradient를 (1, 512, 7*7) 사이즈로 만든 뒤, 이를 3번째 차원 기준으로 평균을 냅니다.

 

이는 논문에서 다음 수식을 코드로 구현한 것입니다.

 

 

따라서 이 변수는 크기가 (1, 512)가 되며, 각 feature map k에 대한 importance weight를 나타냅니다.

 

논문에서 보셨겠지만, 이 importance weight는 forward activation map과의 weighted combination을 진행해야 하므로 사이즈가 동일해야 합니다.

 

이를 위해 .view를 이용하여 (1, 512, 1, 1)로 만들어줍니다.

 

grad_cam_map = (weights*activations).sum(1, keepdim = True) # alpha * A^k = (1, 512, 7, 7) => (1, 1, 7, 7)
grad_cam_map = F.relu(grad_cam_map) # Apply R e L U
grad_cam_map = F.interpolate(grad_cam_map, size=(224, 224), mode='bilinear', align_corners=False) # (1, 1, 224, 224)
map_min, map_max = grad_cam_map.min(), grad_cam_map.max()
grad_cam_map = (grad_cam_map - map_min).div(map_max - map_min).data # (1, 1, 224, 224), min-max scaling

 

앞에서 구해놓은 weights와 forward activation map인 activations를 곱해주고, 이를 2 번째 차원 기준으로 더해줍니다.

 

이는 논문의 해당 수식을 코드로 구현한 것입니다.

 

 

그리고 여기에 F.relu로 ReLU를 적용하고요.

 

마지막 conv layer의 feature map 사이즈가 7x7였기 때문에, 이를 그대로 두면 원본 이미지와 더해줄 수가 없습니다. 

 

따라서 F.interpolate 함수를 이용하여 이를 224x224로 upsampling 해줍니다. 

 

mode는 bilinear로 설정해주는데, 이는 논문에 나온 내용을 그대로 구현한 것입니다.

 

논문의 3.2 Guided Grad-CAM에는 다음과 같은 내용이 적혀있습니다.

 

 

다음으로는 grad_cam_map을 min과 max를 구해 이를 min-max scaling 해줍니다. 

 

이를 통해 (1, 1, 224, 224)의 shape를 가지고 값은 0 ~ 1을 가지는 Grad-CAM을 가지게 되었습니다.

 

# grad_cam_map.squeeze() : (224, 224)
grad_heatmap = cv2.applyColorMap(np.uint8(255 * grad_cam_map.squeeze().cpu()), cv2.COLORMAP_JET) # (224, 224, 3), numpy

# Grad-CAM heatmap save
cv2.imwrite(os.path.join(saved_loc, "Grad_CAM_heatmap.jpg"), grad_heatmap)

grad_heatmap = np.float32(grad_heatmap) / 255

grad_result = grad_heatmap + img
grad_result = grad_result / np.max(grad_result)
grad_result = np.uint8(255 * grad_result)

# Grad-CAM Result save
cv2.imwrite(os.path.join(saved_loc, "Grad_Result.jpg"), grad_result)

 

논문의 그림을 보시면, Grad-CAM이라고 하는 것이 빨간색, 초록색, 파란색 등 색깔이 있는 무언가로 만들어지는 것을 확인하실 수 있는데요. 이는 Grad-CAM heatmap입니다.

 

이를 코드로 구현하려면 opencv에서 지원하는 cv2.applyColorMap 함수를 통해 구현이 됩니다.

 

cv2는 input이 반드시 uint8 타입일 때만 작동하므로 주의해야 하고요.

 

아까 구해둔 grad_cam_map은 (1, 1, 224, 224)였으므로, 이를 .squeeze()를 통해서 (224, 224)의 2차원 형식으로 바꿔줍니다.

 

이에 255를 곱해주고, np.uint8을 적용하여 0 ~ 255의 값을 가지는 numpy array로 변환합니다. 

 

그리고 cv2.applyColorMap을 적용하면 여전히 numpy array이고, 크기는 (224, 224, 3) 짜리의 RGB image가 됩니다.

 

opencv의 cv2.imwrite 함수를 이용해서 이를 이미지로 저장해줍니다.

 

이를 통해 저장되는 이미지는 다음과 같습니다.

 

 

Grad-CAM heatmap

 

 

다음으로는 Grad-CAM heatmap과 original image가 같이 나오는 사진을 만들어보겠습니다.

 

먼저 기존에 만들어뒀던 heatmap을 float로 바꾼 뒤, 255로 나눠줍니다. 이러면 값이 0 ~ 1 사이로 맞춰집니다.

 

그리고 이 heatmap과 기존에 저장해뒀던 img와 더해줍니다. img도 마찬가지로 0 ~ 1 사이의 값을 가집니다.

 

이렇게 둘을 더하면, 값이 1을 벗어나기 때문에 0 ~ 1 사이로 값을 맞춰주고자 np.max 값으로 나눠줍니다.

 

마지막으로 이를 다시 np.uint8(255 * grad_result)로 0 ~ 255의 값을 가지는 데이터로 바꿔줍니다.

 

또다시 cv2.imwrite를 이용해 이미지로 저장해줍니다.

 

이를 통해 저장되는 이미지는 다음과 같습니다.

 

Grad-CAM heatmap + original image

 

개로 예측한 케이스이므로, 개의 주변에 빨간색으로 만들어지는 것을 알 수 있습니다.

 

즉, 개의 얼굴에 해당하는 이미지의 region을 보고 개로 예측했다는 것을 의미합니다.

 

 

# ============================= #
# ==Guided-Backprop main lines= #
# ============================= #

# gb_model => ReLU function in resnet50 change to GuidedBackpropReLU.
gb_model = GuidedBackpropReLUModel(model=resnet50, use_cuda=True)
gb_num = gb_model(torch_img, target_category = 254)
gb = deprocess_image(gb_num) # (224, 224, 3), numpy

# Guided Backprop save
cv2.imwrite(os.path.join(saved_loc, "Guided_Backprop.jpg"), gb)

 

다음으로는 앞에서 소개해드렸던 Guided Backpropagation 관련 코드입니다.

 

먼저 GuidedBackpropReLUModel이라는 클래스를 이용해서, 기존 resnet50이라는 모델의 ReLU를 GuidedBackpropReLU로 변경해줍니다.

 

이를 통해 얻게 되는 것이 gb_model입니다.

 

다음으로는 이 모델에 image를 투입하여 얻게 되는 Guided Backpropagation 이미지는 gb_num으로 저장됩니다.

 

저 위에서 소개해드렸지만, gb_num은 input 이미지의 gradient가 됩니다.

 

그리고 deprocess_image라는 함수를 이용해 이 gb_num을 처리해줍니다.

 

이를 통해 얻게 되는 Guided Backpropagation의 결과는 다음과 같습니다.

 

Guided Backpropagation

 

 

# Guided-Backpropagation * Grad-CAM => Guided Grad-CAM 
# See Fig. 2 in paper.
# grad_cam_map : (1, 1, 224, 224) , torch.Tensor
grayscale_cam = grad_cam_map.squeeze(0).cpu().numpy() # (1, 224, 224), numpy
grayscale_cam = grayscale_cam[0, :] # (224, 224)
cam_mask = cv2.merge([grayscale_cam, grayscale_cam, grayscale_cam]) # (224, 224, 3)

cam_gb = deprocess_image(cam_mask * gb_num)

# Guided Grad-CAM save
cv2.imwrite(os.path.join(saved_loc, "Guided_Grad_CAM.jpg"), cam_gb)

 

이제 소개해드릴 코드 중 거의 마지막에 다 왔습니다.

 

해당 부분은 Guided-Backpropagation과 Grad-CAM을 곱해서 얻어지는 Guided Grad-CAM을 만드는 코드입니다.

 

이전에 만들어둔 grad_cam_map을 가져옵니다. 이는 (1, 1, 224, 224) shape를 가지는 torch.Tensor였는데, 이를 0차원을 기준으로 squeeze 시키고 numpy로 변환합니다.

 

그러고 나서 첫 번째 데이터를 갖도록 만들어 결국 (224, 224) shape가 되도록 만들어줍니다.

 

만들어진 grayscale_cam을 cv2.merge를 통해서 합쳐줍니다. 

 

이를 앞에서 만들어둔 Guided Backpropagation의 결과인 gb_num과 곱해줍니다. 

 

이를 저장한 결과는 다음과 같습니다.

 

Guided Grad-CAM

 

 

Original_image = cv2.cvtColor(cv2.imread(os.path.join(img_dir, img_name)), cv2.COLOR_BGR2RGB)
G_heatmap = cv2.cvtColor(cv2.imread(os.path.join(saved_loc, "Grad_CAM_heatmap.jpg")), cv2.COLOR_BGR2RGB)
G_result = cv2.cvtColor(cv2.imread(os.path.join(saved_loc, "Grad_Result.jpg")), cv2.COLOR_BGR2RGB)
G_Back = cv2.cvtColor(cv2.imread(os.path.join(saved_loc, "Guided_Backprop.jpg")), cv2.COLOR_BGR2RGB)
G_CAM = cv2.cvtColor(cv2.imread(os.path.join(saved_loc, "Guided_Grad_CAM.jpg")), cv2.COLOR_BGR2RGB)


Total = cv2.hconcat([Original_image, G_heatmap, G_result, G_Back, G_CAM])

plt.rcParams["figure.figsize"] = (20, 4)
plt.imshow(Total)
ax = plt.gca()
ax.axes.xaxis.set_visible(False)
ax.axes.yaxis.set_visible(False)
plt.savefig(os.path.join(saved_loc, "Final_result.jpg"))
plt.show()

 

마지막은 지금까지 만들어준 이미지들을 모두 합쳐 하나의 이미지로 만드는 과정입니다.

 

코드를 보시면, cv2.cvtColor라는 함수가 보이실 텐데, 이는 color space를 바꿔주는 함수입니다.

 

기본적으로 opencv는 BGR (Blue, Green, Red) 순으로 데이터를 저장합니다.

 

따라서, 그냥 cv2.imread를 이용해서 읽게 되면 RGB 데이터를 BGR로 읽은 것이므로 색이 이상하게 나오게 됩니다.

 

이를 방지하고자 cv2.cvtColor( ~~, cv2.COLOR_BGR2RGB)라는 기능을 사용하게 되는 것입니다.

 

그리고 cv2.hconcat은 가로로 이미지를 합치는 함수입니다. 

 

이를 통해서 최종적으로 얻어지는 이미지는 다음과 같습니다.

 

Final Result

 

맨 위에서 얘기했던 논문의 Fig. 1과 동일한 그림을 얻을 수 있었습니다.

 

 

이번 글은 꽤나 내용이 많아 글이 많이 길어졌습니다.

 

사실 모델 자체가 엄청 복잡하거나 하지는 않지만, forward pass를 통해서 특정한 conv layer를 통과하고 난 이후의 결과를 얻는 방법, backward pass를 거쳐서 얻게 되는 gradient를 저장하는 방법, 이미지의 차원이나 값들을 적절히 처리하는 방법 등 다양한 기술들이 요구되는 코드라고 생각합니다.

 

저 또한 이렇게 min-max scaling을 했다가, 255로 곱했다가 이런저런 과정들을 거치면서 이미지를 처리해본 경험은 처음이라 많이 헤매면서 만들게 되었습니다.

 

특히 opencv의 함수를 쓰는 과정에서 np.uint8을 사용하지 않아 오류가 나거나, 이미지가 0 ~ 1 사이의 값이라서 cv2.imwrite를 했을 때 이미지가 검은색으로 저장되거나, color space가 RGB가 아닌 것을 모르고 저장했다가 이미지가 이상한 색으로 저장되는 등 다양한 상황에 부딪힐 수 있었습니다.

 

어쨌든 우여곡절 끝에 논문에서 나온 그림을 온전히 만들 수 있어서 재미있는 코드 작업이었습니다.

 

이번 포스팅에서 다룬 코드는 제 Github에서 확인하실 수 있으며, 코드 자체는 .ipynb로 되어 있어 바로바로 출력 결과를 확인하실 수 있습니다.

 

 

https://github.com/PeterKim1/paper_code_review/tree/master/9.%20Visual%20Explanations%20from%20Deep%20Networks%20via%20Gradient-based%20Localization(Grad-CAM) 

 

PeterKim1/paper_code_review

paper review with codes. Contribute to PeterKim1/paper_code_review development by creating an account on GitHub.

github.com

 

다음 포스팅에서는 또 다른 논문을 가지고 찾아뵙겠습니다.

 

 

감사합니다.

 

 

+ Recent posts