[OCR] 로컬 OCR로 상용 OCR API 대체하기 (PaddleOCR와 ONNX)
포스트
취소

[OCR] 로컬 OCR로 상용 OCR API 대체하기 (PaddleOCR와 ONNX)

로컬 OCR을 고민한 이유

신분증 OCR은 보통 상용 API에 맡깁니다. 호출 한 번에 결과가 나오고, 인식 품질도 검증되어 있어 직접 만들 이유가 없어 보입니다.

그런데 운영을 하다 보면 두 가지가 계속 걸립니다.

  • 비용: 호출 건당 과금이라 사용자가 늘수록 그대로 비용이 됩니다.
  • 개인정보: 신분증 이미지를 외부로 내보내야 합니다.

문제는 GPU입니다. OCR 모델을 직접 돌리려면 GPU 인스턴스가 필요하고, 그러면 상용 API보다 비싸집니다.

그래서 질문을 이렇게 바꿨습니다. 2코어 CPU 서버만으로 상용 API를 대체할 만한 신분증 OCR이 가능한가?

이 글은 그 질문에 답을 찾아가는 과정입니다. 경량 OCR 모델을 고르고, CPU 환경에 맞는 배포 구조를 설계하고, ONNX 런타임으로 추론 시간을 줄이고, 인식 정확도를 끌어올린 기록입니다.

PaddleOCR과 PP-OCRv5

PaddleOCR

바이두에서 개발한 OCR 툴킷입니다. 미리 모델을 로컬에 다운받아두고 로드하면 외부 인터넷 연결 없이 사용이 가능합니다. 딥러닝 기반 OCR 모델을 묶어 제공하며, 단순 글자 인식뿐 아니라 글자 영역의 bbox 좌표까지 함께 돌려줍니다.

신분증 OCR에서는 이 bbox가 중요합니다. “이름”이라는 라벨 옆에 있는 글자가 이름이고, 주민등록번호 위에 있는 글자가 이름이라는 식으로 위치 관계로 필드를 판별해야 하기 때문입니다.

PP-OCRv5 모델 구성

PaddleOCR에 최적화된 모델 시리즈로, ViT와 CRNN을 조합해 파라미터 규모 대비 성능이 좋습니다.

PP-OCRv5는 하나의 모델이 아니라 5개 모델의 조합입니다.

모델 역할
detection 텍스트가 있는 영역을 찾음
recognition 찾은 영역의 글자를 읽음
textline orientation 텍스트 줄의 방향을 판별
document orientation 문서 전체의 방향을 판별
UVDoc 구겨진 문서를 평면으로 폄

신분증 OCR은 클라이언트에서 촬영 가이드를 제공해 어느 정도 정렬된 이미지가 들어온다고 전제할 수 있습니다. 그래서 document orientation과 UVDoc은 제외하고 detection, recognition, textline orientation 세 개만 사용하도록 구성했습니다.

Server 버전과 Mobile 버전

각 모델은 Server와 Mobile 두 가지로 나뉩니다.

구분 파라미터 특징
Server 약 5M 일반적인 PP-OCRv5 모델
Mobile 약 0.9M 모바일 구동을 위해 경량화한 버전

Server 버전조차 5M 파라미터에 불과합니다. PP-OCRv5 논문에 따르면 파라미터가 15~40배 많은 ViT 기반 범용 모델과 비교해도 OCR 성능에서 밀리지 않으며, Tesseract 대비로는 에러율이 약 5배 낮습니다.

이 정도 크기라면 CPU에서도 실용적인 추론이 가능하겠다는 판단이 섰고, 여기서부터 실제 구축을 시작했습니다.

테스트에 사용한 이미지

앞으로 나오는 속도 측정과 부하 테스트는 아래 다섯 종류의 신분증으로 진행했습니다. 실제 개인정보가 담긴 신분증을 쓸 수 없으므로 공개된 예시 이미지를 사용했습니다.

주민등록증

운전면허증

여권 - 구

여권 - 신

외국인등록증

테스트는 모두 png 원본으로 진행했습니다.

반면 뒤에 나오는 인식 정확도 개선과 상용 API 비교는 실제 서비스로 들어온 요청 데이터를 기준으로 했습니다. 빛 반사나 초점이 나간 사진처럼 견본 이미지로는 재현할 수 없는 문제들이기 때문입니다.

다섯 종류를 모두 쓴 이유가 있습니다. 인식 난이도가 서로 크게 다르기 때문입니다.

신분증 특징
주민등록증 텍스트가 적고 글자가 큼. 가장 쉬움
운전면허증 홀로그램 패턴 위에 작은 글자가 얹힘
여권 - 구 텍스트가 많고 얇음. MRZ 보유
여권 - 신 구여권과 배치가 달라 별도 파싱 필요
외국인등록증 한글/영문 혼용, 배경 무늬 있음

배포 아키텍처 설계

모델을 고르는 것보다 어려운 건 어디에 어떻게 띄울 것인가였습니다.

Django에 그냥 올리면 안 되는 이유

가장 간단한 방법은 웹 서버 안에서 OCR 클래스를 로드해 쓰는 것입니다. 구현이 제일 쉽지만 두 가지 문제가 있습니다.

1. 메모리

Django 워커 안에 모델을 띄우면 워커 개수만큼 모델이 로드됩니다.

1
2
모델 약 200MB + 이미지 약 2MB (1080x1080 기준) ≈ 워커당 200~250MB
워커 4개 - 약 1GB

단순하게 계산해도, 2GB 서버에서 1GB를 OCR이 가져가는 구조입니다. 모델 수를 줄이려고 워커를 줄이면 이번엔 서버 전체 처리량이 떨어집니다.

2. 응답 시간

추후 자세히 설명하겠지만 보통 추론에 1~2초, 많으면 7초가 걸립니다. 동시에 여러 요청이 들어오면 뒤에 온 요청은 앞선 추론이 끝날 때까지 대기하게 됩니다.

그런 상황에서 응답 제한 시간을 넘기면 그 요청은 실패 처리되고, 사용자는 재시도합니다. 재시도가 다시 큐에 쌓이면서 상황이 악화됩니다.

동기 요청-응답 모델에 오래 걸리는 작업을 얹었을 때 나오는 전형적인 실패입니다.

Celery 기반 비동기 큐

그래서 OCR을 Celery를 활용해 별도의 큐를 두어 분리했습니다.

Celery는 파이썬 기반 분산 작업 큐로, 오래 걸리는 작업을 큐에 넣고 워커가 순차 처리하는 구조입니다. 모델 추론처럼 시간이 걸리는 작업에 적합합니다.

구성은 Django, Redis, Celery 세 가지입니다. Django가 Redis의 작업 큐에 태스크를 넣으면 Celery가 꺼내서 처리하고, 결과는 다시 Redis나 DB를 통해 전달합니다.

처리 흐름

핵심은 OCR 요청과 결과 조회를 분리한 것입니다.

celery-flow

  1. 클라이언트가 신분증 OCR을 요청합니다.
  2. 서버는 현재 큐에 얼마나 많은 작업이 쌓여 있는지 확인합니다.
    • 기준치를 넘어 응답이 오래 걸릴 것으로 예상되면 상용 API로 우회합니다. - 감당 가능한 한계를 넘어 요청이 급증하는 상황을 대비한 설계입니다.
    • 기준치 아래면 이미지를 스토리지에 저장하고 태스크를 추가합니다.
  3. 태스크가 등록되면 상태 조회용 티켓을 발급해 즉시 응답합니다.
    • 여기서 OCR 요청 통신이 끝납니다.
  4. Celery 워커가 큐에서 작업을 꺼내 추론과 파싱까지 수행합니다.
  5. 결과를 DB에 저장하고 상태를 갱신합니다.
  6. 클라이언트가 티켓으로 상태를 조회합니다.
    • 성공이면 데이터를, 대기/실패면 상태를 돌려줍니다.

자원 사용량과 CPU 제한

워커 2개로 띄우고 맥북에서 실제 사용량을 측정했습니다.

항목 사용량
메모리 23%
CPU 96%

메모리는 모델이 가벼워 여유가 있었지만, CPU는 모델이 추론하는 데 전부 사용하는 걸 볼 수 있습니다.

추론 중에 Celery가 CPU를 독점하면 같은 인스턴스의 웹 서버 프로세스가 매우 느려지고, 타임아웃이 발생할 수 있습니다. 그래서 Celery 프로세스가 쓸 수 있는 CPU에 상한을 걸고, 어느 정도가 적당한지 측정했습니다.

2코어 서버의 최대치는 200%입니다. 단일 요청 기준으로 측정한 결과입니다. (이미지 400px)

  주민등록증 운전면허증 여권 - 구 여권 - 신 외국인등록증
125% 1.4초 3.1초 4.2초 2초 2.8초
150% 1.7초 3초 3.9초 3.2초 2.2초
제한없음 0.9초 2.5초 3.1초 2.9초 2초

단일 요청에서는 차이가 크지 않아 보입니다. 그런데 동시에 15건이 들어오는 상황을 가정하고 부하 테스트를 돌리자 결과가 달라졌습니다.

평균 응답 (15건) 주민등록증 운전면허증 여권 - 신 여권 - 구 외국인등록증
125% 15.140s 31.893s 38.018s 43.442s 27.892s
150% 12.466s 24.373s 31.973s 33.672s 22.506s
제한없음 9.005s 20.587s 26.923s 28.339s 16.988s

가장 느린 구여권은 125% 설정에서 마지막 15번째 요청이 75초를 기다렸습니다.

단일 요청에서는 의미 없어 보이던 CPU 제한이, 워커가 모두 돌고 요청이 쌓이는 순간 큰 차이가 발생합니다. 결국 CPU는 가능한 한 많이 주는 게 유리하고, 웹 서버를 지키기 위한 최소한의 상한만 걸어야 한다는 결론을 내리게 됩니다.

이 시점의 성능이 만족스럽지는 않았습니다. 여권 기준 평균 30초는 사용자의 입장에서는 너무 긴 소요시간이기 때문입니다. 그래서 도입하게 된게 ONNX 런타임입니다.

ONNX 런타임 적용

ONNX Runtime이란

ONNX는 마이크로소프트가 개발한 고성능 추론 엔진입니다. 모델을 ONNX 포맷으로 변환한 뒤, 실행되는 하드웨어에 맞춰 연산 그래프를 최적화해 추론 시간과 메모리 사용량을 줄입니다. JVM처럼 여러 플랫폼에서도 ONNX 포맷의 모델을 바로바로 실행이 가능하다는 장점도 있습니다.

학습이 완료된 모델을 ONNX 포멧으로 변환하여 추론 전용으로 사용합니다.

모델 변환

앞서 말했듯이 기존 모델을 ONNX 포멧으로 변환하는 과정이 필요합니디. 아래 과정은 PaddleOCR 모델을 ONNX로 바꾸는 과정입니다.

1
2
3
4
pip install paddlepaddle
pip install paddleocr
pip install onnx
paddlex --install paddle2onnx
1
2
3
4
5
6
7
8
9
# detection 모델
paddlex --paddle2onnx --paddle_model_dir ./PP-OCRv5_server_det_infer \
        --onnx_model_dir ./PP-OCRv5_server_det_onnx
# recognition 모델
paddlex --paddle2onnx --paddle_model_dir ./PP-OCRv5_server_rec_infer \
        --onnx_model_dir ./PP-OCRv5_server_rec_onnx
# textline orientation 모델
paddlex --paddle2onnx --paddle_model_dir ./PP-LCNet_x1_0_doc_ori_infer \
        --onnx_model_dir ./PP-OCRv5_server_cls_onnx

모델 3개를 각각 변환합니다. 변환된 디렉터리에는 inference.onnx 파일이 생성됩니다.

PaddleOCR의 추론 백엔드만 갈아끼우기

변환은 쉬운데, PaddleOCR이 이 ONNX 모델을 쓰게 만드는 게 문제였습니다.

PaddleOCR도 High-Performance Inference라는 이름으로 ONNX 추론을 지원하긴 합니다. 다만 요구하는 환경 설정과 의존성이 까다로워 기존 배포 구성에 얹기가 어려웠습니다.

그래서 다른 방향을 택했습니다. PaddleOCR의 전처리/후처리 파이프라인은 그대로 두고, 추론을 수행하는 부분만 onnxruntime으로 교체하는 방식입니다.

먼저 ONNX 세션을 감싸는 얇은 래퍼를 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pathlib import Path
from typing import List, Sequence

import numpy as np


class ONNXInfer:
    def __init__(self, model_dir) -> None:
        import onnxruntime as ort

        onnx_path = Path(model_dir) / "inference.onnx"
        self.session = ort.InferenceSession(
            str(onnx_path), providers=["CPUExecutionProvider"]
        )
        self.input_names = [i.name for i in self.session.get_inputs()]
        self.output_names = [o.name for o in self.session.get_outputs()]

    def __call__(self, x: Sequence[np.ndarray]) -> List[np.ndarray]:
        feed = {
            name: np.ascontiguousarray(arr)
            for name, arr in zip(self.input_names, x)
        }
        return self.session.run(self.output_names, feed)

그리고 PaddleOCR이 추론기를 생성하는 지점에 ONNX 런타임을 사용하게 만들었습니다.

1
2
3
4
5
6
7
8
9
10
11
def patch():
    from paddlex.inference.models.base.predictor.base_predictor import BasePredictor

    _original = BasePredictor.create_static_infer

    def create_static_infer(self):
        if (Path(self.model_dir) / "inference.onnx").exists():
            return ONNXInfer(self.model_dir)
        return _original(self)

    BasePredictor.create_static_infer = create_static_infer

create_static_infer는 PaddleOCR이 각 모델의 추론기를 만들 때 호출하는 메서드입니다. 여기서 모델 디렉터리에 inference.onnx가 있으면 ONNX 세션을, 없으면 원래 구현을 돌려줍니다.

이 방식의 장점은 전환 비용이 거의 없다는 것입니다.

  • 모델 디렉터리에 .onnx 파일을 넣으면 ONNX로, 빼면 원래 방식으로 돌아갑니다.
  • PaddleOCR의 전처리, bbox 후처리, CTC 디코딩은 전혀 건드리지 않으므로 인식 결과가 달라지지 않습니다.
  • 문제가 생기면 patch() 호출만 빼면 즉시 롤백됩니다.

성능 비교

단건 추론 (로컬)

먼저 로컬 개발 환경(ARM 기반 맥)에서 단건 추론 시간을 비교했습니다. 런타임 외의 조건은 모두 동일하며, 이미지는 최대 500px입니다.

  주민등록증 운전면허증 여권 - 구 여권 - 신 외국인등록증
일반 0.848초 2.634초 4.423초 3.65초 2.423초
ONNX 0.281초 1.291초 1.237초 1.06초 0.798초
개선율 66.86% 50.99% 72.03% 70.9% 67%

절반에서 70% 넘게 줄었습니다. 같은 모델, 같은 입력인데 추론 엔진만 바꿔서 나온 차이입니다.

부하 테스트 (서버)

로컬 결과가 좋아도 실제 서버에서 같은 폭이 나오리라는 보장은 없습니다. 2코어 2GB 서버에서 15건 동시 요청 기준으로 다시 측정했습니다. (400px, 워커 2개)

150% 제한 기준 평균 주민등록증 운전면허증 여권 - 신 여권 - 구 외국인등록증
일반 12.466s 24.373s 31.973s 33.672s 22.506s
ONNX 8.367s 21.126s 29.960s 30.328s 19.764s
개선율 32.87% 13.31% 6.29% 6.30% 9.92%

로컬의 50~70%와 비교하면 폭이 확 줄었습니다. 신분증 종류에 따라서도 6%에서 33%까지 편차가 큽니다.

한 가지 특이한 게, 추론이 가벼운 신분증일수록 개선 폭이 큽니다.

  • 텍스트가 적은 주민등록증: 32.87%
  • 텍스트가 빽빽한 여권: 6.29%

이는 ONNX가 CPU 연산을 더 적극적으로 활용하는 방향으로 최적화하기 때문입니다. CPU에 여유가 있을 때는 그 최적화가 그대로 속도로 이어지지만, 이미 CPU가 포화된 무거운 작업에서는 최적화를 제대로 활용하지 못합니다.

Mobile 모델 실험

ONNX로 얻을 수 있는 만큼 얻었다면, 그다음은 모델 자체를 줄이는 것입니다.

recognition만 Mobile을 쓰고 있었는데, detection까지 Mobile로 내리고 textline orientation을 제외하면 어떻게 되는지 확인했습니다. 모델이 가벼워진 만큼 이미지를 400px에서 750px로 키워 인식률을 보전했습니다.

150% 제한 기준 평균 주민등록증 운전면허증 여권 - 신 여권 - 구 외국인등록증
일반 (400px) 12.466s 24.373s 31.973s 33.672s 22.506s
ONNX (400px) 8.367s 21.126s 29.960s 30.328s 19.764s
ONNX(Mobile) (750px) 5.744s 11.558s 19.103s 21.709s 10.297s
개선율 (일반 대비) 53.93% 52.57% 40.25% 35.53% 54.25%

이미지를 1.9배 키웠는데도 추론 시간이 절반으로 줄었습니다.

그런데 인식 결과에서 문제가 나왔습니다. 이미지가 커지면서 한 줄로 인식되어야 할 텍스트가 여러 영역으로 쪼개져 검출되는 현상이 발생했습니다.

글자 자체는 정확히 읽혔지만, 필드를 정규식으로 찾는 단계에서 매칭에 실패했습니다. 구여권의 만료일, 외국인등록증의 발급일자가 이런 식으로 누락됐습니다.

인식은 성공했는데 파싱이 실패하는, 지표만 보면 놓치기 쉬운 종류의 문제입니다.

textline orientation 제외 실험

방향을 바꿔서, detection은 Server 버전으로 유지하되 textline orientation만 제외해봤습니다.

150% 제한, 400px 기준 평균 주민등록증 운전면허증 여권 - 신 여권 - 구 외국인등록증
ONNX 8.367s 21.126s 29.960s 30.328s 19.764s
ONNX(Mobile) 3.354s 9.673s 16.193s 18.768s 9.063s
ONNX(orientation 제외) 7.724s 19.424s 27.515s 27.290s 18.058s
개선율 (ONNX 대비) 7.69% 8.06% 8.16% 10.02% 8.63%

속도는 8~10% 줄었지만 이번엔 bbox 좌표가 틀어졌습니다. 신분증 OCR은 인식한 개인정보 영역을 마스킹해서 저장하는데, 좌표가 어긋나면 마스킹이 엉뚱한 곳에 찍힙니다.

결론적으로 모델을 바꿔 성능 이득을 보는 건 어렵다고 판단하고 포기했습니다.

특이사항: ARM과 x86의 결과 차이

ONNX 모델은 JVM처럼 어디서든 실행된다고 하고, 실제로도 그랬지만 약간의 차이가 있었습니다.

같은 ONNX 모델을 ARM 기반 맥과 x86_64 서버에서 돌렸을 때 결과값 자체가 미세하게 달랐습니다. 구글에 이런 현상을 검색해보니, 세부적인 부동소수점 처리 방식이 아키텍처마다 다르기 때문으로 보인다는 의견이 많았습니다.

로컬에서 잘 되던 인식이 서버에서 실패하는 케이스를 처음 봤을때는 어이가 없었는데, ARM 기반의 프로세서와 x86기반의 프로세서의 처리 방식에 따라 결과가 달리질 수도 있다는걸 알게되니 조금 신기했습니다.

앵커 기반 필드 추출

정확도로 넘어가기 전에, OCR 결과를 어떻게 필드로 바꾸는지 먼저 설명드리고 넘어가고자 합니다. 뒤에 나오는 문제들이 대부분 이 구조에서 발생하는 문제이기 때문입니다.

OCR은 구조를 주지 않습니다

PaddleOCR이 돌려주는 건 텍스트와 좌표의 목록입니다.

1
2
3
4
5
6
7
[
    {"text": "주민등록증",        "bbox": [[62, 18], [186, 18], [186, 44], [62, 44]]},
    {"text": "홍길동(洪吉童)",    "bbox": [[70, 55], [210, 55], [210, 83], [70, 83]]},
    {"text": "501111-1234566",  "bbox": [[68, 92], [232, 92], [232, 118], [68, 118]]},
    {"text": "서울특별시 종로구", "bbox": [[66, 130], [240, 130], [240, 154], [66, 154]]},
    ...
]

어떤 게 이름이고 어떤 게 주소인지는 알려주지 않습니다. 신분증에는 이름: 홍길동처럼 키-값 형태로 적혀 있지도 않습니다.

형식이 확실한 것을 기준점으로 삼기

그래서 고안된 방법이 앵커 기반 필드 추출 방법입니다. 보통 인식에 문제가 없는 텍스트의 위치를 기준(앵커)로 잡고, 그 주변의 박스들을 거리 또는 일정한 규칙에 따라 인식해서 가져오는 방식입니다.

신분증에서는 주민등록번호가 좋은 앵커입니다. 6자리-7자리 숫자라는 형식이 신분증 안에서 유일하고, 글자가 커서 인식률도 높습니다.

여기에 레이아웃이 고정되어 있다는 신분증의 특징을 이용할 수 있습니다. 주민등록증이라면 주민등록번호 바로 위는 항상 이름이고, 아래는 항상 주소입니다. 예외가 없습니다.

신분증 앵커 앵커로 쓴 이유
주민등록증 주민등록번호 형식이 유일하고 글자가 큼
운전면허증 운전면허번호 하이픈이 3개라 주민등록번호와 구분됨
외국인등록증 외국인등록번호 주민등록번호와 같은 형식
여권 MRZ 44자 2줄 고정, 가장 확실

여권은 앵커를 잡는 방식이 조금 다릅니다. MRZ 두 줄을 찾으면 위치 관계를 따질 필요 없이 문자열 자체를 파싱해 이름, 생년월일, 여권번호, 만료일을 한 번에 얻습니다. 여권이 인식 실패에 강했던 이유가 이것입니다.

필드에 따라서는 위치뿐 아니라 형식까지 함께 검사합니다. 발급일이라면 “앵커 아래쪽에 있으면서 날짜 형식인 박스”로 좁히는 식입니다. 이렇게 조건을 하나만 더해도 오탐이 크게 줄어듭니다.

단점

하지만 앵커 방식은 명백한 단점이 존재합니다.

앵커가 깨지면 전부 실패합니다. 주민등록번호를 못 읽으면 이름도 발급일도 찾을 수 없습니다. 뒤에서 다룰 형식 기반 보정은 앵커를 어떻게든 살려내기 위해 고려된 방법입니다.

대상이 없어도 무언가를 찾아냅니다. 가장 가까운 박스를 고르는 방식이라, 후보가 하나라도 있으면 반드시 답을 내놓습니다. 발급일이 사진에서 잘려 나갔더라도 아래쪽에서 가장 가까운 날짜 비슷한 박스를 발급일이라고 답합니다. 상용 API와 비교했을 때 True Negative 판단에서 더 안좋은 성능을 보여주는 이유입니다.

인식 정확도 끌어올리기

여기서부터는 모델이 아니라 앵커 기반 방식의 전/후처리 코드를 고치는 작업입니다.

이미지 크기와 인식률의 트레이드오프

먼저 이미지 크기가 인식률에 어떤 영향을 주는지 확인했습니다.

응답 속도 주민등록증 운전면허증 여권 - 구 여권 - 신 외국인등록증
가로 300 1.7초 2.7초 - 2.4초 2.4초
가로 500 - 3.1초 4.4초 3.6초 2.6초

크기를 줄이면 확실히 빨라집니다. 문제는 인식률입니다.

  • 주민등록증: 300px에서도 이름, 주민등록번호, 발급일 모두 인식. 글자가 크고 선명합니다.
  • 운전면허증: 300px에서 숫자는 읽었지만 이름의 획이 복잡한 글자를 오인식했습니다.
  • 여권(신): 300px에서 MRZ는 굵어서 읽혔지만, 직접 읽어야 하는 이름과 발급일은 전부 실패했습니다.
  • 여권(구): 300px에서 MRZ 인식까지 실패했습니다.
  • 외국인등록증: 300px에서 등록번호는 읽었지만 성명은 일부만, 발급일자는 실패했습니다.

정보가 많아 글자가 얇은 신분증일수록 작은 이미지에서 좋지 않은 성능을 보입니다. 결국 속도만 보고 이미지를 줄이면 인식률이 하락한다는 것을 확인하고, 400~500px을 기준선으로 잡았습니다.

여권처럼 MRZ가 있는 신분증은 조금 더 유리합니다. 개별 필드 인식에 실패해도 MRZ 문자열을 파싱해 이름, 생년월일, 만료일을 복구할 수 있기 때문입니다.

전처리 재설계

테스트 하다보니 사진 촬영시 빛 반사와 포커스가 맞지 않는 이미지가 인식에 실패하는 케이스가 많았습니다.
그래서 빛 반사와 아웃포커싱에 대응하기 위해 전처리를 넣었습니다.

기존 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
gray = cv2.cvtColor(processed_img, cv2.COLOR_BGR2GRAY)

kernel_size = 25
bg_kernel = cv2.getStructuringElement(
    cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)
)

background = cv2.morphologyEx(gray, cv2.MORPH_DILATE, bg_kernel)
background = cv2.GaussianBlur(background, (25, 25), 0)

diff = cv2.absdiff(background, gray)
flat_img = cv2.bitwise_not(diff)

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced_gray = clahe.apply(flat_img)

enhanced_gray = cv2.GaussianBlur(enhanced_gray, (3, 3), 0)
sharpen_kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
final_gray = cv2.filter2D(enhanced_gray, -1, sharpen_kernel)

processed_img = cv2.cvtColor(final_gray, cv2.COLOR_GRAY2BGR)

흑백 변환 - 배경 반사 추출 - 반사 평탄화 - 선명도 강화 순서입니다.

논리적으로는 맞는데, 실제로는 효과가 거의 없었습니다. 반사를 지우는 과정에서 반사와 겹친 글자까지 같이 지워졌기 때문입니다. 배경을 어둡게 만들다 보니 검은 글자와 배경이 뭉개지는 부작용도 있었습니다.

개선 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
blurred = cv2.GaussianBlur(processed_img, (0, 0), sigmaX=1.5)
detail = processed_img.astype(np.float32) - blurred.astype(np.float32)
processed_img = np.clip(
    processed_img.astype(np.float32) + 1.5 * detail, 0, 255
).astype(np.uint8)

rgb = processed_img[:, :, ::-1].astype(np.float32) / 255.0
k_channel = ((1.0 - np.max(rgb, axis=2)) * 255).clip(0, 255).astype(np.uint8)

clahe = cv2.createCLAHE(clipLimit=5.0, tileGridSize=(8, 8))
k_enhanced = clahe.apply(k_channel).astype(np.float32) / 255.0

lab = cv2.cvtColor(processed_img, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
l = (l.astype(np.float32) * k_enhanced + 255.0 * (1.0 - k_enhanced)).clip(0, 255).astype(np.uint8)

processed_img = cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)

방향을 바꿨습니다.

  • Unsharp Masking으로 아웃포커싱에 대응하게 했습니다. 블러 이미지를 원본에서 빼서 디테일만 추출하고, 추출된 정보를 원본에 다시 더해 윤곽을 살렸습니다.
  • 반사 추출과 평탄화를 제거했습니다. 반사와 겹친 글자가 지워지는 문제가 더 컸기때문입니다.
  • CMYK의 K 채널을 뽑아 검정 계열과 그 외 색상의 대비를 키웠습니다. 검정이 아닌 색은 흰색에 가깝게 바꿔 글자만 남게 했습니다.

신분증은 배경 무늬와 홀로그램이 화려한 대신 글자는 거의 검정이라는 특성이 있습니다. K 채널 기반 접근은 그 특성을 직접 이용한 것입니다.

형식 기반 보정

테스트를 진행하다보니 일부만 잘못 인식되거나, 일부만 인식되지 않거나, 아예 인식은 성공했는데 기호가 달라져서 인식에 실패하는 경우도 많았습니다. 그래서 이런 케이스를 해결하기 위해 형식 기반 보정 기법을 고려하게 되었습니다.

신분증 필드는 대부분 형식이 정해져 있습니다. 주민등록번호는 6자리-7자리 숫자이고, 운전면허번호도 정해진 패턴이 있습니다.

이 형식 제한을 활용하면 오인식을 상당 부분 되돌릴 수 있습니다.

운전면허증의 주민등록번호는 특히 오인식이 잦았습니다. 주민등록증의 것보다 글자가 작고, 배경 홀로그램 패턴 위에 얹혀 있기 때문입니다.

실제로 나온 실패 유형은 세 가지였습니다.

유형 인식 결과 원인
구분자에 문자 혼입 숫자6-:숫자7 패턴이 글자로 함께 인식
구분자 오인식 숫자6+숫자7 -+로 인식
구분자 누락 구분자 없이 13자리 홀로그램에 가려 미인식

이런식으로 일부만 잘못 인식되어도 전부 “형식에 맞지 않음”으로 처리되어 인식 실패가 됩니다. 글자는 거의 다 읽었는데 결과는 실패인 상황입니다.

1단계: 명백한 오염 제거

1
2
3
4
5
6
7
8
9
10
11
text = box["text"].replace(" ", "")

# - 을 제외한 모든 특수문자 제거
text = re.sub(r'[^가-힣a-zA-Z0-9\-]', '', text)

if len(text) > 14: continue
if text.count('-') > 1: continue

if len(re.sub(r'\d', '', text.replace('-', ''))) > 2:
    # 숫자를 제거하고 남은 문자가 3개 이상이면 주민등록번호가 아님
    continue

숫자를 문자로 잘못 읽었을 가능성을 열어두고 특수문자만 먼저 걷어냅니다. -가 두 개 이상이면 운전면허번호이므로 제외하고, 숫자를 뺀 나머지가 3글자 이상이면 애초에 주민등록번호가 아니라고 판단합니다.

2단계: 구분자 복원

1
2
3
4
5
6
7
8
# - 가 없는데 13자리라면 구분자 추가
if '-' not in text and len(text) == 13:
    text = text[:6] + "-" + text[6:]

match = regex.search(text)
if match:
    rrn_value = "-".join(match.groups())
    return box, rrn_value

13자리 숫자인데 구분자만 없다면 넣어줍니다.

3단계: 문자를 숫자로 치환

앞뒤 자릿수가 6/7로 맞는데 중간에 문자가 섞인 경우입니다. 자릿수가 맞다는 건 글자 수는 제대로 읽었고 일부만 잘못 읽었다는 뜻이므로, 치환을 시도해볼 가치가 있습니다.

1
2
3
4
5
6
7
8
9
if '-' in text:
    front, back = text.split('-')
    if len(front) == 6 and len(back) == 7:
        front, back = self._correct_rrn(front, back)
        text = front + '-' + back
        match = regex.search(text)
        if match:
            rrn_value = "-".join(match.groups())
            return box, rrn_value

치환은 두 가지 정보를 씁니다.

오인식 빈도: 과거에 어떤 문자가 어떤 숫자로 잘못 읽혔는지 누적한 테이블입니다.

1
2
3
4
5
6
7
8
9
class OcrCharCorrection(models.Model):
    pk = models.CompositePrimaryKey('key', 'value')

    key = models.CharField(max_length=1)      # 오인식된 문자
    value = models.IntegerField()             # 원래 숫자
    count = models.IntegerField()             # 발생 횟수

    class Meta:
        db_table = "ocr_char_correction"

날짜 규칙: 주민등록번호 앞 6자리는 생년월일이므로 각 자리에 올 수 있는 숫자가 정해져 있습니다. 월 십의 자리는 0 또는 1이고, 일의 최대값은 월마다 다르며 윤년까지 따져야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def _correct_rrn(self, front, back):

    def is_valid_front(index, digit, current_string):
        """생년월일 자릿수 규칙에 맞는 숫자인지 검사."""
        if index in (0, 1):   # YY
            return True
        elif index == 2:      # MM 십의 자리
            return digit in ('0', '1')
        elif index == 3:      # MM 일의 자리
            ...
        elif index == 4:      # DD 십의 자리 — 해당 월의 최대 일수로 제한
            ...
        elif index == 5:      # DD 일의 자리 — 윤년까지 따져 검증
            ...
        return False

    def find_correction_number(character, is_front, index, current_string):
        # 가장 많이 오인식된 순서로 후보를 꺼내 규칙에 맞을 때까지 시도
        corrections = OcrCharCorrection.objects.filter(
            key=character
        ).order_by('-count')

        for correction in corrections:
            candidate = str(correction.value)
            if not is_front:
                return candidate   # 뒷자리는 형식 제약이 없음
            if is_valid_front(index, candidate, current_string):
                return candidate

        return '0'   # 맞는 후보가 없으면 0

    new_front = ''
    for i, f in enumerate(front):
        new_front += f if f.isdigit() else find_correction_number(f, True, i, new_front)

    new_back = ''
    for i, f in enumerate(back):
        new_back += f if f.isdigit() else find_correction_number(f, False, i, new_back)

    return new_front, new_back

문자를 만나면 오인식 빈도가 높은 숫자부터 하나씩 넣어보고, 날짜 규칙에 어긋나면 다음 후보로 넘어갑니다. 끝까지 맞는 게 없으면 0으로 둡니다.

통계와 도메인 규칙을 함께 쓴다는 게 핵심입니다.

CustomCTCDecoder로 문자 후보 제한

형식 기반 보정은 이미 나온 결과를 되돌리는 사후 처리입니다. 그런데 이 방식은 어쨋거나 인식이 잘못된 데이터를 보정하는 방식이라 인식 결과에 크게 영향을 받을 수 밖에 없습니다.

그렇다면 애초에 잘못된 문자가 나오지 않게 할 수는 없을까요? 그 방법이 바로 CustomCTCDecoder로 문자 후보를 제한하는 방법입니다.

PP-OCR의 recognition 모델은 글자로 보이는 구간을 잘게 자른 뒤(시퀀스), 각 구간이 어떤 문자인지 확률 분포를 출력합니다. 한국어 PP-OCRv5 모델의 후보 문자는 총 11,947개입니다.

PaddleOCR에는 각 시퀀스에서 가장 확률이 높은 문자를 골라 문자열로 만드는 CTC 디코더가 내장되어 있습니다.

CTC는 실제 문자 길이에 상관없이 각 프레임마다 확률이 가장 높은 문자를 예측한 뒤, 중복과 공백을 규칙에 따라 정리해 최종 문자열을 만드는 방식입니다.

여기서 아이디어가 나옵니다. 주민등록번호를 읽는 중이라면 후보에 한글이 있을 이유가 없습니다. 디코딩 시점에 허용 문자를 제한하면 애초에 엉뚱한 문자가 나올 수 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class CustomCTCDecoder:
    def __init__(self, capture: _LogitCapture, allowed_chars: str):
        full_chars = capture._original.character
        allowed_set = set(allowed_chars.replace(' ', ''))

        self.restricted_chars = [' ']
        self._col_indices = [0]
        for i, c in enumerate(full_chars):
            if c in allowed_set:
                self.restricted_chars.append(c)
                self._col_indices.append(i)

        self._col_indices = np.array(self._col_indices)

    def decode(self, logits: np.ndarray) -> str:
        sliced = logits[:, self._col_indices]
        res = []
        prev = None
        for i, s in enumerate(sliced):
            l = int(np.argmax(s))
            if l == 0:
                prev = l
                continue
            if prev is not None and l == prev:
                continue
            res.append(self.restricted_chars[l])
            prev = l

        return ''.join(res)

허용 문자에 해당하는 열만 남기고 나머지는 잘라낸 뒤 argmax를 취합니다. 11,947개 중 주민등록번호라면 0123456789- 11개만 남습니다.

전체 인식에 항상 적용하면 안 됩니다. 어떤 필드인지 모르는 상태에서는 전체 문자셋이 필요하기 때문입니다. 그래서 1차 인식에서 형식은 맞는데 허용되지 않은 문자가 섞여 있을 때만 해당 영역을 제한된 디코더로 재시도하는 식으로 사용합니다.

이름 필드의 함정들

이름은 형식 제약이 거의 없어서 오히려 까다로웠습니다.

한자 병기

주민등록증은 이름이 홍길동(洪吉童) 형태로 표기됩니다. 그런데 한자가 한글로 오인식되면 이름 뒤에 이상한 글자가 붙습니다.

기존 추출 코드는 이랬습니다.

1
2
3
4
box = closest_above(pattern=None)
if box:
    text = box["text"].replace(" ", "")
    return box, re.sub(r"[^가-힣a-zA-Z]", "", text)

한글과 알파벳을 제외한 모든 문자를 지웁니다. 괄호도 같이 지워지므로 홍길동(童)홍길동童을 거쳐 홍길동동이 됩니다.

1
2
3
4
5
6
text = box["text"].replace(" ", "")
if '(' in text:
    text = text.split("(")[0]
if ')' in text:
    text = text.split(")")[0]
return box, re.sub(r"[^가-힣a-zA-Z]", "", text)

그래서 괄호를 기준으로 왼쪽만 남기도록 바꿨습니다. 여는 괄호가 인식되지 않고 닫는 괄호만 잡히는 경우도 있어 양쪽 모두 처리하게 했습니다.

이름 패턴 자체에도 버그가 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 수정 전 — 한 글자만 캡처됨
self.name_hanja_pattern = re.compile(
    r"^([가-힣a-zA-Z])\(?[一-鿿㐀-䶿豈-﫿]+\)?$"
)
self.name_paren_pattern = re.compile(
    r"^([가-힣a-zA-Z])(?:\([^()]*\)?|[^()]*\))$"
)

# 수정 후 — + 를 붙여 이름 전체를 캡처
self.name_hanja_pattern = re.compile(
    r"^([가-힣a-zA-Z]+)\(?[一-鿿㐀-䶿豈-﫿]+\)?$"
)
self.name_paren_pattern = re.compile(
    r"^([가-힣a-zA-Z]+)\([^()]*\)?$"
)

# 닫는 괄호만 있는 케이스 추가
self.name_close_paren_pattern = re.compile(
    r"^[가-힣a-zA-Z][^(]*\)$"
)

캡처 그룹에 +가 빠져 있어 이름의 첫 글자만 잡히고 있었습니다.

앵커 기반 필드 추출의 함정

신분증 OCR은 “주민등록번호 바로 위에 있는 박스가 이름”처럼 위치 관계로 필드를 판별합니다. 이때 거리를 어떻게 재느냐가 생각보다 중요합니다.

1
2
# 기존 — y축 간격만 계산
dist = rrn_box["min_y"] - box["max_y"]

기준 박스의 상단 y에서 대상 박스의 하단 y를 뺀 값입니다. 그렇다보니 x축을 고려하지 않고 y축의 최단기리로만 판별했습니다. 그래서 가로로 멀리 떨어진 박스라도 y축으로만 가까우면 선택됩니다. 실제로 홀로그램이 텍스트로 오인식된 영역이 이름으로 뽑히는 일이 있었습니다.

1
2
3
4
5
# 개선 — 유클리드 거리
dist = math.hypot(
    box["center_x"] - rrn_box["center_x"],
    box["center_y"] - rrn_box["center_y"],
)

그래서 중심점 사이의 유클리드 거리로 가까운 박스를 판별하도록 바꿨습니다.

상용 API와 비교한 결과

약 5주간 같은 신분증 사진을 상용 API와 로컬 OCR에 모두 태워 결과를 쌓고 비교했습니다. 외국인등록증은 기간 중 요청이 들어오지 않아 검증하지 못했습니다.

로컬 OCR이 더 나았던 부분

형식이 정해진 필드에서는 로컬 OCR이 확실히 앞섰습니다.

  • 상용 API가 주민번호 앞자리를 7자리로 읽은 케이스가 여러 건 있었습니다. 로컬 OCR은 형식 보정 덕분에 인식에 성공했습니다.
  • 운전면허번호도 상용 API는 형식에 맞지 않게 읽거나 불필요한 문자를 섞었습니다.
  • 이름 뒤에 i, -, licens 같은 문자가 붙는 경우도 상용 API 쪽에서 나왔습니다. 로컬 OCR은 한글 외 문자를 제거하는 로직이 있어 깔끔했습니다.

여권에서는 MRZ 파싱이 인식 실패를 줄이는 역할을 했습니다. 영문 이름이나 생년월일 인식에 실패해도 MRZ에서 복구하기 때문에, 상용 API가 실패한 케이스를 로컬 OCR이 채운 경우가 여러 건 있었습니다.

범용 OCR API에는 이런 보정 장치가 없습니다. 특정 도메인에 맞춘 후처리를 붙일 수 있다는 게 직접 만드는 것의 가장 큰 이점이었습니다.

상용 API가 더 나았던 부분

반대로 명확히 밀리는 영역도 있었습니다.

가장 큰 차이는 빛 반사 사진의 인식 성공률이었습니다. 홀로그램 반사가 글자 위에 겹치면 로컬 OCR은 이름을 다른 글자로 읽거나 아예 인식에 실패했습니다. 같은 사진에서 상용 API는 정확히 읽어냈습니다.

인식 대상이 아예 없을 때의 판단은 격차가 더 컸습니다. 발급일이 사진에서 잘려나간 케이스에서, 상용 API는 대부분 빈 값 또는 아예 반환하지 않았고, 이게 정답입니다. 반면에 로컬 OCR은 근처에 있는 갱신 유효기간을 발급일로 읽었습니다. 이는 앵커 기반 인식 기법의 한계입니다.

결론적으로 빛 반사와 없는 것을 없다고 판단하는 능력이 부족합니다.

결론

항목 우세
형식이 정해진 필드 (주민번호, 면허번호) 로컬 OCR
인식 실패 시 보완 (MRZ 파싱) 로컬 OCR
불필요한 문자 혼입 방지 로컬 OCR
빛 반사가 글자를 덮은 경우 상용 API
대상이 없을 때의 True Negative 상용 API

정상적으로 촬영된 사진에서는 로컬 OCR이 상용 API를 대체할 수 있다는 결론이 나왔고, 상용 OCR API에서 로컬 OCR 방식으로 교체하기로 결정했습니다. 만에하나 빛 반사나 사진 잘림 같은 케이스가 발생하면, 이런 경우만 상용 API로 넘기는 하이브리드 구성으로 해결했습니다. 전체에서 차지하는 비중이 크지 않기 때문입니다.

2코어 CPU 서버에서 경량 모델로도 상용 API에 준하는 결과를 낼 수 있다는 것이 이 실험의 결론입니다.

정리

  • 모델: PP-OCRv5는 5M 파라미터로 CPU 추론이 가능하고, 필요한 모델만 골라 쓰면 더 가벼워집니다.
  • 배포: 추론을 요청-응답을 Celery 큐로 넘기고 티켓으로 결과를 조회하면 타임아웃 문제를 방지할 수 있습니다.
  • ONNX: 추론 백엔드를 ONNX로 교체해 로컬에서 50~70%, 서버 부하 상황에서 6~33% 단축했습니다.
  • 정확도: 모델을 바꾸는 것보다 도메인 제약을 후처리에 활용하는 방법이 효과가 더 컸습니다. 형식 보정, CTC 문자 제한, MRZ 안전망 모두 여기에 해당합니다.

경량 모델의 한계는 분명히 있습니다. 빛 반사로 획이 끊긴 글자는 어떤 전처리로도 해결하지 못했습니다. 그래도 전체 요청의 대부분을 차지하는 정상 케이스를 CPU 서버에서 처리할 수 있다면, 그것만으로 도입할 이유는 충분합니다.

개인적으로 이 프로젝트를 진행하면서 경량 모델의 성능이 이정도까지 올라온게 정말 신기했습니다. 당장 5~6년전만 해도 YOLO와 같은 모델로 텍스트 인식, 위치 추출을 하려면 상당한 리소스가 필요했는데 AI분야의 빠른 발전을 체감하게 되었던것 같습니다.

레퍼런스

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.