신분증 스캔
다각형 근사와 투시 변환을 이용한 신분증 스캔
도형 탐지의 다각형 근사로 신분증의 네 꼭짓점을 찾고, 기하학적 변환의 투시 변환으로 정면 스캔 형태로 보정. 윤곽선 탐지와 좌표 변환을 연결한 응용.
카드 영역 찾기
배경보다 밝은 신분증을 오츠 이진화로 분리. 바깥 윤곽선을 다각형으로 근사하고, 네 꼭짓점 후보 중 면적이 가장 큰 것을 카드로 선택.
이미지 이진화
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의 최솟값과 최댓값: 왼쪽 위와 오른쪽 아래
- 좌표 차 y−x의 최솟값과 최댓값: 오른쪽 위와 왼쪽 아래
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 경계, 모폴로지 연산, 면적·가로세로비 조건을 함께 사용.
퀴즈
신분증 스캔 예제의 처리 흐름으로 올바른 것을 모두 고르세요.
퀴즈를 풀려면 대화형 기능을 불러와야 합니다.