관리 메뉴

귀퉁이 서재

OpenCV - 14. 이미지 뒤틀기(어핀 변환, 원근 변환) 본문

OpenCV

OpenCV - 14. 이미지 뒤틀기(어핀 변환, 원근 변환)

데이터 파수꾼 Baek Kyun Shin 2020. 9. 29. 20:11

이번 포스팅에서는 이미지를 뒤트는 방법에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/05.geometric_transform

이전 포스팅에서는 이미지를 이동, 확대/축소, 회전하는 방법에 대해서 알아봤습니다. 이동, 확대/축소, 회전을 한 후에는 이미지의 모양이 그대로 유지되지만, 이번 포스팅에서 배울 이미지 뒤틀기(wraping)를 하면 기존 모양과 달라집니다. 이미지 뒤틀기에는 크게 두 가지가 있는데 어핀 변환과 원근 변환이 있습니다.

어핀 변환(Affine Transform)

어핀 변환은 뒤틀기 방법 중 하나입니다. 말이 어려워 보이지만 아래 예제를 보면 어떤 변환인지 쉽게 이해가 될 겁니다.

이 함수는 3개의 좌표인 pts1이 pts2로 위치가 변한 만큼 이미지를 뒤트는 기능을 제공합니다. 

# 어핀 변환 (getAffine.py)

import cv2
import numpy as np
from matplotlib import pyplot as plt

file_name = '../img/fish.jpg'
img = cv2.imread(file_name)
rows, cols = img.shape[:2]

# ---① 변환 전, 후 각 3개의 좌표 생성
pts1 = np.float32([[100, 50], [200, 50], [100, 200]])
pts2 = np.float32([[80, 70], [210, 60], [250, 120]])

# ---② 변환 전 좌표를 이미지에 표시
cv2.circle(img, (100,50), 5, (255,0), -1)
cv2.circle(img, (200,50), 5, (0,255,0), -1)
cv2.circle(img, (100,200), 5, (0,0,255), -1)

#---③ 짝지은 3개의 좌표로 변환 행렬 계산
mtrx = cv2.getAffineTransform(pts1, pts2)
#---④ 어핀 변환 적용
dst = cv2.warpAffine(img, mtrx, (int(cols*1.5), rows))

#---⑤ 결과 출력
cv2.imshow('origin',img)
cv2.imshow('affin', dst)
cv2.waitKey(0)
cv2.destroyAllWindows()

이처럼 어핀 변환 전후 3개의 좌표만 지정해주면 변환 행렬을 알아서 구해줍니다. 상당히 편하죠?

원근 변환(Perspective Transform)

어핀 변환은 이미지를 2차원으로 뒤트는 변환이었습니다. 반면 원근 변환은 이미지를 3차원으로 변환한다고 보시면 됩니다. 멀리 있는 것은 작게 보이고, 가까이 있는 것은 크게 보이는 게 원근법의 원리입니다. 이 원근법의 원리를 적용해 변환하는 방식이 원근 변환입니다. 원근 변환에 필요한 변환 행렬을 반환해주는 함수는 아래와 같습니다.

지금까지는 이 변환행렬을 cv2.warpAffine() 함수에 전달해주었는데, 원근 변환은 별도의 함수 cv2.warpPerspective() 함수를 써야 합니다. 이 함수의 모든 파라미터는 cv2.warpAffine()과 동일합니다.

# 원근 변환 (perspective.py)

import cv2
import numpy as np

file_name = "../img/fish.jpg"
img = cv2.imread(file_name)
rows, cols = img.shape[:2]

#---① 원근 변환 전 후 4개 좌표
pts1 = np.float32([[0,0], [0,rows], [cols, 0], [cols,rows]])
pts2 = np.float32([[100,50], [10,rows-50], [cols-100, 50], [cols-10,rows-50]])

#---② 변환 전 좌표를 원본 이미지에 표시
cv2.circle(img, (0,0), 10, (255,0,0), -1)
cv2.circle(img, (0,rows), 10, (0,255,0), -1)
cv2.circle(img, (cols,0), 10, (0,0,255), -1)
cv2.circle(img, (cols,rows), 10, (0,255,255), -1)

#---③ 원근 변환 행렬 계산
mtrx = cv2.getPerspectiveTransform(pts1, pts2)
#---④ 원근 변환 적용
dst = cv2.warpPerspective(img, mtrx, (cols, rows))

cv2.imshow("origin", img)
cv2.imshow('perspective', dst)
cv2.waitKey(0)
cv2.destroyAllWindows()

실행 결과 이미지의 위쪽 부분의 폭이 좁아져서 마치 멀리 있는 것처럼 보입니다. 이것이 바로 원근 변환입니다. 하지만 실제로 자주 쓰이는 원근 변환은 이와 반대입니다. 위 예제에서는 평면 이미지를 원근 이미지로 변환했지만, 실제 많이 쓰이는 것은 원근 이미지를 평면 이미지로 변환하는 것입니다. 예를 들어 휴대폰 카메라의 스캔 기능을 이용하면 찍힌 문서를 스캔한 문서처럼 만들어 줍니다. 원근감이 있는 문서 이미지를 평면 이미지로 바꿔주는 것입니다. 

# 마우스와 원근 변환으로 문서 스캔 효과 내기 (perspective_scan.py)

import cv2
import numpy as np

win_name = "scanning"
img = cv2.imread("../img/paper.jpg")
rows, cols = img.shape[:2]
draw = img.copy()
pts_cnt = 0
pts = np.zeros((4,2), dtype=np.float32)

def onMouse(event, x, y, flags, param):  #마우스 이벤트 콜백 함수 구현 ---① 
    global  pts_cnt                     # 마우스로 찍은 좌표의 갯수 저장
    if event == cv2.EVENT_LBUTTONDOWN:  
        cv2.circle(draw, (x,y), 10, (0,255,0), -1) # 좌표에 초록색 동그라미 표시
        cv2.imshow(win_name, draw)

        pts[pts_cnt] = [x,y]            # 마우스 좌표 저장
        pts_cnt+=1
        if pts_cnt == 4:                       # 좌표가 4개 수집됨 
            # 좌표 4개 중 상하좌우 찾기 ---② 
            sm = pts.sum(axis=1)                 # 4쌍의 좌표 각각 x+y 계산
            diff = np.diff(pts, axis = 1)       # 4쌍의 좌표 각각 x-y 계산

            topLeft = pts[np.argmin(sm)]         # x+y가 가장 값이 좌상단 좌표
            bottomRight = pts[np.argmax(sm)]     # x+y가 가장 큰 값이 우하단 좌표
            topRight = pts[np.argmin(diff)]     # x-y가 가장 작은 것이 우상단 좌표
            bottomLeft = pts[np.argmax(diff)]   # x-y가 가장 큰 값이 좌하단 좌표

            # 변환 전 4개 좌표 
            pts1 = np.float32([topLeft, topRight, bottomRight , bottomLeft])

            # 변환 후 영상에 사용할 서류의 폭과 높이 계산 ---③ 
            w1 = abs(bottomRight[0] - bottomLeft[0])    # 상단 좌우 좌표간의 거리
            w2 = abs(topRight[0] - topLeft[0])          # 하당 좌우 좌표간의 거리
            h1 = abs(topRight[1] - bottomRight[1])      # 우측 상하 좌표간의 거리
            h2 = abs(topLeft[1] - bottomLeft[1])        # 좌측 상하 좌표간의 거리
            width = max([w1, w2])                       # 두 좌우 거리간의 최대값이 서류의 폭
            height = max([h1, h2])                      # 두 상하 거리간의 최대값이 서류의 높이
            
            # 변환 후 4개 좌표
            pts2 = np.float32([[0,0], [width-1,0], 
                                [width-1,height-1], [0,height-1]])

            # 변환 행렬 계산 
            mtrx = cv2.getPerspectiveTransform(pts1, pts2)
            # 원근 변환 적용
            result = cv2.warpPerspective(img, mtrx, (width, height))
            cv2.imshow('scanned', result)
cv2.imshow(win_name, img)
cv2.setMouseCallback(win_name, onMouse)    # 마우스 콜백 함수를 GUI 윈도우에 등록 ---④
cv2.waitKey(0)
cv2.destroyAllWindows()

문서의 네 꼭짓점을 시계 방향으로 클릭해주면 원근 변환을 활용하여 스캔한 것과 같이 평면 이미지를 만들어 줍니다. 

삼각형 어핀 변환

OpenCV가 제공하는 기하학적 변환은 기본적으로 사각형이 기준입니다. 따라서 삼각형 모양의 변환을 하려면 아래와 같이 복잡한 과정을 거쳐야 합니다.

1. 어핀 변환 전 삼각형 좌표 3개를 정한다.
2. 어핀 변환 후 삼각형 좌표 3개를 정한다.
3. 변환 전 삼각형 좌표를 감싸는 외접 사각형 좌표를 구한다.
4. 변환 후 삼각형 좌표를 감싸는 외접 사각형 좌표를 구한다.
5. 과정 3, 4의 사각형 영역을 관심 영역(ROI, regison of interest)으로 지정한다.
6. 과정 5의 관심 영역을 기준으로 변환 전, 후의 삼각형 좌표를 다시 계산한다.
7. 과정 6의 변환 전 삼각형 좌표를 변환 후 삼각형 좌표로 어핀 변환해주는 변환 행렬을 구한다.
8. 과정 7에서 구한 변환행렬을 적용해 어핀 변환을 한다.
9. 과정 8에서 변환된 관심 영역에서 과정 2의 삼각형 좌표만 마스킹한다.
10. 과정 9에서 구한 마스크를 이용해서 어핀 변환한 이미지와 원본 이미지를 합성한다.

글로만 읽었을 때는 상당히 복잡해 보입니다. 위 과정과 아래의 코드를 함께 보면 이해가 좀 더 수월할 겁니다. 또한, 위의 과정 3, 4처럼 삼각형 좌표를 감싸는 외접 사각형 좌표를 구하려면 cv2.boundingRect() 함수를 써야 합니다.

  • x, y, w, h = cv2.boudingRect(pts)
    pts: 다각형 좌표
    x, y, w, h = 외접 사각형의 좌표와 폭과 높이

그리고 과정 9의 마스크를 구하기 위해 아래 함수가 필요합니다.

  • cv2.fillConvexPoly(img, pts, color, lineTypes)
    img: 입력 이미지
    pts: 다각형 좌표
    color: 다각형을 채울 색상
    lineType(optional): 선 그리기 알고리즘 선택 플래그

아래는 로봇 태권 V 장난감 이미지의 얼굴 부분을 삼각형 어핀 변환하는 예제입니다.

# 삼각형 어핀 변환 (triangle_affine.py)

import cv2
import numpy as np

img = cv2.imread("../img/taekwonv1.jpg")
img2 = img.copy()
draw = img.copy()

# 변환 전,후 삼각형 좌표 ---①
pts1 = np.float32([[188,14], [85,202], [294,216]])
pts2 = np.float32([[128,40], [85,307], [306,167]])

# 각 삼각형을 완전히 감싸는 사각형 좌표 구하기 ---②
x1,y1,w1,h1 = cv2.boundingRect(pts1)
x2,y2,w2,h2 = cv2.boundingRect(pts2)

# 사각형을 이용한 관심영역 설정 ---③
roi1 = img[y1:y1+h1, x1:x1+w1]
roi2 = img2[y2:y2+h2, x2:x2+w2]

# 관심영역을 기준으로 좌표 계산 ---④
offset1 = np.zeros((3,2), dtype=np.float32)
offset2 = np.zeros((3,2), dtype=np.float32)
for i in range(3):
    offset1[i][0], offset1[i][1] = pts1[i][0]-x1, pts1[i][1]-y1
    offset2[i][0], offset2[i][1] = pts2[i][0]-x2, pts2[i][1]-y2

# 관심 영역을 주어진 삼각형 좌표로 어핀 변환 ---⑤
mtrx = cv2.getAffineTransform(offset1, offset2)
warped = cv2.warpAffine( roi1, mtrx, (w2, h2), None, \
                        cv2.INTER_LINEAR, cv2.BORDER_REFLECT_101)

# 어핀 변환 후 삼각형만 골라 내기 위한 마스크 생성 ---⑥
mask = np.zeros((h2, w2), dtype = np.uint8)
cv2.fillConvexPoly(mask, np.int32(offset2), (255))

# 삼각형 영역만 마스킹해서 합성 ---⑦
warped_masked = cv2.bitwise_and(warped, warped, mask=mask)
roi2_masked = cv2.bitwise_and(roi2, roi2, mask=cv2.bitwise_not(mask))
roi2_masked = roi2_masked + warped_masked
img2[y2:y2+h2, x2:x2+w2] = roi2_masked

# 관심 영역과 삼각형에 선 그려서 출력 ---⑧
cv2.rectangle(draw, (x1, y1), (x1+w1, y1+h1), (0,255,0), 1)
cv2.polylines(draw, [pts1.astype(np.int32)], True, (255,0,0), 1)
cv2.rectangle(img2, (x2, y2), (x2+w2, y2+h2), (0,255,0), 1)
cv2.imshow('origin', draw)
cv2.imshow('warped triangle', img2)
cv2.waitKey(0)
cv2.destroyAllWindows()

 

이상으로 이미지를 뒤트는 방법인 어핀 변환, 원근 변환, 삼각형 어핀 변환에 대해 알아봤습니다.

2 Comments
  • 프로필사진 스투키 2021.08.27 19:47 안녕하세요 글 잘 읽었습니다. 한가지 궁금한 점이 있어서 댓글 남깁니다. 위에 종이사진에서 좌표 4개를 통해서 스캔한 것처럼 원근 변환을 하셨는데
    이 이미지를 다시 원본 이미지로 되돌리려면 어떻게 하면 되나요? 파라미터를 바꾸면 된다고 들어서 해봤는데 잘 모르겠습니다. 혹시 설명해주시면 감사하겠습니다.
  • 프로필사진 데이터 파수꾼 Baek Kyun Shin 2021.08.27 23:13 신고 읽어주셔서 감사합니다!

    cv2.warpPerspective()을 사용하면 종이사진을 스캔본처럼 만들 수도 있고, 원본사진을 뒤트는 것도 가능하니 말씀하신대로 파라미터를 잘 조절하면 될 것 같습니다. 다만, 완벽하게 복원하려면 뭔가 다른 방법이 있을 것 같은데 저도 거기까진 모르겠습니다 ㅜㅜ
댓글쓰기 폼