logo

신분증 스캔

다각형 근사와 투시 변환을 이용한 신분증 스캔

실습 준비

import cv2 as cv
import numpy as np
from yeom import to_pil

도형 탐지의 다각형 근사로 신분증의 네 꼭짓점을 찾고, 기하학적 변환의 투시 변환으로 정면 스캔 형태로 보정. 윤곽선 탐지와 좌표 변환을 연결한 응용.

카드 영역 찾기

배경보다 밝은 신분증을 오츠 이진화로 분리. 바깥 윤곽선을 다각형으로 근사하고, 네 꼭짓점 후보 중 면적이 가장 큰 것을 카드로 선택.

이미지 이진화

card_image = cv.imread("id_card.png")
card_gray = cv.cvtColor(
    card_image,
    cv.COLOR_BGR2GRAY,
)

card_image.shape  # 원본 형태
실행 결과
(512, 819, 3)

원본은 높이 512픽셀, 너비 819픽셀의 3채널 컬러 이미지.

to_pil(card_image)

나무 배경 위에 놓인 신분증 원본

오츠 알고리즘으로 클래스 간 분산이 가장 큰 임계값을 자동 선택.

otsu_threshold, card_binary = cv.threshold(
    card_gray,
    0,    # 오츠 알고리즘이 임계값을 결정
    255,  # 임계값보다 밝은 픽셀에 넣을 값
    cv.THRESH_BINARY | cv.THRESH_OTSU,
)

otsu_threshold  # 자동 결정 임계값
실행 결과
134.0

밝기 134보다 큰 픽셀은 흰색, 나머지는 검은색으로 분리.

to_pil(card_binary)

오츠 이진화로 신분증을 흰색 전경으로 분리한 결과

카드 외곽은 하나의 큰 흰색 영역. 카드 내부의 검은 글자와 사진은 cv.RETR_EXTERNAL로 바깥 윤곽선만 찾을 때 제외.

사각형 윤곽선 선택

card_contours, _ = cv.findContours(
    card_binary,
    cv.RETR_EXTERNAL,
    cv.CHAIN_APPROX_SIMPLE,
)

len(card_contours)  # 바깥 윤곽선 수
실행 결과
1

이진 이미지에서 카드 외곽에 해당하는 윤곽선 1개를 검출.

각 윤곽선의 둘레 중 2%를 허용 거리로 사용하여 다각형 근사. 먼저 꼭짓점이 네 개인 후보만 수집.

card_rectangles = []

for card_contour in card_contours:
    perimeter = cv.arcLength(card_contour, True)
    approximation = cv.approxPolyDP(
        card_contour,
        0.02 * perimeter,  # 둘레의 2%까지 오차 허용
        True,              # 닫힌 다각형
    )

    if len(approximation) == 4:
        card_rectangles.append(approximation)

len(card_rectangles)  # 사각형 후보 수
실행 결과
1

네 꼭짓점으로 근사된 후보는 1개. 후보가 여러 개면 면적이 가장 큰 것을 카드로 선택.

if not card_rectangles:
    raise ValueError("신분증의 네 꼭짓점을 찾지 못했습니다")

card_rectangle = max(
    card_rectangles,
    key=cv.contourArea,
)

cv.contourArea(card_rectangle)  # 선택한 후보의 면적
실행 결과
74289.0

선택한 사각형의 면적은 74,289픽셀². 검출된 네 점의 배열 순서는 투시 변환에 필요한 모서리 순서와 다름.

card_rectangle.reshape(4, 2)  # 검출 순서의 꼭짓점
실행 결과
array([[683, 396],
       [571, 259],
       [121, 333],
       [193, 481]], dtype=int32)

꼭짓점 정렬과 투시 변환

투시 변환은 원본 좌표와 출력 좌표에서 같은 인덱스의 점을 서로 대응. 검출한 네 점을 왼쪽 위, 오른쪽 위, 오른쪽 아래, 왼쪽 아래 순서로 정렬.

꼭짓점 정렬

def order_points(points):
    points = points.reshape(4, 2).astype(np.float32)
    coordinate_sums = points.sum(axis=1)
    coordinate_differences = np.diff(points, axis=1).ravel()

    return np.float32([
        points[np.argmin(coordinate_sums)],        # 왼쪽 위
        points[np.argmin(coordinate_differences)], # 오른쪽 위
        points[np.argmax(coordinate_sums)],        # 오른쪽 아래
        points[np.argmax(coordinate_differences)], # 왼쪽 아래
    ])
  • 좌표 합 x+y의 최솟값과 최댓값: 왼쪽 위와 오른쪽 아래
  • 좌표 차 yx의 최솟값과 최댓값: 오른쪽 위와 왼쪽 아래
source_points = order_points(card_rectangle)

source_points  # 정렬한 원본 좌표
실행 결과
array([[121., 333.],
       [571., 259.],
       [683., 396.],
       [193., 481.]], dtype=float32)

검출 순서와 달리 네 모서리가 시계 방향으로 정렬된 결과.

출력 좌표와 변환

출력 크기를 400×200픽셀로 정하고 마지막 픽셀 좌표인 (399, 199)까지 네 모서리를 배치.

destination_points = np.float32([
    [0, 0],
    [399, 0],
    [399, 199],
    [0, 199],
])

destination_points  # 출력 이미지의 네 모서리
실행 결과
array([[  0.,   0.],
       [399.,   0.],
       [399., 199.],
       [  0., 199.]], dtype=float32)

네 쌍의 대응점으로 3×3 투시 변환 행렬을 계산.

perspective_matrix = cv.getPerspectiveTransform(
    source_points,
    destination_points,
)

perspective_matrix.shape  # 투시 변환 행렬 형태
실행 결과
(3, 3)

계산한 행렬을 원본 이미지에 적용. cv.warpPerspective()의 출력 크기는 (너비, 높이) 순서.

scanned_card = cv.warpPerspective(
    card_image,
    perspective_matrix,
    (400, 200),  # 출력 크기: 너비 400, 높이 200
)

scanned_card.shape  # 출력 형태
실행 결과
(200, 400, 3)
to_pil(scanned_card)

네 꼭짓점을 기준으로 투시 변환한 신분증 스캔 결과

기울어진 카드가 높이 200픽셀, 너비 400픽셀의 정면 형태로 보정. 이 예제는 배경과 카드의 밝기 차이가 크고 외곽이 끊기지 않아 하나의 큰 사각형으로 검출. 배경이 복잡하거나 밝기가 비슷하면 Canny 경계, 모폴로지 연산, 면적·가로세로비 조건을 함께 사용.

퀴즈

문제 1 / 1맞음: 0힌트: 0틀림: 0채점중: 0남음: 1

신분증 스캔 예제의 처리 흐름으로 올바른 것을 모두 고르세요.

  • 네 꼭짓점으로 근사되는 윤곽선 중 면적이 가장 큰 것을 카드 후보로 선택한다
  • 투시 변환 전에 꼭짓점을 왼쪽 위, 오른쪽 위, 오른쪽 아래, 왼쪽 아래 순서로 맞춘다
  • 목표 좌표 destination_points는 출력 이미지의 네 모서리 좌표를 나타낸다
  • 꼭짓점 순서는 OpenCV가 항상 자동으로 바로잡으므로 신경 쓰지 않아도 된다

퀴즈를 풀려면 대화형 기능을 불러와야 합니다.

Previous
도형 탐지