스마트폰으로 문서를 촬영하면 보통 비스듬하게 찍힙니다. 이를 정면에서 본 것처럼 보정하려면 어떻게 해야 할까요? 이번 글에서는 OpenCV를 사용해 문서 이미지를 자동으로 보정하는 파이프라인을 구현하고, 단순 2D 변환과 3D 기반 복원의 차이를 비교해봅니다.
일단 쉽게 진행하기 위해서 흑백으로 함
일단 작업을 진행하기 전에 어떻게 작업을 해야 하는지 한번 생각해 봅시다.
위의 이미지를 우리가 볼 때 어떻게 생각을 하는 과정을 한번 추적해보면 아래와 같을 겁니다.
- 이미지의 가운데에 카드 같은 게 있네
- 조금 기울어져 있는걸 보니 카메라가 카드를 비스듬하게 찍었겠구나
- 살짝 기울어진 채로 읽으면 되겠네
여기서 힌트를 얻어서 앞으로 해야할 것들을 정리해보죠
- 카드로 추정되는 영역을 찾기
- 어떻게 왜곡되어 있는지 확인하기
- 2번에서의 추측을 토대로 보정하기
이제 이를 토대로 한번 해보겠습니다.
사람들에게 앞선 사진에서 문서인 영역을 표시해보라고 한다면 아래처럼 될것입니다.
우리가 저기 부분에 카드가 있단 걸 어떻게 알게 되었을까요?
아마 대부분 배경과 저 문서의 영역이 다르단 걸 눈치챘기 때문이겠죠.
그렇다면 왜 우리는 저 영역이 다르단 걸 알 수 있었을까요? 바로 색이 다르기 때문이죠.
이를 정리해보면 픽셀의 값이 바뀌는 곳이 경계라고 볼 수 있죠.
그러면 값이 변화하는 곳을 OpenCV를 사용해 표시해보도록 합시다.
경계면을 찾는데 주로 쓰이는 메소드입니다.
edges = cv2.Canny(blurred, 50, 150)동작 원리:
- Gradient 계산: 픽셀 밝기가 급격히 변하는 지점 탐지
- Non-maximum Suppression: 경계를 1픽셀 두께로 얇게 만듦
- Double Thresholding:
- 150 이상: 강한 경계
- 50 이하: 경계 아님
- 중간값 → 강한 경계와 연결된 경우만 유지
Canny를 통해서 이제 우리는 엣지를 알아낼 수 있게 되었습니다. 한번 그 결과를 보시죠.
불필요한 부분까지 경계선으로 표시가 되었습니다. 물론 지금 이상태로도 작업은 가능하지만 쓸데없는 정보가 너무 많아 작업이 효율적이지 못합니다.
이를 해결하기 위해 사진을 조금 뭉갠 뒤 경계선을 찾아보죠.
가우시안 블러는 로우패스 필터라고도 불리죠. 즉 디테일을 뭉개는데 효과적인 필터입니다.
blurred = cv2.GaussianBlur(image, (9, 9), 0)목적:
- 문서 내 텍스트나 작은 얼룩이 엣지로 잘못 검출되는 것을 방지- 문서 내 텍스트, 패턴 등 세부사항 제거
- 9×9 커널로 적당한 블러링
사진이 확실히 뿌예지면서 배경에 사용된 나무의 줄무늬 등이 확연히 줄었습니다. 이 상태로 한번 Canny를 다시 해보죠.
자 이제 우리는 위 이미지에서 쓸만한 경계면들을 찾는 데 성공했습니다. 그러면 이젠 저 경계면들에서 깔끔한 직선을 얻어보도록 하죠.
투표 기반 직선 검출
lines = cv2.HoughLines(edges, 1, math.pi/180, 100)핵심 개념: 투표 기반 검출
- 직선을 극좌표로 표현:
ρ = x·cos(θ) + y·sin(θ) - 각 엣지 픽셀이 가능한 모든 (ρ, θ) 조합에 투표
- threshold(100) 이상 득표한 것만 직선으로 인정
극좌표 사용 이유:
- 수직선(x=c)을 무한대 기울기 없이 표현
- 파라미터 공간이 유한하고 균일함
위의 이미지에서 보듯 빨간 직선을 r, theta로 나타냅니다.
어느 한점을 지나가는 모든 직선을 극좌표 공간에 표시하면 위의 그림처럼 나옵니다.
위처럼 어느 직선 위의 점들에 대해서 해당 점을 지나가는 직선들을 표시해보면 위의 그림처럼 나옵니다. 우리는 이를 투표한다고 표현할 겁니다.
직선위의 모든 점들에 대해서 투표를 실시하면 위의 이미지처럼 가장 많은 투표를 받은 부분들이 직선을 이룬다고 볼 수 있습니다.
위의 방법을 앞서 우리가 구해둔 경계면에 적용하면 직선으로 추정되는 것들을 뽑아낼 수 있게 되었습니다. 한번 결과를 확인해보죠.
결과를 보니 비슷해 보이지만 다른 선들이 엄청 많군요. 이 모든 경우의 수를 확인해도 되지만 좀 더 효율적으로 접근하기 위해서 비슷한 선들끼리 묶어보겠습니다.
OpenCV에서 제공하는 K-Means 클러스터링을 적용해 유사한 직선을 통합합니다.
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 0.2)
ret, labels, centers = cv2.kmeans(data, n_clusters, None, criteria, 10, cv2.KMEANS_PP_CENTERS)목적: 유사한 직선들을 대표 직선으로 통합
- 입력: (ρ, θ) 좌표들
- 출력: 클러스터 중심점들
빨간선이 클러스터링 된 선, 초록선이 기존 선입니다.
10개 정도로 클러스터링을 해보니 확실히 이전에 비해서 깔끔해진 것을 볼 수 있습니다.
이로써 우리는 경계면으로부터 쓸만한 직선들을 추려내는 데 성공했습니다.
이제 경계를 깔끔하게 나누는 직선들을 찾았으니, 우리가 원하는 정보가 담겨진 사각형을 찾아봅시다.
진행하기 전에 현재 깔고있는 전제 조건
타겟의 왜곡이 크지 않고 이미지에는 우리가 원하는 부분만 담겨있다고 가정
즉, 아래처럼 화면 안에는 교점이 4개만 보인다고 가정 아니면 좀 복잡해지기 때문에
for line_comb in combinations(lines, 4):
# 6개의 교점 계산 (4C2 = 6)
# 유효성 검증위처럼 대략 코드를 작성해 가능한 조합들을 한번 확인해보죠.
| 교점 개수 | 상태 |
|---|---|
| 0개 | ![]() |
| 1개 | ![]() |
| 2개 | ![]() |
| 3개 | ![]() |
| 5개 | ![]() |
| 6개 | ![]() |
위의 케이스들을 보면 우리가 쓸 수 있는 영역을 결정할 몇 가지 단서를 정리해봅시다.
유효성 검증 조건:
- 4개의 교점이 모두 이미지 내부에 있어야 함
- 각 직선이 정확히 2개의 교점에만 관여
추후 사용할 getPerspectiveTransform이 점 순서에 의존하므로
위의 반복문에서 얻어낸 좌표들을 시계방향으로 정리해서 사용할겁니다.
위에서 직선들로부터 가능한 사각형들의 조합을 알아냈으니, 이 중 가장 쓸만한 사각형을 골라봅시다.
이번 아이디어는 이전에 찾아둔 엣지들과 가장 많이 매칭되는 사각형을 고르는 겁니다.
def pick_best_rect(rects, edges):
# 사각형 변 위의 샘플 점들이 엣지와 얼마나 일치하는지 측정
best = max(rects, key=calculate_edge_points)![]() |
![]() |
![]() |
![]() |
![]() |
위처럼 엣지랑 매칭되는 부분을 찾아 빨간색 부분이 가장 커지는 사각형을 찾는 데 성공했네요.

이 방법은 사각형 내부의 이미지를 잡아당겨서 직사각형으로 만드는 방법입니다.
def deskew_document(image, rect):
(tl, tr, br, bl) = rect
# 목표 크기 계산
maxWidth = max(distance(tl, tr), distance(bl, br))
maxHeight = max(distance(tl, bl), distance(tr, br))
# 변환 행렬 계산
dst = [[0, 0], [maxWidth, 0], [maxWidth, maxHeight], [0, maxHeight]]
M = cv2.getPerspectiveTransform(src, dst)
# 변환 적용
warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))동작 원리:
- 원본 4개 점 → 직사각형 4개 점으로 매핑하는 3×3 행렬 계산
- 모든 픽셀에 이 변환 적용
결과:
꽤 괜찮게 된 것 같네요. 하지만 왜곡이 심한 이미지도 이렇게 잘 될까요?
조금 더 왜곡이 큰 이미지에 한번 해보겠습니다.
| 원본 | 2D 변환 결과 |
|---|---|
![]() |
![]() |
명함의 비율이 조금 이상하게 나오네요
한계:
- 왜곡된 길어 보이는 변을 그대로 사용
- 문서 원래 종횡비가 왜곡됨
- 예: 정사각형 문서가 직사각형으로 보임
핵심 아이디어:
- 2D 이미지 좌표 ↔ 3D 실제 좌표 관계로 카메라 위치 추정
- 정면에서 본 것처럼 재구성
# 2D 관찰 크기로 대략적 비율 계산
avg_width = (top_width + bottom_width) / 2
avg_height = (left_height + right_height) / 2
initial_ratio = avg_width / avg_height# 3D 공간에서 문서를 평면 사각형으로 정의
object_points = [
[-half_width, -half_height, 0], # top-left
[half_width, -half_height, 0], # top-right
[half_width, half_height, 0], # bottom-right
[-half_width, half_height, 0] # bottom-left
]
# 카메라 포즈 추정
success, rvec, tvec = cv2.solvePnP(
object_points, image_points, camera_matrix, dist_coeffs
)PnP (Perspective-n-Point):
- 3D 좌표 ↔ 2D 좌표 관계로 카메라 위치/회전 역산
- 실제 물리적 거리 계산 가능
# 3D 공간에서 실제 거리 측정
real_width = (real_width_top + real_width_bottom) / 2
real_height = (real_height_left + real_height_right) / 2
real_ratio = real_width / real_height결과 비교:
2D 추정: ratio = 1.234 (1000×810)
3D PnP: ratio = 1.415 (1000×707)
# 가상의 정면 카메라 설정
frontal_points = cv2.projectPoints(
corrected_3d_points, frontal_rotation, frontal_translation,
frontal_camera_matrix, dist_coeffs
)
# 최종 변환
M = cv2.getPerspectiveTransform(image_points, frontal_points)
result = cv2.warpPerspective(image, M, (target_width, target_height))성능 비교:
| 방법 | 결과 |
|---|---|
| 2D Transform | ![]() |
| 3D PnP | ![]() |
완벽하진 않지만 이전보단 조금 더 비율이 괜찮아졌습니다.
개선점:
- 원근 왜곡 보정
- 물리적으로 거리 계산
조금 더 왜곡이 심한 이미지로 해보죠.
| 방법 | 결과 |
|---|---|
| 원본 | ![]() |
| 2D Transform | ![]() |
| 3D PnP | ![]() |
2D보다는 품질이 좋지만 아쉽습니다.
한계:
- 카메라의 위치, 정보 등을 알지 못해 정확하게 계산이 불가능
- 2D 정보를 토대로 유추한 거라 정확하지 못함
- 사진상 비슷해 보이지만 다르게 생긴 문서가 현실에서 가능함 (착시 느낌)
그렇다면 우리는 사진에서 문서 같은 걸 봤을 때 어떻게 원래 사이즈를 짐작하는 걸까요? 이는 우리가 저 문서의 크기나 비율을 얼추 알고 있고, 짐작하기 때문이죠.
원래 문서의 비율을 안다면 훨씬 퀄리티 좋게 할 수 있습니다.
A4는 보통 sqrt(2)이므로 이를 토대로 한다면
def deskew_document_with_ratio(image, rect, target_ratio=A4_RATIO):
# A4 비율: 1/√2 ≈ 0.707
target_width = base_size
target_height = int(base_size / target_ratio)
# 정확한 비율의 직사각형으로 변환
dst = [[0, 0], [target_width, 0],
[target_width, target_height], [0, target_height]]
M = cv2.getPerspectiveTransform(src, dst)
return cv2.warpPerspective(image, M, (target_width, target_height))| 방법 | 결과 |
|---|---|
| 원본 | ![]() |
| 2D Transform | ![]() |
| 3D PnP | ![]() |
| A4 비율로 조절 | ![]() |
이렇게 거의 완벽하게 복원이 가능합니다.
장점:
- 완벽한 문서 비율 복원
- 간단한 구현
- 안정적인 결과
단점:
- 문서 종류별 비율 정보 필요
- 비율이 틀린 경우 심각한 왜곡
| 방법 | 비율 정확도 | 처리 속도 | 복잡도 | 안정성 |
|---|---|---|---|---|
| 2D Transform | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐ |
| 3D PnP | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 알려진 비율 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
테스트 이미지: sample3 (심한 왜곡)
원본 관찰 비율: 1.234 (왜곡됨)
실제 A4 비율: 0.707
방법별 결과:
- 2D Transform: 1.234 (원본 왜곡 그대로)
- 3D PnP: 0.85 (개선되었으나 부정확)
- 알려진 비율: 0.707 (완벽)
2D Transform 권장:
- 실시간 처리가 필요한 모바일 앱
- 문서 비율이 중요하지 않은 경우 (영수증, 메모)
- 빠른 프로토타이핑
3D PnP 권장:
- 문서 비율 복원이 중요한 경우
- 다양한 문서 타입 처리
- OCR 전처리 단계
알려진 비율 권장:
- A4, 명함 등 표준 문서 처리
- 최고 품질이 필요한 경우
- 스캔 품질이 중요한 비즈니스 앱
문제: 배경과 문서 색상이 유사한 경우
- Canny Edge 검출 실패
- 경계 구분 어려움
해결책:
- 적응형 임계값 (Adaptive Thresholding)
- HSV 색공간 활용
- 딥러닝 기반 세그멘테이션
문제: Hough Transform threshold에 과도하게 의존
- 이미지별 최적값 상이
- 수동 튜닝 필요
해결책:
- HoughLinesP (확률적 허프 변환)
- 자동 파라미터 최적화
- 다중 임계값 사용
문제: 손가락, 그림자, 반사 등으로 경계 방해
해결책:
- RANSAC 기반 직선 추정
- 딥러닝 기반 문서 영역 검출
- 다중 프레임 융합
# GUI 통합
- 자동 검출 실패 시 수동 꼭짓점 지정
- 실시간 미리보기
- 배치 처리 지원
# 후처리 추가
- 그림자 제거 (Shadow Removal)
- 이진화로 텍스트 가독성 향상
- 원시 이미지 품질 개선- GPU 가속 (OpenCV CUDA)
- 멀티스레딩
- 모바일 최적화
- 딥러닝 모델 통합 (YOLO, U-Net)
- 다중 해상도 처리
즐겁다!
































