OpenCV와 Tesseract를 활용한 문서 스캐너 구현 완벽 가이드

서론: 모바일 문서 디지털화의 도전

현대 모바일 워크 환경에서는 스마트폰으로 문서를 촬영하는 것이 일반화되었으나, 이미지 왜곡, 조명 불균형, 텍스트 기울기 등의 문제가 OCR 인식 정확도를 크게 저하시킵니다. 본 글에서는 OpenCV와 Tesseract를 기반으로 실시간 미리보기 기능을 갖춘 문서 스캐너 도구를 구축하는 방법을 상세히 설명합니다.

1. 기술 스택 및 개발 환경

1.1 핵심 기술 구성요소

  • OpenCV: 이미지 처리 및 기하학적 변환을 담당하는 컴퓨터 비전 라이브러리
  • Tesseract: 다국어 텍스트 인식을 지원하는 오픈소스 OCR 엔진
  • PyQt5: 실시간 미리보기 인터페이스 구축을 위한 GUI 프레임워크
  • NumPy: 행렬 연산 및 수치 계산 지원

1.2 개발 환경 설정

# 필수 의존성 설치
pip install opencv-python pytesseract numpy pyqt5

# Tesseract 엔진 설치 (Windows 기준)
# 1. 설치 패키지 다운로드: https://github.com/UB-Mannheim/tesseract/wiki
# 2. 설치 디렉토리를 시스템 PATH에 추가
# 3. 설치 확인 명령어: tesseract --version

2. 핵심 알고리즘 구현

2.1 이미지 처리 파이프라인 개요

이미지 처리 파이프라인은 복잡한 이미지 처리 작업을 여러 개의 모듈화된 단계로 분해하여 자동화하는 접근 방식입니다. 주요 단계는 다음과 같습니다: 이미지 캡처 → 전처리(노이즈 제거, 대비 향상) → 특징 분석 → 후처리 → 결과 출력. 이러한 구조는 처리 속도와 정확도 사이의 균형을 유지하며 대규모 이미지 처리 작업에 적합합니다.

2.2 단계별 구현

단계1: 이미지 전처리
def prepare_image(source):
    # grayscale로 변환
    gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)
    # 가우시안 블러링 적용
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    # 적응형 임계값 처리
    binary = cv2.adaptiveThreshold(blur, 255, 
                                 cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                 cv2.THRESH_BINARY_INV, 11, 2)
    return binary
단계2: 에지 탐지 및 윤곽선 추출
def detect_document_boundary(binary_image):
    # Canny 에지 탐지 수행
    edges = cv2.Canny(binary_image, 50, 150)
    # 윤곽선 탐지
    contours_found, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    # 면적이 가장 큰 윤곽선 선택
    largest = max(contours_found, key=cv2.contourArea)
    return cv2.approxPolyDP(largest, 3, True)
단계3: 원근 변환 적용
def apply_perspective_correction(image, boundary):
    # 최소 영역 사각형 계산
    rect = cv2.minAreaRect(boundary)
    w, h = int(rect[1][0]), int(rect[1][1])
    
    # 변환 좌표 설정
    pts_input = np.float32(boundary.reshape(4, 2))
    pts_output = np.float32([[0, 0], [w, 0], [w, h], [0, h]])
    transform_matrix = cv2.getPerspectiveTransform(pts_input, pts_output)
    
    # 원근 변환 실행
    return cv2.warpPerspective(image, transform_matrix, (w, h))
단계4: OCR 텍스트 인식
def extract_text(image_data):
    # 전처리 적용
    prepared = prepare_image(image_data)
    # Tesseract로 텍스트 추출
    text_result = pytesseract.image_to_string(prepared, lang='chi_sim+eng')
    return text_result

3. GUI 인터페이스 구현

3.1 인터페이스 레이아웃 설계

인터페이스 레이아웃은 화면 요소의 배치, 시각적 계층 구조, 상호작용 로직을 설계하는 과정입니다. 핵심 원칙은 다음과 같습니다: 1) 사용자 행동 패턴에 따른 정보 우선순위 설정, 2) 정렬, 대비, 여백을 활용한 시각적 계층 구축, 3) 다양한 화면 크기에 대응하는 반응형 레이아웃 적용, 4) 미학과 기능성의 균형 유지.

3.2 실시간 미리보기 기능 구현

class DocumentScannerApp(QWidget):
    def __init__(self):
        super().__init__()
        self.camera = cv2.VideoCapture(0)
        self.update_timer = QTimer()
        self.initialize_ui()
        
    def initialize_ui(self):
        # 메인 레이아웃 설정
        layout = QVBoxLayout()
        
        # 비디오 미리보기 레이블
        self.preview_label = QLabel(self)
        layout.addWidget(self.preview_label)
        
        # 버튼 영역
        button_layout = QHBoxLayout()
        self.capture_button = QPushButton('촬영', self)
        self.capture_button.clicked.connect(self.process_current_frame)
        button_layout.addWidget(self.capture_button)
        
        layout.addLayout(button_layout)
        self.setLayout(layout)
        
        # 타이머 설정 - 30ms마다 프레임 업데이트
        self.update_timer.timeout.connect(self.refresh_frame)
        self.update_timer.start(30)
    
    def refresh_frame(self):
        success, frame = self.camera.read()
        if success:
            # 색상 공간 변환 (BGR → RGB)
            rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            height, width, channels = rgb_frame.shape
            bytes_per_line = channels * width
            qt_image = QImage(rgb_frame.data, width, height, bytes_per_line, QImage.Format_RGB888)
            self.preview_label.setPixmap(QPixmap.fromImage(qt_image))
    
    def process_current_frame(self):
        # 현재 프레임 가져오기 및 처리
        success, frame = self.camera.read()
        if success:
            # 전체 처리 파이프라인 실행
            result = self.execute_pipeline(frame)
            # 결과 표시
            self.display_result(result)

4. 성능 최적화 기법

4.1 멀티스레딩 적용

from threading import Thread

class FrameProcessor(Thread):
    def __init__(self, image_data, callback_func):
        super().__init__()
        self.input_frame = image_data
        self.callback = callback_func
        
    def run(self):
        processed_result = self.execute_pipeline(self.input_frame)
        self.callback(processed_result)

4.2 동적 파라미터 조정

def calculate_optimal_params(image):
    #图像 크기에 따른 커널 크기 자동 계산
    kernel_dim = (int(image.shape[1] / 50) * 2 + 1, int(image.shape[0] / 50) * 2 + 1)
    #평균 밝기에 기반한 임계값 동적 조정
    threshold_val = cv2.mean(image)[0] * 0.8
    return kernel_dim, threshold_val

5. 통합 코드 구현

import sys
import cv2
import numpy as np
import pytesseract
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *

class DocScanner(QWidget):
    def __init__(self):
        super().__init__()
        self.camera = cv2.VideoCapture(0)
        self.latest_frame = None
        self.setup_ui()
        
    def setup_ui(self):
        self.setWindowTitle('스마트 문서 스캐너')
        self.setGeometry(100, 100, 800, 600)
        
        # 메인 레이아웃
        main_layout = QVBoxLayout()
        
        # 미리보기 영역
        self.viewer = QLabel(self)
        main_layout.addWidget(self.viewer)
        
        # 컨트롤 버튼 영역
        control_layout = QHBoxLayout()
        self.capture_btn = QPushButton('촬영 및 처리', self)
        self.capture_btn.clicked.connect(self.run_processing)
        control_layout.addWidget(self.capture_btn)
        
        self.save_btn = QPushButton('결과 저장', self)
        self.save_btn.clicked.connect(self.save_output)
        control_layout.addWidget(self.save_btn)
        
        main_layout.addLayout(control_layout)
        
        # 텍스트 결과 영역
        self.output_area = QTextEdit(self)
        self.output_area.setReadOnly(True)
        main_layout.addWidget(self.output_area)
        
        self.setLayout(main_layout)
        
        # 주기적 프레임 업데이트
        self.timer = QTimer()
        self.timer.timeout.connect(self.refresh_view)
        self.timer.start(30)
    
    def refresh_view(self):
        ret, frame = self.camera.read()
        if ret:
            self.latest_frame = frame.copy()
            # 표시용 색상 변환
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            h, w, c = rgb.shape
            line_bytes = c * w
            qimg = QImage(rgb.data, w, h, line_bytes, QImage.Format_RGB888)
            self.viewer.setPixmap(QPixmap.fromImage(qimg))
    
    def run_processing(self):
        if self.latest_frame is not None:
            # 전체 처리 파이프라인 실행
            processed = self.processing_pipeline(self.latest_frame)
            
            # 처리 결과 표시
            processed = cv2.cvtColor(processed, cv2.COLOR_BGR2RGB)
            h, w, c = processed.shape
            line_bytes = c * w
            qimg = QImage(processed.data, w, h, line_bytes, QImage.Format_RGB888)
            self.viewer.setPixmap(QPixmap.fromImage(qimg))
            
            # OCR 실행
            text = self.perform_ocr(processed)
            self.output_area.setText(text)
    
    def processing_pipeline(self, img):
        # 전처리
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        blur = cv2.GaussianBlur(gray, (5, 5), 0)
        binary = cv2.adaptiveThreshold(blur, 255, 
                                     cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                     cv2.THRESH_BINARY_INV, 11, 2)
        
        # 에지 탐지
        edges = cv2.Canny(binary, 50, 150)
        contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
        
        if len(contours) > 0:
            max_contour = max(contours, key=cv2.contourArea)
            approx = cv2.approxPolyDP(max_contour, 3, True)
            
            if len(approx) == 4:
                # 원근 변환
                rect = cv2.minAreaRect(approx)
                w, h = int(rect[1][0]), int(rect[1][1])
                
                pts1 = np.float32(approx.reshape(4, 2))
                pts2 = np.float32([[0, 0], [w, 0], [w, h], [0, h]])
                matrix = cv2.getPerspectiveTransform(pts1, pts2)
                warped = cv2.warpPerspective(img, matrix, (w, h))
                
                # 최종 이진화
                final_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
                _, final_bin = cv2.threshold(final_gray, 0, 255, 
                                           cv2.THRESH_BINARY | cv2.THRESH_OTSU)
                return final_bin
        return img
    
    def perform_ocr(self, img):
        # 회색조 변환
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        # OCR 수행
        text = pytesseract.image_to_string(gray, lang='chi_sim+eng')
        return text
    
    def save_output(self):
        if self.latest_frame is not None:
            # 처리된 이미지 저장
            processed = self.processing_pipeline(self.latest_frame)
            cv2.imwrite('result_document.jpg', processed)
            
            # 텍스트 결과 저장
            content = self.output_area.toPlainText()
            with open('ocr_text.txt', 'w', encoding='utf-8') as f:
                f.write(content)
            QMessageBox.information(self, '저장 완료', '결과가 프로그램 디렉토리에 저장되었습니다')

if __name__ == '__main__':
    app = QApplication(sys.argv)
    scanner = DocScanner()
    scanner.show()
    sys.exit(app.exec_())

6. 문제 해결 가이드

6.1 조명 불균형 처리

def adjust_illumination(img):
    # CLAHE를 이용한 대비 제한 적응형 히스토그램 평활화
    lab_color = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
    l_channel, a, b = cv2.split(lab_color)
    clahe_op = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
    l_adjusted = clahe_op.apply(l_channel)
    merged = cv2.merge((l_adjusted, a, b))
    return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)

6.2 복잡한 배경 처리

태그:
            OpenCV
            Tesseract
            python
            OCR
            PyQt5
        

7월 6일 19:33에 게시됨