프로젝트

일반

사용자정보

Actions

새기능 #5794

완료됨

[공유저작물 동의서] 스캔 PDF OCR 파일명 자동변경기 (PDF_rename.exe)

이호영이(가) 약 2달 전에 추가함. 약 2달 전에 수정됨.

상태:
완료
우선순위:
보통
담당자:
시작시간:
2026/07/10
완료기한:
진척도:

0%

추정시간:
소요 시간:
난이도:

설명

개요

스캐너에서 20260710121527.pdf 처럼 스캔 시각으로만 저장되는 「공유저작물 이용허락 동의서」 스캔본을, 첫 페이지를 OCR로 읽어 YYYYMMDD_성명.pdf (동의서 작성일자 + 동의자 성명) 형식으로 자동 리네임하는 Windows exe 도구를 제작했다.

  • 실행파일: PDF_rename.exe (PyInstaller onedir, 폴더째 배포 PDF_rename_배포용.zip)
  • 소스: rename_pdf.py
  • 사용법: exe를 PDF가 들어있는 폴더에 넣고 더블클릭 → 그 폴더의 모든 PDF를 처리
  • 결과 로그: 같은 폴더에 rename_log.txt 누적 기록
===== 2026-07-10 16:11:05 =====
  [완료] 20260710111618.pdf  ->  20131112_이만수.pdf
  [완료] 20260710111703.pdf  ->  20131129_이미숙.pdf

처리 흐름

  1. 대상 선별 — 실행 폴더의 *.pdf 중 이미 ^\d{8}_.+ 형식인 파일은 [건너뜀-이미변경됨] 처리. 재실행해도 안전(멱등).
  2. 첫 페이지 래스터화 — PyMuPDF(fitz)로 1페이지만 120dpi 픽스맵 → numpy RGB 배열. (RGBA/Gray 자동 보정)
  3. 밴드 크롭(1차, 빠른 경로) — 동의서 양식은 상단에 성명/생년월일 표, 하단에 작성일자가 고정 위치. 페이지 세로 30%~85% 구간만 잘라 OCR → 인식 영역 축소로 속도 확보.
  4. OCR — RapidOCR(onnxruntime) + 한글 PP-OCRv5 mobile 모델.
  5. 날짜 추출 — OCR 텍스트를 한 줄로 이어붙여 (\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2}) 매칭. 본문에도 연도가 나올 수 있어 마지막 매칭(=하단 서명일) 을 채택 → YYYYMMDD.
  6. 성명 추출 — 단순 텍스트 매칭이 아니라 박스 좌표 기반. 생년월일 라벨 박스를 앵커로 잡고, ① 같은 행(중심 y 차이 45px 이내) ② 앵커보다 왼쪽(x) 에 있는 ③ 2글자 이상 한글 을 후보로 모아, 성명/주소/전화번호 등 라벨 단어(LABELS)를 제외한 뒤 가장 왼쪽 후보를 성명으로 확정.
  7. 폴백(2차) — 1차에서 날짜·이름 중 하나라도 못 찾으면 크롭 없이 전체 페이지 재인식. 여기서도 실패하면 [실패]로 로그만 남기고 파일은 그대로 둔다.
  8. 리네임 + 백업 — 원본을 _원본백업/원래이름.pdf 로 복사(copy2)해 보관한 뒤 os.rename. 동명이인 등 파일명 충돌 시 _2, _3 자동 부여.

핵심 코드

OCR 엔진 (속도 최적화)

def build_engine():
    from rapidocr import RapidOCR, LangRec, OCRVersion, ModelType
    return RapidOCR(params={
        "Rec.lang_type":   LangRec.KOREAN,
        "Rec.ocr_version": OCRVersion.PPOCRV5,
        "Rec.model_type":  ModelType.MOBILE,
        "Global.use_cls":  False,   # 문서가 정방향 → 각도분류 생략(속도↑)
        "Det.limit_side_len": 640,  # 글자 탐지 입력 축소(속도↑)
    })

PDF 첫 페이지 → 이미지, 관심영역 크롭

def page_image(pdf_path, dpi=150):
    doc = fitz.open(pdf_path)
    pix = doc[0].get_pixmap(dpi=dpi)
    img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
    if pix.n == 4:      img = img[:, :, :3]      # RGBA -> RGB
    elif pix.n == 1:    img = np.repeat(img, 3, axis=2)  # gray -> RGB
    doc.close()
    return np.ascontiguousarray(img)

def band(img, top=0.30, bottom=0.85):
    H = img.shape[0]
    return np.ascontiguousarray(img[int(H*top):int(H*bottom), :, :])

작성일자 추출 (마지막 날짜 채택)

def extract_date(results):
    joined = " ".join(txt for _, txt, _ in results)
    m = list(re.finditer(r"(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})", joined))
    if not m:
        return None
    y, mo, d = m[-1].groups()          # 마지막(하단) 날짜 = 서명일
    return "%04d%02d%02d" % (int(y), int(mo), int(d))

성명 추출 (‘생년월일’ 앵커 + 같은 행 왼쪽 셀)

LABELS = {"성","명","성명","생년월일","주소","주","소","전화번호","이용허락","저작물","동의서","성별"}

def extract_name(results):
    def center(box):
        return (box[0][1] + box[2][1]) / 2.0, (box[0][0] + box[2][0]) / 2.0  # (cy, cx)

    anchor = None
    for box, txt, _ in results:                 # 1) '생년월일' 앵커
        if "생년" in txt.replace(" ", ""):
            anchor = center(box); break

    candidates = []
    for box, txt, _ in results:
        cy, cx = center(box)
        hangul = re.sub(r"[^가-힣]", "", txt)   # 한글만 남김
        if len(hangul) < 2 or hangul in LABELS: # 라벨/노이즈 제거
            continue
        if anchor:
            row_y, birth_x = anchor
            if abs(cy - row_y) < 45 and cx < birth_x:   # 같은 행 & 생년월일 왼쪽
                candidates.append((cx, hangul))
        else:
            candidates.append((cy, hangul))
    if not candidates:
        return None
    candidates.sort()
    return candidates[0][1]                     # 가장 왼쪽 후보

메인 루프 (1차 밴드 → 2차 전체 폴백 → 백업 후 리네임)

img = page_image(src, dpi=120)
results = to_results(engine(band(img)))          # 1차: 크롭 영역만
date, name = extract_date(results), extract_name(results)

if not date or not name:                         # 2차: 전체 페이지
    results = to_results(engine(img))
    date, name = extract_date(results), extract_name(results)

if not date or not name:
    log("  [실패] %s -> %s 인식 못함" % (f, "/".join(miss))); continue

stem = "%s_%s" % (date, name)
dst  = safe_target(stem, ".pdf", BASE, src)      # 중복시 _2, _3
os.makedirs(BACKUP, exist_ok=True)
shutil.copy2(src, os.path.join(BACKUP, f))      # 원본 백업
os.rename(src, dst)

exe 대응 (PyInstaller 실행 경로)

def base_dir():
    if getattr(sys, "frozen", False):       # PyInstaller exe
        return os.path.dirname(sys.executable)
    return os.path.dirname(os.path.abspath(__file__))

사용 패키지 / 설치

pip install rapidocr onnxruntime pymupdf numpy opencv-python
pip install pyinstaller
패키지 버전 용도
rapidocr 3.9.1 OCR (PP-OCRv5 KOREAN mobile)
onnxruntime 1.23.2 RapidOCR 추론 백엔드(CPU)
PyMuPDF (fitz) 1.26.3 PDF 첫 페이지 래스터화
numpy 2.2.0 이미지 배열/크롭
opencv-python 5.0.0.93 RapidOCR 전처리 의존
pyinstaller 6.12.0 exe 빌드

빌드

pyinstaller --noconfirm --name PDF_rename rename_pdf.py
# → dist/PDF_rename/ (PDF_rename.exe + _internal) 폴더째 zip 배포

특이사항 / 결정

  • onedir 배포: RapidOCR ONNX 모델·onnxruntime DLL이 커서 onefile로 묶으면 실행 시마다 임시폴더 압축해제로 느려짐 → 폴더형(onedir) zip 배포 선택.
  • 성명은 좌표 기반으로 뽑음: 양식에 ‘성명’ 글자와 실제 이름이 따로 인식되고, 주소/전화 등 다른 한글도 잡히기 때문에 텍스트만으로는 특정 불가. ‘생년월일’ 셀 기준 같은 행 왼쪽 셀이라는 양식의 구조를 이용.
  • 날짜는 마지막 매칭 사용: 문서 상단 본문에도 연도 표기가 있어 하단 서명일을 잡기 위함.
  • 원본 무손실: 삭제 없이 _원본백업 보관 + rename, 재실행 시 이미 변환된 파일은 skip.
Actions

내보내기 Atom PDF