chore: スクリプト配置をスクリプト/フォルダへ整理しテスト基盤を追加

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kenichiro NOGI 2026-08-02 10:07:24 +09:00
parent c3e52961aa
commit b8b8f4d93c
8 changed files with 2177 additions and 0 deletions

6
PythonProj/ScanOCR/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
dist/
__pycache__/
*.pyc
.pytest_cache/
.lock
ログ/

View File

@ -0,0 +1,59 @@
# PDF氏名・日付抽出リネームツール
テンプレート画像赤枠氏名欄、青枠日付欄を基準に、対象フォルダ内のPDFから
氏名・日付をOCRで抽出し、リネーム・振り分けを行うスクリプトです。
## セットアップ
```bash
pip install opencv-python pdf2image pytesseract pillow numpy
```
システムに以下が必要ですUbuntu/Debianの例:
```bash
sudo apt-get install tesseract-ocr tesseract-ocr-jpn poppler-utils
```
## 使い方
1. テンプレート画像を用意する
- 実際の書類1枚をスキャン、または元PDFをそのまま画像化
- 画像編集ソフトなどで、氏名が書かれている位置に赤い四角枠、日付が書かれている位置に青い四角枠を重ねて保存PNG推奨
- **重要**: テンプレートは実際の対象PDFと同じ用紙サイズ・向きで作成してください比率で位置を計算するため、多少の解像度差は自動補正されます
2. フォルダを準備する
- `input/` … 処理対象のPDFを入れる
- `output/` … 成功時、氏名ごとのサブフォルダに `YYYYMMDD_氏名.pdf` として保存される
- `failed/` … 抽出に失敗したPDFがそのまま退避される要目視確認
3. 実行する
```bash
python extract_and_rename.py \
--template template.png \
--input ./input \
--output ./output \
--failed ./failed \
--margin 0.10
```
`--margin` は枠位置の許容誤差率です。0.10 = 上下左右に枠の幅・高さの10%分だけ
広げた範囲までOCR対象としますスキャン時の位置ズレ対策
## 注意点
- OCRは完璧ではありません。特に手書きに近い字体、かすれ、低解像度スキャンでは
誤読が発生します。`output/` の結果は初回運用時に必ず目視確認してください。
- 日付の年が2桁例: 26/08/02の場合、スクリプトは "20XX年" と仮定して補完します。
昭和・平成表記など和暦が使われる書類の場合は `clean_date()` 関数の調整が必要です。
- 複数ページPDFの場合、現在は1ページ目のみを対象にしています。
- 氏名に同姓同名がいる場合や、同じ氏名で複数回書類が発行される場合、ファイル名の
衝突は自動的に連番_1, _2...)で回避されます。
- テンプレートで検出される枠は「最大面積の赤/青矩形」を採用しています。枠線以外に
赤・青の要素(ロゴなど)がテンプレート内にあると誤検出する可能性があるため、
テンプレートはできるだけシンプルな見た目にしてください。
## ファイル構成
- `box_detector.py` … テンプレートから赤枠・青枠の位置を検出するモジュール
- `extract_and_rename.py` … メイン処理OCR抽出・リネーム・移動

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,164 @@
# PDFスキャン監視・自動振り分けツール 設計書
日付: 2026-08-02
対象ブランチ: feature/process-flowchart-generatorScanOCRプロジェクト
## 概要
既存 `extract_and_rename.py`CLI引数型・氏名/日付OCR抽出リネーム全面改修。
バッチダブルクリック起動・固定フォルダ構成・同梱Python完結配布パッケージへ変更。
`box_detector.py`(テンプレート赤枠/青枠検出)無改修流用。
## 配布パッケージ構成
ホームディレクトリ = バッチ配置場所。フォルダごとコピーすればそのまま動作。
```
ScanOCR/
├── OCR仕分け実行.bat 起動バッチ
├── config.txt フォルダ名・動作パラメータ設定(必須ファイル。無ければ起動失敗)
├── スクリプト/
│ ├── extract_and_rename.py メイン処理
│ ├── box_detector.py テンプレート枠検出(変更なし)
│ ├── python/ Python embeddable本体 + site-packages同梱
│ └── tools/
│ ├── tesseract/ tesseract.exe + tessdata/jpn.traineddata
│ └── poppler/ pdftoppm.exe 等
├── テンプレート/ テンプレート画像置き場
├── スキャン/ 処理対象PDF投入先
├── アウトプット/ 成功時リネームコピー先(氏名フォルダ別)
├── 成功/ 処理済みオリジナルPDF退避先
├── 失敗/ 抽出失敗PDF退避先
├── ログ/ 実行ログ(日付別)
└── .lock 二重起動防止ロック(実行中のみ存在)
```
テンプレート/スキャン/アウトプット/成功/失敗/ログの6フォルダ、起動時に無ければ自動作成。
`config.txt`・`スクリプト/`(配下の`python/`・`tools/`含む)は自動生成対象外(無ければエラー扱い)。
## config.txt仕様
key=value形式・UTF-8。**配布パッケージに必須同梱**。存在しなければ起動失敗(自動生成しない)。
```
スクリプトフォルダ=スクリプト
テンプレートフォルダ=テンプレート
スキャンフォルダ=スキャン
アウトプットフォルダ=アウトプット
成功フォルダ=成功
失敗フォルダ=失敗
ログフォルダ=ログ
margin=0.10
DPI=300
ファイル安定待ち秒=1
ファイル安定待ちリトライ回数=5
```
- フォルダ名項目、ホームディレクトリ基準の相対パス
- `スクリプトフォルダ``OCR仕分け実行.bat` がPython起動パスを組み立てる際にも参照する後述
- `margin`・`DPI` は既存スクリプト同名パラメータの外部化
- `ファイル安定待ち秒`・`ファイル安定待ちリトライ回数` は書き込み中PDF対策用後述
## 起動フロー
### OCR仕分け実行.bat
1. カレントディレクトリをバッチ配置場所に固定(`%~dp0`
2. `config.txt` 存在確認
- 無 → 「config.txt が見つかりません。配布パッケージが不完全です」表示・pauseで終了
3. `config.txt` から `スクリプトフォルダ=` 行を読み取り(`for /f "tokens=1,2 delims==" %%a in (config.txt)`、コードページはUTF-8=`chcp 65001`前提)、`<スクリプトフォルダ>\python\python.exe` と `<スクリプトフォルダ>\extract_and_rename.py` の実パスを組み立てる
4. `<スクリプトフォルダ>\python\python.exe` 存在確認
- 無 → 「配布パッケージが不完全ですpythonフォルダが見つかりません。管理者に確認してください」表示・pauseで終了
5. `<スクリプトフォルダ>\python\python.exe <スクリプトフォルダ>\extract_and_rename.py` 実行
**方針転換点**: 従来案システムPython有無チェック→未導入ならインストール案内は撤回。同梱Python完結配布のため、チェック対象は「システムのPython」でなく「同梱ファイル一式の充足」。
### extract_and_rename.py 初期化順序
1. 必要6フォルダテンプレート/スキャン/アウトプット/成功/失敗/ログ)存在チェック・自動作成
2. `config.txt` 読込バッチ側で存在確認済みだが、直接pyを叩いて実行されるケースに備えPython側でも存在チェック・無ければエラー終了
3. 依存モジュールcv2, numpy, PIL, pytesseract, pdf2imageimport確認
- 失敗 → 不足モジュール名明示「配布パッケージが壊れています」表示・終了
4. `<スクリプトフォルダ>/tools/tesseract/tesseract.exe`・`<スクリプトフォルダ>/tools/poppler/pdftoppm.exe` 存在確認
- 無 → 該当ファイル名明示・終了
5. 二重起動防止ロック(`.lock`)確認
- 存在・記録PID稼働中 → 「既に実行中です」表示・終了
- 存在するがPID非稼働前回異常終了の残骸→ 自動削除し続行
- 新規 `.lock` 作成・自プロセスPID記録
6. テンプレートフォルダから対象ファイル(拡張子 .png/.jpg/.jpeg/.bmp、ファイル名昇順先頭1件採用
- 対象ファイル無 → エラー表示(ロック解除の上)終了
7. `box_detector.detect_template_fields` でテンプレート枠検出 → 氏名欄・日付欄の比率座標取得
## メインループ
1件ずつ処理。1件処理毎に以下実施:
1. スキャンフォルダ再glob`*.pdf`)でキュー更新
- キュー空 → ループ終了(正常終了)
2. キュー先頭ファイル取り出し
3. キー入力チェック(`msvcrt.kbhit()`
- 入力あり → 「ユーザー操作により停止しました」ログ記録・ループ脱出
4. ファイル存在チェック(他プロセスが移動・削除済みならスキップ・次周回へ)
5. ファイルサイズ安定待ち(`ファイル安定待ち秒` 間隔でサイズ比較、`ファイル安定待ちリトライ回数` 回試行)
- 不安定 → 「サイズ不安定のためスキップ」ログ記録・今回見送り(次周回で再チェック)
6. OCR処理実行
- 例外発生 → スタックトレース込みログ記録・"失敗"フォルダへ移動
- 氏名または日付いずれか欠如 → ログ記録・"失敗"フォルダへ移動
- 両方取得成功 →
a. ファイル名 = `氏名_YYYYMMDD.pdf`。"アウトプット/氏名/" 配下に同名既存なら `氏名_YYYYMMDD(2).pdf`, `(3)...` 連番付与
b. "アウトプット/氏名/" へコピー
c. 元ファイルを "成功" フォルダへ移動(同名重複時も同様の連番ルール)
d. ログに成功記録
## 終了処理
- ループ脱出後 `.lock` 削除
- 「処理完了。何かキーを押すと終了します」表示・pause
## ログ仕様
- 出力先: `ログ/YYYY-MM-DD.log`日付別、UTF-8 BOM付き
- 1件1行、フォーマット:
```
[YYYY-MM-DD HH:MM:SS] 結果=成功|失敗|スキップ|エラー | 元ファイル=xxx.pdf | 氏名=xxx | 日付=xxx | 備考=...
```
- エラー時、備考欄にスタックトレース概要含める
## ビルドスクリプト(素材自動調達・配布パッケージ生成)
開発者PC上で1回実行すれば `dist/ScanOCR/` 配下に配布用一式(`スクリプト/python/`・`スクリプト/tools/` 含むを自動生成する別スクリプト。実行主体はリポジトリ管理下のシステムPythonネット接続前提。配布先PCはオフラインで動作するが、ビルド実行時のみネット接続必須。
**配置**: `build/build_package.py`(リポジトリ管理下、配布パッケージ本体には含めない)
**リポジトリとdist/の役割分担**:
- リポジトリ直下: コード類のみ管理(`OCR仕分け実行.bat`・`config.txt`・`スクリプト/extract_and_rename.py`・`スクリプト/box_detector.py`
- `dist/`: ビルド生成物置き場。`.gitignore` に追加しコミット対象外とする
**処理内容**:
1. `dist/ScanOCR/` を作成(既存なら中身をクリアしてから再生成)
2. コード類をリポジトリから `dist/ScanOCR/` 配下へコピー
3. Python embeddable版取得・配置
- 固定バージョン(例: 3.11.9 windows embeddable amd64をDL・展開 → `dist/ScanOCR/スクリプト/python/`
- `python311._pth` 内の `#import site` のコメントアウトを解除site有効化、pip動作に必須
- `get-pip.py` をDLし実行してpip有効化
- `python.exe -m pip install opencv-python pdf2image pytesseract pillow numpy` を実行し同梱Python環境に直接インストール
4. tesseract-ocr for Windows取得・配置
- 固定バージョンのポータブル版一式jpn言語データ含む`dist/ScanOCR/スクリプト/tools/tesseract/` へ配置
5. poppler for Windows取得・配置
- 固定バージョンのビルド済みzipをDL・展開 → `dist/ScanOCR/スクリプト/tools/poppler/`
6. 必要6フォルダテンプレート/スキャン/アウトプット/成功/失敗/ログ)の空フォルダを `dist/ScanOCR/` 直下に作成
7. 完了メッセージ表示
**バージョン固定方針**: Python・tesseract-ocr・popplerとも具体バージョン番号をスクリプト内定数として固定ピン留め。互換性問題を避けるため、更新したい場合はスクリプト内の定数を手動で書き換える運用とする。具体的なバージョン番号は実装時に確定。
**ライセンス**: tesseractApache 2.0・popplerGPL系とも再配布可能。社内限定配布であれば問題なし。ライセンス文書の同梱は本設計のスコープ外必要になれば別途対応
## 対象外・既存仕様からの継承事項
- 複数ページPDFは1ページ目のみ対象既存仕様継承
- 和暦・昭和/平成表記非対応(既存 `clean_date()` の制約継承)
- OCR誤読は完全には防げない。運用初期は "アウトプット" 配下の目視確認を推奨
## 今回のスコープ外(将来検討事項)
- 常時監視デーモン化は対象外。1回の起動で「その時点のキューを処理し尽くしたら終了」する設計

View File

@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""
テンプレート画像内の赤枠青枠を検出しOCR抽出用の矩形領域を返すモジュール
前提:
- テンプレートは元PDFと同じ用紙サイズ解像度でスキャン/作成されていること
- 赤枠 = 氏名欄青枠 = 日付欄 必要に応じて色を追加可能
"""
import cv2
import numpy as np
# HSV色空間での色範囲定義赤は色相環の両端にまたがるため2レンジ
COLOR_RANGES = {
"red": [
# (lower_hsv, upper_hsv)
(np.array([0, 100, 100]), np.array([10, 255, 255])),
(np.array([160, 100, 100]), np.array([180, 255, 255])),
],
"blue": [
(np.array([100, 100, 100]), np.array([130, 255, 255])),
],
}
MIN_BOX_AREA = 500 # ノイズ除去用の最小面積(px^2)。テンプレート解像度に応じて調整してください
def _detect_color_boxes(img_bgr: np.ndarray, color_name: str) -> list[tuple[int, int, int, int]]:
"""指定色の矩形枠を検出し、[(x, y, w, h), ...] のリストを返す(枠線内側の矩形を返す)"""
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
mask = np.zeros(hsv.shape[:2], dtype=np.uint8)
for lower, upper in COLOR_RANGES[color_name]:
mask |= cv2.inRange(hsv, lower, upper)
# 枠線の途切れを補正
kernel = np.ones((5, 5), np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
boxes = []
for c in contours:
area = cv2.contourArea(c)
if area < MIN_BOX_AREA:
continue
x, y, w, h = cv2.boundingRect(c)
boxes.append((x, y, w, h))
return boxes
def detect_template_fields(template_path: str) -> dict[str, tuple[int, int, int, int]]:
"""
テンプレート画像から赤枠(氏名)青枠(日付)を検出する
Returns:
{
"name": (x, y, w, h), # 赤枠が見つかった場合
"date": (x, y, w, h), # 青枠が見つかった場合
}
画像サイズは呼び出し側で正規化(比率)して使うためあわせて画像サイズも返す
"""
img = cv2.imread(template_path)
if img is None:
raise FileNotFoundError(f"テンプレート画像を読み込めません: {template_path}")
h_img, w_img = img.shape[:2]
result = {"_image_size": (w_img, h_img)}
red_boxes = _detect_color_boxes(img, "red")
blue_boxes = _detect_color_boxes(img, "blue")
if red_boxes:
# 最大面積のものを採用(複数検出された場合のノイズ対策)
result["name"] = max(red_boxes, key=lambda b: b[2] * b[3])
if blue_boxes:
result["date"] = max(blue_boxes, key=lambda b: b[2] * b[3])
return result
def to_ratio_box(box: tuple[int, int, int, int], image_size: tuple[int, int]) -> tuple[float, float, float, float]:
"""ピクセル座標を画像サイズに対する比率(0.0-1.0)に変換する。
対象PDFの解像度がテンプレートと異なっていても位置を再現できるようにするため"""
x, y, w, h = box
iw, ih = image_size
return (x / iw, y / ih, w / iw, h / ih)
if __name__ == "__main__":
import sys
import json
if len(sys.argv) < 2:
print("使い方: python box_detector.py <テンプレート画像パス>")
sys.exit(1)
fields = detect_template_fields(sys.argv[1])
image_size = fields.pop("_image_size")
print(f"画像サイズ: {image_size}")
for label, box in fields.items():
ratio = to_ratio_box(box, image_size)
print(f"{label}: pixel={box} ratio={tuple(round(v, 4) for v in ratio)}")

View File

@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
"""
テンプレート(赤枠=氏名, 青枠=日付)を基準に対象フォルダ内のスキャンPDFから
氏名日付をOCR抽出し"output/氏名/YYYYMMDD_氏名.pdf" にリネーム移動する
使い方:
python extract_and_rename.py \
--template template.png \
--input ./inbox \
--output ./output \
--failed ./failed \
--margin 0.10
必要ライブラリ: opencv-python, pdf2image, pytesseract, pillow, numpy
必要な外部ツール: tesseract-ocr, tesseract-ocr-jpn, poppler-utils(pdftoppm)
"""
import argparse
import re
import shutil
import sys
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
DPI = 300 # PDF→画像変換の解像度。テンプレートも同じDPIで作成/スキャンしてください
@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 safe_move(src: Path, dest_dir: Path, filename: str) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest_path = dest_dir / filename
# 同名ファイルが既にある場合は連番を付けて衝突を回避
counter = 1
while dest_path.exists():
stem = Path(filename).stem
suffix = Path(filename).suffix
dest_path = dest_dir / f"{stem}_{counter}{suffix}"
counter += 1
shutil.move(str(src), str(dest_path))
return dest_path
def main():
parser = argparse.ArgumentParser(description="テンプレート枠を基準にPDFから氏名・日付を抽出してリネーム・移動する")
parser.add_argument("--template", required=True, help="赤枠(氏名)・青枠(日付)付きテンプレート画像")
parser.add_argument("--input", required=True, help="処理対象PDFが入ったフォルダ")
parser.add_argument("--output", required=True, help="成功時の出力先ルートフォルダ(氏名ごとにサブフォルダ作成)")
parser.add_argument("--failed", required=True, help="抽出失敗時の退避先フォルダ")
parser.add_argument("--margin", type=float, default=0.10, help="枠の許容誤差率(デフォルト 0.10 = 10%%")
args = parser.parse_args()
template_path = Path(args.template)
input_dir = Path(args.input)
output_dir = Path(args.output)
failed_dir = Path(args.failed)
if not template_path.exists():
sys.exit(f"テンプレート画像が見つかりません: {template_path}")
if not input_dir.exists():
sys.exit(f"入力フォルダが見つかりません: {input_dir}")
boxes = load_field_boxes(str(template_path))
print(f"検出フィールド: {[b.label for b in boxes]}")
pdf_files = sorted(input_dir.glob("*.pdf"))
if not pdf_files:
print("対象PDFが見つかりませんでした。")
return
results = []
for pdf_path in pdf_files:
print(f"処理中: {pdf_path.name}")
try:
name, date = process_pdf(pdf_path, boxes, args.margin)
except Exception as e:
print(f" エラー: {e}")
safe_move(pdf_path, failed_dir, pdf_path.name)
results.append((pdf_path.name, None, None, "error", str(e)))
continue
if not name or not date:
print(f" 抽出不十分(氏名={name!r}, 日付={date!r})→ failed へ退避")
safe_move(pdf_path, failed_dir, pdf_path.name)
results.append((pdf_path.name, name, date, "failed", ""))
continue
new_filename = f"{date}_{name}.pdf"
dest_dir = output_dir / name
moved_path = safe_move(pdf_path, dest_dir, new_filename)
print(f"{moved_path}")
results.append((pdf_path.name, name, date, "success", str(moved_path)))
# サマリー出力
print("\n=== 処理結果サマリー ===")
for orig, name, date, status, note in results:
print(f"{status:8s} | {orig:30s} | 氏名={name} 日付={date} | {note}")
success_count = sum(1 for r in results if r[3] == "success")
print(f"\n合計 {len(results)} 件中 {success_count} 件成功")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

View File

@ -0,0 +1,2 @@
def test_smoke():
assert True