Jasontreks Blog

DM 보내기

메세지는 텔레그램 챗봇에 의해 익명으로 전달됩니다. 답장을 받으려면 이메일을 입력하세요.
Send

PDF 문서 분석

논문이나 기타 학술 자료들은 주로 PDF 파일로 저장되기 때문에 PDF 파일을 불러와 문자열 데이터를 추출하는 파이프라인이 필요했다. PDF를 가능한 한 누락되는 데이터 없이 정확하게 추출하기 위해 여러 다양한 방법을 시도하고 테스트하였으며, 이는 본 연구과제에서 가장 어렵고 까다로운 부분이었다. 나는 가장 먼저 pypdf와 같은 널리 알려진 라이브러리를 이용하는 방법을 시도하였다.

단순 PDF 추출의 문제점

pypdf, pdfplumber와 같은 경량 추출 도구는 문서의 구조를 이해하는 기능이 없기 때문에, 페이지로부터 문자열을 추출할 때 항상 맨 위에서부터 아래로 순차적으로 추출해낸다. 그러다 보니 다음과 같은 문제가 발생한다.

pypdf 라이브러리는 기본적으로 PDF 페이를 위에서부터 아래로 순차적으로 읽기 때문에, 다른 단락을 한 줄로 읽어 문장이 뒤섞여버리는 문제가 발생한다.

pdfplumber 라이브러리는 열을 구분하여 읽는 기능을 지원하지만, 아래와 같이 수직선으로 구분된 단락까지는 이해하지 못하고 같은 단락으로 읽어버린다.

문서 안에 표가 있을 때에도 문제가 있다. 아래와 같은 표는 파일 내에 정형화된 객체 형태로 존재하여 라이브러리가 구조를 유지하며 읽어오는데 문제가 없다.

그러나 아래의 표는 수직선으로 행만 구분하여 고유한 스타일로 작성된 비표준적인 표이다. 이런 표는 구조에 대한 정보가 저장되어있지 않아 라이브러리가 일반적인 텍스트로 추출해낸다.

Detectron2 + PubLayNet

따라서 문서의 문자열을 그대로 읽는것이 아니라 문서 구조를 시각적으로 이해하고 레이아웃을 구분해 순차적으로 읽는 프로세스가 필요하다는 결론에 도달했다.

Detectron2는 오래전 Facebook AI Research에서 개발한 오픈소스 객체 탐지 라이브러리이다. 이미지에서 추론해야 하는 특정 대상을 사각형으로 표시하고 라벨링하여 학습시키면 해당 모델은 처음 보는 이미지에서도 같은 종류의 대상을 추론해낼 수 있다.

이미지 내 대상이 자기가 학습한 객체들과 같을 확률을 계산해 추론하는것이다. 그리고 그 학습 데이터는 "사전 훈련 모델(Pre-Trained Model)"이라는 파일 형태로 불러와 사용할 수 있다.

문서 레이아웃 분석을 위한 모델로는 PubLayNet이라는 유명한 데이터셋으로 만들어진 모델이 있다. PubLayNet은 오래전 IBM Research가 문서 레이아웃을 학습시키기 위해 문서 약 36페이지로 구축한 대규모 데이터셋이다.

Detectron2에서 이를 사용힐때는 PubLayNet으로 학습을 마친 결과물인 신경망 가중치와 편향 값이 들어있는 파일을 다운받아 사용하게 된다. 이는 Detectron2에게 제공되는 실질적인 지능에 해당한다고 볼 수 있다.

파이프라인 구현

PDF의 레이아웃을 분석하고 추출하기 위해 구현해야하는 절차는 다음과 같다.

  1. PDF 변환

    PDF의 각 페이지를 이미지로 변환한다.
  2. 레이아웃 분석

    이미지로 변환된 문서 페이지를 Detectron2를 이용해 레이아웃을 추론한다.
  3. 텍스트 추출

    추론 결과로 나온 레이아웃들을 순서대로 영역내 텍스트만 추출한 다음 문장들을 연결한다.

poppler 설치

poppler는 pdf2image 라이브러리가 PDF를 이미지로 변환하기 위해 사용하는 PDF 렌더링 엔진이다. 라이브러리가 아닌 별도의 프로그램으로 설치하여 환경 변수를 설정해야 하는데, 그 과정이 상당히 귀찮기떄문에 conda 가상환경에서 conda-forge채널로 설치하는것을 적극 권장한다.

conda install -c conda-forge poppler

Detectron2 설치

그리고 detectron2를 설치한다. 깃 리포지토리를 클론한 다음 pip 명령어로 설치한다.

git clone https://github.com/facebookresearch/detectron2.git
pip install -e detectron2

torch 설치

Detectron2는 GPU를 사용하므로 현재 하드웨어의 CUDA 버전에 맞는 torch 라이브러리를 설치해야 한다.

nvidia-smi

CUDA 버전 확인

CUDA Version: 12.9

CUDA 버전에 맞는 리포지토리 URL로 설정하고 torch 설치

pip install torch torchvision --index-url https://download.pytorch.org/whl/cu129

파이썬 라이브러리 설치

pip install layoutparser pymupdf pdf2image
  • layoutparser: Detectron2를 사용하기 위한 인터페이스 제공
  • pymupdf: PDF에서 영역을 지정해 텍스트를 추출하는 기능이 있는 고급 PDF 라이브러리
  • pdf2image: poppler를 사용해 PDF를 렌더링하고 이미지로 변환해주는 라이브러리

예제

import numpy as np
import pdf2image as p2i
import cv2, pymupdf

from layoutparser.models import Detectron2LayoutModel
from layoutparser.visualization import draw_box
from layoutparser.elements import TextBlock

def detect_layouts(file_path: str):
    pdf = pymupdf.open(file_path) # 원본  PDF 열기
    page = pdf[0] # 첫 번째 페이지
    
    # PDF를 이미지로 변환(레이아웃 분석 용)
    converted_images = p2i.convert_from_path(file_path, dpi=72)
    # Detectron2가 이미지를 읽으려면 nparray로 변환해야 함
    first_image = np.array(converted_images[0])

    # 모델 객체 생성
    model = Detectron2LayoutModel(
        config_path=r'detectron2\configs\PubLayNet-faster_rcnn_R_50_FPN_3x\config.yml',
        model_path=r'detectron2\configs\PubLayNet-faster_rcnn_R_50_FPN_3x\model_final.pth',
        label_map={0: "Text", 1: "Title", 2: "List", 3:"Table", 4:"Figure"},
        extra_config=["MODEL.ROI_HEADS.SCORE_THRESH_TEST", 0.7]
        )

    # 추론
    layout = model.detect(first_image)
    
    # 레이아웃이 잘 분석되었는지 확인하기 위해 텍스트 상자를 그린 이미지 출력
    draw_box(converted_images[0], layout, box_width=3).show()

    for block in layout._blocks:
        rect = pymupdf.Rect(block.coordinates)
        text = page.get_textbox(rect)
        print('\n\n', text)

분석 결과

각 단락과 제목에 레이아웃으로 인식된 영역이 사각형(bbox)으로 표시된 것을 확인할 수 있다.

한계점

Detectron2와 PubLayNet의 조합은 그리 만족할만한 결과를 내지는 못했다.

  • 한줄 텍스트, 작은 텍스트는 인식이 안되는 경우가 많음
  • bbox가 단락을 정확하게 감싸지 않음
  • bbox가 겹치거나, 중복되거나 bbox 안에 더 작은 bbox가 생겨 뒤죽박죽이 됨

Detectron2는 현재 업데이트가 되고있지 않은 오래된 프로그램으로 잘 사용하지 않는 추세인 듯 하다. PubLayNet 학습 품질 측면에서 다른 데이터셋에 비해 밀리는것으로 보여진다. 따라서 더 나은 결과를 낼 수 있는 객체 탐지 모델을 찾아야 했다.

YOLOv11

ultralytics 에서 개발중인 YOLO는 속도와 정확성 측면에서 최상위권을 차지하고 있는 객체 탐지 알고리즘이며, 연구는 물론 실제 업무 환경에서도 가장 많이 사용되고 있다. YOLO는 이전 버전을 개선한 최신 버전이 계속해서 나오고 있는 현재진행형 모델이다.

Detectron2의 개선책으로 YOLO를 시도하였다. 버전은 본 연구과제를 수행하던 시점에서 가장 최신이었던 YOLOv11을 채택해였다.

기본적으로 YOLO는 각 버전별 베이스 모델이 있으며, 이에 데이터셋을 넣고 추가로 학습해 사전 학습 모델이 만들어진다. 베이스 모델은 nano를 의미하는 n부터 x까지 있으며, x로 갈수록 무거워지지만 더 높은 정확도를 발휘한다.

모델크기 (픽셀)mAPval 50-95속도 CPU ONNX (ms)속도 T4 TensorRT10 (ms)파라미터 (M)FLOPs (B)
YOLO11n64039.556.1 ± 0.81.5 ± 0.02.66.5
YOLO11s64047.090.0 ± 1.22.5 ± 0.09.421.5
YOLO11m64051.5183.2 ± 2.04.7 ± 0.120.168.0
YOLO11l64053.4238.6 ± 1.46.2 ± 0.125.386.9
YOLO11x64054.7462.8 ± 6.711.3 ± 0.256.9194.9

DocLayNet

DocLayNet은 PubLayNet을 만든 IBM Research가 더 뛰어난 학습 품질을 구현하기 위해 만든 문서 레이아웃 학습 데이터셋이다.

학습량은 8만 페이지로 36만 페이지인 PubLayNet보다는 훨씬 적은 양이지만 6가지 종류의 문서들로 매우 다양하게 구성되었고, 5개 클래스로 자동 라벨링된 PubLayNet과 달리 DocLayNet은 전문가들이 직접 레이아웃을 그리고 주석을 달아 11가지 클래스로 분류함으로서 학습 품질을 비약적으로 끌어올렸다.

DocLayNet 데이터셋으로 학습된 사전 학습 모델은 HuggingFace에서 구할 수 있다.

예제

아래 명령어로 두 패키지를 설치하면 필요한게 준비된다. 이전에 Detectron2를 사용하기 위해 설치했던 poppler와 torch는 여전히 필요하다.

pip install ultralytics huggingface_hub

from pathlib import Path
from huggingface_hub import hf_hub_download

# YOLO
from ultralytics import YOLO
from ultralytics.engine.results import Results
from ultralytics.engine.results import Boxes

class YoloLayoutDetector:
	# 모델 초기화
	# 사전 훈련 모델이 없으면 huggingface hub에서 자동으로 다운로드함.
    def __init__(self, model_name: str):
        model_path = hf_hub_download(
            repo_id="hantian/yolo-doclaynet",
            filename=f"{model_name}-doclaynet.pt",
            repo_type="model",
            local_dir="./models",
        )
        self.model = YOLO(model_path)

    # 레이아웃 분석
    def detect(self, image, conf_threshold=0.25):
        # 추론 수행
        results: list[Results] = self.model(image, conf=conf_threshold)
        # 레이아웃 분석한 결과 표시
        results[0].show()
        # 반환할 데이터 구성
        blocks = []
        for result in results:
            if result.boxes:
                for box in result.boxes:
                    x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
                    cls_id = int(box.cls[0].cpu().numpy())
                    conf = float(box.conf[0].cpu().numpy())
                    blocks.append({
                        "box": [x1, y1, x2, y2],           # 텍스트 상자의 좌표
                        "type": self.model.names[cls_id],  # 레이아웃 종류
                        "conf": conf                       # 신뢰도 점수
                    })

        return blocks

결과물

한줄 텍스트도 누락 없이 잘 인식한다.

bbox가 단락 전체를 잘 감싸고 있으며 겹치거나 중복되는 문제가 없다.

이미지, 테이블도 잘 인식한다.

파이프라인 구현

전체적인 PDF 추출 파이프라인은 다음과 같이 만들 수 있다.

# 라이브러리 import
import pymupdf, pdf2image
from pymupdf import Rect, Page
from pymupdf.table import Table
from PIL.Image import Image
import re, pandas as pd

# PDF를 로드합. Page 객체 배열과 PIL.Image 이미지 배열을 반환.
def load_pdf(file_path: str, dpi=72) -> tuple[list[Page], list[Image]] | None:
    try:
        pdf = pymupdf.open(file_path)
    except Exception as e:
        raise e

    images = pdf2image.convert_from_path(file_path, dpi=dpi)
    return ([p for p in pdf], images)

# 사각형 좌표로 해당 구역내의 텍스트를 추출.
def extract_text_from_block(page: Page, rect: Rect) -> str:
    return re.sub(r'\n+', ' ', page.get_textbox(rect).strip())

# 사각형 좌표로 해당 구역내의 테이블을 추출.
def extract_table_from_block(page: Page, rect: Rect) -> pd.DataFrame | None:
    table = pymupdf.find_tables(page, clip=rect)
    if table.tables:
        return table[0].to_pandas()

# 메인함수
def main():
    # YoloLayoutDetector 객체 생성
    layout_detector = YoloLayoutDetector("yolov11l")
    
    # PDF 파일 로드 
    pdf = load_pdf('sample_fft.pdf')
    if pdf is None:
        print("Failed to load PDF.")
        return

    pages, images = pdf
    
    # 페이지 순회
    while pages:
        page = pages.pop(0)
        image = images.pop(0)

        # 레이아웃 분석 수행 -> 분석 결과로 bbox 배열 반환
        blocks = layout_detector.detect(image)
        extracted = {}

        # bbox 순회
        for i, b in enumerate(blocks):
            type = b["type"]
            box = b["box"]
            conf = b["conf"]
            print('\n\n', f'[{i}] : {type} ({conf:.2f})')

            # Table로 추론된 레이아웃이면 테이블 추출
            if type == 'Table':
                table_df = extract_table_from_block(page, Rect(box))
                if table_df is not None:
                    table_md = table_df.to_markdown()
                    print(table_md)
                    extracted[i] = table_md

            # 이외의 레이아웃은 텍스트 추출
            else:
                text = extract_text_from_block(page, Rect(box))
                if text:
                    print(text)
                    extracted[i] = text

        # 텍스트 추출 결과를 저장하기
        if extracted:
            seletected_blocks = [int(s) for s in input('\n\nselect block >>>').split() if s.isdigit()]
            with open('scraped.txt', 'w', encoding='utf-8') as file:
                for s in seletected_blocks:
                    file.write(extracted[s])

if __name__ == "__main__":
    main()

테이블 추출 결과

텍스트 추출 결과

 [0] : Text (0.97)
벡터 연산 명령어군에는 일반적인 실수 덧셈, 뺄셈,  곱셈, 쉬프트 명령어가 포함된다. 특징적인 명령어는  vcadd로 특수 메모리인 벡터 조건 메모리의 값에 따라  선택 적으로 덧셈 혹은 뺄셈 동작을 수행한다. 벡터 조 건 메모리에 사전에 계산된 조건 값을 적정하게 설정하 면 조건 비교 명령이 필요하여 병렬화하기 어려운 연산 을 효과적으로 SIMD 프로세서에서 처리할 수 있다. 이  방법은 연산 시간을 단축시키지만 그 대가로 조건 값을  저장하기 위한 메모리를 더 필요로 한다. 벡터 조건 메 모리에도 다수의 조건 값이 저장되는데 스칼라 레지스


 [1] : Text (0.97)
장치와 벡터 정렬 메모리는 벡터 형태로 정렬된 입력  데이터의 순서를 짧은 시간에 재정렬 하는데 사용된다.  FFT 알고리즘의 경우 한 단의 출력 결과를 다음 단의  입 력으로 제공하기에 앞서 벡터 데이터의 순서를 변경 할 때 이 장치를 사용한다.


 [2] : Text (0.97)
벡터 정렬 명령어군에는 vshuf 명령어가 속한다. 이  명령어는 특수 목적 메모리인 벡터 정렬 메모리에 저장 된 내용에 따라 벡터 데이터 정렬 형태가 정해진다. 따 라서 이 메모리의 내용을 변경하여 벡터 데이터의 정렬  형태를 자유롭게 변경할 수 있다. 벡터 정렬 메모리에 는 다수의 데이터 정렬 형태가 저장되는데, 스칼라 레 지스터 r15를 이용하여 이들 가운데 한 데이터 정렬 형 태를 선택하여 SIMD 연산장치에 제공할 수 있다.

 [4] : Text (0.96)
<그림 4>는 Bruun 알고리즘의 데이터 정렬동작을  실험용 SIMD 프로세서에서 구현된 어셈블리 명령어로  구현한 예이다. 이 예에서 왼쪽 명령어는 SIMD 데이터 패스에서 실행되며 오른쪽 명령어는 스칼라 데이터패스 에서 실행된다. 이 예제에서 r1과 r2는 Bruun 알고리즘 에서 나타나는 2가지 데이터 정렬 형태가 저장된 벡터  정렬 메모리의 주소를 가리키는데 사용된다. r1은 <그 림 2>의 Bruun 알고리즘 신호 흐름도에서 실선으로 표 현된 벡터 데이터 정렬 형태를 가리키며, r2는 점선으로  표현된 벡터  데이터 정렬 형태를 나타내고 있다. 이 예

 [5] : Text (0.94)
터인 r14를 이용하여 이들 가운데 하나를 선택한다. 벡터 메모리 명령어군은 SIMD 레지스터 파일에 저 장된 벡터 데이터를 SIMD 메모리에 저장하는 vstr 명 령어와, SIMD 메 모리의 데이터를 SIMD 레지스터 파 일로 읽어 저장하는 vld 명령어로 구성된다.

레이아웃으로 분석된 단락별로 그 안의 텍스트만 추출되는것을 확인할 수 있다.

다음 포스트

Django 웹앱 구축 및 Ollama 챗봇 적용