귀퉁이 서재

OpenCV - 29. 올바른 매칭점 찾기 본문

OpenCV

OpenCV - 29. 올바른 매칭점 찾기

Baek Kyun Shin 2020. 11. 21. 23:12

이번 포스팅은 이전 포스팅의 후속 편입니다. 이전 포스팅에서는 특징 매칭에 대해 알아봤습니다. 그러나 잘못된 특징 매칭이 너무 많았습니다. 잘못된 특징 매칭은 제외하고 올바른 매칭점을 찾는 작업이 추가로 필요합니다. 이번 포스팅에서는 올바른 매칭점을 찾는 방법에 대해 배워보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

올바른 매칭점 찾기

이전 포스팅에서 match(), knnMatch(), radiusMatch() 함수를 활용하여 매칭점을 찾는 실습을 했습니다. 그러나 잘못된 매칭 결과가 굉장히 많이 포함되어 있었습니다. 매칭 결과에서 쓸모없는 매칭점은 버리고 올바른 매칭점만 골라내는 작업이 필요합니다. 만약 올바른 매칭점만 남겼는데 매칭점이 몇 개 없다면 두 이미지는 서로 연관관계가 없다고 판단해야 합니다.

radiusMatch()는 maxDistance 파라미터를 조절하는 것 말고는 큰 의미가 없으므로 제외하고, match()와 knnMatch() 함수를 활용해 올바른 매칭점을 찾는 방법에 대해 알아보겠습니다.

match() 함수는 모든 디스크립터를 하나하나 비교하여 매칭점을 찾습니다. 따라서 가장 작은 거리 값과 큰 거리 값의 상위 몇 퍼센트만 골라서 올바른 매칭점을 찾을 수 있습니다.

아래는 match() 함수를 통해 올바른 매칭점을 찾는 코드입니다. ORB로 디스크립터를 추출하고 BF-Hamming 매칭기로 매칭을 계산했습니다. 매칭 결과의 거리(distance)를 기준으로 정렬하고 거리가 짧은 20%의 매칭점만 골랐습니다.

# match 함수로부터 올바른 매칭점 찾기 (match_good.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB로 서술자 추출 ---①
detector = cv2.ORB_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)
# BF-Hamming으로 매칭 ---②
matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = matcher.match(desc1, desc2)

# 매칭 결과를 거리기준 오름차순으로 정렬 ---③
matches = sorted(matches, key=lambda x:x.distance)
# 최소 거리 값과 최대 거리 값 확보 ---④
min_dist, max_dist = matches[0].distance, matches[-1].distance
# 최소 거리의 20% 지점을 임계점으로 설정 ---⑤
ratio = 0.2
good_thresh = (max_dist - min_dist) * ratio + min_dist
# 임계점 보다 작은 매칭점만 좋은 매칭점으로 분류 ---⑥
good_matches = [m for m in matches if m.distance < good_thresh]
print('matches:%d/%d, min:%.2f, max:%.2f, thresh:%.2f' \
        %(len(good_matches),len(matches), min_dist, max_dist, good_thresh))
# 좋은 매칭점만 그리기 ---⑦
res = cv2.drawMatches(img1, kp1, img2, kp2, good_matches, None, \
                flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# 결과 출력
cv2.imshow('Good Match', res)
cv2.waitKey()
cv2.destroyAllWindows()

대부분 매칭이 잘 되었습니다.

이제는 knnMatch() 함수로 올바른 매칭점을 찾아보겠습니다. knnMatch() 함수는 디스크립터당 k개의 최근접 이웃 매칭점을 가까운 순서대로 반환합니다. k개의 최근접 이웃 중 거리가 가까운 것은 좋은 매칭점이고, 거리가 먼 것은 좋지 않은 매칭점일 가능성이 높습니다. 최근접 이웃 중 거리가 가까운 것 위주로 골라내면 좋은 매칭점을 찾아낼 수 있습니다.

# knnMatch 함수로부터 올바른 매칭점 찾기 (match_good_knn.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB로 서술자 추출 ---①
detector = cv2.ORB_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)
# BF-Hamming 생성 ---②
matcher = cv2.BFMatcher(cv2.NORM_HAMMING2)
# knnMatch, k=2 ---③
matches = matcher.knnMatch(desc1, desc2, 2)

# 첫번재 이웃의 거리가 두 번째 이웃 거리의 75% 이내인 것만 추출---⑤
ratio = 0.75
good_matches = [first for first,second in matches \
                    if first.distance < second.distance * ratio]
print('matches:%d/%d' %(len(good_matches),len(matches)))

# 좋은 매칭만 그리기
res = cv2.drawMatches(img1, kp1, img2, kp2, good_matches, None, \
                    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# 결과 출력                    
cv2.imshow('Matching', res)
cv2.waitKey()
cv2.destroyAllWindows()

knnMatch() 함수에서 k는 2로 설정했습니다. 최근접 이웃 중 거리가 가까운 것 중 75%만 골랐습니다. 반환된 결과를 보면 모두 잘 매칭이 되었습니다.

매칭 영역 원근 변환

올바르게 매칭된 좌표들에 원근 변환 행렬을 구하면 매칭 되는 물체가 어디 있는지 표시할 수 있습니다. 위에서 실습했던 이미지를 보겠습니다. 찾고자 하는 물체는 로봇 태권 V입니다. 두 사진에서 로봇 태권 V의 크기, 방향, 색상 등이 거의 동일합니다만 조금 다릅니다. 비교하려는 물체가 두 사진 상에서 약간 회전했을 수도 있고 크기가 조금 다를 수도 있습니다. 이에 대해 원근 변환 행렬을 구하면 찾고자 하는 물체의 위치를 잘 찾을 수 있습니다. 더불어 원근 변환 행렬에 들어맞지 않는 매칭점을 구분할 수 있어서 나쁜 매칭점을 한번 더 제거할 수 있습니다.

여러 매칭점으로 원근 변환 행렬을 구하는 함수는 cv2.findHomography()이고, 원래 좌표들을 원근 변환 행렬로 변환하는 함수는 cv2.perspectiveTransform()입니다.

  • mtrx, mask = cv2.findHomography(srcPoints, dstPoints, method, ransacReprojThreshold, mask, maxIters, confidence)
    srcPoints: 원본 좌표 배열
    dstPoints: 결과 좌표 배열
    method=0(optional): 근사 계산 알고리즘 선택 (0: 모든 점으로 최소 제곱 오차 계산, cv2.RANSAC, cv2.LMEDS, cv2.RHO)
    ransacReprojThreshold=3(optional): 정상치 거리 임계 값(RANSAC, RHO인 경우)
    maxIters=2000(optional): 근사 계산 반복 횟수
    confidence=0.995(optional): 신뢰도(0~1의 값)
    mtrx: 결과 변환 행렬
    mask: 정상치 판별 결과, N x 1 배열 (0: 비정상치, 1: 정상치)
  • dst = cv2.perspectiveTransform(src, m, dst)
    src: 입력 좌표 배열
    m: 변환 배열
    dst(optional): 출력 좌표 배열

cv2.findHomography() 함수는 cv2.getPerspectiveTransform() 함수와 비슷합니다. 다만 cv2.getPerspectiveTransform()은 4개의 꼭짓점으로 정확한 원근 변환 행렬을 반환하지만, cv2.findHomography()는 여러 개의 점으로 근사 계산한 원근 변환 행렬을 반환합니다. cv2.perspectiveTransform() 함수는 원근 변환할 새로운 좌표 배열을 반환합니다.

cv2.findHomography() 함수의 method 파라미터에는 0, cv2.RANSAC, cv2.LMEDS, cv2.RHO의 값을 전달할 수 있습니다. 이는 원근 변환의 근사 계산을 위한 알고리즘입니다. default 값인 0은 모든 좌표를 최소 제곱 법으로 근사 계산합니다. 이는 모든 좌표에 대해 계산되므로 틀린 매칭점이 있다면 오차가 클 수 있습니다.

method 파라미터에 cv2.RANSAC를 전달하면 RANSAC(Random Sample Consensus) 알고리즘을 사용합니다. 이는 모든 좌표를 사용하지 않고 임의의 좌표만 선정해서 만족도를 구하는 방식인데, 이렇게 구한 만족도가 큰 것만 선정하여 근사 계산합니다. 선정된 점들은 정상치로 분류하고 그 외의 점들은 이상치로 분류해서 노이즈로 판단합니다. 이때 이상치를 구분하는 임계 값으로 ransacReprojThreshold 값을 정하면 됩니다. 변환 값은 결과 변환 행렬(mtrx)과 정상치 판별 결과(mask)입니다. mask에는 입력 좌표와 동일한 인덱스에 정상치는 1, 이상치는 0으로 표시됩니다. 이는 올바른 매칭점과 나쁜 매칭점을 구분하는데 활용할 수 있습니다. 특징 매칭을 하더라도 올바르지 않은 매칭점들이 굉장히 많다는 걸 이전 포스팅에서 살펴봤습니다. 하지만 cv2.RANSAC을 활용한다면 정상치와 이상치를 구분해주는 mask를 반환하므로 올바른 매칭점과 나쁜 매칭점을 한번 더 구분할 수 있습니다.

method 파라미터에 cv2.LMEDS를 전달하면 LMedS(Least Median of Squares) 알고리즘을 활용하여 제곱의 최소 중간값을 사용합니다. 이 알고리즘은 추가적인 파라미터를 요구하지 않아 사용하기에는 편하지만, 정상치가 50% 이상인 경우에만 정상적으로 작동하니 주의할 필요가 있습니다.

cv2.RHO는 RANSAC을 개선한 PROSAC(Progressive Sample Consensus) 알고리즘을 사용합니다. 아 알고리즘은 이상치가 많은 경우에 더 빠릅니다.

아래 코드는 올바른 매칭점을 활용해 원근 변환 행렬을 구하고, 원본 이미지 크기만큼의 사각형 도형을 원근 변환하여 결과 이미지에 표시하는 코드입니다. 이렇게 함으로써 찾고자 하는 물체가 어디 있는지 표시할 수 있습니다.

# 매칭점 원근 변환으로 영역 찾기 (match_homography.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB, BF-Hamming 로 knnMatch  ---①
detector = cv2.ORB_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)
matcher = cv2.BFMatcher(cv2.NORM_HAMMING2)
matches = matcher.knnMatch(desc1, desc2, 2)

# 이웃 거리의 75%로 좋은 매칭점 추출---②
ratio = 0.75
good_matches = [first for first,second in matches \
                    if first.distance < second.distance * ratio]
print('good matches:%d/%d' %(len(good_matches),len(matches)))

# 좋은 매칭점의 queryIdx로 원본 영상의 좌표 구하기 ---③
src_pts = np.float32([ kp1[m.queryIdx].pt for m in good_matches ])
# 좋은 매칭점의 trainIdx로 대상 영상의 좌표 구하기 ---④
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in good_matches ])
# 원근 변환 행렬 구하기 ---⑤
mtrx, mask = cv2.findHomography(src_pts, dst_pts)
# 원본 영상 크기로 변환 영역 좌표 생성 ---⑥
h,w, = img1.shape[:2]
pts = np.float32([ [[0,0]],[[0,h-1]],[[w-1,h-1]],[[w-1,0]] ])
# 원본 영상 좌표를 원근 변환  ---⑦
dst = cv2.perspectiveTransform(pts,mtrx)
# 변환 좌표 영역을 대상 영상에 그리기 ---⑧
img2 = cv2.polylines(img2,[np.int32(dst)],True,255,3, cv2.LINE_AA)

# 좋은 매칭 그려서 출력 ---⑨
res = cv2.drawMatches(img1, kp1, img2, kp2, good_matches, None, \
                    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
cv2.imshow('Matching Homography', res)
cv2.waitKey()
cv2.destroyAllWindows()

결과 이미지에서 찾고자 하는 물체(여기서는 로봇 태권 v)가 어디 있는지 표시되었습니다.

print('good matches:%d/%d' %(len(good_matches),len(matches)))

이 부분까지는 '올바른 매칭점 찾기'에서 활용한 코드와 동일합니다. 

원근 변환 행렬을 구하는 코드는 아래 부분입니다.

# 좋은 매칭점의 queryIdx로 원본 영상의 좌표 구하기 ---③
src_pts = np.float32([ kp1[m.queryIdx].pt for m in good_matches ])
# 좋은 매칭점의 trainIdx로 대상 영상의 좌표 구하기 ---④
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in good_matches ])
# 원근 변환 행렬 구하기 ---⑤
mtrx, mask = cv2.findHomography(src_pts, dst_pts)

우선 good_matches는 knnMatch() 함수의 반환 결과입니다.

이전 포스팅에서 살펴본 바와 같이 match(), knnMatch(), radiusMatch() 함수의 반환 결과는 DMatch 객체 리스트입니다.

  • DMatch: 매칭 결과를 표현하는 객체 
    queryIdx: queryDescriptors의 인덱스
    trainIdx: trainDescriptors의 인덱스
    imgIdx: trainDescriptor의 이미지 인덱스
    distance: 유사도 거리

따라서 good_matches 배열에서 하나의 원소 m에 대한 m.queryIdx 값은 입력 이미지의 디스크립터(queryDescriptors)에 해당하는 인덱스입니다. 그리하여 kp1[m.queryIdx].pt는 ORB로 추출한 모든 특징점들 중 m.queryIdx에 해당하는 특징점 좌표들만 선택한다는 뜻입니다. 이것이 바로 올바른 매칭점입니다. 입력 이미지에 대한 올바른 매칭점 src_pts와 대상 이미지에 대한 올바른 매칭점 dst_pts를 구했습니다. 올바른 매칭점인 src_pts, dst_pts를 활용하여 cv2.findHomography() 함수로 원근 변환 행렬을 구할 수 있습니다. 

아래의 코드를 활용하여 원근 변환한 도형을 대상 이미지에 표시했습니다.

h,w, = img1.shape[:2]
pts = np.float32([ [[0,0]],[[0,h-1]],[[w-1,h-1]],[[w-1,0]] ])
# 원본 영상 좌표를 원근 변환  ---⑦
dst = cv2.perspectiveTransform(pts,mtrx)
# 변환 좌표 영역을 대상 영상에 그리기 ---⑧
img2 = cv2.polylines(img2,[np.int32(dst)],True,255,3, cv2.LINE_AA)

먼저, 원본 이미지의 크기에 대한 사각형 좌표를 구합니다. 그다음 이 사각형 좌표에 대해 원근 변환 행렬(mtrx)을 합니다. 이렇게 구한 원근 변환된 좌표를 대상 이미지에 표시했습니다. 이렇게 해서 찾는 물체가 어디 있는지 나타낼 수 있습니다. 사실 본 예제에는 잘못된 매칭점이 포함되어 있지 않았습니다. 하지만 대부분의 경우 올바른 매칭점을 골라내도 그 속에는 잘못된 매칭점이 섞여 있습니다. 이 경우 결과 값 mask를 활용하여 정상치와 이상치를 구별해주어 잘못된 매칭점을 추가로 제거해주어야 합니다. 

다음은 RANSAC 원근 변환 근사 계산으로 잘못된 매칭을 추가로 제거하는 코드입니다.

# RANSAC 원근 변환 근사 계산으로 나쁜 매칭 제거 (match_homography_accuracy.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures2.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB, BF-Hamming 로 knnMatch  ---①
detector = cv2.ORB_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)
matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = matcher.match(desc1, desc2)

# 매칭 결과를 거리기준 오름차순으로 정렬 ---③
matches = sorted(matches, key=lambda x:x.distance)
# 모든 매칭점 그리기 ---④
res1 = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

# 매칭점으로 원근 변환 및 영역 표시 ---⑤
src_pts = np.float32([ kp1[m.queryIdx].pt for m in matches ])
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in matches ])
# RANSAC으로 변환 행렬 근사 계산 ---⑥
mtrx, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
h,w = img1.shape[:2]
pts = np.float32([ [[0,0]],[[0,h-1]],[[w-1,h-1]],[[w-1,0]] ])
dst = cv2.perspectiveTransform(pts,mtrx)
img2 = cv2.polylines(img2,[np.int32(dst)],True,255,3, cv2.LINE_AA)

# 정상치 매칭만 그리기 ---⑦
matchesMask = mask.ravel().tolist()
res2 = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                    matchesMask = matchesMask,
                    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# 모든 매칭점과 정상치 비율 ---⑧
accuracy=float(mask.sum()) / mask.size
print("accuracy: %d/%d(%.2f%%)"% (mask.sum(), mask.size, accuracy))

# 결과 출력                    
cv2.imshow('Matching-All', res1)
cv2.imshow('Matching-Inlier ', res2)
cv2.waitKey()
cv2.destroyAllWindows()

원근 변환 행렬을 구할 때 RANSAC을 사용했고, 그 결과인 mask를 활용하여 잘못된 매칭점을 제거했습니다. mask에는 입력 좌표와 동일한 인덱스에 정상치에는 1, 이상치에는 0이 표시된다고 했습니다. 이는 올바른 매칭점과 나쁜 매칭점을 구분하는데 활용할 수 있습니다. 

Comments