사무실에서 영수증을 처리하거나 또는 상품을 구매하고 영수증 OCR 자동화를 구현하고 싶은데, 일반 OCR로는 표 구조가 뭉개지거나 상품명과 금액이 뒤섞여 제대로 된 결과를 얻지 못한 경험이 있으신가요? 이번 글에서는 비전언어모델(Vision-Language Model, VLM) 기반의 PaddleOCR-VL을 활용하여 영수증 이미지에서 상품명·수량·단가·금액을 자동으로 추출하고 JSON으로 저장하는 Python 영수증 인식 자동화 GUI 프로그램을 제작합니다.
단순한 문자 인식 수준의 PaddleOCR 영수증 인식이 아니라, paddleocr doc-parser를 통한 문서 레이아웃 분석 파이프라인을 사용하기 때문에 영수증의 표 구조(테이블)까지 정밀하게 파악하고 품목별 데이터를 깔끔하게 분리할 수 있습니다. PaddlePaddle 파이썬 영수증 파싱 파이프라인으로 추출된 JSON 데이터는 회계 자동화, AI 학습 데이터 구축, 지출 분석 등 다양한 후처리 파이프라인에 바로 연결할 수 있습니다.
이 글에서 다루는 내용:
- PaddleOCR-VL과 일반 OCR의 차이점 및 선택 이유
- 전체 파이프라인 아키텍처 — 코드 작성 전 구조 먼저 파악
- PaddleOCR-VL Windows CPU·GPU 설치 — conda 가상환경 구성부터 CUDA 11.8 패키지까지
paddleocr[doc-parser]패키지를 활용한 영수증 테이블 인식- NumpyEncoder로 JSON 직렬화 오류 완전 해결 (
TypeError: Object of type ndarray is not JSON serializable) - tkinter GUI 프로그램 전체 소스코드
- 자주 발생하는 오류 5가지 해결법
목차
1. PaddleOCR-VL이란? 비전언어모델(VLM) OCR이 일반 PaddleOCR 영수증 인식과 다른 이유
1-1. 기존 PaddleOCR 영수증 인식의 한계
기존의 OCR(Optical Character Recognition) 엔진은 이미지에서 문자를 인식하는 데 집중합니다. 영수증처럼 표 구조로 정렬된 데이터를 처리할 때 단순 문자 인식 방식은 다음과 같은 근본적인 문제를 드러냅니다.
- 상품명, 수량, 단가, 금액이 같은 줄에 있는지 파악하지 못함
- 열(column) 구분 없이 단순 텍스트 나열로 결과 반환
- 바코드 번호와 상품명을 구별하지 못함
- 금액의 쉼표(
,)를 문자로 처리해 숫자 파싱 실패
이 한계는 단순 문자 인식 방식이 문서의 의미 구조(semantic structure)를 이해하지 못하기 때문에 발생합니다. 영수증 한 장에는 헤더, 품목 테이블, 합계, 사업자 정보 등 여러 종류의 정보가 레이아웃 규칙에 따라 배치되어 있는데, 기존 OCR은 이 맥락을 무시하고 픽셀 단위의 문자 인식에만 집중합니다.
1-2. 비전언어모델 OCR — PaddleOCR-VL의 접근 방식
PaddleOCR-VL(Vision-Language Model 기반 OCR)은 단순한 문자 인식을 넘어 문서의 레이아웃 구조와 의미(semantics)를 함께 이해합니다. 텍스트를 “읽는 것”이 아니라 문서를 “이해하는 것”에 가까운 방식으로, 이것이 Python 영수증 인식 자동화에서 PaddleOCR-VL을 선택해야 하는 핵심 이유입니다.
주요 특징:
- 문서 분석 파이프라인(doc-parser): 문서를
paragraph(단락),title(제목),table(표),figure(그림),formula(수식) 등 블록 단위로 분류하여 영수증의 구조적 의미를 그대로 보존 - 표 구조 인식: 영수증의 행·열 구조를 그대로 파악해
block_label: "table"로 구분하고 품목별 데이터를 정밀하게 분리 - PaddleOCR-VL-0.9B 경량 모델: 약 9억 개(0.9 Billion) 파라미터의 경량 VLM으로, 일반적인 VLM(7B, 13B급)보다 훨씬 작아 CPU 환경에서도 실용적인 속도로 동작하도록 설계됨. 이 경량화 설계 덕분에 대형 GPU 없이도 영수증 OCR 자동화가 가능
- JSON 직접 출력:
save_to_json()메서드로 분석 결과를 구조화된 JSON으로 즉시 저장
PaddleOCRVL(pipeline_version="v1") 설정이 바로 이 paddleocr doc-parser 영수증 테이블 인식 파이프라인을 활성화하는 핵심 파라미터입니다. pipeline_version="v1" 설정 하나로 단순 OCR에서 문서 이해 수준의 인식으로 전환됩니다.
1-3. PaddleOCR-VL 주요 활용 분야
| 활용 분야 | 설명 |
|---|---|
| 회계·경비 자동화 | 영수증 스캔 → JSON 추출 → ERP 시스템 자동 입력 |
| AI 학습 데이터 | 영수증 구조화 데이터셋 생성 |
| 지출 분석 앱 | 구매 항목 자동 분류 및 통계 |
| 재고 관리 | 납품서·거래명세서 품목 자동 등록 |
2. PaddleOCR-VL 영수증 파싱 파이프라인 전체 아키텍처 설계
본격적인 코드 작성 전에 PaddlePaddle 파이썬 영수증 파싱 파이프라인의 전체 구조를 이해하면 각 컴포넌트의 역할이 명확해집니다. 코드를 먼저 보는 것보다 데이터 흐름을 먼저 파악하면, 각 클래스가 왜 이런 구조로 설계되었는지 자연스럽게 이해됩니다.
[영수증 이미지 (JPG/PNG)]
↓
[OCRProcessor._init_ocr()]
└─ PaddleOCRVL(pipeline_version="v1") 초기화
└─ PaddleOCR-VL-0.9B 모델 로드
↓
[OCRProcessor.extract_receipt_json()]
└─ pipeline.predict(image_path) 실행
└─ parsing_res_list → block_label / block_content 추출
└─ save_to_json() → ocr_results/input.json 저장
↓
[OCRProcessor.parse_receipt_items()]
└─ block_label == "table" 블록 필터링
└─ _parse_table_content() → 행·열 파싱
└─ 상품명 / 수량 / 단가 / 금액 딕셔너리 리스트 생성
↓
[NumpyEncoder (커스텀 JSON 인코더)]
└─ ndarray → list 변환
└─ PaddleOCRVLBlock → dict 변환
↓
[OCRGuiApp (tkinter GUI)]
└─ 이미지 미리보기 (Canvas)
└─ 품목 목록 출력 (ScrolledText)
└─ JSON 결과 출력 (ScrolledText)
└─ JSON 파일 저장 (ocr_results/*.json)컴포넌트별 역할 요약:
| 컴포넌트 | 담당 역할 | 재사용 가능 여부 |
|---|---|---|
OCRProcessor | OCR 엔진 초기화, 이미지 분석, 품목 파싱 | GUI 없이 독립 사용 가능 |
NumpyEncoder | NumPy·PaddleOCR 객체 JSON 직렬화 | 프로젝트 전체에서 범용 사용 |
OCRGuiApp | tkinter UI 레이아웃, 이벤트 처리 | GUI 전용 |
핵심 설계 포인트 3가지:
① 관심사 분리(Separation of Concerns): OCRProcessor(OCR 로직)와 OCRGuiApp(UI)을 완전히 분리하여 OCR 엔진을 GUI 없이 독립적으로 재사용 가능하도록 설계했습니다. 나중에 배치 처리나 CLI 스크립트로 전환할 때 OCRProcessor 클래스를 그대로 가져다 쓸 수 있습니다.
② 비동기 처리(Async via Thread): Thread를 사용해 OCR 처리 중에도 GUI가 멈추지 않도록 설계했습니다. tkinter는 기본적으로 싱글 스레드로 동작하기 때문에 무거운 모델 추론을 메인 스레드에서 직접 실행하면 UI가 완전히 프리징됩니다. Thread(target=..., daemon=True).start() + root.after(0, ...) 패턴으로 이를 해결합니다.
③ 직렬화 일괄 처리(Unified Serialization): NumpyEncoder로 PaddleOCR 특유의 직렬화 불가 객체 문제를 일괄 해결합니다. 모든 JSON 출력 경로에 cls=NumpyEncoder를 적용해 어디서 호출하든 오류 없이 동작합니다.
3. PaddlePaddle Windows CPU·GPU 개발 환경 구축: conda 가상환경부터 CUDA 11.8 패키지 설치까지
PaddleOCR-VL Windows 설치를 위한 개발 환경을 단계별로 구성합니다. GPU가 있는 경우와 CPU 전용 환경 모두 다룹니다.
3-1. 확인된 실행 환경
| 항목 | 사양 |
|---|---|
| OS | Windows 10/11 |
| GPU | NVIDIA RTX 2060 (12GB) 이상 권장 |
| CUDA Driver | 13.2 (하위 호환으로 CUDA 11.8 사용 가능) |
| Python | 3.11 |
GPU가 없거나 VRAM이 4GB 미만인 경우에는 아래 3-3. CPU 전용 버전 설치를 참고하세요. CPU 모드로도 영수증 1장 처리에 충분히 실용적인 속도를 냅니다.
3-2. 사전 준비: NVIDIA 드라이버 확인 및 conda 가상환경 생성
GPU 드라이버 확인:
bash
nvidia-smiCUDA Version이 11.8 이상인지 확인합니다. Driver Version이 높더라도 하위 호환을 통해 CUDA 11.8용 PaddlePaddle을 설치할 수 있습니다.
Conda 가상환경 생성:
bash
# Python 3.11 환경 생성
conda create -n paddle_ocr python=3.11 -y
conda activate paddle_ocr캐시 디렉토리 설정 (C 드라이브 용량 부족 시):
PaddleOCR-VL-0.9B 모델은 최초 실행 시 약 1~2 GB를 다운로드합니다. C 드라이브 여유 공간이 5 GB 미만이라면 캐시 경로를 다른 드라이브로 미리 설정해 두는 것을 권장합니다. 한 번 설정해 두면 이후 실행에서 모델을 재다운로드하지 않고 캐시를 재사용하므로 시간과 네트워크 트래픽을 절약할 수 있습니다.
bash
# G 드라이브에 캐시 디렉토리 생성 (드라이브명은 환경에 맞게 조정)
mkdir G:\AI_Study\paddle_cache
mkdir G:\AI_Study\pip_cache
mkdir G:\AI_Study\tmp
# 환경 변수 설정
set TMPDIR=G:\AI_Study\tmp
set TEMP=G:\AI_Study\tmp
set TMP=G:\AI_Study\tmp
pip config set global.cache-dir G:\AI_Study\pip_cache3-3. PaddlePaddle 설치 — GPU 버전 / CPU 버전 선택
PaddlePaddle CUDA 11.8 conda 가상환경 기반의 GPU 버전과 CPU 전용 버전 중 환경에 맞게 하나를 선택하여 설치합니다.
GPU 버전 (CUDA 11.8) — 권장:
공식 패키지 인덱스 URL을 지정하여 설치합니다. 일반 PyPI에는 GPU 빌드가 없으므로 반드시 아래 URL을 사용해야 합니다.
bash
pip install paddlepaddle-gpu==3.2.2 -i https://www.paddlepaddle.org.cn/packages/stable/cu118/CPU 전용 버전 — GPU 없는 환경:
GPU가 없거나 VRAM이 4 GB 미만인 경우 CPU 전용 PaddlePaddle을 설치합니다. 속도는 더 느리지만 영수증 1장 처리에는 충분히 실용적입니다.
bash
# 안정 버전 (MKL/AVX 최적화 빌드)
pip install paddlepaddle==2.6.2 -f https://www.paddlepaddle.org.cn/whl/windows/mkl/avx/stable.html
# 또는 최신 버전
pip install paddlepaddle==3.2.2설치 확인:
python
import paddle
print("Paddle 버전:", paddle.__version__)
print("CUDA 사용 가능:", paddle.is_compiled_with_cuda())
print("GPU 개수:", paddle.device.cuda.device_count())
paddle.utils.run_check()GPU 설치 성공 시 출력:
Running verify PaddlePaddle program ...
PaddlePaddle works well on 1 GPU.
PaddlePaddle is installed successfully!3-4. PaddleOCR, PaddleX 및 주변 패키지 설치
PaddleOCR-VL의 doc-parser 파이프라인을 사용하려면 paddlex[ocr] 옵션을 포함한 설치가 필수입니다. [ocr] 옵션 없이 설치하면 문서 분석 파이프라인이 활성화되지 않으므로 주의하세요.
bash
# 1. PaddlePaddle GPU 버전 (위에서 이미 설치한 경우 생략)
pip install paddlepaddle-gpu==3.2.2
# 2. PaddleX (OCR 옵션 포함 — 중요!)
pip install "paddlex[ocr]==3.6.1"
# 3. PaddleOCR
pip install paddleocr==3.6.0
# 4. 이미지 처리
pip install opencv-contrib-python==4.10.0.84
pip install pillow==12.2.0
# 5. 문서 처리
pip install PyMuPDF==1.27.2.3
pip install python-docx==1.2.0
pip install openpyxl==3.1.53-5. HuggingFace 및 Modelscope 설치
PaddleOCR-VL-0.9B 모델 가중치는 HuggingFace Hub 또는 Modelscope를 통해 자동 다운로드됩니다. 두 패키지를 모두 설치해 두면 네트워크 상황에 따라 더 빠른 경로를 자동 선택합니다.
bash
# HuggingFace 생태계
pip install huggingface_hub==1.18.0
pip install safetensors==0.8.0
# Modelscope
pip install modelscope==1.37.13-6. 전체 패키지 한 번에 설치 (requirements.txt)
개발 환경을 팀원과 공유하거나 재현 가능한 환경을 만들 때는 requirements.txt를 사용합니다.
requirements.txt 파일을 생성합니다:
paddlepaddle-gpu==3.2.2
paddleocr==3.6.0
paddlex==3.6.1
modelscope==1.37.1
huggingface_hub==1.18.0
opencv-contrib-python==4.10.0.84
pillow==12.2.0
numpy==1.26.4
pandas==3.0.3
PyMuPDF==1.27.2.3
python-docx==1.2.0
openpyxl==3.1.5
safetensors==0.8.0
tqdm==4.68.2
matplotlib==3.10.9설치:
bash
pip install -r requirements.txt3-7. 전체 설치 확인
python
import paddle
import paddleocr
import paddlex
print("PaddlePaddle:", paddle.__version__)
print("CUDA:", paddle.is_compiled_with_cuda())
print("GPU Count:", paddle.device.cuda.device_count())
print("PaddleOCR:", paddleocr.__version__)
print("PaddleX:", paddlex.__version__)3-8. 설치 완료 후 핵심 패키지 버전 참조표
환경이 정상 구성되면 아래 버전으로 각 패키지가 설치됩니다. 이후 오류 발생 시 버전 불일치를 먼저 확인하세요.
핵심 OCR 및 딥러닝 패키지:
| 패키지 | 버전 | 설명 |
|---|---|---|
| paddlepaddle-gpu | 3.2.2 | GPU 지원 PaddlePaddle (CUDA 11.8) |
| paddleocr | 3.6.0 | PaddleOCR 메인 패키지 |
| paddlex | 3.6.1 | PaddleX 통합 API |
| modelscope | 1.37.1 | 모델스코프 |
| huggingface_hub | 1.18.0 | HuggingFace 허브 |
NVIDIA CUDA 관련 패키지 (GPU 지원):
nvidia-cublas-cu11 11.11.3.6
nvidia-cuda-nvrtc-cu11 11.8.89
nvidia-cuda-runtime-cu11 11.8.89
nvidia-cudnn-cu11 8.9.4.19
nvidia-cufft-cu11 10.9.0.58
nvidia-curand-cu11 10.3.0.86
nvidia-cusolver-cu11 11.4.1.48
nvidia-cusparse-cu11 11.7.5.86이미지 처리:
| 패키지 | 버전 |
|---|---|
| opencv-contrib-python | 4.10.0.84 |
| pillow | 12.2.0 |
문서 처리:
| 패키지 | 버전 |
|---|---|
| PyMuPDF | 1.27.2.3 |
| python-docx | 1.2.0 |
| openpyxl | 3.1.5 |
유틸리티:
| 패키지 | 버전 |
|---|---|
| numpy | 1.26.4 |
| pandas | 3.0.3 |
| matplotlib | 3.10.9 |
| scikit-image | 0.26.0 |
| tqdm | 4.68.2 |
라이브러리 목록 (OCR 환경)
| 라이브러리 | 버전 | 역할 |
|---|---|---|
| paddlepaddle-gpu | 3.2.2 | 딥러닝 프레임워크 (GPU 지원) |
| paddleocr | 3.6.0 | OCR 엔진 (텍스트 추출) |
| paddlex | 3.6.1 | PaddleOCR 통합 및 모델 관리 |
| opencv-contrib-python | 4.10.0.84 | 이미지 처리 |
| pillow | 12.2.0 | 이미지 로딩/저장 |
| PyMuPDF | 1.27.2.3 | PDF 파일 처리 |
| python-docx | 1.2.0 | Word 문서 처리 |
| openpyxl | 3.1.5 | Excel 파일 처리 |
| streamlit | – | 웹 앱 프레임워크 (미표시) |
| torch | (nvidia-cuda-*) | PyTorch (딥러닝) |
| numpy | 1.26.4 | 수치 연산 |
| pandas | 3.0.3 | 데이터 분석 |
| matplotlib | 3.10.9 | 데이터 시각화 |
| scikit-learn | 1.9.0 | 머신러닝 |
| scipy | 1.17.1 | 과학 계산 |
| requests | 2.34.2 | HTTP 통신 |
| beautifulsoup4 | 4.15.0 | HTML/XML 파싱 (크롤링) |
| Flask | 3.1.3 | 웹 서버 프레임워크 |
| Jinja2 | 3.1.6 | 템플릿 엔진 |
| openai | 2.41.0 | OpenAI API 연동 |
| huggingface_hub | 1.18.0 | HuggingFace 모델 다운로드 |
| modelscope | 1.37.1 | 모델 스코프 (PaddleOCR 의존성) |
| tqdm | 4.68.2 | 진행률 표시 |
| PyYAML | 6.0.2 | YAML 파일 처리 |
| lxml | 6.1.1 | XML/HTML 파싱 |
| typing_extensions | 4.15.0 | 타입 힌트 확장 |
| packaging | 26.2 | 패키지 버전 관리 |
| click | 8.4.1 | CLI 명령어 처리 |
| colorama | 0.4.6 | 터미널 컬러 출력 |
| certifi | 2026.5.20 | SSL 인증서 |
| charset-normalizer | 3.4.7 | 문자 인코딩 정규화 |
| idna | 3.18 | 국제 도메인 처리 |
| urllib3 | 2.7.0 | HTTP 클라이언트 |
| psutil | 7.2.2 | 시스템/프로세스 모니터링 |
| shapely | 2.1.2 | 기하학적 객체 처리 |
| scikit-image | 0.26.0 | 이미지 처리 알고리즘 |
| imageio | 2.37.3 | 이미지 I/O |
| tifffile | 2026.3.3 | TIFF 파일 처리 |
| networkx | 3.6.1 | 그래프/네트워크 분석 |
| regex | 2026.5.9 | 정규 표현식 |
| safetensors | 0.8.0 | 모델 가중치 안전 저장 |
| tokenizers | 0.23.1 | 텍스트 토큰화 |
| tiktoken | 0.13.0 | OpenAI 토크나이저 |
| sentencepiece | 0.2.1 | 서브워드 토크나이저 |
| joblib | 1.5.3 | 파이프라인 캐싱 |
| threadpoolctl | 3.6.0 | 스레드풀 제어 |
| pydantic | 2.13.4 | 데이터 검증 |
| anyio | 4.13.0 | 비동기 I/O |
| httpx | 0.28.1 | 비동기 HTTP 클라이언트 |
| httpcore | 1.0.9 | HTTP 프로토콜 코어 |
| h11 | 0.16.0 | HTTP/1.1 프로토콜 |
| sniffio | 1.3.1 | 비동기 라이브러리 감지 |
| distro | 1.9.0 | OS 배포판 정보 |
| shellingham | 1.5.4 | 쉘 감지 |
| typer | 0.25.1 | CLI 앱 빌더 |
| rich | 15.0.0 | 터미널 풍성한 출력 |
| Pygments | 2.20.0 | 코드 하이라이팅 |
| markdown-it-py | 4.2.0 | 마크다운 파싱 |
| mdurl | 0.1.2 | URL 인코딩/디코딩 |
| wcwidth | 0.8.1 | 문자열 너비 계산 |
| python-dateutil | 2.9.0 | 날짜/시간 처리 |
| pytz | 2026.2 | 시간대 처리 |
| tzdata | 2026.2 | IANA 시간대 데이터 |
| et_xmlfile | 2.0.0 | XML 파일 처리 |
| lxml | 6.1.1 | XML/HTML 파싱 |
| cssselect | 1.4.0 | CSS 선택자 |
| cssutils | 2.15.0 | CSS 파싱 |
| premailer | 3.10.0 | 이메일 CSS 인라인화 |
| encutils | 1.0.0 | 인코딩 유틸리티 |
| imagesize | 2.0.0 | 이미지 크기 감지 |
| babel | 2.18.0 | 국제화 |
| flask-babel | 4.0.0 | Flask 국제화 |
| itsdangerous | 2.2.0 | 데이터 서명 |
| MarkupSafe | 3.0.3 | HTML 이스케이프 |
| Werkzeug | 3.1.8 | WSGI 유틸리티 |
| blinker | 1.9.0 | 신호 처리 |
| click | 8.4.1 | CLI 프레임워크 |
| einops | 0.8.2 | 텐서 연산 재구성 |
| ftfy | 6.3.1 | 유니코드 텍스트 정리 |
| fire | 0.7.1 | CLI 자동 생성 |
| termcolor | 3.3.0 | 터미널 컬러 출력 |
| astor | 0.8.1 | AST 조작 |
| decorator | 5.3.1 | 데코레이터 유틸리티 |
| attrdict | 2.0.1 | 속성 기반 딕셔너리 |
| more-itertools | 11.1.0 | itertools 확장 |
| prettytable | 3.17.0 | 테이블 형식 출력 |
| visualdl | 2.5.3 | 딥러닝 시각화 |
| lmdb | 2.2.1 | LMDB 데이터베이스 |
| rarfile | 4.2 | RAR 압축 해제 |
| pycryptodome | 3.23.0 | 암호화 라이브러리 |
| cachetools | 7.1.4 | 캐싱 도구 |
| frozenlist | 1.8.0 | 불변 리스트 |
| multidict | 6.7.1 | 다중 값 딕셔너리 |
| propcache | 0.5.2 | 속성 캐싱 |
| yarl | 1.24.2 | URL 파싱 |
| aiohttp | 3.14.1 | 비동기 HTTP 클라이언트/서버 |
| aiosignal | 1.4.0 | 비동기 신호 |
| aiohappyeyeballs | 2.6.2 | 비동기 연결 최적화 |
| filelock | 3.29.1 | 파일 기반 락 |
| fsspec | 2026.4.0 | 파일 시스템 인터페이스 |
| hf-xet | 1.5.1 | HuggingFace XET |
| jiter | 0.15.0 | JSON 반복자 |
| narwhals | 2.22.1 | 데이터프레임 추상화 |
| lazy-loader | 0.5 | 지연 임포트 |
| cycler | 0.12.1 | 순환 색상 지정 |
| contourpy | 1.3.3 | 등고선 플롯 |
| fonttools | 4.63.0 | 폰트 처리 |
| kiwisolver | 1.5.0 | 제약 조건 해결 |
| pyparsing | 3.3.2 | 파서 생성기 |
| Cython | 3.2.5 | C 확장 컴파일 |
| imgaug | 0.4.0 | 이미지 증강 |
| latex2mathml | 3.81.0 | LaTeX → MathML 변환 |
| python-bidi | 0.6.10 | 양방향 텍스트 처리 |
| pdf2docx | 0.5.13 | PDF → DOCX 변환 |
| pypdfium2 | 5.9.0 | PDF 렌더링 |
| RapidFuzz | 3.14.5 | 빠른 문자열 유사도 |
| ruamel.yaml | 0.19.1 | YAML (주석 보존) |
| setuptools | 65.5.0 | 패키지 빌드 도구 |
| pip | 24.0 | 패키지 설치 관리자 |
핵심 라이브러리 요약
| 구분 | 라이브러리 | 버전 | 설명 |
|---|---|---|---|
| OCR 핵심 | paddlepaddle-gpu | 3.2.2 | PaddlePaddle 딥러닝 GPU |
| paddleocr | 3.6.0 | 텍스트 인식 엔진 | |
| paddlex | 3.6.1 | OCR/문서 분석 통합 | |
| 이미지 처리 | opencv-contrib-python | 4.10.0.84 | 이미지 처리/카메라 |
| pillow | 12.2.0 | 이미지 I/O | |
| 문서 처리 | PyMuPDF | 1.27.2.3 | PDF 파싱 |
| python-docx | 1.2.0 | Word 문서 | |
| openpyxl | 3.1.5 | Excel 파일 | |
| 딥러닝 | torch (nvidia-*) | – | PyTorch GPU |
| numpy | 1.26.4 | 수치 연산 | |
| 웹/API | requests | 2.34.2 | HTTP 통신 |
| beautifulsoup4 | 4.15.0 | HTML 크롤링 | |
| openai | 2.41.0 | OpenAI API |
4. NumpyEncoder로 PaddleOCR JSON 직렬화 오류(ndarray is not JSON serializable) 완전 해결
환경 구축 다음 단계로 코드를 작성하기 전에 반드시 해결해야 하는 문제가 있습니다. PaddleOCR-VL 결과를 JSON으로 저장하려는 모든 시도에서 직렬화 오류가 발생하는데, 이를 NumpyEncoder 하나로 일괄 해결합니다.
4-1. 오류 발생 원인과 진단
PaddleOCR-VL 분석 결과를 json.dumps()로 저장하려 할 때 다음 오류가 반드시 발생합니다:
TypeError: Object of type ndarray is not JSON serializable또는:
TypeError: Object of type PaddleOCRVLBlock is not JSON serializable이 오류의 원인은 PaddleOCR-VL 결과물 안에 Python 기본 json 모듈이 처리하지 못하는 타입들이 섞여 있기 때문입니다:
| 직렬화 불가 타입 | 발생 이유 |
|---|---|
numpy.ndarray | 바운딩 박스 좌표가 NumPy 배열 형태로 반환됨 |
numpy.integer / numpy.floating | NumPy 고유 정수·부동소수점 타입 |
PaddleOCRVLBlock | PaddleOCR-VL 고유의 결과 블록 객체 |
단순히 str() 변환을 하거나 직렬화 전에 결과를 복사해서 쓰는 방법은 유지보수가 어렵고 누락이 발생하기 쉽습니다. NumpyEncoder 방식이 가장 근본적이고 안전한 해결책입니다.
4-2. NumpyEncoder 구현 — JSON 직렬화 오류 일괄 처리
python
# ========== 2. JSON 직렬화를 위한 커스텀 인코더 ==========
class NumpyEncoder(json.JSONEncoder):
"""NumPy 배열 및 PaddleOCRVLBlock 객체를 JSON 직렬화 가능하게 변환"""
def default(self, obj):
# PaddleOCRVLBlock 객체 처리
if hasattr(obj, '__class__') and obj.__class__.__name__ == 'PaddleOCRVLBlock':
return {
"block_label": getattr(obj, 'block_label', None),
"block_content": getattr(obj, 'block_content', None),
"block_bbox": getattr(obj, 'block_bbox', None)
}
# NumPy 배열 처리
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, np.integer):
return int(obj)
if isinstance(obj, np.floating):
return float(obj)
return super().default(obj)처리하는 타입과 변환 방식
| 입력 타입 | 변환 결과 | 이유 |
|---|---|---|
PaddleOCRVLBlock | {"block_label": ..., "block_content": ..., "block_bbox": ...} | OCR 결과 블록 객체. hasattr 패턴으로 duck typing 처리해 버전 차이에도 안전하게 동작 |
np.ndarray | Python list | 바운딩 박스 좌표 배열 — tolist()로 중첩 리스트까지 재귀 변환 |
np.integer | Python int | NumPy 정수형 (int32, int64 등 모든 하위 타입 포함) |
np.floating | Python float | NumPy 부동소수점 (float32, float64 등) |
PaddleOCRVLBlock 처리에서 isinstance() 대신 hasattr(obj, '__class__') and obj.__class__.__name__ == 'PaddleOCRVLBlock' 패턴을 사용하는 이유는 PaddleOCR 버전에 따라 이 클래스가 다른 모듈 경로에 위치할 수 있기 때문입니다. 클래스 이름으로 비교하는 duck typing 방식이 임포트 경로에 무관하게 안정적으로 동작합니다.
4-3. 올바른 사용법
이 인코더는 cls=NumpyEncoder를 지정하는 것만으로 모든 JSON 직렬화 호출에 적용됩니다:
python
# 올바른 사용법
json.dumps(data, ensure_ascii=False, indent=2, cls=NumpyEncoder)
# 오류 발생 — cls 미지정 시 TypeError
json.dumps(data, ensure_ascii=False, indent=2)프로그램 내 모든 json.dumps() 및 json.dump() 호출에 일관되게 cls=NumpyEncoder를 적용하면 어떤 경로에서 JSON을 출력하든 직렬화 오류가 발생하지 않습니다.
5. OCRProcessor 구현: paddleocr doc-parser로 영수증 테이블 인식 및 상품명·수량·단가 자동 추출
paddleocr doc-parser 영수증 테이블 인식 및 영수증 상품명 수량 단가 금액 자동 추출의 핵심 로직을 담당하는 OCRProcessor 클래스입니다. 이 클래스는 GUI와 완전히 분리되어 있어 독립적으로 재사용할 수 있습니다.
OCRProcessor 메서드 구성:
| 메서드 | 역할 |
|---|---|
__init__() | 클래스 초기화 및 _init_ocr() 호출 |
_init_ocr() | PaddleOCR-VL 파이프라인 초기화, 모델 로드 |
extract_receipt_json() | 이미지 경로를 받아 OCR 실행 후 JSON 데이터 반환 |
parse_receipt_items() | JSON 데이터에서 테이블 블록 필터링 후 품목 리스트 반환 |
_parse_table_content() | 파이프 구분자(|) 형식의 표 텍스트를 품목 딕셔너리로 변환 |
5-1. 환경 변수 및 초기 설정
PaddleOCRVL 파이프라인을 사용하기 전에 캐시 경로 환경 변수와 PaddlePaddle 실행 모드를 설정합니다. 이 설정들은 Python 코드 최상단, 모든 import 구문보다 앞에 위치해야 합니다.
python
"""
PaddleOCR-VL 영수증 분석기 - GUI 프로그램
"""
import os
import numpy as np
import paddle
import re
import json
import tkinter as tk
from tkinter import filedialog, scrolledtext, messagebox
from threading import Thread
from PIL import Image, ImageTk
from datetime import datetime
from paddleocr import PaddleOCRVL
# ========== 1. 환경 변수 설정 (G 드라이브에 캐시 저장) ==========
os.environ['PADDLE_HOME'] = 'G:/AI_Study/paddle_cache'
os.environ['PADDLEX_HOME'] = 'G:/AI_Study/paddlex_cache'
os.environ['PPOCR_HOME'] = 'G:/AI_Study/ppocr_cache'
os.environ['HUGGINGFACE_HUB_CACHE'] = 'G:/AI_Study/huggingface_cache'
os.environ['TRANSFORMERS_CACHE'] = 'G:/AI_Study/transformers_cache'
os.environ['PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK'] = 'True'
# 동적 그래프 모드 설정 (정적 그래프 오류 방지)
os.environ['FLAGS_use_pir_api'] = '0'
paddle.disable_static()환경 변수 설명:
| 변수명 | 역할 |
|---|---|
PADDLE_HOME | PaddlePaddle 핵심 모델 캐시 경로 |
PADDLEX_HOME | PaddleX 파이프라인 모델 캐시 경로 |
HUGGINGFACE_HUB_CACHE | HuggingFace에서 받는 VLM 가중치 저장 경로 |
FLAGS_use_pir_api=0 | PIR(Program IR) API 비활성화 → 정적 그래프 오류 방지 |
paddle.disable_static() | 동적 그래프(Eager Mode) 강제 적용 |
FLAGS_use_pir_api=0과 paddle.disable_static()은 PaddlePaddle 3.x 버전에서 특히 중요합니다. PIR API가 활성화된 상태에서 doc-parser 파이프라인을 실행하면 정적 그래프 관련 내부 오류가 발생할 수 있으며, 동적 그래프 모드(Eager Execution)를 명시적으로 적용해야 Python 코드와의 호환성이 보장됩니다.
5-2. OCRProcessor 클래스 전체 구현
python
# ========== 3. OCR 처리 클래스 ==========
class OCRProcessor:
def __init__(self):
self.pipeline = None
self._init_ocr()
def _init_ocr(self):
"""PaddleOCR-VL 파이프라인 초기화"""
try:
print("[OCR] PaddleOCR-VL 초기화 중...")
print("문서 분석 모델(v1)을 로드합니다. (첫 실행 시 약 1-2GB 다운로드)")
self.pipeline = PaddleOCRVL(pipeline_version="v1")
print("[OCR] 초기화 완료! (PaddleOCR-VL-0.9B)")
return True
except Exception as e:
print(f"[OCR] 초기화 실패: {e}")
return False
def extract_receipt_json(self, image_path):
"""영수증 이미지에서 JSON 데이터 추출"""
if not self.pipeline:
return None, "OCR 엔진이 초기화되지 않았습니다."
if not os.path.exists(image_path):
return None, f"이미지 파일 없음: {image_path}"
try:
print(f"\n[OCR] 이미지 분석 시작: {os.path.basename(image_path)}")
# OCR 실행
output = self.pipeline.predict(image_path)
# 결과 저장 디렉토리
output_dir = os.path.join(os.path.dirname(__file__), "ocr_results")
os.makedirs(output_dir, exist_ok=True)
result_data = None
for res in output:
# 결과 출력
if hasattr(res, 'print'):
res.print()
# JSON 저장
if hasattr(res, 'save_to_json'):
try:
res.save_to_json(save_path=output_dir)
except Exception as e:
print(f"JSON 저장 중 오류 (무시): {e}")
# 결과 데이터 추출
if hasattr(res, 'res'):
result_data = res.res
elif isinstance(res, dict):
result_data = res
# 저장된 JSON 파일 읽기 (save_to_json 성공 시)
json_path = os.path.join(output_dir, "input.json")
if os.path.exists(json_path) and result_data is None:
with open(json_path, 'r', encoding='utf-8') as f:
result_data = json.load(f)
print(f"[OCR] 분석 완료!")
return result_data, None
except Exception as e:
print(f"[OCR 오류] {e}")
return None, str(e)
def parse_receipt_items(self, json_data):
"""JSON 데이터에서 상품 목록 추출"""
if not json_data:
return []
items = []
if isinstance(json_data, dict):
# parsing_res_list에서 테이블 블록 찾기
if 'parsing_res_list' in json_data:
for block in json_data['parsing_res_list']:
# 블록 타입 확인 (객체 또는 딕셔너리 모두 처리)
if hasattr(block, 'block_label'):
label = block.block_label
content = block.block_content
elif isinstance(block, dict):
label = block.get('block_label')
content = block.get('block_content')
else:
continue
# 테이블 블록만 처리
if label == 'table' and content:
items.extend(self._parse_table_content(content))
return items
def _parse_table_content(self, table_text):
"""표 형식 텍스트에서 상품 정보 추출"""
items = []
if not table_text:
return items
lines = table_text.strip().split('\n')
for line in lines:
if '|' not in line:
continue
cells = [c.strip() for c in line.split('|')[1:-1]]
if len(cells) >= 4:
# 헤더 행 스킵
if cells[0] in ['상품명', '품명', 'Product', 'Item']:
continue
# 바코드(13자리 이상 숫자) 스킵
if cells[0].isdigit() and len(cells[0]) >= 13:
continue
# 숫자 추출 (수량, 단가, 금액)
numbers = []
for cell in cells[1:4]:
nums = re.findall(r'[\d,]+', cell)
for n in nums:
try:
num = int(n.replace(',', ''))
if num >= 100: # 100원 이상만 가격으로 간주
numbers.append(num)
except:
pass
if len(numbers) >= 2:
# 수량, 단가, 금액 매칭 로직
total_price = numbers[-1]
if len(numbers) >= 3:
quantity = numbers[0] if numbers[0] < 100 else 1
unit_price = numbers[1]
else:
unit_price = numbers[0]
quantity = total_price // unit_price if unit_price > 0 else 1
items.append({
"product": cells[0],
"quantity": quantity,
"unit_price": unit_price,
"total_price": total_price
})
return items5-3. extract_receipt_json() 핵심 로직 해설
pipeline.predict(image_path)가 반환하는 output은 이터러블 객체입니다. 각 res 항목에서 결과를 추출할 때 두 가지 경로가 존재하는 이유는 PaddleOCR 버전에 따라 반환 형식이 다르기 때문입니다:
res.res접근 방식: 최신 버전에서 결과 객체가.res속성으로 딕셔너리를 반환isinstance(res, dict)방식: 구버전에서 결과가 직접 딕셔너리로 반환되는 경우의 fallback
save_to_json() 호출 역시 실패해도 try/except로 무시하고, 저장된 input.json 파일에서 다시 읽는 이중 fallback 구조를 갖추고 있습니다. 이 방어적 설계 덕분에 PaddleOCR 마이너 버전 변경에도 비교적 안정적으로 동작합니다.
5-4. _parse_table_content() 파싱 로직 상세 — paddleocr doc-parser 영수증 테이블 인식
paddleocr doc-parser 영수증 테이블 인식 결과로 반환되는 표(table) 내용은 파이프(|) 구분자 형식의 텍스트입니다:
| 상품명 | 수량 | 단가 | 금액 |
| 처음처럼PET400m | 20 | 1,400 | 28,000 |
| *풀/소가크고단단 | 2 | 2,030 | 4,060 |이 메서드가 처리하는 예외 케이스 5가지:
| 케이스 | 판단 기준 | 처리 방식 |
|---|---|---|
| 헤더 행 | cells[0]이 상품명, 품명, Product, Item | continue로 스킵 |
| EAN-13 바코드 | cells[0]이 13자리 이상 순수 숫자 | continue로 스킵 |
| 100원 미만 숫자 | 금액 파싱 시 100 미만 값 | 수량이 아닌 가격 셀 추출 필터 |
| 수량 정보 누락 | 숫자 셀이 2개만 존재 | total_price ÷ unit_price로 수량 역산 |
| 구분선 행 | | 미포함 줄 | 파싱 건너뜀 |
parse_receipt_items()에서 block 처리 시 hasattr(block, 'block_label')과 isinstance(block, dict) 두 가지 경로를 모두 지원하는 이유도 동일합니다. PaddleOCR 버전에 따라 parsing_res_list의 각 항목이 PaddleOCRVLBlock 객체로 반환되기도 하고, 이미 딕셔너리로 변환된 형태로 반환되기도 하므로 두 경우를 모두 방어적으로 처리합니다.
6. Python tkinter 영수증 OCR GUI 프로그램: OCRGuiApp 전체 구현
Python tkinter 영수증 OCR GUI 프로그램의 UI 레이어를 담당하는 OCRGuiApp 클래스입니다. 3패널 구조로 이미지 미리보기, 품목 목록, JSON 결과를 동시에 확인할 수 있습니다.
3패널 레이아웃 구성
| 패널 위치 | 내용 | 위젯 |
|---|---|---|
| 왼쪽 (450px) | 영수증 이미지 미리보기 + 버튼 | Canvas + Button |
| 중간 (350px) | 추출된 품목 목록 | ScrolledText |
| 오른쪽 (나머지) | 전체 JSON 결과 | ScrolledText |
python
# ========== 4. GUI 애플리케이션 클래스 ==========
class OCRGuiApp:
def __init__(self, root):
self.root = root
self.root.title("📷 PaddleOCR-VL 영수증 분석기")
self.root.geometry("1200x800")
self.root.configure(bg="#1a1a2e")
self.current_image_path = None
self.receipt_items = []
self.raw_json = None
self.ocr = None
self.output_dir = os.path.join(os.path.dirname(__file__), "ocr_results")
os.makedirs(self.output_dir, exist_ok=True)
self.setup_gui()
self.root.after(100, self.init_ocr_engine)
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def init_ocr_engine(self):
"""별도 스레드에서 OCR 엔진 초기화 — GUI 블로킹 방지"""
def _init():
self.ocr = OCRProcessor()
if self.ocr.pipeline:
self.root.after(0, lambda: self.status.config(
text="OCR 엔진 준비 완료", fg="#00b894"))
else:
self.root.after(0, lambda: self.status.config(
text="OCR 엔진 초기화 실패", fg="#e94560"))
Thread(target=_init, daemon=True).start()
def setup_gui(self):
"""GUI 레이아웃 구성 — 3패널 구조"""
main = tk.Frame(self.root, bg="#1a1a2e")
main.pack(fill=tk.BOTH, expand=True, padx=15, pady=15)
# ===== 왼쪽 패널: 이미지 미리보기 영역 =====
left = tk.Frame(main, bg="#16213e", width=450)
left.pack(side=tk.LEFT, fill=tk.BOTH, expand=False, padx=(0, 15))
left.pack_propagate(False)
tk.Label(left, text="🖼️ 영수증 이미지", font=("Malgun Gothic", 14, "bold"),
bg="#16213e", fg="#e2e2e2").pack(anchor=tk.W, padx=15, pady=(15, 10))
self.canvas = tk.Canvas(left, width=420, height=400, bg="#0f3460")
self.canvas.pack(padx=15, pady=10)
self.canvas.create_text(210, 200, text="📁 이미지를 선택하세요",
fill="#888", font=("Malgun Gothic", 12))
# 버튼 프레임
btn_frame = tk.Frame(left, bg="#16213e")
btn_frame.pack(fill=tk.X, padx=15, pady=10)
btn_style = {"font": ("Malgun Gothic", 10, "bold"), "padx": 20, "pady": 8}
tk.Button(btn_frame, text="📁 파일 선택", command=self.select_image,
bg="#e94560", fg="white", **btn_style).pack(side=tk.LEFT, padx=5)
tk.Button(btn_frame, text="🔍 OCR 실행", command=self.run_ocr,
bg="#533483", fg="white", **btn_style).pack(side=tk.LEFT, padx=5)
tk.Button(btn_frame, text="💾 JSON 저장", command=self.save_json,
bg="#00b894", fg="white", **btn_style).pack(side=tk.LEFT, padx=5)
# ===== 중간 패널: 추출된 품목 목록 =====
center = tk.Frame(main, bg="#16213e", width=350)
center.pack(side=tk.LEFT, fill=tk.BOTH, expand=False, padx=(0, 15))
center.pack_propagate(False)
tk.Label(center, text="📋 추출된 품목", font=("Malgun Gothic", 14, "bold"),
bg="#16213e", fg="#e2e2e2").pack(anchor=tk.W, padx=15, pady=(15, 10))
self.items_area = scrolledtext.ScrolledText(center, font=("Malgun Gothic", 11),
bg="#0f3460", fg="#00ff88")
self.items_area.pack(fill=tk.BOTH, expand=True, padx=15, pady=10)
# ===== 오른쪽 패널: 전체 JSON 결과 =====
right = tk.Frame(main, bg="#16213e")
right.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
tk.Label(right, text="📄 JSON 결과", font=("Malgun Gothic", 14, "bold"),
bg="#16213e", fg="#e2e2e2").pack(anchor=tk.W, padx=15, pady=(15, 10))
self.json_area = scrolledtext.ScrolledText(right, font=("Consolas", 10),
bg="#0f3460", fg="#a6e3a1", wrap=tk.WORD)
self.json_area.pack(fill=tk.BOTH, expand=True, padx=15, pady=10)
# ===== 하단 상태바 =====
self.status = tk.Label(self.root, text="🔄 OCR 엔진 초기화 중...", font=("Malgun Gothic", 9),
bg="#1a1a2e", fg="#ffd93d", anchor=tk.W)
self.status.pack(side=tk.BOTTOM, fill=tk.X, padx=15, pady=5)
def select_image(self):
"""이미지 파일 선택 다이얼로그"""
file_path = filedialog.askopenfilename(
title="영수증 이미지 선택",
filetypes=[("이미지 파일", "*.jpg *.jpeg *.png *.bmp")]
)
if not file_path:
return
self.current_image_path = file_path
self.show_preview(file_path)
self.status.config(text=f"📷 이미지 로드: {os.path.basename(file_path)}", fg="#ffd93d")
self.items_area.delete(1.0, tk.END)
self.json_area.delete(1.0, tk.END)
self.receipt_items = []
def show_preview(self, path):
"""이미지 미리보기 — Canvas에 축소 렌더링"""
try:
img = Image.open(path)
img.thumbnail((420, 400), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(img)
self.canvas.delete("all")
self.canvas.create_image(210, 200, anchor=tk.CENTER, image=photo)
self.canvas.image = photo # 가비지 컬렉션 방지를 위한 참조 유지
except Exception as e:
self.status.config(text=f"이미지 오류: {e}", fg="#e94560")
def run_ocr(self):
"""OCR 실행 — 별도 스레드에서 비동기 처리"""
if not self.ocr or not self.ocr.pipeline:
messagebox.showerror("오류", "OCR 엔진이 초기화 중입니다.")
return
if not self.current_image_path:
messagebox.showwarning("경고", "먼저 이미지를 선택하세요")
return
def _worker():
self.root.after(0, lambda: self.status.config(text="🔍 분석 중...", fg="#ffd93d"))
json_data, error = self.ocr.extract_receipt_json(self.current_image_path)
self.raw_json = json_data
if error:
self.root.after(0, lambda: self._update_display_error(error))
return
# 품목 추출
self.receipt_items = self.ocr.parse_receipt_items(json_data)
# 결과 표시
items_text = self._format_items_display()
json_text = json.dumps(json_data, ensure_ascii=False, indent=2, cls=NumpyEncoder) if json_data else "결과 없음"
self.root.after(0, lambda: self._update_display(items_text, json_text))
Thread(target=_worker, daemon=True).start()
def _format_items_display(self):
"""품목 목록을 사람이 읽기 좋은 형태로 포맷팅"""
if not self.receipt_items:
return "⚠️ 품목을 찾을 수 없습니다."
lines = []
total_sum = 0
for item in self.receipt_items:
total_sum += item.get("total_price", 0)
lines.append(
f"📦 {item.get('product', 'N/A')}\n"
f" 수량: {item.get('quantity', 1)}개\n"
f" 단가: {item.get('unit_price', 0):,}원\n"
f" 금액: {item.get('total_price', 0):,}원\n"
f" ─────────────────"
)
lines.append(f"\n💰 총 합계: {total_sum:,}원")
return "\n".join(lines)
def _update_display(self, items_text, json_text):
"""GUI 메인 스레드에서 화면 업데이트"""
self.items_area.delete(1.0, tk.END)
self.items_area.insert(1.0, items_text)
self.json_area.delete(1.0, tk.END)
self.json_area.insert(1.0, json_text)
if self.receipt_items:
self.status.config(text=f"✅ 분석 완료: {len(self.receipt_items)}개 품목 추출", fg="#00b894")
self.save_json(auto=True) # 분석 완료 즉시 자동 저장
else:
self.status.config(text="⚠️ 품목을 찾을 수 없음", fg="#e94560")
def _update_display_error(self, error):
self.items_area.delete(1.0, tk.END)
self.items_area.insert(1.0, f"❌ 오류 발생:\n{error}")
self.status.config(text=f"❌ 분석 실패", fg="#e94560")
def save_json(self, auto=False):
"""JSON 파일 저장 — 자동 저장(auto=True) 및 수동 저장 모두 지원"""
if not self.receipt_items and not self.raw_json:
if not auto:
messagebox.showwarning("경고", "먼저 OCR을 실행하세요")
return
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.basename(self.current_image_path) if self.current_image_path else "unknown"
name, ext = os.path.splitext(filename)
total_sum = sum(item.get("total_price", 0) for item in self.receipt_items)
# 저장 데이터 구조: 메타데이터 + 품목 리스트 + 원본 OCR 결과
data = {
"metadata": {
"source_image": self.current_image_path,
"image_name": filename,
"extracted_at": timestamp,
"total_items": len(self.receipt_items),
"total_amount": total_sum,
"ocr_engine": "PaddleOCR-VL-0.9B"
},
"receipt_items": self.receipt_items,
"raw_result": self.raw_json
}
json_path = os.path.join(self.output_dir, f"{name}_{timestamp}.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, cls=NumpyEncoder)
self.status.config(text=f"💾 저장 완료: {os.path.basename(json_path)}", fg="#00b894")
if not auto:
messagebox.showinfo("저장 완료", f"JSON 파일이 저장되었습니다.\n{json_path}")
def on_closing(self):
self.root.destroy()
# ========== 5. 메인 실행 ==========
if __name__ == "__main__":
root = tk.Tk()
app = OCRGuiApp(root)
root.mainloop()6-1. tkinter Thread + root.after() 패턴의 필요성 — GUI 블로킹 방지
tkinter는 메인 스레드에서만 UI 위젯을 업데이트할 수 있습니다. PaddleOCR-VL-0.9B 모델 추론은 수 초에서 수십 초가 소요되는 무거운 작업이므로, 이를 메인 스레드에서 직접 실행하면 분석이 완료될 때까지 창이 완전히 프리징됩니다(응답 없음 상태).
이를 해결하기 위해 Thread(target=_worker, daemon=True).start()로 백그라운드 스레드에서 OCR을 실행하고, 결과가 나오면 self.root.after(0, lambda: ...) 를 통해 메인 스레드의 이벤트 루프에 UI 업데이트를 예약합니다. daemon=True로 설정하면 메인 윈도우가 닫힐 때 백그라운드 스레드도 함께 종료됩니다.
tkinter 스레드 패턴 요약
| 역할 | 코드 패턴 |
|---|---|
| 백그라운드 OCR 실행 | Thread(target=_worker, daemon=True).start() |
| 메인 스레드 UI 업데이트 | self.root.after(0, lambda: widget.config(...)) |
| 앱 종료 시 스레드 정리 | daemon=True (메인 스레드 종료 시 자동 종료) |
6-2. canvas.image 참조 유지의 중요성
show_preview() 메서드에서 self.canvas.image = photo 라인은 단순히 변수를 저장하는 것처럼 보이지만 실제로는 매우 중요합니다. ImageTk.PhotoImage 객체는 Python의 가비지 컬렉터가 로컬 변수를 수거하면 메모리에서 해제되어 Canvas에 표시된 이미지가 사라집니다. self.canvas.image에 명시적으로 참조를 유지함으로써 객체가 GC에 의해 해제되지 않도록 보호합니다.
6-3. auto=True 자동 저장 로직
_update_display() 내에서 self.save_json(auto=True)를 호출하면 분석 완료 즉시 자동으로 JSON 파일이 저장됩니다. auto=True 플래그가 있을 때는 messagebox.showinfo() 팝업을 띄우지 않아 사용자 경험을 방해하지 않습니다. 수동으로 💾 JSON 저장 버튼을 누르면 auto=False로 호출되어 저장 완료 팝업이 표시됩니다.
7. 프로그램 실행 방법 및 영수증 데이터 JSON 추출 결과 구조 {#7}
7-1. 실행 명령어
bash
# 가상환경 활성화 확인 후 실행
conda activate paddle_ocr
python ocr_receipt_app.py7-2. 단계별 사용 방법
Step 1 — 이미지 선택: 📁 파일 선택 버튼을 클릭하면 파일 탐색기가 열립니다. JPG, JPEG, PNG, BMP 형식의 영수증 이미지를 선택하면 왼쪽 캔버스에 미리보기가 표시됩니다.
Step 2 — OCR 실행: 🔍 OCR 실행 버튼을 클릭합니다. 첫 실행 시 PaddleOCR-VL-0.9B 모델(약 1~2GB)이 자동으로 다운로드되므로 네트워크 상태에 따라 초기 로딩에 수 분이 소요될 수 있습니다. 이후 실행부터는 캐시에서 즉시 로드됩니다.
Step 3 — 결과 확인: 분석이 완료되면 두 가지 결과가 화면에 표시됩니다.
- 중간 패널 (추출된 품목): 상품명, 수량, 단가, 금액을 사람이 읽기 좋은 형식으로 정리
- 오른쪽 패널 (JSON 결과): PaddleOCR-VL의 전체 파싱 결과를 JSON 형식으로 출력
Step 4 — JSON 저장: OCR 실행 시 품목 추출에 성공하면 ocr_results/ 폴더에 자동 저장됩니다. 💾 JSON 저장 버튼으로 수동 저장도 가능합니다.
7-3. 화면 출력 예시 (중간 패널)
📦 처음처럼PET400m
수량: 20개
단가: 1,400원
금액: 28,000원
─────────────────
📦 *풀/소가크고단단
수량: 2개
단가: 2,030원
금액: 4,060원
─────────────────
...
💰 총 합계: 69,600원7-4. 저장된 JSON 파일 구조 — 영수증 데이터 JSON 추출 3섹션 설계
paddleocr doc-parser 영수증 테이블 인식 JSON 저장 결과 파일은 세 가지 섹션으로 구성됩니다:
json
{
"metadata": {
"source_image": "G:/Downloads/receipt.jpg",
"image_name": "receipt.jpg",
"extracted_at": "20260608_120000",
"total_items": 10,
"total_amount": 69600,
"ocr_engine": "PaddleOCR-VL-0.9B"
},
"receipt_items": [
{
"product": "처음처럼PET400m",
"quantity": 20,
"unit_price": 1400,
"total_price": 28000
},
{
"product": "*풀/소가크고단단",
"quantity": 2,
"unit_price": 2030,
"total_price": 4060
}
],
"raw_result": {
"parsing_res_list": [
{
"block_label": "table",
"block_content": "| 상품명 | 수량 | 단가 | 금액 |\n| 처음처럼PET400m | 20 | 1,400 | 28,000 |\n..."
}
]
}
}각 섹션의 용도:
| 섹션 | 용도 |
|---|---|
metadata | 이미지 경로, 추출 일시, 총 금액 등 요약 정보. 대용량 처리 시 인덱싱 및 중복 처리 방지에 활용 |
receipt_items | 후처리 파이프라인(회계 시스템, AI 학습)에 직접 사용하는 정제 데이터. 별도 파싱 없이 바로 사용 가능 |
raw_result | PaddleOCR-VL의 원본 출력. 재파싱이나 디버깅 시 활용하며, 파싱 로직을 개선할 때 기준 데이터로 사용 |
7-5. 생성되는 폴더 구조
G:\AI_Study\PaddleOCR-VL_Project/
├── ocr_receipt_app.py # 메인 프로그램
├── ocr_env/ # Python 가상환경
├── ocr_results/ # OCR 결과 저장 폴더
│ ├── input.json # PaddleOCR-VL 원본 출력
│ └── receipt_20260608_120000.json # 가공된 최종 결과 JSON
│
G:\AI_Study\
├── paddle_cache/ # PaddlePaddle 모델 캐시
├── paddlex_cache/ # PaddleX 파이프라인 캐시
├── ppocr_cache/ # PPOCR 모델 캐시
├── huggingface_cache/ # HuggingFace VLM 가중치 (~1-2GB)
└── transformers_cache/ # Transformers 모델 캐시8. PaddleOCR-VL 자주 발생하는 오류 5가지 해결법 {#8}
PaddleOCR 영수증 인식 자동화 구현 과정에서 가장 자주 마주치는 오류와 정확한 해결 방법을 정리했습니다. 오류 메시지를 직접 검색해서 이 섹션에 도달한 경우, 해당 오류 번호로 바로 이동하세요.
8-1. ModuleNotFoundError: No module named ‘paddle’
증상: python ocr_receipt_app.py 실행 시 가장 첫 번째 줄에서 발생
원인: PaddlePaddle이 설치되지 않았거나, 가상환경 외부(base 환경)에 설치된 경우. (paddle_ocr) 접두어 없이 base 환경에서 설치했을 때 가장 많이 발생합니다.
해결:
bash
# 가상환경 활성화 확인 후 재설치
pip uninstall paddlepaddle -y
pip install paddlepaddle==2.6.2 -f https://www.paddlepaddle.org.cn/whl/windows/mkl/avx/stable.html가상환경이 활성화된 상태((paddle_ocr) 표시)에서 설치하는 것을 반드시 확인하세요. 터미널에서 which python 또는 where python으로 현재 파이썬 경로를 확인해 가상환경 경로 내에 있는지 검증합니다.
8-2. TypeError: Object of type ndarray is not JSON serializable
증상: json.dumps() 또는 json.dump() 호출 시 발생
원인: PaddleOCR-VL 결과에 포함된 numpy.ndarray 또는 PaddleOCRVLBlock 객체를 json.dumps()로 직렬화하려 할 때 발생합니다. Python의 기본 JSON 인코더는 NumPy 타입을 인식하지 못합니다.
해결: 이 코드에는 이미 NumpyEncoder 클래스가 포함되어 있어 자동으로 해결됩니다. 만약 프로그램의 다른 부분에서 직접 json.dumps()를 호출한다면 반드시 cls=NumpyEncoder를 추가하세요.
python
# 올바른 사용법
json.dumps(data, ensure_ascii=False, indent=2, cls=NumpyEncoder)
# 오류 발생
json.dumps(data, ensure_ascii=False, indent=2)8-3. Unknown argument: show_log
증상: PaddleOCRVL() 초기화 시 발생
원인: 오래된 버전의 PaddleOCR API와 최신 버전의 파라미터 불일치. PaddleOCR 3.x부터 일부 초기화 파라미터가 변경되었습니다.
해결:
bash
pip install --upgrade paddleocr패키지 업그레이드 후 paddleocr.__version__을 확인해 3.6.0 이상인지 검증하세요.
8-4. GPU 메모리 부족 (Out of Memory) 오류
증상: CUDA out of memory 또는 cudaErrorMemoryAllocation 메시지
원인: GPU VRAM이 PaddleOCR-VL-0.9B 모델을 올리기에 부족한 경우. 일반적으로 4 GB 미만 GPU에서 자주 발생하며, 다른 프로세스가 VRAM을 점유 중일 때도 발생할 수 있습니다.
해결: 환경 변수로 CPU 강제 사용. 이 설정은 Python 코드 최상단, 모든 import 구문보다 앞에 위치해야 합니다:
python
os.environ['CUDA_VISIBLE_DEVICES'] = '' # 코드 최상단, import 전에 설정CPU 모드로 전환하면 처리 속도는 느려지지만 메모리 오류 없이 안정적으로 동작합니다. 영수증 1장 처리에는 CPU로도 충분히 실용적인 결과를 얻을 수 있습니다.
8-5. 모델 다운로드 실패 또는 중간에 멈추는 경우
증상: 첫 실행 시 다운로드 진행 중 특정 퍼센트에서 멈추거나 연결 오류 메시지 출력
원인: 네트워크 불안정, 또는 이전에 부분적으로 다운로드된 캐시 파일이 손상된 경우. 손상된 캐시가 남아 있으면 재실행해도 계속 같은 위치에서 멈춥니다.
해결: 캐시 폴더를 삭제하고 재실행:
bash
# Windows 기준 기본 캐시 삭제
rmdir /s /q "%USERPROFILE%\.paddlex"
rmdir /s /q "%USERPROFILE%\.paddleocr"
# G 드라이브 캐시 삭제 (이 프로젝트 설정 기준)
rmdir /s /q "G:\AI_Study\paddle_cache"
rmdir /s /q "G:\AI_Study\paddlex_cache"
rmdir /s /q "G:\AI_Study\huggingface_cache"캐시 삭제 후 python ocr_receipt_app.py를 다시 실행하면 모델이 처음부터 다시 다운로드됩니다. 네트워크가 불안정한 환경이라면 VPN 변경이나 다른 네트워크로 전환 후 시도해 보세요.
9. 영수증 OCR 자동화 확장 아이디어: 회계 자동화·배치 처리·다국어 지원
이 PaddleOCR-VL 영수증 OCR 자동화 프로그램을 기반으로 다양한 방향으로 확장할 수 있습니다. OCRProcessor와 OCRGuiApp이 완전히 분리된 설계 덕분에, GUI를 제거하거나 교체하는 방식으로 아래 시나리오를 빠르게 구현할 수 있습니다.
9-1. 회계 자동화 파이프라인 구성
영수증 이미지 → PaddleOCR-VL → JSON
↓
Python pandas로 월별 지출 집계
↓
Excel 또는 Google Sheets 자동 업로드
↓
경비 처리 시스템 API 연동receipt_items 섹션은 product, quantity, unit_price, total_price 키를 가진 딕셔너리 리스트이므로 pandas.DataFrame에 직접 전달해 월별·카테고리별 집계를 바로 수행할 수 있습니다.
python
import pandas as pd
# receipt_items 리스트를 바로 DataFrame으로 변환
df = pd.DataFrame(receipt_items)
monthly_total = df.groupby('product')['total_price'].sum()
print(monthly_total)9-2. 배치 처리 — 폴더 내 영수증 이미지 일괄 처리
GUI 없이 CLI 형태로 전환하면 여러 영수증 이미지를 한 번에 처리할 수 있습니다. OCRProcessor 클래스는 OCRGuiApp과 완전히 분리되어 있어 GUI 코드를 제거하고 독립적으로 재사용할 수 있습니다:
python
import glob
processor = OCRProcessor()
for img_path in glob.glob("receipts/*.jpg"):
json_data, error = processor.extract_receipt_json(img_path)
items = processor.parse_receipt_items(json_data)
# items를 데이터베이스 또는 CSV에 저장이처럼 관심사 분리(OCR 로직 ↔ UI)로 설계된 덕분에 월말 영수증 일괄 처리 자동화 스크립트를 빠르게 만들 수 있습니다.
9-3. 다국어 영수증 지원
PaddleOCR-VL은 한국어 외에도 영어, 중국어, 일본어 등 다국어 인식을 지원합니다. pipeline_version="v1" 설정 그대로 해외 영수증에도 적용 가능하며, 별도의 언어 파라미터 변경 없이 문서 내 언어를 자동으로 감지합니다. 다국적 기업의 해외 지점 경비 처리 자동화에도 동일한 코드를 그대로 활용할 수 있습니다.
핵심 내용 요약
이 가이드에서 구현한 PaddleOCR-VL 영수증 OCR 자동화 프로그램의 핵심 포인트를 정리합니다.
- PaddleOCR-VL은 비전언어모델(VLM) 기반으로 영수증 표 구조를 정밀하게 인식하며, 단순 문자 인식을 넘어 문서의 레이아웃과 의미를 함께 이해합니다
paddleocr[doc-parser]설치와pipeline_version="v1"설정으로 paddleocr doc-parser 영수증 테이블 인식 파이프라인 활성화- NumpyEncoder로
ndarray·PaddleOCRVLBlock의 JSON 직렬화 오류를 일괄 해결 (cls=NumpyEncoder한 줄로 적용) Thread+root.after()패턴으로 OCR 중 tkinter GUI 블로킹 방지 — 무거운 VLM 추론과 UI 응답성을 동시에 확보OCRProcessor와OCRGuiApp분리 설계로 OCR 엔진을 배치 처리, CLI 스크립트 등 다양한 환경에서 재사용 가능- 추출된 JSON은
metadata,receipt_items,raw_result3섹션 구조로 회계 자동화·AI 학습 데이터 등 다양한 후처리에 즉시 활용 가능 - PaddleOCR-VL Windows 설치 시 CUDA 11.8 기반 GPU 버전과 CPU 전용 버전 모두 지원하며, conda 가상환경과 캐시 경로 설정으로 안정적인 환경 구성 가능
