"""旧 exe を自動操作して回帰テストの採取表を作る(TEST_01 の全自動化).
単色 PNG を 1 枚ずつ旧 exe に読み込ませて «舌色診断» を実行し,表示された
L\\*a\\*b\\* と 5 色スコアを `WM_GETTEXT` で読み取って CSV に追記する.
人手の操作は不要(画像の枚数だけ exe の起動と終了を繰り返す).
依存は Python 標準の `ctypes` / `subprocess` のみ.**Windows 専用**.
値の読み取りとパースは `read_legacy_values.py` を再利用する.
## 使い方
```bash
uv run python scripts/auto_capture.py \\
--images-dir <単色 PNG のフォルダ> \\
--matrix <変換行列 .csv のパス> \\
--out out/capture_sheet.csv
```
まず 1 枚だけで動作を確かめる(推奨):
```bash
uv run python scripts/auto_capture.py \\
--images-dir <フォルダ> --matrix <パス> --out out/capture_sheet.csv \\
--single rgb_156_102_098.png
```
画像ファイル名は `rgb_RRR_GGG_BBB.png`(`scripts/generate_regression_images.py` の出力)
とし,そこから入力 RGB を読み取る.出力はヘッダ付きの CSV へ**追記**する.
## 動作の流れ(1 枚あたり)
1. 旧 exe を起動する(画像ごとに起動・終了する.理由は下の «設計判断»)
2. 変換行列と画像のパスを,参照用エディットコントロールへ直接設定する
3. 画像上を 4 点クリックする(ダイアログへ `WM_LBUTTONDOWN` / `WM_LBUTTONUP` を送る)
4. «舌色診断» ボタンを押し,`L*` の表示が変わるまで待つ
5. 8 値を読み取り,CSV に 1 行追記する
6. 結果画面と本体を閉じる
## 設計判断
- **画像ごとに exe を起動・終了する**: «診断続行» の確認ダイアログを自動化せずに済み,
クリック数などの内部状態が毎回まっさらになる.1 枚が失敗しても次に影響しない.
- **ファイルダイアログを開かず,エディットコントロールへ直接パスを書き込む**:
旧実装はパスの読み込みを `EN_CHANGE`(`OnEnChangeMfceditbrowseTransmat` /
`OnEnChangeMfceditbrowseFilename2`)で行っており,`WM_SETTEXT` でも同じ経路が走る.
参照ボタンは `CMFCEditBrowseCtrl` がエディット内部に描くもので**独立した HWND を持たない**
ため,ボタンを押す方式は座標依存で壊れやすい.うまくいかない環境向けに
`--use-file-dialog` で «参照ボタンの位置をクリックして共通ダイアログを操作する»
方式も選べるようにしてある.
- **クリックはダイアログ本体へ送る**: 旧実装の `OnLButtonDown` はダイアログの
メッセージマップにあり,座標は**ダイアログのクライアント座標**として扱われる
(`tonguediagnosisMFC2Dlg.cpp:313`).画像コントロールへ送っても届かない.
- **結果画面をエラーダイアログと区別する**: 結果画面(`CResultDlg`)も MFC のダイアログで
クラス名は `#32770` と,メッセージボックスと同じになる.診断が成功すると必ず開くため,
クラス名だけで «エラーが出た» と判定すると必ず失敗する.タイトルに «結果» を含むものは
結果画面として除外する.
- **診断失敗は読み取った値から判定する**: 旧実装が «Diagnosis failed» を出す条件は
スコア組が (紫 42, 暗赤 0, 紅 5, 淡紅 11, 淡白 3) と一致することなので
(`ResultDlg.cpp` の判定・`core.svm.LEGACY_FAILURE_PERCENTAGES` と同じ),
結果画面を読む必要が無い.
うまくいかないときは `--verbose` で各段階の途中経過(設定したパス・クリック数・
エラーダイアログの文言)を表示できる.
## 注意
- 実行中は旧 exe のウィンドウが繰り返し開閉する.操作中の PC で流さないこと.
- メッセージの送信先は**起動した exe のプロセス ID を持つウィンドウに限定**しており,
他のアプリのウィンドウには一切送らない.手動で起動した旧 exe が別に開いていても
影響を受けない(終了させるのも自分が起動したプロセスだけ).
"""
from __future__ import annotations
import argparse
import csv
import ctypes
import re
import subprocess
import sys
import time
from ctypes import wintypes
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
# 同じ `scripts/` にあるモジュール(スクリプト実行時は自動で import パスに入る)
import read_legacy_values as legacy
# --- 旧 exe のコントロール ID(`org/src/tonguediagnosisMFC2/resource.h`)---
IDC_PICT = 1000
IDC_MFCEDITBROWSE_TRANSMAT = 1007
IDC_BUTTON_CALC = 1008
IDC_OKETSU = 1009 # L* の表示ラベル
IDC_MFCEDITBROWSE_FILENAME2 = 1017
IDC_POINT_CLICKED_IDS: tuple[int, ...] = (1029, 1031, 1032, 1033)
#: 旧 exe の既定の場所(リポジトリ相対.`--exe` で上書きできる)
DEFAULT_EXE_RELATIVE = Path("org/src/x64/Release/tonguediagnosisMFC2.exe")
#: 旧 exe の作業フォルダ(`data.csv` と `img/` を相対パスで開くため)
DEFAULT_WORK_DIR_RELATIVE = Path("org/src/tonguediagnosisMFC2")
#: 旧 exe のメインウィンドウのタイトルに含まれる文字列
TITLE_KEYWORD = "TIDA"
#: 結果画面(`CResultDlg`)のタイトルに含まれる文字列.
#: 結果画面も MFC のダイアログなのでクラス名はメッセージボックスと同じ `#32770` になる.
#: 診断が成功すると必ず開くので,エラーダイアログと取り違えないよう除外する
RESULT_TITLE_KEYWORD = "結果"
#: 採取表(`capture_sheet.csv`)の列
CSV_FIELDS: tuple[str, ...] = (
"matrix",
"r",
"g",
"b",
"l_star",
"a_star",
"b_star",
"purple",
"dark_red",
"crimson",
"pink",
"pale",
"failed",
"note",
)
#: 旧実装が «Diagnosis failed» を表示するスコア組(`core.svm.LEGACY_FAILURE_PERCENTAGES`).
#: 学習データ域外の入力に対する SVM の既定応答であり,この組と完全一致したときだけ失敗とする
LEGACY_FAILURE_SCORES: dict[str, int] = {
"purple": 42,
"dark_red": 0,
"crimson": 5,
"pink": 11,
"pale": 3,
}
#: 画像ファイル名から入力 RGB を読む(`generate_regression_images.py` の命名)
IMAGE_NAME_PATTERN = re.compile(r"^rgb_(\d{1,3})_(\d{1,3})_(\d{1,3})\.png$", re.IGNORECASE)
#: クリックする位置(ダイアログのクライアント座標).
#: 旧実装は `point * 2` を画像座標として使い,`x > cols/2` / `y > rows/2` を捨てるため,
#: 1024x1280 の画像では x <= 512・y <= 640 が有効範囲.ROI は ±25 px なので端は避ける
CLICK_POINTS: tuple[tuple[int, int], ...] = ((200, 250), (300, 250), (200, 350), (300, 350))
# --- タイムアウト(秒)---
WINDOW_TIMEOUT = 20.0
DIALOG_TIMEOUT = 10.0
DIAGNOSE_TIMEOUT = 60.0
SHUTDOWN_TIMEOUT = 10.0
#: ポーリングの間隔
POLL_INTERVAL = 0.2
#: 操作の合間に置く待ち(メッセージ処理が追いつくように)
SETTLE_DELAY = 0.4
# --- Win32 API ---
WM_CLOSE = 0x0010
WM_SETTEXT = 0x000C
WM_COMMAND = 0x0111
#: エディットコントロールが «内容が変わった» ことを親へ知らせる通知コード
EN_CHANGE = 0x0300
WM_LBUTTONDOWN = 0x0201
WM_LBUTTONUP = 0x0202
BM_CLICK = 0x00F5
MK_LBUTTON = 0x0001
SMTO_ABORTIFHUNG = 0x0002
SEND_MESSAGE_TIMEOUT_MS = 5000
#: 共通ファイルダイアログ(およびメッセージボックス)のウィンドウクラス
DIALOG_CLASS = "#32770"
#: 共通ファイルダイアログのファイル名入力欄・「開く」ボタン
FILE_DIALOG_EDIT_ID = 0x047C
IDOK = 1
if sys.platform == "win32":
_user32 = ctypes.WinDLL("user32", use_last_error=True)
else: # pragma: no cover - Windows 以外では起動時に弾く
_user32 = None
def _configure_api() -> None:
"""本モジュールで使う API の引数・戻り値の型を設定する."""
legacy.configure_api()
_user32.GetDlgItem.argtypes = [wintypes.HWND, ctypes.c_int]
_user32.GetDlgItem.restype = wintypes.HWND
_user32.PostMessageW.argtypes = [
wintypes.HWND,
wintypes.UINT,
wintypes.WPARAM,
wintypes.LPARAM,
]
_user32.PostMessageW.restype = wintypes.BOOL
_user32.SendMessageTimeoutW.argtypes = [
wintypes.HWND,
wintypes.UINT,
wintypes.WPARAM,
wintypes.LPARAM,
wintypes.UINT,
wintypes.UINT,
ctypes.POINTER(ctypes.c_size_t),
]
_user32.SendMessageTimeoutW.restype = wintypes.LPARAM
_user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
_user32.GetWindowThreadProcessId.restype = wintypes.DWORD
_user32.IsWindowVisible.argtypes = [wintypes.HWND]
_user32.IsWindowVisible.restype = wintypes.BOOL
_user32.IsWindow.argtypes = [wintypes.HWND]
_user32.IsWindow.restype = wintypes.BOOL
_user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
_user32.GetClassNameW.restype = ctypes.c_int
_user32.GetClientRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)]
_user32.GetClientRect.restype = wintypes.BOOL
#: `--verbose` が指定されたら途中経過を表示する
_verbose = False
def _log(message: str) -> None:
"""`--verbose` のときだけ途中経過を表示する."""
if _verbose:
print(message)
class CaptureError(RuntimeError):
"""1 枚分の採取に失敗したことを表す(この画像を飛ばして次へ進む)."""
@dataclass(frozen=True)
class CaptureTarget:
"""採取対象の 1 枚.
Attributes:
path: 単色 PNG のパス.
rgb: ファイル名から読み取った入力 RGB.
"""
path: Path
rgb: tuple[int, int, int]
# --- Win32 の薄いラッパ ---
def _process_id(handle: int) -> int:
"""ウィンドウを所有するプロセス ID を返す."""
pid = wintypes.DWORD(0)
_user32.GetWindowThreadProcessId(handle, ctypes.byref(pid))
return int(pid.value)
def _class_name(handle: int) -> str:
"""ウィンドウクラス名を返す."""
buffer = ctypes.create_unicode_buffer(256)
_user32.GetClassNameW(handle, buffer, 256)
return buffer.value
def _send_text(handle: int, text: str) -> bool:
"""`WM_SETTEXT` でコントロールに文字列を設定する."""
buffer = ctypes.create_unicode_buffer(text)
result = ctypes.c_size_t(0)
ok = _user32.SendMessageTimeoutW(
handle,
WM_SETTEXT,
0,
ctypes.cast(buffer, ctypes.c_void_p).value or 0,
SMTO_ABORTIFHUNG,
SEND_MESSAGE_TIMEOUT_MS,
ctypes.byref(result),
)
return bool(ok)
def _post_click(handle: int, x: int, y: int) -> None:
"""クライアント座標 (x, y) への左クリックを送る(押下と解放)."""
position = (y << 16) | (x & 0xFFFF)
_user32.PostMessageW(handle, WM_LBUTTONDOWN, MK_LBUTTON, position)
_user32.PostMessageW(handle, WM_LBUTTONUP, 0, position)
def _press_button(handle: int) -> None:
"""ボタンを押す(診断のように時間がかかるため送りっぱなしにする)."""
_user32.PostMessageW(handle, BM_CLICK, 0, 0)
def _wait_until(predicate, timeout: float, description: str): # noqa: ANN001, ANN202
"""`predicate()` が真の値を返すまで待ち,その値を返す.
Raises:
CaptureError: `timeout` 秒を過ぎても真にならなかった場合.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(POLL_INTERVAL)
raise CaptureError(f"{description}({timeout:.0f} 秒待っても変化しませんでした)")
def _windows_of_process(pid: int) -> list[int]:
"""指定プロセスが持つ可視のトップレベルウィンドウを返す."""
return [
handle for handle, _title in legacy.list_top_level_windows() if _process_id(handle) == pid
]
def _find_main_window(pid: int) -> int | None:
"""旧 exe のメインウィンドウ(タイトルに TIDA を含む)を返す."""
for handle, title in legacy.list_top_level_windows():
if _process_id(handle) == pid and TITLE_KEYWORD.casefold() in title.casefold():
return handle
return None
def _popup_text(handle: int) -> str:
"""ダイアログ(メッセージボックス)に出ている文言を取り出す.
どのガードに引っかかったのかを切り分けるために使う.
"""
parts = [legacy.window_text(handle)]
parts.extend(
control.text
for control in legacy.read_controls(handle)
if control.class_name == "Static" and control.text.strip()
)
return " / ".join(part.replace("\n", " ").strip() for part in parts if part.strip())
def _is_result_window(handle: int) -> bool:
"""診断結果画面かどうかを返す(エラーダイアログと区別するため)."""
return RESULT_TITLE_KEYWORD in legacy.window_text(handle)
def _find_popup(pid: int, main_window: int) -> int | None:
"""旧 exe が出したエラーダイアログ等を返す(無ければ None).
**結果画面は除外する**.結果画面も `#32770` クラスのダイアログであり,
診断が成功すると必ず開くため,これをエラー扱いすると必ず失敗してしまう.
"""
for handle in _windows_of_process(pid):
if handle == main_window or _class_name(handle) != DIALOG_CLASS:
continue
if _is_result_window(handle):
continue
return handle
return None
def _find_result_window(pid: int, main_window: int) -> int | None:
"""診断結果画面を返す(無ければ None)."""
for handle in _windows_of_process(pid):
if handle != main_window and _is_result_window(handle):
return handle
return None
def _close_popups(pid: int, main_window: int) -> list[str]:
"""旧 exe が出したダイアログ類をすべて閉じ,出ていた文言の一覧を返す.
エラー用のメッセージボックス(MB_OK)は `WM_CLOSE` で閉じられる.
出たままだと以降の操作が一切効かなくなるため,各操作の後に呼ぶ.
"""
messages: list[str] = []
for _ in range(5):
popup = _find_popup(pid, main_window)
if popup is None:
break
messages.append(_popup_text(popup))
_user32.PostMessageW(popup, WM_CLOSE, 0, 0)
time.sleep(SETTLE_DELAY)
return messages
# --- 旧 exe の操作 ---
def _launch(exe: Path, work_dir: Path) -> subprocess.Popen[bytes]:
"""旧 exe を起動する."""
return subprocess.Popen([str(exe)], cwd=str(work_dir)) # noqa: S603 - 引数は利用者指定の exe
def _shutdown(process: subprocess.Popen[bytes], main_window: int | None) -> None:
"""結果画面を閉じてから本体を終了する(応答が無ければ強制終了)."""
pid = process.pid
if main_window is not None:
# 結果画面など,メインウィンドウ以外を先に閉じる
for handle in _windows_of_process(pid):
if handle != main_window:
_user32.PostMessageW(handle, WM_CLOSE, 0, 0)
time.sleep(SETTLE_DELAY)
_user32.PostMessageW(main_window, WM_CLOSE, 0, 0)
try:
process.wait(timeout=SHUTDOWN_TIMEOUT)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=SHUTDOWN_TIMEOUT)
except subprocess.TimeoutExpired:
process.kill()
def _set_path_via_edit(main_window: int, control_id: int, path: Path, label: str) -> None:
"""参照用エディットコントロールにパスを直接書き込む.
旧実装はパスの読み込みを `EN_CHANGE` ハンドラで行うが,別プロセスからの
`WM_SETTEXT` では通知が飛ばないことがある.そこでテキストを設定したあと,
エディットコントロールが送るのと同じ `WM_COMMAND`(`EN_CHANGE`)を
親ダイアログへ明示的に送って,ハンドラを確実に走らせる.
ハンドラは «エディットのテキストを読んで読み込む» だけなので,
通知が二重になっても結果は変わらない(冪等).
"""
control = _user32.GetDlgItem(main_window, control_id)
if not control:
raise CaptureError(f"{label}の入力欄(ID={control_id})が見つかりません")
if not _send_text(control, str(path)):
raise CaptureError(f"{label}のパスを設定できませんでした: {path}")
_log(f" {label}のテキスト設定後: {legacy.control_text(control)!r}")
notification = (EN_CHANGE << 16) | (control_id & 0xFFFF)
result = ctypes.c_size_t(0)
_user32.SendMessageTimeoutW(
main_window,
WM_COMMAND,
notification,
control,
SMTO_ABORTIFHUNG,
SEND_MESSAGE_TIMEOUT_MS,
ctypes.byref(result),
)
time.sleep(SETTLE_DELAY)
def _set_path_via_dialog(
main_window: int, control_id: int, path: Path, label: str, pid: int
) -> None:
"""参照ボタンを押して共通ファイルダイアログでパスを入力する(代替手段).
`CMFCEditBrowseCtrl` の参照ボタンは独立した HWND を持たないため,
エディットコントロールの**右端付近**をクリックして開く.
"""
control = _user32.GetDlgItem(main_window, control_id)
if not control:
raise CaptureError(f"{label}の入力欄(ID={control_id})が見つかりません")
rect = wintypes.RECT()
_user32.GetClientRect(control, ctypes.byref(rect))
# 右端の内側(参照ボタンが描かれている位置)をクリックする
_post_click(control, max(rect.right - 10, 0), max(rect.bottom // 2, 0))
dialog = _wait_until(
lambda: _find_popup(pid, main_window),
DIALOG_TIMEOUT,
f"{label}のファイルダイアログが開きませんでした",
)
edit = _user32.GetDlgItem(dialog, FILE_DIALOG_EDIT_ID)
if not edit:
raise CaptureError(f"{label}のファイルダイアログに入力欄が見つかりません")
_send_text(edit, str(path))
time.sleep(SETTLE_DELAY)
ok_button = _user32.GetDlgItem(dialog, IDOK)
if ok_button:
_press_button(ok_button)
else:
_user32.PostMessageW(dialog, WM_CLOSE, 0, 0)
_wait_until(
lambda: not _user32.IsWindow(dialog),
DIALOG_TIMEOUT,
f"{label}のファイルダイアログが閉じませんでした",
)
def _click_image(main_window: int, pid: int, clicks: int) -> None:
"""画像上を指定回数クリックする(ダイアログのクライアント座標へ送る)."""
for index in range(min(clicks, len(CLICK_POINTS))):
x, y = CLICK_POINTS[index]
_post_click(main_window, x, y)
time.sleep(SETTLE_DELAY)
messages = _close_popups(pid, main_window)
if messages:
# 「変換行列を読み込んでください」等のガードに引っかかった状態
raise CaptureError(f"クリック時にエラーダイアログが出ました: {messages}")
marks = _clicked_marks(main_window)
_log(f" クリック {index + 1} 回目 ({x}, {y}) -> クリック済みマーク {marks} 個")
if marks >= len(IDC_POINT_CLICKED_IDS):
break
if _clicked_marks(main_window) == 0:
raise CaptureError(
"クリックが 1 点も登録されませんでした"
"(座標が画像の範囲外か,クリックが届いていません)"
)
def _clicked_marks(main_window: int) -> int:
"""クリック済みを示すマーク(●)が表示されている数を返す."""
return sum(
1
for mark_id in IDC_POINT_CLICKED_IDS
if _user32.IsWindowVisible(_user32.GetDlgItem(main_window, mark_id))
)
def _run_diagnosis(main_window: int, pid: int) -> None:
"""«舌色診断» を押し,`L*` の表示が変わるまで待つ."""
l_star = _user32.GetDlgItem(main_window, IDC_OKETSU)
before = legacy.control_text(l_star) if l_star else ""
button = _user32.GetDlgItem(main_window, IDC_BUTTON_CALC)
if not button:
raise CaptureError("«舌色診断» ボタンが見つかりません")
_press_button(button)
def diagnosed() -> bool:
if _find_popup(pid, main_window) is not None:
# エラーダイアログが出たら待っても無駄なので抜ける(文言は finally で拾う)
return True
current = legacy.control_text(l_star)
return bool(current.strip()) and current != before
try:
_wait_until(diagnosed, DIAGNOSE_TIMEOUT, "診断が終わりませんでした")
finally:
messages = _close_popups(pid, main_window)
if messages:
raise CaptureError(f"診断中にエラーダイアログが出ました: {messages}")
_log(f" 診断完了: L* 表示 {before!r} -> {legacy.control_text(l_star)!r}")
def capture_one(target: CaptureTarget, options: argparse.Namespace) -> dict[str, object]:
"""1 枚分を採取して CSV 1 行分の辞書を返す.
Raises:
CaptureError: 途中で失敗した場合(呼び出し元はこの画像を飛ばす).
"""
process = _launch(options.exe, options.work_dir)
main_window: int | None = None
try:
main_window = _wait_until(
lambda: _find_main_window(process.pid),
WINDOW_TIMEOUT,
"旧 exe のウィンドウが開きませんでした",
)
_close_popups(process.pid, main_window)
set_path = _set_path_via_dialog if options.use_file_dialog else None
for control_id, path, label in (
(IDC_MFCEDITBROWSE_TRANSMAT, options.matrix, "変換行列"),
(IDC_MFCEDITBROWSE_FILENAME2, target.path, "画像"),
):
if set_path is None:
_set_path_via_edit(main_window, control_id, path, label)
else:
set_path(main_window, control_id, path, label, process.pid)
messages = _close_popups(process.pid, main_window)
if messages:
raise CaptureError(f"{label}の読み込みでエラーが出ました: {messages}")
_log(f" {label}を設定しました: {path}")
_click_image(main_window, process.pid, options.clicks)
_run_diagnosis(main_window, process.pid)
controls = legacy.read_controls(main_window)
result = legacy.parse_values([control.text for control in controls])
if result.missing:
raise CaptureError(f"値を読み取れませんでした: {', '.join(result.missing)}")
return _build_row(target, result.values, options)
finally:
_shutdown(process, main_window)
def _build_row(
target: CaptureTarget, values: dict[str, int], options: argparse.Namespace
) -> dict[str, object]:
"""採取表 1 行分の辞書を作る."""
scores = {key: values[key] for key in LEGACY_FAILURE_SCORES}
failed = "yes" if scores == LEGACY_FAILURE_SCORES else "no"
red, green, blue = target.rgb
row: dict[str, object] = {
"matrix": options.matrix.name,
"r": red,
"g": green,
"b": blue,
"failed": failed,
"note": f"auto_capture {datetime.now().astimezone().isoformat(timespec='seconds')}",
}
row.update({key: values[key] for key in legacy.OUTPUT_FIELDS})
return row
# --- 入出力 ---
def collect_targets(images_dir: Path, single: str | None) -> list[CaptureTarget]:
"""対象の PNG を集める(ファイル名から RGB を読む)."""
targets: list[CaptureTarget] = []
paths = sorted(images_dir.glob("*.png")) if single is None else [images_dir / single]
for path in paths:
matched = IMAGE_NAME_PATTERN.match(path.name)
if matched is None:
print(f" スキップ(ファイル名が rgb_RRR_GGG_BBB.png 形式でない): {path.name}")
continue
targets.append(CaptureTarget(path, (int(matched[1]), int(matched[2]), int(matched[3]))))
return targets
def append_row(out_path: Path, row: dict[str, object]) -> None:
"""CSV に 1 行追記する(ヘッダが無ければ先に書く)."""
out_path.parent.mkdir(parents=True, exist_ok=True)
write_header = not out_path.exists() or out_path.stat().st_size == 0
with out_path.open("a", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS)
if write_header:
writer.writeheader()
writer.writerow(row)
def _build_parser() -> argparse.ArgumentParser:
"""コマンドライン引数のパーサを作る."""
parser = argparse.ArgumentParser(
description="旧 exe を自動操作して回帰テストの採取表を作る.",
)
parser.add_argument("--images-dir", type=Path, required=True, help="単色 PNG が入ったフォルダ")
parser.add_argument("--matrix", type=Path, required=True, help="変換行列 CSV のパス")
parser.add_argument("--out", type=Path, required=True, help="出力する採取表 CSV のパス")
parser.add_argument("--single", help="この 1 枚だけ採取する(ファイル名.動作確認用)")
parser.add_argument("--exe", type=Path, help="旧 exe のパス(既定はリポジトリ内の Release)")
parser.add_argument(
"--work-dir",
type=Path,
help="旧 exe の作業フォルダ(data.csv と img/ のある場所)",
)
parser.add_argument(
"--clicks",
type=int,
default=len(CLICK_POINTS),
help=f"画像をクリックする回数(既定 {len(CLICK_POINTS)}.1〜4)",
)
parser.add_argument(
"--use-file-dialog",
action="store_true",
help="パス入力に共通ファイルダイアログを使う(既定は入力欄へ直接書き込み)",
)
parser.add_argument(
"--verbose", action="store_true", help="各段階の途中経過を表示する(デバッグ用)"
)
return parser
def _resolve_paths(options: argparse.Namespace) -> None:
"""既定パスの補完と存在確認を行う.
Raises:
FileNotFoundError: 必要なファイル・フォルダが無い場合.
"""
repository_root = Path(__file__).resolve().parent.parent
if options.exe is None:
options.exe = repository_root / DEFAULT_EXE_RELATIVE
if options.work_dir is None:
options.work_dir = repository_root / DEFAULT_WORK_DIR_RELATIVE
options.exe = options.exe.resolve()
options.work_dir = options.work_dir.resolve()
options.matrix = options.matrix.resolve()
options.images_dir = options.images_dir.resolve()
if not options.exe.is_file():
raise FileNotFoundError(
f"旧 exe が見つかりません: {options.exe}\n"
" → --exe でパスを指定してください(Release ビルドが必要です)."
)
if not (options.work_dir / "data.csv").is_file():
raise FileNotFoundError(
f"旧 exe の作業フォルダに data.csv がありません: {options.work_dir}\n"
" → --work-dir で data.csv と img/ のあるフォルダを指定してください."
)
if not options.matrix.is_file():
raise FileNotFoundError(f"変換行列 CSV が見つかりません: {options.matrix}")
if not options.images_dir.is_dir():
raise FileNotFoundError(f"画像フォルダが見つかりません: {options.images_dir}")
def main(argv: list[str] | None = None) -> int:
"""エントリポイント.戻り値はプロセスの終了コード."""
global _verbose # noqa: PLW0603 - デバッグ表示の切り替えだけに使う
options = _build_parser().parse_args(argv)
_verbose = options.verbose
if sys.platform != "win32":
print("エラー: このスクリプトは Windows 専用です.", file=sys.stderr)
return 1
try:
_resolve_paths(options)
except FileNotFoundError as error:
print(f"エラー: {error}", file=sys.stderr)
return 1
_configure_api()
targets = collect_targets(options.images_dir, options.single)
if not targets:
print("エラー: 採取対象の PNG がありません.", file=sys.stderr)
return 1
print(f"# 旧 exe: {options.exe}")
print(f"# 変換行列: {options.matrix}")
print(f"# 出力: {options.out}")
print(f"# 対象: {len(targets)} 枚")
succeeded: list[str] = []
failed: list[tuple[str, str]] = []
for index, target in enumerate(targets, start=1):
print(f"\n[{index}/{len(targets)}] {target.path.name} RGB={target.rgb}")
try:
row = capture_one(target, options)
except CaptureError as error:
print(f" 失敗: {error}")
failed.append((target.path.name, str(error)))
continue
append_row(options.out, row)
succeeded.append(target.path.name)
print(
" L*a*b* = "
f"({row['l_star']}, {row['a_star']}, {row['b_star']})"
f" / 紫 {row['purple']} 暗赤 {row['dark_red']} 紅 {row['crimson']}"
f" 淡紅 {row['pink']} 淡白 {row['pale']} / failed={row['failed']}"
)
print(f"\n# 完了: 成功 {len(succeeded)} 枚 / 失敗 {len(failed)} 枚")
for name, reason in failed:
print(f" - {name}: {reason}")
return 0 if not failed else 2
if __name__ == "__main__":
raise SystemExit(main())