콘텐츠로 건너뛰기
Home » OpenCV를 사용하여 두 이미지의 다른 부분 찾기

OpenCV를 사용하여 두 이미지의 다른 부분 찾기

두 이미지의 구조적 유사성을 분석하는 알고리듬으로 SSIM (Structural Similarity)가 있는데, 이를 사용하면 두 이미지가 어느 정도로 유사한지, 어느 부위가 다른지를 알아낼 수 있다. ssim을 수행하는 함수는 scikit-image 라이브러리가 제공하고 있으니, 이를 사용해서 두 이미지의 다른 부분을 찾는 작업을 수행할 수 있다. 다만 이미지를 비교하기 위해 필요한 전처리와 이미지 차이를 구분한 이후의 처리를 위해서는 opencv의 사용이 필수불가결하다.

https://docs.opencv.org/master/modules.html 에서 각 모듈에 대한 정의와 Python API를 볼 수 있다.

필요한 라이브러리 설치

몇 가지 라이브러리가 필요하다. 먼저 opencv-python을 설치해서 OpenCV를 사용할 수 있어야 하고, SSIM 적용을 위해서 scikit-image를 사용 가능하도록 준비한다. 그외에 (이 글에서는 쓰지 않지만) imutils를 설치해두면 opencv에서 귀찮게 해야하는 여러 작업들을 편리하게 할 수 있다고 하니, 같이 설치해주자. (근데 사실 이 예제에서는 쓸모가 없음…)

> pip install imutils opencv-python scikit-image

자 그러면 코드를 작성한다. image_diff.py 파일을 생성하고 먼저 필요한 모듈들을 가져온다.

from skimage.measure import compare_ssim
import imutils
import cv2
import argparse

참고로 compare_ssim 함수는 현재 웹에서 찾아볼 수 있는 대부분의 예제에서 skimage.measure 아래에 있는 것을 사용하라고 하는데, 이는 조만간 제거될 예정이며, skimage.metrics.structural_similiarity 로 대체된다고 한다. 따라서 맨 첫줄을 다음과 같이 바꾼다.

from skimage.metrics import structural_similarity as compare_ssim

명령줄 인자 처리 부분 작성

인자를 처리하는 부분을 따로 작성한다. 이 코드는 두 개의 이미지 파일을 받아서 두 이미지간의 차이를 분석한다. 따라서 항상 두 개의 인자를 받을 필요가 있다. argparse를 쓰는 것이 귤 껍질 까는데 중식도를 쓰는 기분이 들 수도 있는데, 되려 이게 더 편할 수도 있다.

참고로 이 때 두 이미지는 동일한 사이즈여야 한다. 좀 더 원활하게 작동하게 하고 싶다면, 이미지 크기를 조정하는 코드를 추가할 필요가 있을 것이다.

def parse_args():
    parser = argparse.ArgumentParser()
    parser.add_argument("original")
    parser.add_argument("modified")
    return parser.parse_args()

이미지 차이를 구하는 과정을 먼저 간략히 요약해보자.

  1. 비교를 하려는 이미지는 둘 다 그레이스케일로 변환해야 한다.
  2. compare_ssim() 함수를 사용하여 두 개의 그레이스케일 이미지를 비교한다. 그 결과 중 2차원 배열의 각 값을 8비트 정수로 변환하면 역시 그레이스케일 이미지로 변환할 수 있다. (동일한 영역은 흰색으로, 차이가 클수록 어두운 색으로 표시된다.)
  3. 변환된 이미지를 이진화하여 흑/백으로 구분된 이미지를 만들어낸다.
  4. 흑백 이미지로부터 외곽선을 추출한다.
  5. 각 외곽선의 영역을 원본 및 대조본에 그려준다.

회색조 변환

compare_ssim()을 적용하기 위해서는 두 이미지가 모두 그레이스케일로 전환되어야 한다. 이는 cv2.cvtColor() 함수를 사용하여 수행할 수 있다. (문서보기) 인터넷에 보이는 예제들에서는 이때 사용되는 컬러코드를 cv2.COLOR_BGR2GRAY를 사용하던데, cv2.COLOR_RGB2GRAY를 사용해서도 변환할 수 있다. 이 둘의 결과 차이는 어느 정도 있는 것으로 보이는데, 정확한 차이는 잘 모르겠다(;;;)

같은 원본을 RGB2GRAY, BGR2GRAY로 변형했을 때와, 두 이미지의 차이(맨 오른쪽)

인자로 받아들인 두 개의 이미지를 읽어서 회색조로 변환하기 까지의 코드는 다음과 같다.

args = parse_args()
imageA = cv2.imread(args.original)
imageB = cv2.imread(args.modified)
grayA = cv2.cvtColor(imageA, cv2.COLOR_BGR2GRAY)
grayB = cv2.cvtColor(imageB, cv2.COLOR_BGR2GRAY)

이미지의 차이 구하기

compare_ssim() 함수를 사용하여 두 이미지의 차이를 구한다. 리턴되는 값은 두 이미지의 유사도와 실제 각 픽셀의 차이를 담은 2차원 배열이다. 이 배열의 각 원소는 0~1사이의 값을 가지고 있는데 255를 곱해 uint8로 변경해서 그레이스케일 이미지로 변환할 수 있다. (회색조 이미지는 8비트로 명암만을 보존하는 2차원 array이다.). 이 배열을 이미지로 만들어서 실제 차이가 발생한 영역을 구할 것이다.

score, diff = compare_ssim(grayA, grayB, full=True)
# full=True: 이미지 전체에 대해서 구조비교를 수행한다.
diff = (diff * 255).astype('uint8')
print(f'SSIM: {score:.6f}')

이렇게 얻어진 diff 이미지는 두 이미지 중 차이가 나는 영역에 대한 정보를 담는다. 아래 예시는 두 개의 이미지를 가지고 위 처리를 했을 때, 두 이미지와 이 때 diff 이미지가 어떻게 보이는지를 표시한 것이다. 이미지에서 진하게 표시된 영역이 차이가 크게 나는 부분이다. 레몬조각이나 꽃잎, 노끈등의 차이점이 인식되는 것을 볼 수 있다.

왼쪽부터 원본, 대조본, 두 이미지의 픽셀 차이값을 이미지로 변환한 것

diff 이미지의 차이 부분을 ‘도려내기’ 위해서는 이 이미지를 이진비트맵, 즉 0과 1만 있는 완전 흑백 이미지로 바꿔야 한다. 이 때 사용하는 함수가 cv2.threashold()이다. 특정 임계치를 기준으로 값을 바꾼다.

retval, result = cv2.threadhold(A, B, t, maxVal, options)

이 때 넘겨주는 파라미터 중 t 값은 임계값이며, maxVal은 임계값을 초과하는 값이 가지는 값이다. 옵션은 픽셀을 처리하는 방법이다. 기본적으로 cv2.THRESH_BINARY_INV를 썼다. (외곽선 검출을 위해서는 검은 배경에 흰 오브젝트가 되어야 한다.) 아래를 보면 왼쪽 그림은 임계값을 1로, 가운데는 128, 오른쪽은 임계값 200을 준 것이다. (임계값이 너무 크면 하얗게 날라간다.) 200에서는 불필요한 노이즈가 많이 잡히기 때문에 128을 쓰도록하자.

cv2.threadhold()를 사용하여 이미지를 이진화할 때 t 값에 따른 차이. 왼쪽부터 t=1, t=128, t=200

이미지 이진화에는 Otsu의 방법을 적용하면 시행착오를 거쳐 가장 좋은 이미지를 얻을 수 있다고 하는데…. 사실 잘 모르겠다. 아래 두 이미지 중 오른쪽이 THRESH_OTSU를 같이 적용한 것인데… 되려 노이즈는 증가해보인다. 대신에 일부 오브젝트의 구분이 좀 더 명확해지는 것 같다.

diff 이미지의 이진화 알고리듬에 대한 결과물의 차이. 왼쪽은 THREAD_INV, 오른쪽은 THREAD_OTSU 사용.

암튼 많은 예제들이 THREAD_OTSU가 좋다고 하니, 이걸로 하겠다. 이렇게 이진화하여 흑백 이미지를 만들었다면, 이 다음은 해당 이미지를 통해서 각 무늬의 경계부분을 따라서 외곽선을 추출한다. 이것은 cv2.findContours() 함수를 사용한다. (cv2.threadhold()의 리턴 중에서 retval 값은 무시한다.)

외곽선 검출하기

아래는 이진화된 비트맵에서 외곽선을 검출하는 코드이다. findContours() 함수에 넘겨지는 두 번째, 세 번째 인자는 각각 외각선 검출 방법과 근사화 방법으로, 이 값들은 cv2 모듈에 상수로 정해진 값을 사용하면 된다.

_, thresh = cv2.threshold(diff, 128, 255, cv2.THRESH_BINARY_INV|cv2.THRESH_OTSU)
cnts, _ = cv2.findContours(
            thresh, 
            cv2.RETR_EXTERNAL, 
            cv2.CHAIN_APPROX_SIMPLE)

외곽선 검출 방법으로는 다음의 것들이 있다.

  • cv2.RETR_EXTERNAL : 최외곽 윤곽선만 검출하며, 내부에 대한 계층구조를 만들지 않음
  • cv2.RETR_LIST: 모든 윤곽을 검출하며, 계층구조를 만들지 않음
  • cv2.RETR_CCOMP: 모든 윤곽을 검출하며 계층구조는 2단계만 구성
  • cv2.RETR_TREE: 모든 윤곽을 검출하며, 계층구조를 형성

근사화방법으로는 다음과 같은 것이 있다.

  • cv2.CHAIN_APPROX_NONE : 근사화 없음
  • cv2.CHAIN_APPROX_SIMPLE: 수평/수직/대각선에서 압축
  • cv2.CHAIN_APPROX_TC89_L1: Teh-Chin 연쇄 근사화 알고리듬 중 하나 적용
  • cv2.CHAIN_APPROX_TC89_KCOS: Teh-Chin 연쇄 근사화 알고리듬 중 하나 적용

findContours()의 결과는 (contours, hierachy)의 튜플이다. 우리는 외곽선을 검출하는 방법으로 RETR_EXTERNAL을 사용했기 때문에, 계층구조를 딱히 고려하지 않을 것이다.

웹에서 찾은 몇몇 예제에서는 여기서 얻은 외곽선 정보를 다시 imutils.grab_contours()에 넘겨서 외곽선 데이터로 검출하던데, 여기서는 굳이 그럴 필요는 없을 것 같다. openCV 문서를 봤을 땐, 리턴된 튜플의 첫번째 원소만 취해주어도 될 것고, 실제로 그렇게만 해도 작동은 한다.

참고로, findContours()함수는 원본 이미지를 변경한다면서 대부분의 예제들은 원본을 copy()해서 넘겨준다. 이건 일종의 부작용으로 보이는데, openCV 3.2부터 해결됐다고 한다. 지금은 OpenCV 4.x를 쓰고 있기 때문에 굳이 원본을 복사하여 넘겨줄 필요가 없다.

어쨌든, 이제 추출한 각 외곽선 영역들을 원본과 편집본에 대해서 각각을 표시해 보자. 이 때, 이진화된 이미지에서도 보듯이 너무 소소한 점같은 영역들은 무시하는 것이 좋겠다. 따라서 cv2.contourArea() 함수를 이용해서 영역의 넓이를 구해서 너무 작으면 무시한다. 각 외곽선에 대한 바운딩 박스를 사각형으로 원본에 그려주고, 편집본에는 영역의 외곽선을 따서 그리도록 한다.

for c in cnts:
    area = cv2.contourArea(c)
    if area > 20:
        cv2.drawContours(imageB, [c], 0, (0, 0, 255), 1)
        x, y, w, h = cv2.boundingRect(c)
        cv2.rectangle(imageA, (x, y), (x+w, y+h), (255, 0, 0), 2)

# 두 이미지를 연결하여 표시
result = cv2.hconcat([imageA, imageB])
cv2.imshow('Result', result)
cv2.waitKey(0)

최종 결과는 아래와 같으며, 정리된 코드도 함께 첨부한다.

두 이미지에서 차이가 발생한 부분을 표시하기. 왼쪽은 영역의 바운딩박스, 오른쪽은 실제 차이가 나는 부분의 외곽선

전체 코드는 아래와 같다.

# from skimage.measure import compare_ssim
from skimage.metrics import structural_similarity as compare_ssim
import imutils
import cv2
import numpy as np
import argparse


def parse_args():
    parser = argparse.ArgumentParser()
    parser.add_argument("original")
    parser.add_argument("modified")
    return parser.parse_args()


def main():
    args = parse_args()
    imageA = cv2.imread(args.original)
    imageB = cv2.imread(args.modified)
    # if needed resize images using cv2.resize()
    # cv2.imshow("Original", imageA)
    # cv2.imshow("Modified", imageB)
    # cv2.waitKey(0)
    grayA = cv2.cvtColor(imageA, cv2.COLOR_BGR2GRAY)
    grayB = cv2.cvtColor(imageB, cv2.COLOR_BGR2GRAY)
    (score, diff) = compare_ssim(grayA, grayB, full=True)
    diff = (diff * 255).astype("uint8")
    print(f"SSIM: {score}")
    thresh = cv2.threshold(
                 diff, 0, 200, 
                 cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
             )[1]
    cnts, _ = cv2.findContours(
                thresh, 
                cv2.RETR_EXTERNAL, 
                cv2.CHAIN_APPROX_SIMPLE
              )
    for c in cnts:
        area = cv2.contourArea(c)
        if area > 40:
            x, y, w, h = cv2.boundingRect(c)
            cv2.rectangle(imageA, (x, y), (x + w, y + h), (0, 0, 255), 2)
            cv2.drawContours(imageB, [c], -1, (0, 0, 255), 2)
    cv2.imshow("Original", imageA)
    cv2.imshow("Modified", imageB)
    cv2.waitKey(0)


if __name__ == "__main__":
    main()