ken_nogi/PythonProj/ScanOCR/スクリプト/extract_and_rename.py
Kenichiro NOGI 1d283eb1c6 feat: 固定フォルダ構成・ロック・ログ統合のmain()に置き換え
旧CLI引数型(argparse)を廃止し、config.txt駆動の固定フォルダ運用に一本化。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 10:14:37 +09:00

306 lines
9.8 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
テンプレート(赤枠=氏名, 青枠=日付)を基準に、"スキャン"フォルダ内のPDFから
氏名・日付をOCR抽出し、"アウトプット/氏名/氏名_YYYYMMDD.pdf" にコピー、
元ファイルは"成功"(または"失敗")フォルダへ移動する。
起動は OCR仕分け実行.bat から行う想定config.txt の固定フォルダ構成に依存)。
必要ライブラリ: opencv-python, pdf2image, pytesseract, pillow, numpy
必要な外部ツール: tesseract-ocr, tesseract-ocr-jpn, poppler-utils(pdftoppm)
"""
import importlib
import re
import time
import traceback
from dataclasses import dataclass
from pathlib import Path
import pytesseract
from pdf2image import convert_from_path
from PIL import Image
from box_detector import detect_template_fields, to_ratio_box
from config_loader import load_config
from file_ops import copy_with_unique_name, move_with_unique_name, wait_until_stable
from lock_manager import LockAcquisitionError, acquire_lock, release_lock
from logger import append_log
DPI = 300 # PDF→画像変換の解像度。テンプレートも同じDPIで作成/スキャンしてください
TEMPLATE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp"}
def select_template_file(template_dir: Path) -> Path:
candidates = sorted(
p for p in template_dir.iterdir()
if p.is_file() and p.suffix.lower() in TEMPLATE_EXTENSIONS
)
if not candidates:
raise FileNotFoundError(
f"テンプレートフォルダに画像ファイルが見つかりません: {template_dir}"
)
return candidates[0]
REQUIRED_MODULES = ("cv2", "numpy", "PIL", "pytesseract", "pdf2image")
def check_dependencies(modules: tuple[str, ...] = REQUIRED_MODULES) -> list[str]:
missing = []
for mod in modules:
try:
importlib.import_module(mod)
except ImportError:
missing.append(mod)
return missing
def check_external_tools(tesseract_exe: Path, poppler_exe: Path) -> list[str]:
missing = []
if not tesseract_exe.exists():
missing.append(str(tesseract_exe))
if not poppler_exe.exists():
missing.append(str(poppler_exe))
return missing
def key_pressed() -> bool:
import msvcrt
return msvcrt.kbhit()
@dataclass
class FieldBox:
label: str
x_ratio: float
y_ratio: float
w_ratio: float
h_ratio: float
def to_pixel_box(self, img_w: int, img_h: int, margin: float) -> tuple[int, int, int, int]:
"""比率座標を対象画像の実ピクセルに変換し、上下左右にmargin(比率)だけ広げる"""
x = self.x_ratio * img_w
y = self.y_ratio * img_h
w = self.w_ratio * img_w
h = self.h_ratio * img_h
mx = w * margin
my = h * margin
x0 = max(0, int(x - mx))
y0 = max(0, int(y - my))
x1 = min(img_w, int(x + w + mx))
y1 = min(img_h, int(y + h + my))
return x0, y0, x1, y1
def load_field_boxes(template_path: str) -> list[FieldBox]:
fields = detect_template_fields(template_path)
image_size = fields.pop("_image_size")
boxes = []
for label, box in fields.items():
xr, yr, wr, hr = to_ratio_box(box, image_size)
boxes.append(FieldBox(label, xr, yr, wr, hr))
if not boxes:
raise ValueError("テンプレートから赤枠・青枠が検出できませんでした。枠の色・太さを確認してください。")
return boxes
def ocr_region(page_img: Image.Image, box: tuple[int, int, int, int], lang: str = "jpn") -> str:
x0, y0, x1, y1 = box
cropped = page_img.crop((x0, y0, x1, y1))
text = pytesseract.image_to_string(cropped, lang=lang)
return text.strip()
def clean_name(raw: str) -> str:
"""OCR結果から氏名らしき文字列を抽出改行・空白・記号イズを除去"""
text = re.sub(r"[\s ]+", "", raw)
text = re.sub(r"[^\w一-龠ぁ-んァ-ヶー]", "", text)
return text
def clean_date(raw: str) -> str | None:
"""OCR結果から日付を検出し YYYYMMDD 形式で返す。見つからなければNone。"""
text = raw.replace(" ", "").replace(" ", "")
patterns = [
r"(\d{4})[年/\-\.](\d{1,2})[月/\-\.](\d{1,2})", # 2026年08月02日 / 2026/08/02 等
r"(\d{2})[年/\-\.](\d{1,2})[月/\-\.](\d{1,2})", # 26/08/02 のような2桁年
]
for pat in patterns:
m = re.search(pat, text)
if m:
y, mo, d = m.groups()
if len(y) == 2:
y = "20" + y # 2桁年は20XX年と仮定。運用に応じて要調整
return f"{int(y):04d}{int(mo):02d}{int(d):02d}"
return None
def process_pdf(pdf_path: Path, boxes: list[FieldBox], margin: float) -> tuple[str | None, str | None]:
"""PDFの1ページ目を画像化し、氏名・日付を抽出して返す"""
pages = convert_from_path(str(pdf_path), dpi=DPI, first_page=1, last_page=1)
if not pages:
return None, None
page_img = pages[0]
img_w, img_h = page_img.size
name_text, date_text = None, None
for box in boxes:
pixel_box = box.to_pixel_box(img_w, img_h, margin)
raw = ocr_region(page_img, pixel_box, lang="jpn")
if box.label == "name":
name_text = clean_name(raw)
elif box.label == "date":
date_text = clean_date(raw)
return name_text, date_text
def run_queue(
scan_dir: Path,
output_dir: Path,
success_dir: Path,
failed_dir: Path,
log_dir: Path,
boxes: list[FieldBox],
margin: float,
stable_wait_sec: float,
stable_retries: int,
process_pdf_func=process_pdf,
key_check=key_pressed,
sleep_func=time.sleep,
) -> None:
while True:
queue = sorted(scan_dir.glob("*.pdf"))
if not queue:
break
pdf_path = queue[0]
if key_check():
append_log(
log_dir, "エラー", pdf_path.name, None, None,
"ユーザー操作により停止しました",
)
break
if not pdf_path.exists():
continue
if not wait_until_stable(pdf_path, stable_wait_sec, stable_retries, sleep_func):
append_log(
log_dir, "スキップ", pdf_path.name, None, None,
"サイズ不安定のためスキップ",
)
continue
try:
name, date = process_pdf_func(pdf_path, boxes, margin)
except Exception as e:
append_log(
log_dir, "エラー", pdf_path.name, None, None,
f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
)
move_with_unique_name(pdf_path, failed_dir, pdf_path.stem, pdf_path.suffix)
continue
if not name or not date:
append_log(log_dir, "失敗", pdf_path.name, name, date, "")
move_with_unique_name(pdf_path, failed_dir, pdf_path.stem, pdf_path.suffix)
continue
copy_with_unique_name(pdf_path, output_dir / name, f"{name}_{date}", ".pdf")
move_with_unique_name(pdf_path, success_dir, pdf_path.stem, pdf_path.suffix)
append_log(log_dir, "成功", pdf_path.name, name, date, "")
HOME_DIR = Path(__file__).resolve().parent.parent
RUNTIME_FOLDER_KEYS = (
"テンプレートフォルダ",
"スキャンフォルダ",
"アウトプットフォルダ",
"成功フォルダ",
"失敗フォルダ",
"ログフォルダ",
)
def main() -> None:
config_path = HOME_DIR / "config.txt"
try:
config = load_config(config_path)
except Exception as e:
print(f"設定エラー: {e}")
input("何かキーを押すと終了します...")
return
folders = {key: HOME_DIR / config[key] for key in RUNTIME_FOLDER_KEYS}
for folder in folders.values():
folder.mkdir(parents=True, exist_ok=True)
missing_modules = check_dependencies()
if missing_modules:
print(f"配布パッケージが壊れています。不足モジュール: {', '.join(missing_modules)}")
input("何かキーを押すと終了します...")
return
script_dir = HOME_DIR / config["スクリプトフォルダ"]
tesseract_exe = script_dir / "tools" / "tesseract" / "tesseract.exe"
poppler_exe = script_dir / "tools" / "poppler" / "pdftoppm.exe"
missing_tools = check_external_tools(tesseract_exe, poppler_exe)
if missing_tools:
print(f"配布パッケージが壊れています。不足ファイル: {', '.join(missing_tools)}")
input("何かキーを押すと終了します...")
return
lock_path = HOME_DIR / ".lock"
try:
acquire_lock(lock_path)
except LockAcquisitionError as e:
print(str(e))
input("何かキーを押すと終了します...")
return
try:
try:
template_path = select_template_file(folders["テンプレートフォルダ"])
except FileNotFoundError as e:
print(str(e))
return
print(f"テンプレート採用: {template_path.name}")
boxes = load_field_boxes(str(template_path))
print(f"検出フィールド: {[b.label for b in boxes]}")
margin = float(config["margin"])
stable_wait_sec = float(config["ファイル安定待ち秒"])
stable_retries = int(config["ファイル安定待ちリトライ回数"])
run_queue(
folders["スキャンフォルダ"],
folders["アウトプットフォルダ"],
folders["成功フォルダ"],
folders["失敗フォルダ"],
folders["ログフォルダ"],
boxes,
margin,
stable_wait_sec,
stable_retries,
)
finally:
release_lock(lock_path)
print("処理完了。")
input("何かキーを押すと終了します...")
if __name__ == "__main__":
main()