From b8b8f4d93ccd378c73937c97db2c659b20e5e419 Mon Sep 17 00:00:00 2001 From: Kenichiro NOGI Date: Sun, 2 Aug 2026 10:07:24 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97?= =?UTF-8?q?=E3=83=88=E9=85=8D=E7=BD=AE=E3=82=92=E3=82=B9=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=97=E3=83=88/=E3=83=95=E3=82=A9=E3=83=AB=E3=83=80?= =?UTF-8?q?=E3=81=B8=E6=95=B4=E7=90=86=E3=81=97=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E5=9F=BA=E7=9B=A4=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- PythonProj/ScanOCR/.gitignore | 6 + PythonProj/ScanOCR/README.md | 59 + .../plans/2026-08-02-watch-launcher-plan.md | 1640 +++++++++++++++++ .../specs/2026-08-02-watch-launcher-design.md | 164 ++ PythonProj/ScanOCR/スクリプト/box_detector.py | 103 ++ .../ScanOCR/スクリプト/extract_and_rename.py | 199 ++ .../ScanOCR/スクリプト/tests/conftest.py | 4 + .../ScanOCR/スクリプト/tests/test_smoke.py | 2 + 8 files changed, 2177 insertions(+) create mode 100644 PythonProj/ScanOCR/.gitignore create mode 100644 PythonProj/ScanOCR/README.md create mode 100644 PythonProj/ScanOCR/docs/superpowers/plans/2026-08-02-watch-launcher-plan.md create mode 100644 PythonProj/ScanOCR/docs/superpowers/specs/2026-08-02-watch-launcher-design.md create mode 100644 PythonProj/ScanOCR/スクリプト/box_detector.py create mode 100644 PythonProj/ScanOCR/スクリプト/extract_and_rename.py create mode 100644 PythonProj/ScanOCR/スクリプト/tests/conftest.py create mode 100644 PythonProj/ScanOCR/スクリプト/tests/test_smoke.py diff --git a/PythonProj/ScanOCR/.gitignore b/PythonProj/ScanOCR/.gitignore new file mode 100644 index 00000000..d68ba310 --- /dev/null +++ b/PythonProj/ScanOCR/.gitignore @@ -0,0 +1,6 @@ +dist/ +__pycache__/ +*.pyc +.pytest_cache/ +.lock +ログ/ diff --git a/PythonProj/ScanOCR/README.md b/PythonProj/ScanOCR/README.md new file mode 100644 index 00000000..b60c2198 --- /dev/null +++ b/PythonProj/ScanOCR/README.md @@ -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抽出・リネーム・移動) diff --git a/PythonProj/ScanOCR/docs/superpowers/plans/2026-08-02-watch-launcher-plan.md b/PythonProj/ScanOCR/docs/superpowers/plans/2026-08-02-watch-launcher-plan.md new file mode 100644 index 00000000..36aa6f91 --- /dev/null +++ b/PythonProj/ScanOCR/docs/superpowers/plans/2026-08-02-watch-launcher-plan.md @@ -0,0 +1,1640 @@ +# PDFスキャン監視・自動振り分けツール Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 既存のCLI型OCR氏名/日付抽出ツールを、バッチダブルクリック起動・固定フォルダ構成・同梱Python完結配布パッケージ型に全面改修する。 + +**Architecture:** `box_detector.py`(テンプレート枠検出)はそのまま流用。設定読込・ファイル操作・ロック管理・ログ出力を責任ごとに独立モジュールへ分離し、`extract_and_rename.py` はそれらを組み合わせるオーケストレーション層とする。配布用の`python/`・`tools/`(tesseract/poppler)はビルドスクリプトが別途生成する。 + +**Tech Stack:** Python 3.11系(配布用embeddable、開発機は3.12.1で代用)、opencv-python、pdf2image、pytesseract、pillow、numpy、pytest(開発依存)、Windows batファイル。 + +## Global Constraints + +- 対象OS: Windows専用(`msvcrt`・`ctypes.windll`使用前提) +- 配布用Python: 3.11.9 embeddable amd64(バージョン固定)。開発・テストは開発機のシステムPython 3.12.1を使う(別物、配布物には含めない) +- pipパッケージ: opencv-python, pdf2image, pytesseract, pillow, numpy +- 外部バイナリ: tesseract-ocr(UB-Mannheim版 5.4.0.20240606固定)、poppler for Windows(poppler-windows 24.02.0-0固定) +- ログ出力エンコーディング: UTF-8 BOM付き(`utf-8-sig`)、出力先 `ログ/YYYY-MM-DD.log` +- ファイル名衝突回避ルール: `stem(2).suffix`, `stem(3).suffix` ... の形式(既存の `_1, _2` 形式ではない) +- `config.txt` は必須ファイル。存在しなければエラー終了(自動生成しない) +- リポジトリ内のコード配置は `スクリプト/` フォルダ配下(`OCR仕分け実行.bat` と `config.txt` のみリポジトリ直下) +- 全てのファイル・フォルダ・ログの文言は日本語 + +--- + +## Task 1: リポジトリ構造整備 + +**Files:** +- Create: `スクリプト/`(新規フォルダ) +- Move: `box_detector.py` → `スクリプト/box_detector.py` +- Move: `extract_and_rename.py` → `スクリプト/extract_and_rename.py` +- Create: `.gitignore` +- Create: `スクリプト/tests/conftest.py` +- Create: `スクリプト/tests/test_smoke.py` + +**Interfaces:** +- Produces: `スクリプト/tests/conftest.py` が `sys.path` に `スクリプト/` を追加する。以降の全テストファイルはこれを前提に `from config_loader import ...` のような直接importができる。 + +- [ ] **Step 1: 既存ファイルをスクリプト/フォルダへ移動** + +```bash +cd "c:/Users/k.nogi/#GitHub/ken_nogi/PythonProj/ScanOCR" +mkdir -p スクリプト +git mv box_detector.py スクリプト/box_detector.py +git mv extract_and_rename.py スクリプト/extract_and_rename.py +``` + +- [ ] **Step 2: .gitignore作成** + +`.gitignore` に以下を書く: + +```gitignore +dist/ +__pycache__/ +*.pyc +.pytest_cache/ +.lock +ログ/ +``` + +- [ ] **Step 3: pytestインストール・conftest.py作成** + +```bash +python -m pip install pytest +mkdir -p スクリプト/tests +``` + +`スクリプト/tests/conftest.py`: + +```python +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +``` + +`スクリプト/tests/test_smoke.py`: + +```python +def test_smoke(): + assert True +``` + +- [ ] **Step 4: テスト実行確認** + +Run: `python -m pytest スクリプト/tests -v` +Expected: `test_smoke.py::test_smoke PASSED`、1 passed + +- [ ] **Step 5: コミット** + +```bash +git add .gitignore スクリプト/ +git commit -m "chore: スクリプト配置をスクリプト/フォルダへ整理しテスト基盤を追加" +``` + +--- + +## Task 2: config_loader.py(config.txt読込) + +**Files:** +- Create: `スクリプト/config_loader.py` +- Test: `スクリプト/tests/test_config_loader.py` + +**Interfaces:** +- Produces: + - `class ConfigError(Exception)` + - `REQUIRED_KEYS: tuple[str, ...]` + - `load_config(config_path: Path) -> dict[str, str]` — 全項目を文字列のまま返す。数値変換は呼び出し側の責務 + +- [ ] **Step 1: 失敗するテストを書く** + +`スクリプト/tests/test_config_loader.py`: + +```python +import pytest +from pathlib import Path + +from config_loader import ConfigError, load_config + + +VALID_CONTENT = """\ +スクリプトフォルダ=スクリプト +テンプレートフォルダ=テンプレート +スキャンフォルダ=スキャン +アウトプットフォルダ=アウトプット +成功フォルダ=成功 +失敗フォルダ=失敗 +ログフォルダ=ログ +margin=0.10 +DPI=300 +ファイル安定待ち秒=1 +ファイル安定待ちリトライ回数=5 +""" + + +def test_load_config_missing_file_raises(tmp_path): + missing_path = tmp_path / "config.txt" + with pytest.raises(ConfigError): + load_config(missing_path) + + +def test_load_config_parses_all_keys(tmp_path): + config_path = tmp_path / "config.txt" + config_path.write_text(VALID_CONTENT, encoding="utf-8") + + config = load_config(config_path) + + assert config["スクリプトフォルダ"] == "スクリプト" + assert config["テンプレートフォルダ"] == "テンプレート" + assert config["margin"] == "0.10" + assert config["ファイル安定待ちリトライ回数"] == "5" + + +def test_load_config_missing_required_key_raises(tmp_path): + config_path = tmp_path / "config.txt" + config_path.write_text("スクリプトフォルダ=スクリプト\n", encoding="utf-8") + + with pytest.raises(ConfigError): + load_config(config_path) + + +def test_load_config_ignores_blank_lines_and_comments(tmp_path): + config_path = tmp_path / "config.txt" + content = "# comment\n\n" + VALID_CONTENT + config_path.write_text(content, encoding="utf-8") + + config = load_config(config_path) + + assert config["スクリプトフォルダ"] == "スクリプト" +``` + +- [ ] **Step 2: テスト実行し失敗を確認** + +Run: `python -m pytest スクリプト/tests/test_config_loader.py -v` +Expected: `ModuleNotFoundError: No module named 'config_loader'` で全件FAIL + +- [ ] **Step 3: config_loader.py実装** + +`スクリプト/config_loader.py`: + +```python +# -*- coding: utf-8 -*- +"""config.txt(key=value形式)読込モジュール。""" +from __future__ import annotations + +from pathlib import Path + + +class ConfigError(Exception): + pass + + +REQUIRED_KEYS = ( + "スクリプトフォルダ", + "テンプレートフォルダ", + "スキャンフォルダ", + "アウトプットフォルダ", + "成功フォルダ", + "失敗フォルダ", + "ログフォルダ", + "margin", + "DPI", + "ファイル安定待ち秒", + "ファイル安定待ちリトライ回数", +) + + +def load_config(config_path: Path) -> dict[str, str]: + if not config_path.exists(): + raise ConfigError(f"config.txt が見つかりません: {config_path}") + + config: dict[str, str] = {} + for raw_line in config_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + config[key.strip()] = value.strip() + + missing = [key for key in REQUIRED_KEYS if key not in config] + if missing: + raise ConfigError( + f"config.txt に必須項目が不足しています: {', '.join(missing)}" + ) + + return config +``` + +- [ ] **Step 4: テスト実行し成功を確認** + +Run: `python -m pytest スクリプト/tests/test_config_loader.py -v` +Expected: 4 passed + +- [ ] **Step 5: コミット** + +```bash +git add スクリプト/config_loader.py スクリプト/tests/test_config_loader.py +git commit -m "feat: config.txt読込モジュールを追加" +``` + +--- + +## Task 3: file_ops.py(ファイル名衝突回避コピー・移動) + +**Files:** +- Create: `スクリプト/file_ops.py` +- Test: `スクリプト/tests/test_file_ops.py` + +**Interfaces:** +- Produces: + - `resolve_unique_path(dest_dir: Path, stem: str, suffix: str) -> Path` + - `copy_with_unique_name(src: Path, dest_dir: Path, stem: str, suffix: str) -> Path` + - `move_with_unique_name(src: Path, dest_dir: Path, stem: str, suffix: str) -> Path` + +- [ ] **Step 1: 失敗するテストを書く** + +`スクリプト/tests/test_file_ops.py`: + +```python +from pathlib import Path + +from file_ops import copy_with_unique_name, move_with_unique_name, resolve_unique_path + + +def test_resolve_unique_path_no_collision(tmp_path): + result = resolve_unique_path(tmp_path, "氏名A_20260101", ".pdf") + assert result == tmp_path / "氏名A_20260101.pdf" + + +def test_resolve_unique_path_with_collision(tmp_path): + (tmp_path / "氏名A_20260101.pdf").write_bytes(b"x") + + result = resolve_unique_path(tmp_path, "氏名A_20260101", ".pdf") + + assert result == tmp_path / "氏名A_20260101(2).pdf" + + +def test_resolve_unique_path_with_multiple_collisions(tmp_path): + (tmp_path / "氏名A_20260101.pdf").write_bytes(b"x") + (tmp_path / "氏名A_20260101(2).pdf").write_bytes(b"x") + + result = resolve_unique_path(tmp_path, "氏名A_20260101", ".pdf") + + assert result == tmp_path / "氏名A_20260101(3).pdf" + + +def test_copy_with_unique_name_creates_dest_dir(tmp_path): + src = tmp_path / "src.pdf" + src.write_bytes(b"content") + dest_dir = tmp_path / "output" / "氏名A" + + result = copy_with_unique_name(src, dest_dir, "氏名A_20260101", ".pdf") + + assert result == dest_dir / "氏名A_20260101.pdf" + assert result.read_bytes() == b"content" + assert src.exists() # コピーなので元ファイルは残る + + +def test_move_with_unique_name_moves_source(tmp_path): + src = tmp_path / "src.pdf" + src.write_bytes(b"content") + dest_dir = tmp_path / "success" + + result = move_with_unique_name(src, dest_dir, "src", ".pdf") + + assert result == dest_dir / "src.pdf" + assert result.read_bytes() == b"content" + assert not src.exists() # 移動なので元ファイルは消える +``` + +- [ ] **Step 2: テスト実行し失敗を確認** + +Run: `python -m pytest スクリプト/tests/test_file_ops.py -v` +Expected: `ModuleNotFoundError: No module named 'file_ops'` で全件FAIL + +- [ ] **Step 3: file_ops.py実装(衝突回避部分)** + +`スクリプト/file_ops.py`: + +```python +# -*- coding: utf-8 -*- +"""ファイル名衝突回避コピー・移動、ファイルサイズ安定待ちモジュール。""" +from __future__ import annotations + +import shutil +import time +from pathlib import Path +from typing import Callable + + +def resolve_unique_path(dest_dir: Path, stem: str, suffix: str) -> Path: + candidate = dest_dir / f"{stem}{suffix}" + if not candidate.exists(): + return candidate + + counter = 2 + while True: + candidate = dest_dir / f"{stem}({counter}){suffix}" + if not candidate.exists(): + return candidate + counter += 1 + + +def copy_with_unique_name(src: Path, dest_dir: Path, stem: str, suffix: str) -> Path: + dest_dir.mkdir(parents=True, exist_ok=True) + dest_path = resolve_unique_path(dest_dir, stem, suffix) + shutil.copy2(str(src), str(dest_path)) + return dest_path + + +def move_with_unique_name(src: Path, dest_dir: Path, stem: str, suffix: str) -> Path: + dest_dir.mkdir(parents=True, exist_ok=True) + dest_path = resolve_unique_path(dest_dir, stem, suffix) + shutil.move(str(src), str(dest_path)) + return dest_path +``` + +- [ ] **Step 4: テスト実行し成功を確認** + +Run: `python -m pytest スクリプト/tests/test_file_ops.py -v` +Expected: 5 passed + +- [ ] **Step 5: コミット** + +```bash +git add スクリプト/file_ops.py スクリプト/tests/test_file_ops.py +git commit -m "feat: ファイル名衝突回避コピー・移動モジュールを追加" +``` + +--- + +## Task 4: file_ops.py(ファイルサイズ安定待ち) + +**Files:** +- Modify: `スクリプト/file_ops.py`(Task 3で作成したファイルに追記) +- Test: `スクリプト/tests/test_file_ops.py`(追記) + +**Interfaces:** +- Consumes: なし(Task 3の関数とは独立) +- Produces: `wait_until_stable(path: Path, interval_sec: float, retries: int, sleep_func: Callable[[float], None] = time.sleep) -> bool` + - ファイルが存在しなければ即 `False` + - `interval_sec` 秒待って直前サイズと比較、一致すれば `True` + - `retries` 回試行しても一致しなければ `False` + +- [ ] **Step 1: 失敗するテストを書く** + +`スクリプト/tests/test_file_ops.py` に追記: + +```python +from file_ops import wait_until_stable + + +def test_wait_until_stable_missing_file_returns_false(tmp_path): + missing = tmp_path / "missing.pdf" + + result = wait_until_stable(missing, interval_sec=0, retries=3, sleep_func=lambda s: None) + + assert result is False + + +def test_wait_until_stable_stable_file_returns_true(tmp_path): + path = tmp_path / "stable.pdf" + path.write_bytes(b"1234") + + result = wait_until_stable(path, interval_sec=0, retries=3, sleep_func=lambda s: None) + + assert result is True + + +def test_wait_until_stable_growing_file_returns_false(tmp_path): + path = tmp_path / "growing.pdf" + path.write_bytes(b"1") + + call_count = {"n": 0} + + def fake_sleep(_seconds): + call_count["n"] += 1 + path.write_bytes(b"1" * (call_count["n"] + 1)) + + result = wait_until_stable(path, interval_sec=0, retries=3, sleep_func=fake_sleep) + + assert result is False +``` + +- [ ] **Step 2: テスト実行し失敗を確認** + +Run: `python -m pytest スクリプト/tests/test_file_ops.py -v -k wait_until_stable` +Expected: `ImportError: cannot import name 'wait_until_stable'` で全件FAIL + +- [ ] **Step 3: wait_until_stable実装** + +`スクリプト/file_ops.py` の末尾に追記: + +```python +def wait_until_stable( + path: Path, + interval_sec: float, + retries: int, + sleep_func: Callable[[float], None] = time.sleep, +) -> bool: + if not path.exists(): + return False + + previous_size = path.stat().st_size + for _ in range(retries): + sleep_func(interval_sec) + if not path.exists(): + return False + current_size = path.stat().st_size + if current_size == previous_size: + return True + previous_size = current_size + + return False +``` + +- [ ] **Step 4: テスト実行し成功を確認** + +Run: `python -m pytest スクリプト/tests/test_file_ops.py -v` +Expected: 8 passed + +- [ ] **Step 5: コミット** + +```bash +git add スクリプト/file_ops.py スクリプト/tests/test_file_ops.py +git commit -m "feat: ファイルサイズ安定待ちロジックを追加" +``` + +--- + +## Task 5: lock_manager.py(二重起動防止ロック) + +**Files:** +- Create: `スクリプト/lock_manager.py` +- Test: `スクリプト/tests/test_lock_manager.py` + +**Interfaces:** +- Produces: + - `class LockAcquisitionError(Exception)` + - `acquire_lock(lock_path: Path, pid_checker: Callable[[int], bool] = _is_pid_running_windows) -> None` + - `release_lock(lock_path: Path) -> None` + +- [ ] **Step 1: 失敗するテストを書く** + +`スクリプト/tests/test_lock_manager.py`: + +```python +import os + +import pytest + +from lock_manager import LockAcquisitionError, acquire_lock, release_lock + + +def test_acquire_lock_creates_file_with_pid(tmp_path): + lock_path = tmp_path / ".lock" + + acquire_lock(lock_path, pid_checker=lambda pid: True) + + assert lock_path.exists() + assert lock_path.read_text(encoding="utf-8").strip() == str(os.getpid()) + + +def test_acquire_lock_raises_when_existing_pid_running(tmp_path): + lock_path = tmp_path / ".lock" + lock_path.write_text("12345", encoding="utf-8") + + with pytest.raises(LockAcquisitionError): + acquire_lock(lock_path, pid_checker=lambda pid: True) + + +def test_acquire_lock_replaces_stale_lock(tmp_path): + lock_path = tmp_path / ".lock" + lock_path.write_text("12345", encoding="utf-8") + + acquire_lock(lock_path, pid_checker=lambda pid: False) + + assert lock_path.read_text(encoding="utf-8").strip() == str(os.getpid()) + + +def test_release_lock_removes_file(tmp_path): + lock_path = tmp_path / ".lock" + lock_path.write_text("12345", encoding="utf-8") + + release_lock(lock_path) + + assert not lock_path.exists() + + +def test_release_lock_missing_file_no_error(tmp_path): + lock_path = tmp_path / ".lock" + + release_lock(lock_path) # 例外が出ないことを確認するだけ +``` + +- [ ] **Step 2: テスト実行し失敗を確認** + +Run: `python -m pytest スクリプト/tests/test_lock_manager.py -v` +Expected: `ModuleNotFoundError: No module named 'lock_manager'` で全件FAIL + +- [ ] **Step 3: lock_manager.py実装** + +`スクリプト/lock_manager.py`: + +```python +# -*- coding: utf-8 -*- +"""二重起動防止ロックファイル管理モジュール(Windows専用)。""" +from __future__ import annotations + +import ctypes +import os +from pathlib import Path +from typing import Callable + + +class LockAcquisitionError(Exception): + pass + + +def _is_pid_running_windows(pid: int) -> bool: + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if handle: + ctypes.windll.kernel32.CloseHandle(handle) + return True + return False + + +def acquire_lock( + lock_path: Path, + pid_checker: Callable[[int], bool] = _is_pid_running_windows, +) -> None: + if lock_path.exists(): + try: + recorded_pid = int(lock_path.read_text(encoding="utf-8").strip()) + except ValueError: + recorded_pid = None + + if recorded_pid is not None and pid_checker(recorded_pid): + raise LockAcquisitionError(f"既に実行中です(PID={recorded_pid})") + + lock_path.unlink() + + lock_path.write_text(str(os.getpid()), encoding="utf-8") + + +def release_lock(lock_path: Path) -> None: + if lock_path.exists(): + lock_path.unlink() +``` + +- [ ] **Step 4: テスト実行し成功を確認** + +Run: `python -m pytest スクリプト/tests/test_lock_manager.py -v` +Expected: 5 passed + +- [ ] **Step 5: コミット** + +```bash +git add スクリプト/lock_manager.py スクリプト/tests/test_lock_manager.py +git commit -m "feat: 二重起動防止ロック管理モジュールを追加" +``` + +--- + +## Task 6: logger.py(実行ログ出力) + +**Files:** +- Create: `スクリプト/logger.py` +- Test: `スクリプト/tests/test_logger.py` + +**Interfaces:** +- Produces: + - `LOG_ENCODING = "utf-8-sig"` + - `log_path_for_today(log_dir: Path, now_func: Callable[[], datetime] = datetime.now) -> Path` + - `append_log(log_dir: Path, result: str, original_filename: str, name: str | None, date: str | None, note: str = "", now_func: Callable[[], datetime] = datetime.now) -> None` + +- [ ] **Step 1: 失敗するテストを書く** + +`スクリプト/tests/test_logger.py`: + +```python +from datetime import datetime + +from logger import LOG_ENCODING, append_log, log_path_for_today + + +def fixed_now(): + return datetime(2026, 8, 2, 10, 30, 15) + + +def test_log_path_for_today_uses_now_func(tmp_path): + result = log_path_for_today(tmp_path, now_func=fixed_now) + assert result == tmp_path / "2026-08-02.log" + + +def test_append_log_creates_dir_and_file(tmp_path): + log_dir = tmp_path / "ログ" + + append_log( + log_dir, + result="成功", + original_filename="scan001.pdf", + name="山田太郎", + date="20260802", + note="", + now_func=fixed_now, + ) + + log_path = log_dir / "2026-08-02.log" + assert log_path.exists() + + content = log_path.read_text(encoding=LOG_ENCODING) + assert "[2026-08-02 10:30:15]" in content + assert "結果=成功" in content + assert "元ファイル=scan001.pdf" in content + assert "氏名=山田太郎" in content + assert "日付=20260802" in content + + +def test_append_log_handles_none_name_and_date(tmp_path): + log_dir = tmp_path / "ログ" + + append_log( + log_dir, + result="失敗", + original_filename="scan002.pdf", + name=None, + date=None, + note="抽出不十分", + now_func=fixed_now, + ) + + content = (log_dir / "2026-08-02.log").read_text(encoding=LOG_ENCODING) + assert "氏名=" in content + assert "日付=" in content + assert "備考=抽出不十分" in content + + +def test_append_log_appends_multiple_lines(tmp_path): + log_dir = tmp_path / "ログ" + + append_log(log_dir, "成功", "a.pdf", "氏名A", "20260801", "", now_func=fixed_now) + append_log(log_dir, "成功", "b.pdf", "氏名B", "20260802", "", now_func=fixed_now) + + content = (log_dir / "2026-08-02.log").read_text(encoding=LOG_ENCODING) + assert content.count("結果=成功") == 2 +``` + +- [ ] **Step 2: テスト実行し失敗を確認** + +Run: `python -m pytest スクリプト/tests/test_logger.py -v` +Expected: `ModuleNotFoundError: No module named 'logger'` で全件FAIL + +- [ ] **Step 3: logger.py実装** + +`スクリプト/logger.py`: + +```python +# -*- coding: utf-8 -*- +"""実行ログ出力モジュール。日付別ファイル・UTF-8 BOM付き。""" +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Callable + +LOG_ENCODING = "utf-8-sig" + + +def log_path_for_today( + log_dir: Path, now_func: Callable[[], datetime] = datetime.now +) -> Path: + return log_dir / f"{now_func().strftime('%Y-%m-%d')}.log" + + +def append_log( + log_dir: Path, + result: str, + original_filename: str, + name: str | None, + date: str | None, + note: str = "", + now_func: Callable[[], datetime] = datetime.now, +) -> None: + log_dir.mkdir(parents=True, exist_ok=True) + now = now_func() + line = ( + f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] " + f"結果={result} | 元ファイル={original_filename} | " + f"氏名={name or ''} | 日付={date or ''} | 備考={note}\n" + ) + path = log_path_for_today(log_dir, now_func) + with open(path, "a", encoding=LOG_ENCODING) as f: + f.write(line) +``` + +- [ ] **Step 4: テスト実行し成功を確認** + +Run: `python -m pytest スクリプト/tests/test_logger.py -v` +Expected: 4 passed + +- [ ] **Step 5: コミット** + +```bash +git add スクリプト/logger.py スクリプト/tests/test_logger.py +git commit -m "feat: 実行ログ出力モジュールを追加" +``` + +--- + +## Task 7: extract_and_rename.py — 初期化系ヘルパー関数(テンプレート選定・依存チェック) + +**Files:** +- Modify: `スクリプト/extract_and_rename.py` +- Test: `スクリプト/tests/test_extract_and_rename.py` + +**Interfaces:** +- Consumes: なし +- Produces: + - `TEMPLATE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp"}` + - `select_template_file(template_dir: Path) -> Path` + - `REQUIRED_MODULES = ("cv2", "numpy", "PIL", "pytesseract", "pdf2image")` + - `check_dependencies(modules: tuple[str, ...] = REQUIRED_MODULES) -> list[str]` + - `check_external_tools(tesseract_exe: Path, poppler_exe: Path) -> list[str]` + +この時点では既存の `FieldBox` / `load_field_boxes` / `ocr_region` / `clean_name` / `clean_date` / `process_pdf` はそのまま残す(Task 8で `main()` を書き換える際に整理する)。既存の `safe_move` 関数と旧 `main()`(argparse版)はこのTaskではまだ削除しない。 + +- [ ] **Step 1: 失敗するテストを書く** + +`スクリプト/tests/test_extract_and_rename.py`: + +```python +from pathlib import Path + +from extract_and_rename import ( + check_dependencies, + check_external_tools, + select_template_file, +) + + +def test_select_template_file_picks_first_by_name(tmp_path): + (tmp_path / "b_template.png").write_bytes(b"x") + (tmp_path / "a_template.png").write_bytes(b"x") + (tmp_path / "note.txt").write_bytes(b"x") # 対象外拡張子 + + result = select_template_file(tmp_path) + + assert result == tmp_path / "a_template.png" + + +def test_select_template_file_no_candidates_raises(tmp_path): + (tmp_path / "note.txt").write_bytes(b"x") + + try: + select_template_file(tmp_path) + assert False, "FileNotFoundErrorが送出されるべき" + except FileNotFoundError: + pass + + +def test_check_dependencies_detects_missing_module(): + result = check_dependencies(("this_module_does_not_exist_xyz",)) + assert result == ["this_module_does_not_exist_xyz"] + + +def test_check_dependencies_detects_installed_module(): + result = check_dependencies(("os",)) + assert result == [] + + +def test_check_external_tools_detects_missing(tmp_path): + tesseract = tmp_path / "tools" / "tesseract" / "tesseract.exe" + poppler = tmp_path / "tools" / "poppler" / "pdftoppm.exe" + + result = check_external_tools(tesseract, poppler) + + assert str(tesseract) in result + assert str(poppler) in result + + +def test_check_external_tools_all_present(tmp_path): + tesseract = tmp_path / "tesseract.exe" + poppler = tmp_path / "pdftoppm.exe" + tesseract.write_bytes(b"x") + poppler.write_bytes(b"x") + + result = check_external_tools(tesseract, poppler) + + assert result == [] +``` + +- [ ] **Step 2: テスト実行し失敗を確認** + +Run: `python -m pytest スクリプト/tests/test_extract_and_rename.py -v` +Expected: `ImportError`(`select_template_file` 等が存在しない)で全件FAIL + +- [ ] **Step 3: ヘルパー関数を実装** + +`スクリプト/extract_and_rename.py` の先頭 import 群の直後に追記(既存コードは変更しない): + +```python +import importlib + +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 +``` + +- [ ] **Step 4: テスト実行し成功を確認** + +Run: `python -m pytest スクリプト/tests/test_extract_and_rename.py -v` +Expected: 5 passed + +- [ ] **Step 5: コミット** + +```bash +git add スクリプト/extract_and_rename.py スクリプト/tests/test_extract_and_rename.py +git commit -m "feat: テンプレート選定・依存チェックヘルパーを追加" +``` + +--- + +## Task 8: extract_and_rename.py — キュー処理ループ(run_queue) + +**Files:** +- Modify: `スクリプト/extract_and_rename.py` +- Test: `スクリプト/tests/test_extract_and_rename.py`(追記) + +**Interfaces:** +- Consumes: + - `file_ops.copy_with_unique_name`, `file_ops.move_with_unique_name`, `file_ops.wait_until_stable` + - `logger.append_log` + - 既存 `process_pdf(pdf_path: Path, boxes: list[FieldBox], margin: float) -> tuple[str | None, str | None]` +- Produces: + - `key_pressed() -> bool`(`msvcrt.kbhit()` の薄いラッパー) + - `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` + +- [ ] **Step 1: 失敗するテストを書く** + +`スクリプト/tests/test_extract_and_rename.py` に追記: + +```python +from extract_and_rename import run_queue + + +def _make_dirs(tmp_path): + dirs = { + "scan": tmp_path / "スキャン", + "output": tmp_path / "アウトプット", + "success": tmp_path / "成功", + "failed": tmp_path / "失敗", + "log": tmp_path / "ログ", + } + dirs["scan"].mkdir() + return dirs + + +def test_run_queue_success_moves_and_copies(tmp_path): + dirs = _make_dirs(tmp_path) + pdf = dirs["scan"] / "scan001.pdf" + pdf.write_bytes(b"dummy") + + def fake_process_pdf(path, boxes, margin): + return ("氏名A", "20260802") + + run_queue( + dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"], + boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1, + process_pdf_func=fake_process_pdf, + key_check=lambda: False, + sleep_func=lambda s: None, + ) + + assert (dirs["output"] / "氏名A" / "氏名A_20260802.pdf").exists() + assert (dirs["success"] / "scan001.pdf").exists() + assert not pdf.exists() + log_content = (dirs["log"] / f"{__import__('datetime').datetime.now().strftime('%Y-%m-%d')}.log").read_text(encoding="utf-8-sig") + assert "結果=成功" in log_content + + +def test_run_queue_missing_name_moves_to_failed(tmp_path): + dirs = _make_dirs(tmp_path) + pdf = dirs["scan"] / "scan002.pdf" + pdf.write_bytes(b"dummy") + + def fake_process_pdf(path, boxes, margin): + return (None, "20260802") + + run_queue( + dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"], + boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1, + process_pdf_func=fake_process_pdf, + key_check=lambda: False, + sleep_func=lambda s: None, + ) + + assert (dirs["failed"] / "scan002.pdf").exists() + assert not pdf.exists() + + +def test_run_queue_exception_moves_to_failed(tmp_path): + dirs = _make_dirs(tmp_path) + pdf = dirs["scan"] / "scan003.pdf" + pdf.write_bytes(b"dummy") + + def fake_process_pdf(path, boxes, margin): + raise RuntimeError("OCRエラー") + + run_queue( + dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"], + boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1, + process_pdf_func=fake_process_pdf, + key_check=lambda: False, + sleep_func=lambda s: None, + ) + + assert (dirs["failed"] / "scan003.pdf").exists() + + +def test_run_queue_stops_on_key_press(tmp_path): + dirs = _make_dirs(tmp_path) + pdf = dirs["scan"] / "scan004.pdf" + pdf.write_bytes(b"dummy") + + calls = [] + + def fake_process_pdf(path, boxes, margin): + calls.append(path) + return ("氏名A", "20260802") + + run_queue( + dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"], + boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1, + process_pdf_func=fake_process_pdf, + key_check=lambda: True, # 常に押下扱い + sleep_func=lambda s: None, + ) + + assert calls == [] + assert pdf.exists() # 処理されず残る + + +def test_run_queue_skips_file_deleted_before_processing(tmp_path): + dirs = _make_dirs(tmp_path) + pdf = dirs["scan"] / "scan005.pdf" + pdf.write_bytes(b"dummy") + + calls = [] + + def fake_process_pdf(path, boxes, margin): + calls.append(path) + return ("氏名A", "20260802") + + def key_check_and_delete(): + if pdf.exists(): + pdf.unlink() + return False + + run_queue( + dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"], + boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1, + process_pdf_func=fake_process_pdf, + key_check=key_check_and_delete, + sleep_func=lambda s: None, + ) + + assert calls == [] + + +def test_run_queue_empty_scan_dir_returns_immediately(tmp_path): + dirs = _make_dirs(tmp_path) + + run_queue( + dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"], + boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1, + process_pdf_func=lambda p, b, m: ("氏名A", "20260802"), + key_check=lambda: False, + sleep_func=lambda s: None, + ) + # 例外が出ずに戻ることのみ確認 + + +def test_run_queue_name_collision_gets_suffix(tmp_path): + dirs = _make_dirs(tmp_path) + existing_output_dir = dirs["output"] / "氏名A" + existing_output_dir.mkdir(parents=True) + (existing_output_dir / "氏名A_20260802.pdf").write_bytes(b"old") + + pdf = dirs["scan"] / "scan006.pdf" + pdf.write_bytes(b"dummy") + + run_queue( + dirs["scan"], dirs["output"], dirs["success"], dirs["failed"], dirs["log"], + boxes=[], margin=0.1, stable_wait_sec=0, stable_retries=1, + process_pdf_func=lambda p, b, m: ("氏名A", "20260802"), + key_check=lambda: False, + sleep_func=lambda s: None, + ) + + assert (existing_output_dir / "氏名A_20260802(2).pdf").exists() +``` + +- [ ] **Step 2: テスト実行し失敗を確認** + +Run: `python -m pytest スクリプト/tests/test_extract_and_rename.py -v -k run_queue` +Expected: `ImportError: cannot import name 'run_queue'` で全件FAIL + +- [ ] **Step 3: run_queue実装** + +`スクリプト/extract_and_rename.py` の Task 7 で追加した関数群の直後に追記。合わせてファイル先頭の import 群に `import time`、`import traceback`、`from file_ops import copy_with_unique_name, move_with_unique_name, wait_until_stable`、`from logger import append_log` を追加する: + +```python +import time +import traceback + +from file_ops import copy_with_unique_name, move_with_unique_name, wait_until_stable +from logger import append_log + + +def key_pressed() -> bool: + import msvcrt + + return msvcrt.kbhit() + + +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, "") +``` + +- [ ] **Step 4: テスト実行し成功を確認** + +Run: `python -m pytest スクリプト/tests/test_extract_and_rename.py -v` +Expected: 12 passed(Task 7の5件 + 本Taskの7件) + +- [ ] **Step 5: コミット** + +```bash +git add スクリプト/extract_and_rename.py スクリプト/tests/test_extract_and_rename.py +git commit -m "feat: キュー処理メインループ(run_queue)を追加" +``` + +--- + +## Task 9: extract_and_rename.py — main()統合・旧CLI削除 + +**Files:** +- Modify: `スクリプト/extract_and_rename.py` + +**Interfaces:** +- Consumes: `config_loader.load_config`, `lock_manager.acquire_lock/release_lock/LockAcquisitionError`, `box_detector.detect_template_fields`, Task 7/8で作った全ヘルパー +- Produces: `main() -> None`(配布パッケージ実行時のエントリーポイント) + +このTaskはオーケストレーション層のみでユニットテストは書かない(下位関数は既にTask 2〜8でテスト済みのため)。既存の `argparse` ベースの旧 `main()` と `safe_move` 関数を削除し、固定フォルダ構成版に置き換える。 + +- [ ] **Step 1: 旧mainとsafe_moveを削除** + +`スクリプト/extract_and_rename.py` から `def safe_move(...)` 関数全体と、旧 `def main():`(argparseを使う版)を削除する。`if __name__ == "__main__":` ブロックも一旦削除する(Step 2で再作成)。 + +- [ ] **Step 2: 新main()を実装** + +ファイル末尾に追記: + +```python +from config_loader import load_config +from lock_manager import LockAcquisitionError, acquire_lock, release_lock + +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() +``` + +- [ ] **Step 3: DPI定数の重複確認** + +ファイル冒頭の既存 `DPI = 300` 定数はそのまま残す(`config.txt` の `DPI` 項目は将来的な外部化用として保持するが、このTaskでは `process_pdf` 内の `convert_from_path` 呼び出しは既存の `DPI` 定数を使い続ける。config由来のDPI値を実際に反映するのは本Taskのスコープ外とし、次Task 10のREADMEに既知の制約として明記する)。 + +- [ ] **Step 4: 全テスト実行し既存テストが壊れていないことを確認** + +Run: `python -m pytest スクリプト/tests -v` +Expected: 全件PASSED(Task 1〜8で書いた全テストが引き続き通る) + +- [ ] **Step 5: コミット** + +```bash +git add スクリプト/extract_and_rename.py +git commit -m "feat: 固定フォルダ構成・ロック・ログ統合のmain()に置き換え" +``` + +--- + +## Task 10: OCR仕分け実行.bat作成 + +**Files:** +- Create: `OCR仕分け実行.bat` + +**Interfaces:** +- Consumes: `config.txt`(`スクリプトフォルダ=` 行)、`<スクリプトフォルダ>/python/python.exe`、`<スクリプトフォルダ>/extract_and_rename.py` + +- [ ] **Step 1: バッチファイル作成** + +`OCR仕分け実行.bat`: + +```bat +@echo off +chcp 65001 >nul +cd /d "%~dp0" + +if not exist "config.txt" ( + echo config.txt が見つかりません。配布パッケージが不完全です。 + pause + exit /b 1 +) + +set "SCRIPT_DIR=" +for /f "usebackq tokens=1,2 delims==" %%A in ("config.txt") do ( + if "%%A"=="スクリプトフォルダ" set "SCRIPT_DIR=%%B" +) + +if "%SCRIPT_DIR%"=="" ( + echo config.txt に スクリプトフォルダ の設定が見つかりません。 + pause + exit /b 1 +) + +set "PYTHON_EXE=%~dp0%SCRIPT_DIR%\python\python.exe" +set "MAIN_PY=%~dp0%SCRIPT_DIR%\extract_and_rename.py" + +if not exist "%PYTHON_EXE%" ( + echo 配布パッケージが不完全です(pythonフォルダが見つかりません)。管理者に確認してください。 + pause + exit /b 1 +) + +if not exist "%MAIN_PY%" ( + echo 配布パッケージが不完全です(extract_and_rename.pyが見つかりません)。管理者に確認してください。 + pause + exit /b 1 +) + +"%PYTHON_EXE%" "%MAIN_PY%" +``` + +- [ ] **Step 2: 手動動作確認(config.txt無しケース)** + +`OCR仕分け実行.bat` をリポジトリルート(`config.txt` がまだ存在しない状態、Task 11で作成予定)でダブルクリックし、「config.txt が見つかりません」メッセージが出て一時停止することを目視確認する。この時点ではまだTask 11未実施のため確認できない場合は、Task 11完了後にまとめて確認してよい。 + +- [ ] **Step 3: コミット** + +```bash +git add "OCR仕分け実行.bat" +git commit -m "feat: 起動バッチOCR仕分け実行.batを追加" +``` + +--- + +## Task 11: config.txt雛形・README更新 + +**Files:** +- Create: `config.txt` +- Modify: `README.md` + +- [ ] **Step 1: config.txt雛形作成** + +`config.txt`: + +``` +スクリプトフォルダ=スクリプト +テンプレートフォルダ=テンプレート +スキャンフォルダ=スキャン +アウトプットフォルダ=アウトプット +成功フォルダ=成功 +失敗フォルダ=失敗 +ログフォルダ=ログ +margin=0.10 +DPI=300 +ファイル安定待ち秒=1 +ファイル安定待ちリトライ回数=5 +``` + +- [ ] **Step 2: README.md更新** + +`README.md` の内容を、旧CLI手順(`--template` 等の引数説明)から新しい運用手順に置き換える: + +```markdown +# PDFスキャン監視・自動振り分けツール + +テンプレート画像(赤枠=氏名欄、青枠=日付欄)を基準に、"スキャン"フォルダ内のPDFから +氏名・日付をOCRで抽出し、リネーム・振り分けを行うツールです。 + +## 使い方 + +1. `config.txt` で各フォルダ名・動作パラメータを確認(通常は初期値のまま利用可) +2. `テンプレート/` フォルダにテンプレート画像を1つ入れる(複数ある場合はファイル名が最も早いもの) + - 画像編集ソフト等で、氏名欄に赤い四角枠、日付欄に青い四角枠を重ねて保存(PNG推奨) + - テンプレートは実際の対象PDFと同じ用紙サイズ・向きで作成すること +3. `スキャン/` フォルダに処理対象PDFを入れる +4. `OCR仕分け実行.bat` をダブルクリック +5. 処理結果は以下に振り分けられる: + - 成功: `アウトプット/氏名/氏名_YYYYMMDD.pdf` にコピー、元ファイルは `成功/` へ移動 + - 失敗: `失敗/` フォルダへ元ファイルのまま移動 +6. 実行ログは `ログ/YYYY-MM-DD.log` に記録される +7. 処理を途中で止めたい場合は、コンソールが表示されている間に何かキーを押す(1件処理する度にチェックされる) + +## 開発者向け(配布パッケージのビルド) + +配布用の同梱Python・tesseract・popplerは `build/build_package.py` で自動生成する。 +詳細は同スクリプトのコメントを参照。ネットワーク接続必須、実行には数分〜数十分かかる。 + +## 開発者向け(テスト実行) + +```bash +python -m pip install pytest +python -m pytest スクリプト/tests -v +``` + +## 注意点 + +- OCRは完璧ではありません。運用初期は `アウトプット/` の結果を必ず目視確認してください +- 日付の年が2桁の場合は "20XX年" と仮定して補完します。和暦は非対応です +- 複数ページPDFは1ページ目のみ対象です +- ファイル名衝突時は `氏名_YYYYMMDD(2).pdf` のように連番が付与されます +``` + +- [ ] **Step 3: コミット** + +```bash +git add config.txt README.md +git commit -m "docs: config.txt雛形とREADMEを新運用フローに合わせて更新" +``` + +--- + +## Task 12: build/build_package.py(配布パッケージ自動生成) + +**Files:** +- Create: `build/build_package.py` + +**Interfaces:** +- Consumes: リポジトリ内の `OCR仕分け実行.bat`、`config.txt`、`スクリプト/*.py` +- Produces: `dist/ScanOCR/` 配下の配布用フォルダ一式 + +このスクリプトはネットワークダウンロード・外部インストーラー実行を伴うため自動テスト対象外とする。実行には7-Zip(`7z` コマンド)がPATHに通っていることが前提(tesseract-ocrインストーラーの展開に使用)。**実際のダウンロード・展開が最後まで成功するかは、この計画のコードレビュー時点では検証できない未知のリスクとして扱う。** Step 3でユーザーが実機で1回実行し、失敗した場合はURLやインストーラーの展開方法を個別に調整する前提とする。 + +- [ ] **Step 1: build_package.py実装** + +`build/build_package.py`: + +```python +# -*- coding: utf-8 -*- +""" +配布パッケージ生成スクリプト。 + +開発者PC上で1回実行すると、dist/ScanOCR/ 配下に +Python embeddable版・依存pipパッケージ・tesseract-ocr・poppler一式を含む +配布用フォルダを生成する。 + +前提: + - ネットワーク接続必須 + - 7-Zip(7zコマンド)がPATHに通っていること(tesseractインストーラー展開用) + +使い方: + python build/build_package.py +""" +from __future__ import annotations + +import shutil +import subprocess +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DIST_DIR = REPO_ROOT / "dist" / "ScanOCR" + +PYTHON_VERSION = "3.11.9" +PYTHON_EMBED_URL = ( + f"https://www.python.org/ftp/python/{PYTHON_VERSION}/" + f"python-{PYTHON_VERSION}-embed-amd64.zip" +) +GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" +PIP_PACKAGES = ["opencv-python", "pdf2image", "pytesseract", "pillow", "numpy"] + +TESSERACT_VERSION = "5.4.0.20240606" +TESSERACT_INSTALLER_URL = ( + "https://github.com/UB-Mannheim/tesseract/releases/download/" + f"v{TESSERACT_VERSION}/tesseract-ocr-w64-setup-{TESSERACT_VERSION}.exe" +) + +POPPLER_VERSION = "24.02.0-0" +POPPLER_ZIP_URL = ( + "https://github.com/oschwartz10612/poppler-windows/releases/download/" + f"v{POPPLER_VERSION}/Release-{POPPLER_VERSION}.zip" +) + +CODE_FILES = ( + "extract_and_rename.py", + "box_detector.py", + "config_loader.py", + "file_ops.py", + "lock_manager.py", + "logger.py", +) + +RUNTIME_FOLDERS = ("テンプレート", "スキャン", "アウトプット", "成功", "失敗", "ログ") + + +def download(url: str, dest: Path) -> None: + print(f"ダウンロード: {url}") + urllib.request.urlretrieve(url, dest) + + +def prepare_dist_dir() -> None: + if DIST_DIR.exists(): + shutil.rmtree(DIST_DIR) + DIST_DIR.mkdir(parents=True) + + +def copy_code_files() -> Path: + script_dst = DIST_DIR / "スクリプト" + script_dst.mkdir(parents=True) + for name in CODE_FILES: + shutil.copy2(REPO_ROOT / "スクリプト" / name, script_dst / name) + shutil.copy2(REPO_ROOT / "OCR仕分け実行.bat", DIST_DIR / "OCR仕分け実行.bat") + shutil.copy2(REPO_ROOT / "config.txt", DIST_DIR / "config.txt") + return script_dst + + +def setup_python(script_dst: Path, tmp_dir: Path) -> None: + python_dir = script_dst / "python" + python_dir.mkdir(parents=True) + + zip_path = tmp_dir / "python-embed.zip" + download(PYTHON_EMBED_URL, zip_path) + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(python_dir) + + pth_files = list(python_dir.glob("python*._pth")) + if not pth_files: + raise RuntimeError("python*._pth が見つかりません(embeddable zipの構成が変わった可能性)") + pth_path = pth_files[0] + content = pth_path.read_text(encoding="utf-8") + content = content.replace("#import site", "import site") + pth_path.write_text(content, encoding="utf-8") + + get_pip_path = tmp_dir / "get-pip.py" + download(GET_PIP_URL, get_pip_path) + python_exe = python_dir / "python.exe" + subprocess.run([str(python_exe), str(get_pip_path)], check=True) + subprocess.run( + [str(python_exe), "-m", "pip", "install", *PIP_PACKAGES], + check=True, + ) + + +def setup_tesseract(script_dst: Path, tmp_dir: Path) -> None: + tools_dst = script_dst / "tools" / "tesseract" + tools_dst.mkdir(parents=True) + + installer_path = tmp_dir / "tesseract-setup.exe" + download(TESSERACT_INSTALLER_URL, installer_path) + + extract_dir = tmp_dir / "tesseract-extract" + extract_dir.mkdir() + subprocess.run( + ["7z", "x", str(installer_path), f"-o{extract_dir}", "-y"], + check=True, + ) + for item in extract_dir.iterdir(): + shutil.move(str(item), str(tools_dst / item.name)) + + +def setup_poppler(script_dst: Path, tmp_dir: Path) -> None: + tools_parent = script_dst / "tools" + tools_parent.mkdir(parents=True, exist_ok=True) + tools_dst = tools_parent / "poppler" + + zip_path = tmp_dir / "poppler.zip" + download(POPPLER_ZIP_URL, zip_path) + extract_dir = tmp_dir / "poppler-extract" + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(extract_dir) + + inner_items = list(extract_dir.iterdir()) + source_dir = inner_items[0] if len(inner_items) == 1 else extract_dir + shutil.move(str(source_dir), str(tools_dst)) + + +def create_runtime_folders() -> None: + for name in RUNTIME_FOLDERS: + (DIST_DIR / name).mkdir(parents=True, exist_ok=True) + + +def main() -> None: + prepare_dist_dir() + script_dst = copy_code_files() + + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) + setup_python(script_dst, tmp_dir) + setup_tesseract(script_dst, tmp_dir) + setup_poppler(script_dst, tmp_dir) + + create_runtime_folders() + print(f"配布パッケージ生成完了: {DIST_DIR}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: .gitignoreにdist/が含まれていることを確認** + +Task 1で作成した `.gitignore` に既に `dist/` が含まれているため追加作業は不要。`git status` で `dist/` がトラッキング対象外になっていることを確認する。 + +Run: `git status --short` +Expected: `dist/` 配下のファイルが(生成されていても)一覧に出ない + +- [ ] **Step 3: コミット** + +```bash +git add build/build_package.py +git commit -m "feat: 配布パッケージ自動生成スクリプトを追加" +``` + +**注記(ユーザー向け確認事項):** このタスクはコードとして実装するが、実際に最後まで実行してdist/ScanOCR/配下が正しく動く状態になるかは、7-Zipインストール状況・各ダウンロードURLの生存・tesseractインストーラーの展開可否に依存する。初回実行時にエラーが出た場合は、エラーメッセージを元に該当関数(`setup_tesseract`が最もリスクが高い)を調整する。 + +--- + +## 全体自己レビュー結果 + +- **spec網羅性**: 設計書の全セクション(配布パッケージ構成/config.txt仕様/起動フロー/メインループ/終了処理/ログ仕様/ビルドスクリプト/対象外事項)に対応するタスクを配置済み +- **placeholder**: なし。全ステップに実コードを記載 +- **型・シグネチャ一貫性**: `run_queue`・`copy_with_unique_name`・`move_with_unique_name`・`append_log`・`acquire_lock` の引数名はTask間で統一済み +- **既知の未実装事項**: `config.txt` の `DPI` 項目は雛形には存在するが `process_pdf` には未接続(Task 9 Step 3に明記)。将来対応が必要ならフォローアップタスクとする diff --git a/PythonProj/ScanOCR/docs/superpowers/specs/2026-08-02-watch-launcher-design.md b/PythonProj/ScanOCR/docs/superpowers/specs/2026-08-02-watch-launcher-design.md new file mode 100644 index 00000000..6694d0ac --- /dev/null +++ b/PythonProj/ScanOCR/docs/superpowers/specs/2026-08-02-watch-launcher-design.md @@ -0,0 +1,164 @@ +# PDFスキャン監視・自動振り分けツール 設計書 + +日付: 2026-08-02 +対象ブランチ: feature/process-flowchart-generator(ScanOCRプロジェクト) + +## 概要 + +既存 `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, pdf2image)import確認 + - 失敗 → 不足モジュール名明示「配布パッケージが壊れています」表示・終了 +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とも具体バージョン番号をスクリプト内定数として固定(ピン留め)。互換性問題を避けるため、更新したい場合はスクリプト内の定数を手動で書き換える運用とする。具体的なバージョン番号は実装時に確定。 + +**ライセンス**: tesseract(Apache 2.0)・poppler(GPL系)とも再配布可能。社内限定配布であれば問題なし。ライセンス文書の同梱は本設計のスコープ外(必要になれば別途対応)。 + +## 対象外・既存仕様からの継承事項 + +- 複数ページPDFは1ページ目のみ対象(既存仕様継承) +- 和暦・昭和/平成表記非対応(既存 `clean_date()` の制約継承) +- OCR誤読は完全には防げない。運用初期は "アウトプット" 配下の目視確認を推奨 + +## 今回のスコープ外(将来検討事項) + +- 常時監視(デーモン化)は対象外。1回の起動で「その時点のキューを処理し尽くしたら終了」する設計 diff --git a/PythonProj/ScanOCR/スクリプト/box_detector.py b/PythonProj/ScanOCR/スクリプト/box_detector.py new file mode 100644 index 00000000..69b040a6 --- /dev/null +++ b/PythonProj/ScanOCR/スクリプト/box_detector.py @@ -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)}") diff --git a/PythonProj/ScanOCR/スクリプト/extract_and_rename.py b/PythonProj/ScanOCR/スクリプト/extract_and_rename.py new file mode 100644 index 00000000..c1b1392f --- /dev/null +++ b/PythonProj/ScanOCR/スクリプト/extract_and_rename.py @@ -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() diff --git a/PythonProj/ScanOCR/スクリプト/tests/conftest.py b/PythonProj/ScanOCR/スクリプト/tests/conftest.py new file mode 100644 index 00000000..ece8e0c1 --- /dev/null +++ b/PythonProj/ScanOCR/スクリプト/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/PythonProj/ScanOCR/スクリプト/tests/test_smoke.py b/PythonProj/ScanOCR/スクリプト/tests/test_smoke.py new file mode 100644 index 00000000..7252c68d --- /dev/null +++ b/PythonProj/ScanOCR/スクリプト/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke(): + assert True