1. 도입
1.1. 필요성
직전에 영수증 이미지 데이터셋으로 COCO 데이터 형식의 OCR 데이터셋을 만들었습니다.
2024.11.07 - [Python/Data Prep] - CVAT로 Labeling 해서 영수증 OCR 데이터셋 만들기
CVAT로 Labeling 해서 영수증 OCR 데이터셋 만들기
1. CVAT 소개딥러닝 프로젝트를 진행하려다 보면, 모델이 없다기 보다는 메모리의 한계이거나 데이터가 없어서 제한되는 경우가 많습니다. 여기서는 객체 탐지, 특히 OCR을 수행하기 위한 데이터
seanpark11.tistory.com
이렇게 데이터셋을 만드는 것은 좋은 모델을 구축하기에 필요한 과정이지만, 시간과 비용이 많이 필요한 작업입니다. 따라서, 필요할 경우 직접 데이터셋을 합성하는 것도 고려해볼 수 있습니다. 최근에는 디퓨전과 같이 성능이 좋은 합성 모델이 많지만, 이러한 모델들 역시 기본적으로 데이터가 많은 상태에서 많은 학습을 거쳐야 효과적인 것으로 보입니다. (예전에 30장 이미지 기준 100번의 학습을 진행해도 원하는 모습이 나오진 않았던 경험이 있습니다.)
모델을 학습시키기 충분한 양의 데이터를 비교적 적은 비용과 시간으로 얻기 위해 이미지 합성을 생각했고, 비교적 정형화된 모습을 갖고 있는 OCR의 경우 정해진 틀에 Rule-based의 방식으로도 충분히 다른 형태의 객체를 만들어낼 수 있을 것이라 생각했습니다.
이를 해내기 위해 기존의 데이터셋에서 객체의 정보를 가져와 이미 학습된 번역기에 전달해서 나온 결과를 기존 이미지에 입히는 방식을 생각했고, 실제 이를 구현하였습니다.
1. 2. 고려 사항
구현하는 과정에서 크게 3개의 고려 사항이 있었습니다.
- 번역된 문자를 넣을 깨끗한 영수증 이미지 필요
- 번역기 문자 수 제한 때문에 번역이 필요한 문자만 추출 필요
- 번역하면서 너무 길어지는 경우 잘라낼 필요
1.3. 설계
1.2에서 언급한 문제나 고려 사항들을 감안하여 다음과 같은 방향으로 진행하고자 설계하였으며, 고려 사항을 해결하기 위해 특별히 필요한 기능이 있다면 기재했습니다,
- Import data
- Clean image
- Translate : 필요한 문자만 추출
- Insert text : 글자 길이 조정
또한 구현을 위한 기초적인 환경 세팅은 다음과 같습니다. (파이썬 표준 라이브러리는 제외)
python == 3.10.12
opencv-python == 4.10.0
numpy == 1.26.4
deepl == 1.19.1
PIL == 10.4.0
2. 구현
2.1. Import Data
우선 COCO 데이터와 이미지 데이터를 불러와야 합니다. COCO 데이터 포맷의 경우 pycocotools라는 라이브러리가 있긴 하지만, 여기서는 사용하지 않았습니다. 아래 코드는 json 데이터를 불러옵니다.
import json
from pathlib import Path
def read_json(path: str):
with Path(path).open(encoding='utf8') as file:
data = json.load(file)
return data
json_data = read_json('./instances_default.json')
이미지도 불러오는 코드입니다. 여기서는 OpenCV를 활용했고, 코드를 돌리면 잘 불러오는 것을 확인할 수 있습니다.
import cv2
import matplotlib.patches as patches
import matplotlib.pyplot as plt
img = cv2.imread('./receipt.jpg')
annotations = json_data['annotations']
fig, ax = plt.subplots(1)
for ann in annotations:
x, y, w, h = ann['bbox']
coordinates = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
polygon = patches.Polygon(coordinates, closed=True, linewidth=0.5, edgecolor='red', facecolor='none')
ax.add_patch(polygon)
plt.axis('off')
plt.imshow(img)
2.2. Clean image
구상했던 이미지 생성방법은 기존 영수증 이미지에서 글자들을 지우고 같은 자리에 번역된 글자를 넣는 방식입니다. 그러다보니, 기존 이미지에서 텍스트만을 지우는 방식을 고민했고 Stackoverflow에 제안된 방식을 활용했습니다. [1]
해당 방식의 가장 핵심적인 것은 모폴로지 연산이라고 생각하는데, 모폴로지 연산이란 이미지의 형태에 기반한 연산들을 말하며, 형태에 집중해야 하기 때문에 일반적으로 흑백 이미지에 적용됩니다. [2] 이를 위해 그레이 스케일로 변환하고 닫힘(Closing) 연산을 통해 텍스트 영역의 작은 구멍을 제거 및 팽창(Dilation) 연산으로 텍스트 영역을 확장합니다.
그 다음 팽창된 이미지에서 텍스트 영역의 윤곽선을 검출해 윤곽선 면적 일정 범위 내에 있는 것만 선택해 실제 텍스트 영역으로 간주합니다. 선택된 윤곽선을 기반으로 마스크 이미지를 생성해 마스크 이미지에서 흰색은 텍스트 / 검은색은 배경 영역으로 사각형을 그립니다. 원본 이미지와 생성된 마스크 이미지를 비교하면서 주변 픽셀 정보를 활용해 자연스럽게 채워넣는 기술인 inpainting으로 처리합니다.
import numpy as np
def inpaint_text_areas(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3))
close = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=1)
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 3))
dilate = cv2.dilate(close, dilate_kernel, iterations=1)
cnts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if len(cnts) == 2 else cnts[1]
mask = np.zeros(image.shape[:2], dtype=np.uint8)
for c in cnts:
area = cv2.contourArea(c)
if area > 100 and area < 30000:
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(mask, (x, y), (x + w, y + h), 255, -1)
inpainted_image = cv2.inpaint(image, mask, inpaintRadius=1, flags=cv2.INPAINT_TELEA)
return inpainted_image
inpainted_img = inpaint_text_areas(img)
plt.axis('off')
plt.imshow(inpainted_img)
아래 이미지를 보면 꽤 자연스럽게 잘 지워진 것을 확인할 수 있습니다.
2.3. Translate
직접적으로 번역을 위한 모델을 구축할 수는 없기에, 번역을 위해서는 DeepL의 API를 사용했습니다. 기존에 알고있는 다른 번역 API들은 유료였기 때문에 간단히 테스트용으로 사용하기는 부적합하다고 판단했고, DeepL은 500,000자까지는 무료로 제공하기 때문에 채택했습니다. 아래 코드는 DeepL 문서에서 알려주고 있는 코드를 활용해 함수를 만들었습니다. [3]
import deepl
import getpass
def authenticate():
auth_key = getpass.getpass("Enter API Key : ")
translator = deepl.Translator(auth_key)
return translator
def translate_text(translator, text, target_lang):
result = translator.translate_text(text, target_lang=target_lang)
return result.text
다만, 500,000자 제한이 있기 때문에 모든 문자를 번역하는 것은 다소 아까운 일입니다. 그래서 정규식을 이용해 해당 문자가 한국어를 포함하는 경우만 번역하도록 했습니다. 구현한 모든 함수들을 활용해 한국어가 포함된 경우에만 일본어로 번역하고 이를 동일한 annotations로 반환하도록 했습니다.
import re
def check_korean(text):
korean_pattern = re.compile(r'[\u3131-\u3163\uac00-\ud7a3]+')
matches = re.findall(korean_pattern, text)
if len(matches) < 1:
return False
return True
translator = authenticate()
translated_annotations = []
for ann in annotations:
transcription = ann['attributes']['transcription']
if check_korean(transcription):
translated_text = translate_text(translator, transcription, 'JA') # JA : Japanese
ann_copy = ann.copy()
ann_copy['attributes']['transcription'] = translated_text
translated_annotations.append(ann_copy)
else:
translated_annotations.append(ann)
2.4. Insert text
2.2.에서 만든 백지 이미지에 번역된 글자들을 추가하는 코드입니다. PIL에서 제공하고 있는 Draw를 통해 텍스트를 삽입하는 방식인데, 사용하는 메서드에 대한 설명은 다음 링크에서 확인하시면 됩니다.
ImageDraw Module - Pillow (PIL Fork) 11.0.0 documentation
add_new_text의 입력 매개변수 중 다른 것보다 특이한 font가 있습니다. 이는 PIL 라이브러리의 ImageFont. truetype으로 생성한 인스턴스로 글씨체와 사이즈를 지정합니다. 여기 프로젝트에서는 많은 세계 각국의 언어를 지원하는 Google과 Adobe가 협업해서 만들었다고 알려진 Noto Fonts 를 사용했습니다.
from PIL import Image, ImageDraw, ImageFont
def add_new_text(image, bbox, text, font):
x, y, w, h = bbox
pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil_image)
text_pos_x = x
text_pos_y = y + h // 2
draw.text((text_pos_x, text_pos_y), text, fill=(0, 0, 0), font=font, anchor='lm')
return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
하지만, 여전히 부족한 부분이 있는데, 번역을 통해 길어진 텍스트에 대한 문제 입니다. 다음 사진을 보면 일본어로 번역했을 때 바운딩 박스를 넘어가는 것을 확인할 수 있습니다. 이는 언어마다 표현하는 길이가 달라지면서 발생하는 문제입니다.
이를 해결하기 위해 3가지 방법을 시도했습니다.
- 폰트 사이즈 조절 : 바운딩 박스에 맞추는 방향으로 폰트 사이즈를 조절했으나, 너무 작아지는 텍스트가 만들어져 학습에 적절하지 않은 데이터가 생성된다고 판단했습니다.
- Pseudo Character Center : 적당히 잘라내기 위해 PCC를 찾았지만, 단순한 방법으로는 문자마다 다른 길이로 표현되기 때문에 너무 많은 문자가 소실이 발생했습니다.
- PIL getbbox : PIL font 인스턴스에 있는 getbbox를 활용하고 있는데, 이는 비교적 최신 라이브러리에서 지원하는 메서드이기 때문에 버전을 확인할 필요가 있습니다.
가장 효과적이라고 판단한 getbbox를 사용해 문자들의 길이를 측정하고, 그에 맞춰서 번역한 텍스트를 반환해서 문자를 기입하는 코드는 다음과 같습니다.
def get_char_widths(text, font:ImageFont.truetype):
char_widths = []
for char in text:
bbox = font.getbbox(char)
char_width = bbox[2] - bbox[0]
char_widths.append((char, char_width))
return char_widths
def get_text_in_box(char_widths):
text_in_box = ''
text_width = 0
for char, width in char_widths:
text_width += width
if text_width <= w:
text_in_box += char
else:
break
return text_in_box
fig, ax = plt.subplots(1)
for ann in translated_annotations:
x, y, w, h = ann['bbox']
coordinates = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
font_size = h
font = ImageFont.truetype(font='./font/NotoSansJP-Regular.ttf', size=font_size)
polygon = patches.Polygon(coordinates, closed=True, linewidth=0.5, edgecolor='red', facecolor='none')
ax.add_patch(polygon)
char_widths = get_char_widths(ann['attributes']['transcription'], font)
text_in_box = get_text_in_box(char_widths)
inpainted_img=add_new_text(inpainted_img, ann['bbox'], text_in_box, font)
plt.axis('off')
plt.imshow(inpainted_img)
완벽하게 기존 것처럼 재현한 것은 아니지만, 꽤 그럴싸하게 만들어진 것 같습니다.
3. 최종 코드
import json
from pathlib import Path
import cv2
import matplotlib.patches as patches
import matplotlib.pyplot as plt
import numpy as np
import deepl
import re
import getpass
from PIL import Image, ImageDraw, ImageFont
def read_json(path: str):
with Path(path).open(encoding='utf8') as file:
data = json.load(file)
return data
def inpaint_text_areas(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3))
close = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_kernel, iterations=1)
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 3))
dilate = cv2.dilate(close, dilate_kernel, iterations=1)
cnts = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if len(cnts) == 2 else cnts[1]
mask = np.zeros(image.shape[:2], dtype=np.uint8)
for c in cnts:
area = cv2.contourArea(c)
if area > 100 and area < 30000:
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(mask, (x, y), (x + w, y + h), 255, -1)
inpainted_image = cv2.inpaint(image, mask, inpaintRadius=1, flags=cv2.INPAINT_TELEA)
return inpainted_image
def authenticate():
auth_key = getpass.getpass("Enter API Key : ")
translator = deepl.Translator(auth_key)
return translator
def translate_text(translator, text, target_lang):
result = translator.translate_text(text, target_lang=target_lang)
return result.text
def check_korean(text):
korean_pattern = re.compile(r'[\u3131-\u3163\uac00-\ud7a3]+')
matches = re.findall(korean_pattern, text)
if len(matches) < 1:
return False
return True
def add_new_text(image, bbox, text, font):
x, y, w, h = bbox
pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil_image)
text_pos_x = x
text_pos_y = y + h // 2
draw.text((text_pos_x, text_pos_y), text, fill=(0, 0, 0), font=font, anchor='lm')
return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
def get_char_widths(text, font:ImageFont.truetype):
char_widths = []
for char in text:
bbox = font.getbbox(char)
char_width = bbox[2] - bbox[0]
char_widths.append((char, char_width))
return char_widths
def get_text_in_box(char_widths):
text_in_box = ''
text_width = 0
for char, width in char_widths:
text_width += width
if text_width <= w:
text_in_box += char
else:
break
return text_in_box
json_data = read_json('./instances_default.json')
img = cv2.imread('./receipt.jpg')
annotations = json_data['annotations']
inpainted_img = inpaint_text_areas(img)
translator = authenticate()
translated_annotations = []
for ann in annotations:
transcription = ann['attributes']['transcription']
if check_korean(transcription):
translated_text = translate_text(translator, transcription, 'JA') # JA : Japanese
ann_copy = ann.copy()
ann_copy['attributes']['transcription'] = translated_text
translated_annotations.append(ann_copy)
else:
translated_annotations.append(ann)
fig, ax = plt.subplots(1)
for ann in translated_annotations:
x, y, w, h = ann['bbox']
coordinates = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
font_size = h
font = ImageFont.truetype(font='./font/NotoSansJP-Regular.ttf', size=font_size)
polygon = patches.Polygon(coordinates, closed=True, linewidth=0.5, edgecolor='red', facecolor='none')
ax.add_patch(polygon)
char_widths = get_char_widths(ann['attributes']['transcription'], font)
text_in_box = get_text_in_box(char_widths)
inpainted_img=add_new_text(inpainted_img, ann['bbox'], text_in_box, font)
plt.axis('off')
plt.imshow(inpainted_img)
4. 한계 및 배운 점
4.1. 한계
(외부 툴) 직접 번역하는 모델을 구축하기 어려운만큼 외부 툴을 사용할 수 밖에 없었지만, 제한되는 환경이라서 실제로 사용하기에는 다소 무리가 있을 것 같습니다.
(완성도) 중간중간 나온 실패작보다는 괜찮은 결과물이지만, 생성한 데이터만으로 영수증을 떠올리기는 쉽지 않은 것 같습니다. 빈 영수증을 만드는 과정에서 그래도 자연스럽게 만들어졌다고 생각하지만, 로고까지 지워지지 않도록 규제하는 방향을 고민해볼 필요가 있습니다. 그리고 번역으로 인해 잘리는 문제가 발생하게 되는데, 위치만 찾는 Text Detector로는 기능할 수 있지만 글자까지 인식해야 하는 Text Recognizer까지 모델이 확장된다면 이러한 데이터 생성은 적절하지 않아 보입니다.
4.2. 배운 점
(OpenCV) Computer Vision에서 많이 사용한다는 OpenCV에 대해 간략하게만 알고 있었지만, 꽤나 강력한 도구들을 제공하고, 제대로 배워보면 이미지를 다루는데 큰 무기를 얻을 수 있겠다는 생각이 들었습니다. 물론 OpenCV 뿐 아니라 PIL도 꽤 유용한 것들이 많아서 앞으로 계속 프로젝트를 해보면서 익숙해져야 할 것 같습니다.
(함수) 프로젝트라는 이름을 붙일 수준은 아니지만, 여러 개의 함수들을 만들고 이들을 최대한 간결하게 작성해보려는 시도를 하면서 앞으로 어떤 방식으로 코드를 작성해야 할지에 대한 감을 늘릴 수 있었습니다. 최근에 본 몇몇 영상과 부스트 캠프 과정에서 배운 내용들을 상기해보면서 몇 번 재배치를 하고, 어떻게 함수를 만드는 것이 나중에 유지보수를 최소화할 수 있을지에 대해 잠깐이지만 고민해볼 기회였습니다.
5. 참고자료
[1] https://stackoverflow.com/questions/58349726/opencv-how-to-remove-text-from-background
[2] https://docs.opencv.org/4.x/d9/d61/tutorial_py_morphological_ops.html
[3] https://developers.deepl.com/docs/ko/api-reference/translate
'Python > Data Prep' 카테고리의 다른 글
Pydantic을 활용 사례 | 웹 데이터 검증, Config 관리 (2) | 2024.12.14 |
---|---|
파이썬 이미지 처리 라이브러리 비교 | PIL, OpenCV, Numpy, PyTorch, Albumentations (0) | 2024.11.18 |
[OD] 객체 탐지(Object Detection) 대표 데이터 포맷 공부 | COCO, Pascal VOC, YOLO (0) | 2024.10.04 |
DataLoader에서 오류가 난다면 누락 데이터가 있는지 확인 필요 | DataLoader는 이터레이터 (2) | 2024.09.26 |
PyTorch에서 Dataset과 DataLoader 클래스를 활용해 데이터 파이프라인 구축하기 (1) | 2024.09.09 |