"bbox가 전체적으로 한 줄 위에 있어요."
OCR 검수 화면에서 영수증 이미지 위에 초록색 하이라이트가 떠야 하는데, 모든 필드의 박스가 정확히 한 줄씩 위를 가리키고 있었다. 어떤 건 맞고 어떤 건 틀린 게 아니라, 전부 한 줄 위. 이렇게 일관적인 오류는 오히려 반가운 법이다. 패턴이 있다는 건, 원인이 하나라는 뜻이니까.
출발점: 스캔 앱처럼 보여주자
이야기는 단순한 기능 요청에서 시작했다. 스마트폰으로 찍은 영수증 사진에는 책상, 손, 그림자 같은 배경이 잔뜩 들어있다. 스캔 앱에서 문서만 깔끔하게 잘라주듯이, 검수 화면에서도 문서 영역만 보여주면 검수가 훨씬 편하지 않을까?
이미 파이프라인 내부에 auto-crop 함수가 있었다. OpenCV의 Otsu 이진화로 밝기를 분리하고, 가장 큰 윤곽선을 찾아서 문서 영역만 잘라내는 함수. bbox 스냅핑의 정확도를 높이기 위해 만들었던 건데, 이걸 검수 화면 API에도 적용하면 되는 거였다.
그런데 여기서 한 가지 깨달음이 있었다. bbox 좌표를 crop 이미지 기준으로 저장하면, 기존에 있던 "crop → 원본 좌표로 역변환" 코드가 불필요해진다. crop 이미지를 보여주고, crop 이미지 기준 좌표를 쓰면 그만이니까. 코드가 줄어드는 방향의 변경은 대체로 옳다.
좌표계라는 보이지 않는 계약
이미지 위에 사각형을 그린다는 건 생각보다 까다로운 일이다.
OCR 파이프라인에서 bbox 좌표가 만들어지는 과정을 따라가 보면: 원본 이미지를 전처리하고(기울기 보정, 샤프닝 등), 리사이즈하고, crop하고, PaddleOCR에 넣어서 텍스트 위치를 감지하고, 그 픽셀 좌표를 이미지 크기로 나눠서 0~1 범위로 정규화한다.
검수 화면에서는 이 정규화된 좌표에 렌더링된 이미지 크기를 곱해서 박스를 그린다.
핵심은 정규화의 기준이 된 이미지와 화면에 보여주는 이미지가 정확히 같아야 한다는 것이다. 전처리 순서가 달라도, 리사이즈 타이밍이 달라도, crop 범위가 달라도 — 어긋난다. 이건 코드 사이의 보이지 않는 계약이다.
변경을 적용하고 reprocess를 돌렸다. 그리고 검수 화면을 열었다.
한 줄의 유령
이미지는 깔끔하게 crop되어 있었다. 배경이 사라지고 영수증만 보였다. 좋다. 그런데 bbox가 이상했다. "김치전 130G X 10" 필드의 초록색 박스가 해당 텍스트가 아니라 그 바로 윗줄을 가리키고 있었다. "MEGRHYTHM STEAM"도 마찬가지. 모든 필드가 정확히 한 줄씩 위.
일관적인 오류. 원인은 하나.
먼저 의심한 건 프론트엔드 렌더링이었다. 브라우저가 이미지를 축소할 때 뭔가 어긋나는 건 아닐까? 하지만 Python으로 이미지에 직접 bbox를 그려본 verify_bbox 스크립트에서도 같은 현상이 나타났다. 프론트엔드 문제가 아니다.
그다음은 좌표 자체를 확인했다. DB에 저장된 bbox 좌표와 PaddleOCR가 감지한 OCR 영역 좌표를 비교했더니 — 완벽히 일치했다. 스냅핑 알고리즘은 정확하게 매칭하고 있었다.
그렇다면 PaddleOCR가 "김치전130GX10"이라는 텍스트를 올바르게 인식하면서, 동시에 그 텍스트의 위치를 잘못 보고하고 있다는 뜻이 된다. 이게 말이 되나?
UVDoc이라는 보이지 않는 손
PaddleOCR v5의 결과에는 doc_preprocessor_res라는 필드가 있다. 문서 전처리 결과를 담고 있는데, 여기에 input_img(입력 이미지)과 output_img(전처리된 이미지)이 들어있다.
두 이미지의 픽셀을 비교했다.
Max pixel diff: 231
Mean pixel diff: 21.88
Pixels changed: 4,581,596 / 4,871,430 (94%)
94%의 픽셀이 바뀌어 있었다. PaddleOCR가 우리 모르게 이미지를 변형하고 있었다.
범인은 UVDoc. PaddleOCR v5에 기본 탑재된 문서 곡면 보정(unwarp) 기능이다. 구부러진 책 페이지처럼 휘어진 문서를 평평하게 펴주는 역할을 한다. 문제는, 이 펴기 과정에서 이미지의 내용이 수직으로 약 20픽셀 이동한다는 것.
텍스트 감지 모델은 이 변형된 이미지 위에서 동작한다. 그래서 감지된 좌표는 변형된 이미지 기준이다. 하지만 우리가 화면에 보여주는 건 원래 이미지. 20픽셀의 차이. 영수증에서 딱 한 줄 높이.
수직 이동량을 정밀 측정했더니 평균 -20.9 픽셀. 마이너스는 위쪽으로의 이동을 의미한다. 한 줄 위를 가리키던 유령의 정체였다.
수술은 한 줄
_OCR_ENGINES[lang] = PaddleOCR(
lang=lang,
use_doc_unwarping=False, # ← 이 한 줄
...
)
우리는 이미 preprocess_for_ocr에서 이미지 전처리(회전 보정, 기울기 보정, 샤프닝, 대비 향상)를 하고 있었다. PaddleOCR의 UVDoc이 또 한 번 이미지를 만지작거릴 필요가 없었다. 오히려 방해만 됐다.
UVDoc을 끄고 다시 돌렸다. input_img과 output_img의 pixel diff가 0이 됐다. bbox가 정확히 텍스트 위에 안착했다.
디버깅에서 배운 것
이 디버깅에서 재미있었던 건, 모든 개별 컴포넌트가 "정상"이었다는 점이다.
- PaddleOCR? 텍스트를 정확하게 읽었다. ✅
- bbox 스냅핑? 올바른 OCR 영역에 매칭했다. ✅
- 좌표 저장? OCR 좌표와 정확히 일치했다. ✅
- 프론트엔드 렌더링? 좌표대로 정확히 그렸다. ✅
- auto-crop? 파이프라인과 API가 동일한 결과. ✅
각 단계를 개별적으로 검증하면 아무 문제가 없다. 문제는 그 사이의 보이지 않는 전제 — PaddleOCR가 입력 이미지를 그대로 쓴다는 전제 — 가 틀렸다는 것이었다.
결국 필요했던 건 "이 이미지를 봤을 때, 좌표계의 기준이 되는 이미지가 정확히 무엇인가?"라는 질문이었다. 코드를 읽는 것만으로는 안 됐다. 실제 픽셀을 비교해야 드러나는 종류의 버그였다.
라이브러리의 기본값은 대부분의 경우에 적절하다. 하지만 "대부분의 경우"에 내 경우가 포함되는지는, 확인하기 전까지 알 수 없다.