요약


Non maximal supression(NMS)은 예측된 오브젝트의 바운딩박스(bounding box)가 중복으로 여러 개가 할당 될 수 있을 때, 중복되는 바운딩박스를 제거하는 기술입니다. 기계적으로 무엇인가 학습하여 처리하는 것이 아닌, 사람이 고아낸 로직대로 제거하는 방식이기 때문에, 휴리스틱한(또는 hand-craft)한 방법으로도 논문에서 주로 소개가 됩니다. 

 

Figure 1. 한 오브젝트(포메라니안)에 다수의 바운딩박스가 예측된 예시. NMS은 이러한 여러박스 중에 최적의 박스남기는 작업이다.

 

NMS을 구현하는 방식은 아래와 같습니다.

  1. 각 바운딩 박스별로 면적(area)을 구합니다.
  2. 특정 박스를 하나 정하고, 정해진 박스외에 나머지 박스와의 오버렙 비율을 구합니다. 
    1.  두 박스의 x_min, y_min중에 최대를 구하면, 아래의 박스(//)의 좌상단 좌표를 구합니다.
    2. 두 박스의 x_max, y_max중에 최소를 구하면, 아래의 박스(//)의 우하단 좌표를 구합니다
  3. 교집합의 박스와, 다른 박스들과의 오버렙 비율이 특정값(cutoff)이상을 넘기면, 1에서 지정한 박스를 삭제합니다(=인덱스를 제거합니다)
  4. 이를 계속 반복합니다.

 

 

가령 아래와 같이 COCO 포맷(좌상단X, 좌상단Y, 가로, 세로)의 바운딩박스가 예측되었다고 생각합시다. 그러면, 첫 번째 [317, 86]의 바운딩박스에 대해서, 첫번째 바운딩 박스를 제외하고, 모든 바운딩박스와의 오버렙을 계산하면 됩니다.

// Array
[317, 86, 38, 41],
[464, 90, 40, 42],  # duplicated
[464, 90, 40, 42],  # duplicated

 

구체적인 방법은 아래와 같습니다.

def process_non_maximal_supression(
    boxes: np.ndarray, overlap_cutoff: float = 0.8
) -> np.ndarray:
    """NMS(Non maximal supression)을 시행함.

    Note:
        NMS 프로세스는 아래와 같다.
        1) 각 bounding box별로 Area(면적)을 계산함
        2) 특정 박스 하나에 대해서, 나머지박스와의 Overlap비율을 계산함
         - 각 박스는 (x_min, y_min, w, h)
         - 두 박스의 x_min, y_min중에 최대를 구하면, 아래의 박스(//)의 좌상단 좌표를 구함
         - 두 박스의 x_max, y_max중에 최소를 구하면, 아래의 박스(//)의 우하단 좌표를 구함
            |---------|
            |         |
            |----|------------|
            |    |////|       |
            |----|----|       |
                 |------------|

        3) 오버렙 컷오프(overlap_cutoff)가 주어진 cutoff보다 큰 경우, 2) 에서 선택된
        박스의 인덱스를 삭제
        4) 1~3)을 반복하여 남은 박스만 선택

    Args:
        boxes (np.ndarray): 각 array은 COCO Style box.
        overlap_cutoff (float, optional): _description_. Defaults to 0.7.

    Returns:
        np.ndarray: _description_
    """
    if len(boxes) == 0:
        return list()

    width = boxes[:, 2]
    height = boxes[:, 3]
    areas = width * height

    box_indices = np.arange(len(boxes))
    for box_idx, box in enumerate(boxes):

        # 자기자신을 제외한 다른 박스들 인덱스를 구함
        other_box_indices = box_indices[box_indices != box_idx]

        # 오버렙 계산을 위해 좌표를 구함
        xx1 = np.maximum(box[0], boxes[other_box_indices, 0])
        yy1 = np.maximum(box[1], boxes[other_box_indices, 1])
        xx2 = np.minimum(
            box[0] + box[2], boxes[other_box_indices, 0] + boxes[other_box_indices, 2]
        )
        yy2 = np.minimum(
            box[1] + box[3], boxes[other_box_indices, 1] + boxes[other_box_indices, 3]
        )

        w = np.maximum(0, xx2 - xx1 + 1)
        h = np.maximum(0, yy2 - yy1 + 1)

        overlap = (w * h) / areas[other_box_indices]
        if np.any(overlap > overlap_cutoff):
            box_indices = box_indices[box_indices != box_idx]

    return boxes[box_indices].astype(int)
반응형

요약


Morphological transformation (형태변형)은 이미지의 형태를 변형하는 작업들 중 하나를 말한다. 이미지의 형태를 변형하는 하는 방법은 여러가지가 있는데, 주로 "입력 이미지"와 "구조적 원소(=커널이미지)"을 이용한 경우를 말한다. 그리고 형태변형은 주로 이진화 이미지(Binary image, 흑백이미지으로 0(흑), 1(백)으로만 이뤄져있는 이미지)를 연산하는 것을 의미한다[1]*. 주로 Erosion, Dilation이 주로 연산이며 Open, closing은 이것들의 변형들이다 . 

*주로라는 것은 이진화 이미지가 아닌 경우도 쓸 수 있다(예, grayscale)

 

 

주로 아래와 같은 연산들이 대표적으로 쓰인다. 아래의 글을 봐도 잘 이해가 안되는데, 예시와 함께 보는 것을 권장한다.

  1. Erosion (부식, 침식, Shrink, Reduce): 이미지의 boundary(가장자리)를 부식시키는 작업. 각 오브젝트의 두께가 줄여진다. [2]
  2. Dilation (확장):이미지의 boundary(가장자리)를 확장시키는 작업. 각 오브젝트의 두께가 커진다.
  3. Opening (오프닝): Erosion후에 Dilation을 적용하는 연산. Erosion으로 노이즈 같은 점을 없애주고 이미지를 확장하기에 noise reduction용으로도 쓰는듯하다.
  4. Closing (클로징): Dilation 후에 Erosion을 적용하는 연산. 반면에 Closing은 Object내에 이미지를 패딩해주는효과가 있다.

위의 모든 단계는 커널(kernel, structuring element 또는 probe 라고도 함)을 이용하는데, 이는 입력이미지와 연산할 매개체를 의미한다. 예를 들어, 입력이미지가 A, 연산이 @, 커널이 B면, A@B의 연산을 한다. 주로 @은 위와 같이 종류가 다양하고, B에 해당하는 커널을 바꾸면서도 여러 종류의 연산이 가능하다.

 

 

알아야할 개념: Kernel, Fit, Hit, Miss. 위의 알고리즘을 설명하기 위해서는 아래의 이미지 처리시 개념을 선행해야한다.


  • Kernel: 이미지 연산에 필요한 구조적인 요소(Structuring element). 이미지인데, 연산을 해줄 0또는 1로만 이뤄진 이미지를 의미한다. 입력이미지를 덮어가면서 연산할 이미지를 의미한다[3].
  • Fit: Kernel 이미지의 픽셀이 모든 입력이미지에 커버가 되는 경우.
  • Hit: Kernel 이미지의 픽셀 중 하나라도, 입력이미지에 커버가 되는 경우.
  • Miss: Kernel 이미지의 픽셀 중 하나라도 입력이미지에 커버가 되지 않는 경우.

Input 이미지와 Kernel이 주어진 경우, Hit, Fit, Miss의 예시

 

 

Erode (침식): 주로 오브젝트의 사이즈를 조금 줄일 때 사용


침식은 수학적인 표현으로는 주로 "$\ominus $"라는 표현을할 수 있다. 침식(Erode)은 모든 형태변형 알고리즘 연산과 같이 2개의 값을 요구한다. 하나는 입력이미지(침식 시킬 이미지, $A$), 또 하나는 커널(Kernel, $B$)이다. A를 커널 B로 침식시킨다는 표현은 $A\ominus B$로 표현할 수 있다.

erosion은 다음의 규칙으로 연산한다.

$A\ominus B$: A와 커널 B가 fit이면 1, 아니면 0을 채운다. 이 과정을 kernel의 중앙부가 0인 지점에따라 계산한다.

import cv2 as cv
import numpy as np

image = np.array(
    [
        [0,0,0,0,0],
        [0,1,1,1,0],
        [0,1,1,1,0],
        [0,1,1,0,0],
        [0,1,0,0,0],
    ],
    np.uint8
)

kernel = np.array(
    [[0,1,0],
     [1,1,1],
     [0,1,0]
     ],
     np.uint8
)

cv.erode(image, kernel)

그림으로 표현하면 다음과 같다. 커널의 중심부가 배경(0)인 지점을 모두 순회하면서 hit인 부분이 있으면 모두 0으로 바꿔준다.

그러면 가운데 하나만 1만 남게된다.

array([[0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0],
       [0, 0, 1, 0, 0],
       [0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0]], dtype=uint8)

응용 예) 주로 두꺼운 펜으로 작성한 글씨나, 번져서온 이미지인 경우 Erode을 시키면 세밀해진 이미지를 얻을 수 있다.

 

erosion은 다음의 규칙으로 연산한다.

$A\ominus B$: A와 커널 B가 fit이면 1, 아니면 0을 채운다. 이 과정을 kernel의 중앙부가 0인 지점에따라 계산한다.

import cv2 as cv
import numpy as np

image = np.array(
    [
        [0,0,0,0,0],
        [0,1,1,1,0],
        [0,1,1,1,0],
        [0,1,1,0,0],
        [0,1,0,0,0],
    ],
    np.uint8
)

kernel = np.array(
    [[0,1,0],
     [1,1,1],
     [0,1,0]
     ],
     np.uint8
)

cv.erode(image, kernel)

그림으로 표현하면 다음과 같다. 커널의 중심부가 배경(0)인 지점을 모두 순회하면서 hit인 부분이 있으면 모두 0으로 바꿔준다.

그러면 가운데 하나만 1만 남게된다.

array([[0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0],
       [0, 0, 1, 0, 0],
       [0, 0, 0, 0, 0],
       [0, 0, 0, 0, 0]], dtype=uint8)

응용 예) 주로 두꺼운 펜으로 작성한 글씨나, 번져서온 이미지인 경우 Erode을 시키면 세밀해진 이미지를 얻을 수 있다.

 

 

Dilation (팽창): 주로 오브젝트보다 약간 큰 사이즈의 이미지를 얻기 위함.


방법: Dliation도 erosion과 마찬가지로, kernel에 hit인 부분을 0으로 바꾸는 대신에, 1로 채워준다. 좀 더 큰 이미지의 영역을 확보하기 위함이다.

응용사례: Pill(알약)의 이미지보다 약간 큰 이미지 영역을 확보하기위해서, 이미지를 이진화(Binarization)시킨 다음, Dilation시켜서 좀더 큰 사이즈의 영역을 확보한다. 이는 약의 그림자 등 조금 큰 영역을 확보해서 Dection하는게 더 성능에 유리하기 때문에 이렇게 진행한다 

Pill Detection Model for Medicine Inspection Based on Deep Learning, ref : https://www.mdpi.com/2227-9040/10/1/4/pdf

 

 

Opening:주로 노이즈를 지우기 위해 사용.


응용 예:  이진화한 이미지에서 작은 점(노이즈)를 지울 때 사용한다. 또는 인공지능이 Segmentation한 영역에서 Background이미지에서 지저분하게 잘못 예측한 영역들을 오프닝으로 지울 수 있다.

 

 

 

Closing: 이미지 내 작은 구멍 및 점들은 채우기 위한 방법으로 사용.


의료영역에서 Closing 사용

Unet의 후속모델인 ResnetUnet을 이용하여 심장부와 폐부를 Segmentation했다. 그리고 그 예측결과 중에, predicted Mask처럼 구멍뚤린 부위를 후처리(Closing)을 이용해서 매꿔주어 성능을 향상시킬 수 있다.

 

 

[1] https://en.wikipedia.org/wiki/Binary_image

[2] https://homepages.inf.ed.ac.uk/rbf/HIPR2/erode.htm 

[3] https://www.cs.auckland.ac.nz/courses/compsci773s1c/lectures/ImageProcessing-html/topic4.htm

[4] https://docs.opencv.org/4.x/d9/d61/tutorial_py_morphological_ops.html

 

 

 

 

 

반응형

 

Grad-CAM의 FeatureMap의 기여도를 찾는 방법은 각 FeatureMap의 셀로판지가 최종 색깔에 어느정도 기여하는지와 동일하다.

 

요약


Grad-CAM은 CNN 기반의 모델을 해석할 때 사용되는 방법이다. 인공지능의 해석방법(XAI)에서는 Grad-CAM은 흔히 Post-hoc으로 분류되고(일단, 모델의 결과(Y)가 나오고 나서 다시 분석사는 방법)으로 취급된다. 또한, 딱 CNN에서만 사용되기 때문에 Model-specific 방법이다. 이 Grad-CAM의 가장 큰 쉬운 마지막 CNN이 나오고 나서 반환되는 Feature Map(본문에서:A)이 평균적으로 Y의 분류에 어느정도 되는지를 계산하고, 각 픽셀별로 이를 선형으로 곱하는 방법이다.

 

 

상세 내용


Grad-CAM은 아래와 같이 계산할 수 있다. 

식1: Grad-CAM의 핵심적인 계산과정

 

각, 기호에 대한 설명은 아래와 같다. y은 모델이 내뱉는 확률이며, c는 특정 라벨을 의미한다. 개 vs 고양이의 분류기이면 c의 최대값은 2이 된다(=1, 2의 분류). 그 다음, $A^k$은 각 Feature Map을 의미한다. Feature Map은 이미지가 CNN을 통과한 결과를 의미한다. $k$가 붙는 이유는 CNN을 통과하고나서 CNN의 필터의 개수(k)만큼 반환되는 값(feature map)의 k채널이 생기기 때문이다. 일종의 채널과 같다. 

마지막으로 $a_{k}^{c}$은 $A^{k}$의 필터맵이 이미지와 유사한데, 이 필터맵의 각 픽셀들이 분류에 어느정도 기여했는지를 의미한다. 아래의 그림(Figure 1)을 보자. Feature maps이 4개가 있으니 $k=4$인 예시이다. 녹색의 feature maps을 보면, 5x5로 이미지가 이루어져 있는데, 각 $i, j$에 해당하는 픽셀이 $y^{c}$에 어느정도 기여했는지를 구한 것이다.

 

Figure 1. GAP의 그림

즉, Grad-CAM은 각 feature Map이 어느정도 모델의 결과값($y$)에 기여했는지($A^{k}$)와 각 픽셀이 모델의 결과값에 어느정도 기여했는지($a_{k}^{c}$)을 곱하는 것이다. 결국, 각 픽셀이 feature maps을 고려하였을 때(가중하였을 때) 결과값에 어느정도 영향을 미쳤는지를 계산할 수 있다.

 

구현:


torch에서는 register_foward_hook과 register_backward_hook을 이용하여 grad cam을 계산할 수 있습니다. register_forward_hook과 register_backward_hook은 PyTorch에서 모델의 레이어에 대한 forward pass와 backward pass 중간에 호출되는 함수를 등록하는 메서드입니다. 이를 통해 레이어의 활성화 맵이나 그래디언트를 추출하거나 조작할 수 있습니다.

register_forward_hook:
이 메서드는 모델의 레이어에 대해 forward pass가 수행될 때 호출되는 함수를 등록합니다. 등록된 함수는 해당 레이어의 출력을 인자로 받아 다양한 작업을 수행할 수 있습니다. 주로 활성화 맵 등 중간 결과를 추출하거나 조작하는 데 사용됩니다.

def forward_hook(module, input, output):
    # module: 레이어 인스턴스
    # input: forward pass의 입력
    # output: forward pass의 출력
    pass

target_layer.register_forward_hook(forward_hook)

 

register_backward_hook:
이 메서드는 모델의 레이어에 대해 backward pass가 수행될 때 호출되는 함수를 등록합니다. 등록된 함수는 해당 레이어의 그래디언트를 인자로 받아 다양한 작업을 수행할 수 있습니다. 주로 그래디언트를 조작하거나 특정 그래디언트 정보를 추출하는 데 사용됩니다.

def backward_hook(module, grad_input, grad_output):
    # module: 레이어 인스턴스
    # grad_input: 입력 그래디언트
    # grad_output: 출력 그래디언트
    pass

target_layer.register_backward_hook(backward_hook)

 

위의 두 함수를 이용하여 아래와 같이 구현할 수 있습니다.

def grad_cam(
    model: torch.nn.Module,
    image: np.ndarray,
    target_layer: torch.nn.Module,
) -> np.ndarray:
    """
    Args:
        model (torch.nn.Module): Grad-CAM을 적용할 딥러닝 모델.
        image (np.ndarray): Grad-CAM을 계산할 입력 이미지.
        target_layer (Type[torch.nn.Module]): Grad-CAM을 계산할 대상 레이어.

    Returns:
        np.ndarray: Grad-CAM 시각화 결과.
    """

    def forward_hook(module, input, output):
        grad_cam_data["feature_map"] = output

    def backward_hook(module, grad_input, grad_output):
        grad_cam_data["grad_output"] = grad_output[0]

    grad_cam_data = {}
    target_layer.register_forward_hook(forward_hook)
    target_layer.register_backward_hook(backward_hook)

    output = model(image)  # 모델의 출력값을 계산합니다. y_c에 해당
    model.zero_grad()

    # 가장 예측값이 높은 그레디언트를 계산합니다. output[0,]은 차원을 하나 제거
    output[0, output.argmax()].backward()

    feature_map = grad_cam_data["feature_map"]
    grad_output = grad_cam_data["grad_output"]
    weights = grad_output.mean(dim=(2, 3), keepdim=True)
    cam = (weights * feature_map).sum(1, keepdim=True).squeeze()
    cam = cam.detach().cpu().numpy()

    return cam
반응형
  • cv2.anything() --> use (width, height)
  • image.anything() --> use (height, width)
  • numpy.anything() --> use (height, width)
반응형
import math
import numpy as np
import cv2

def ssim(img1, img2):
    C1 = (0.01 * 255)**2
    C2 = (0.03 * 255)**2

    img1 = img1.astype(np.float64)
    img2 = img2.astype(np.float64)
    kernel = cv2.getGaussianKernel(11, 1.5)
    window = np.outer(kernel, kernel.transpose())

    mu1 = cv2.filter2D(img1, -1, window)[5:-5, 5:-5]  # valid
    mu2 = cv2.filter2D(img2, -1, window)[5:-5, 5:-5]
    mu1_sq = mu1**2
    mu2_sq = mu2**2
    mu1_mu2 = mu1 * mu2
    sigma1_sq = cv2.filter2D(img1**2, -1, window)[5:-5, 5:-5] - mu1_sq
    sigma2_sq = cv2.filter2D(img2**2, -1, window)[5:-5, 5:-5] - mu2_sq
    sigma12 = cv2.filter2D(img1 * img2, -1, window)[5:-5, 5:-5] - mu1_mu2

    ssim_map = ((2 * mu1_mu2 + C1) * (2 * sigma12 + C2)) / ((mu1_sq + mu2_sq + C1) *
                                                            (sigma1_sq + sigma2_sq + C2))
    return ssim_map.mean()


def calculate_ssim(img1, img2):
    '''calculate SSIM
    the same outputs as MATLAB's
    img1, img2: [0, 255]
    '''
    if not img1.shape == img2.shape:
        raise ValueError('Input images must have the same dimensions.')
    if img1.ndim == 2:
        return ssim(img1, img2)
    elif img1.ndim == 3:
        if img1.shape[2] == 3:
            ssims = []
            for i in range(3):
                ssims.append(ssim(img1, img2))
            return np.array(ssims).mean()
        elif img1.shape[2] == 1:
            return ssim(np.squeeze(img1), np.squeeze(img2))
    else:
        raise ValueError('Wrong input image dimensions.')

 

원본(img1) 가우시안 필터(window), 적용한 이미지(mu1)

반응형

+ Recent posts