PDF 시험지에서 문제별로 이미지 자동 자르기 - 텍스트 기반 (PyMuPDF + OpenCV)

2025. 6. 3. 19:44프로젝트

프로젝트를 진행하면서 수식이 포함된 시험 문제의 경우, 전체 페이지 단위로 OCR을 적용하면 인식 정확도가 떨어진다는 점을 알게 되었습니다.
특히 수식이 많을수록 잘못 인식되거나 문장 구조가 깨지는 경우가 많았습니다.

이 문제를 해결하기 위해, PDF를 문제 단위로 나눠 OCR을 적용해본 결과,
문제별로 분할된 이미지에서 수식을 인식할 때 LaTeX 변환 정확도가 훨씬 높아지는 것을 확인했습니다.

그래서 전체 페이지에서 문제 단위로 이미지를 잘라주는 스크립트를 직접 만들기로 했습니다.

 

<왼쪽> page 단위, <오른쪽> 문제 단위

 

필요 라이브러리 설치

pip install pymupdf opencv-python

 

  • PyMuPDF (fitz): PDF에서 텍스트와 좌표 추출
  • OpenCV: 이미지 자르기 및 저장

전체 코드

1. PDF 열기

import os
import fitz

pdf_path = "2025_재정학.pdf"
doc = fitz.open(pdf_path)
output_folder = pdf_path.replace(".pdf", "")
os.makedirs(output_folder, exist_ok=True)

 

PyMuPDF (fitz) 라이브러리를 이용해 파이썬에서 PDF 파일을 불러옵니다.

 

2. 숫자 패턴을 찾아 문제 후보 모으기

시험지를 보면 보통 문제 번호는 숫자. 형태로 규칙적으로 나타나고,
대부분 페이지의 왼쪽 상단에 위치해 있습니다.

이 점을 활용해, 한 줄의 맨 앞에 등장하는 1~2자리 숫자 + 마침표 형식을 정규표현식으로 찾고,
해당 숫자가 등장하는 텍스트의 좌표(x, y)를 함께 저장하는 방식으로 접근했습니다.

import re

# 문제번호를 찾기 위한 숫자 패턴
# 1~2자리 숫자((ex_1,22)로이고 마침표
# ex_ 1. 22.
pattern = re.compile(r'^(\d{1,2})\.')

# 1. 전체 문제번호 후보 모으기
all_question_candidates = []

for p in range(total_pages):
    page = doc.load_page(p)
    # get_text : 페이지에서 텍스트를 볼록 단위로 추출
    blocks = page.get_text("blocks")
    # (x0, y0, x1, y1, text, block_no, block_type, block_flags)
    for block in blocks:
        x0, y0, x1, y1, text, *_ = block
        for line in text.strip().splitlines():
            line = line.strip()
            match = pattern.match(line)
            if match:
                # "01","02","12"... 두자리 문자열로 반환
                number = match.group(1).zfill(2)
                all_question_candidates.append({
                    'page': p + 1,
                    'number': number,
                    'x': x0,
                    'y': y0
                })
                break

print('all_question_candidates',all_question_candidates)

1~2자리 숫자 + 마침표 형태(예: 1. ,  22.)의 문제 번호 패턴을 정규식을 사용해 찾습니다.
그다음 page.get_text("blocks")를 통해 텍스트를 블록단위로 추출하고,
각 블록의 좌표 정보를 활용해 해당 숫자 패턴이 있는 블록의 위치(x좌표, y좌표)를

all_question_candidates 리스트에 저장합니다.

 

# all_question_candidates
[
{'page': 23, 'number': '62', 'x': 49.4132194519043, 'y': 42.91310119628906}
{'page': 23, 'number': '63', 'x': 49.4132194519043, 'y': 473.9620361328125}
{'page': 24, 'number': '64', 'x': 49.4132194519043, 'y': 42.91310119628906}
{'page': 24, 'number': '12', 'x': 73.16034698486328, 'y': 81.87052917480469}
{'page': 24, 'number': '65', 'x': 49.4132194519043, 'y': 441.357666015625}
{'page': 25, 'number': '66', 'x': 49.4132194519043, 'y': 42.91310119628906}
{'page': 26, 'number': '67', 'x': 49.4132194519043, 'y': 42.91310119628906}
{'page': 26, 'number': '68', 'x': 49.4132194519043, 'y': 356.4903869628906}
]

추출된 문제 번호들의 좌표를 보면, 대부분 x값이 비슷한 위치에 모여 있는 것을 확인할 수 있습니다.
이는 문제 번호가 보통 페이지의 왼쪽 상단에 정렬되어 있기 때문입니다.

 

그런데 아래와 같은 항목을 보면:

{'page': 24, 'number': '12', 'x': 73.16034698486328, 'y': 81.87052917480469}

x값이 확 튀는 것을 볼 수 있습니다.

이는 본문 중간에 등장한 숫자일 가능성이 높기 때문에,
이런 항목은 이상치로 간주하고 제거하는 것이 좋습니다.

 

3. 이상치 제거

import numpy as np

x_list = [q['x'] for q in all_question_candidates]
if x_list:
    mean_x = statistics.mean(x_list)
    lower, upper = mean_x - 15, mean_x + 15
else:
    mean_x = 0
    lower, upper = -1, -1

print(f"\n 전체 문제번호 평균 x = {mean_x:.2f}, 허용 범위 = [{lower:.2f}, {upper:.2f}]")
# 전체 문제번호 평균 x = 50.36, 허용 범위 = [35.36, 65.36]

# 페이지별 문제번호 필터링
for p in range(1, total_pages + 1):
    question_numbers = [q for q in all_question_candidates if q['page'] == p and lower <= q['x'] <= upper]

이상치를 제거하기 위해 x좌표들의 평균값을 구한 뒤,
±15 범위를 허용 오차로 설정해 그 범위를 벗어난 항목은 제외했습니다.

 

4. 페이지별로 문제 단위 이미지 자르기

import cv2

# 페이지별로 필터링
for p in range(1, total_pages + 1):
    question_numbers = [q for q in all_question_candidates if q['page'] == p and lower <= q['x'] <= upper]
    question_numbers.sort(key=lambda q: q['y'])  # y좌표 순으로 정렬

    if not question_numbers:
        continue

    # 페이지 이미지 렌더링
    page = doc.load_page(p - 1)
    page_height = page.rect.height
    pix = page.get_pixmap(dpi=300)
    img = np.frombuffer(pix.samples, dtype=np.uint8).reshape((pix.height, pix.width, pix.n))
    if pix.n == 4:
        img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)

    # 이미지 높이는 픽셀 단위이고, pdf 좌표는 점 단위
    # scale_y : 변환 비율 계산
    img_height = img.shape[0]
    scale_y = img_height / page_height

    # 문제 단위로 crop
    for i, q in enumerate(question_numbers):
        # 각 문제의 y1부터 다음 문제 y2까지의 이미지를 잘라냄
        y1 = int(q['y'] * scale_y)
        y2 = int(question_numbers[i + 1]['y'] * scale_y) if i + 1 < len(question_numbers) else img_height
        cropped = img[y1:y2, :]

        filename = f"{q['number']}.png"
        cv2.imwrite(os.path.join(output_folder, filename), cropped, [cv2.IMWRITE_PNG_COMPRESSION, 0])
        print(f"Saved: {output_folder}/{filename} / p{str(p).zfill(2)} ({y1}px ~ {y2}px)")

페이지별로 문제를 crop하는 코드입니다.

 

각 단계를 자세히 살펴보자면:

4-1. 정상 문제 번호 필터링 후 y좌표 기준 정렬

question_numbers = [
    q for q in all_question_candidates
    if q['page'] == p and lower <= q['x'] <= upper
]
question_numbers.sort(key=lambda q: q['y'])  # y좌표 기준 정렬

앞서 계산한 x좌표의 평균 ±15 범위 내에 있는 숫자만 필터링해,
본문의 숫자나 오탐지된 항목을 제외하고 실제 문제 번호로 보이는 항목만 남깁니다.

그 후, 문제는 위에서 아래로 순서대로 배치되어 있기 때문에 y좌표를 기준으로 정렬하여 문제 순서를 맞춰줍니다.

 

4-2. 페이지를 이미지로 렌더링

pix = page.get_pixmap(dpi=300)
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape((pix.height, pix.width, pix.n))
if pix.n == 4:
    img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)

 

PDF 페이지를 300dpi 고해상도 이미지로 렌더링합니다.

 

4-3. 좌표 단위 변환 (PDF.점 단위 -> 이미지.픽셀)

img_height = img.shape[0]
page_height = page.rect.height
scale_y = img_height / page_height

PDF 좌표는 점 단위이고 이미지는 픽셀 단위입니다.

그래서 이미지와 pdf의 변환 비율을 계산해 y좌표를 정확히 매핑합니다.

 

4-4. 문제 단위 이미지 Crop

for i, q in enumerate(question_numbers):
    y1 = int(q['y'] * scale_y)
    y2 = int(question_numbers[i + 1]['y'] * scale_y) if i + 1 < len(question_numbers) else img_height
    cropped = img[y1:y2, :]

 

y1은 현재 문제의 시작 위치, y2는 다음 문제의 시작 위치로 설정합니다.

y1부터 y2까지의 영역을 잘라내면,

한 문제에 해당하는 이미지 영역만 잘라낼 수 있습니다. 

 

4-5. 파일로 저장

filename = f"{q['number']}.png"
cv2.imwrite(os.path.join(output_folder, filename), cropped)
print(f"Saved: {output_folder}/{filename} / p{str(p).zfill(2)} ({y1}px ~ {y2}px)")
'''
Saved: 2025_재정학/01.png / p01 (452px ~ 1058px)
Saved: 2025_재정학/02.png / p01 (1058px ~ 1969px)
Saved: 2025_재정학/03.png / p01 (1969px ~ 3505px)
Saved: 2025_재정학/04.png / p02 (178px ~ 1249px)
...
'''

 

결과를 확인해보면, 각 문제 영역이 잘 분리되어 이미지로 저장된 것을 확인할 수 있습니다.

 

느낀점

OCR 인식률을 높이기 위해서는 하나의 이미지에 너무 많은 정보를 담지 않는 것이 중요하다는 걸 느꼈습니다.
변환 정확도를 높이기 위해 OpenCV를 활용해 문제 영역만 추출하는 자동화 스크립트를 작성했고,
이를 통해 작업 효율도 크게 개선되었습니다.
이번 작업을 계기로 OpenCV를 배워두면 정말 쓸모가 많겠다는 생각이 들었고, 

앞으로도 비슷한 작업에 적극 활용할 예정입니다.