diff --git a/CLAUDE.md b/CLAUDE.md index 116225b..baed23d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 ステップ 4(core: SVM 分類・ROI)完了.次はステップ 5(回帰テスト.旧 exe 採取はユーザー作業) +最新: PLAN_02 ステップ 5 の準備完了(回帰テスト骨格・採取手順書).採取(TEST_01)はユーザー作業待ち.次はステップ 6(GUI: メイン画面) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) @@ -78,4 +78,6 @@ ### 06_TEST(テスト) +- 回帰テスト採取手順: docs/06_TEST/TEST_01_回帰テスト採取手順.md + diff --git "a/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" "b/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" new file mode 100644 index 0000000..f3881ed --- /dev/null +++ "b/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" @@ -0,0 +1,172 @@ +# 回帰テスト採取手順 (Regression Data Capture Procedure) + +PLAN_02 ステップ 5「回帰テスト」で使う **旧実装 exe の入出力ペア** を採取する手順. +本作業は人間が行う(PLAN_02「人間が行う作業」).採取した CSV を +`tests/fixtures/regression/` に置くと,`uv run pytest` の回帰テストが自動で有効になる. + +- 採取物の CSV 形式: [tests/fixtures/regression/README.md](../../tests/fixtures/regression/README.md) +- 診断ロジックの仕様: [SPEC_02_診断ロジック仕様.md](../04_SPEC/SPEC_02_診断ロジック仕様.md) +- 旧アプリの操作: [SPEC_01_画面仕様.md](../04_SPEC/SPEC_01_画面仕様.md) + +## 目的 (Purpose) + +新実装(`src/tiasjudge/core/`)が旧実装と**同じ診断結果**を返すことを証明する(PLAN_01 成功基準・M1). +そのために «同じ入力に対する旧 exe の出力» を固定データとして採取する. + +## 採取方式の考え方 (Capture Design) + +旧 exe には **RGB や L\*a\*b\* を直接入力する画面が無い**(`org/src` を確認済み). +入力経路は「PNG 画像を読み込み → 画像上をクリック → その ROI の平均色」だけであり, +さらに**クリック位置を手作業で再現できない**という問題がある(PLAN_02 の注記). + +そこで次の方式で採取する. + +- **画像全面を単一色で塗った PNG** を読み込ませる + - ROI(51×51 px)がどこに置かれても平均色は画像の色そのものになる + - → クリック位置が 1 px ずれても入力 RGB が変わらない=再現可能な入力になる + - → 入力 RGB は «画像を作るときに指定した R, G, B» として厳密に分かる +- 出力は 1 回の診断で **L\*a\*b\* と 5 色スコアの両方**をメイン画面から読み取れる + - → 「RGB → Lab」と「RGB → スコア(パイプライン全体)」の 2 種類を同時に採取できる + +※ 「Lab → スコア」を単独で採取することは,旧 exe に Lab の入力手段が無いためできない. +またメイン画面の L\*a\*b\* 表示は**整数に丸められている**ため,表示された Lab を入力として +使い回すと SVM の予測が変わりうる.したがって Lab は «出力側の照合対象» としてのみ扱う +(形式定義 README の ③ の注意を参照). + +## 事前準備 (Prerequisites) + +1. 旧実装 exe を実行できる状態にする + - 実行ファイル: `org/src/tonguediagnosisMFC2/x64/Release/tonguediagnosisMFC2.exe`(または Debug) + - exe と同じ作業フォルダに **`data.csv`** と **`img/img_student2.bmp`** が必要 + (`org/src/tonguediagnosisMFC2/` 直下でそのまま実行するのが確実) +2. 撮影環境の **変換行列 CSV**(17 行 × 3 列)を 1 つ用意する + - 実際の診断で使っているものを使う.どのファイルを使ったかを必ず控える +3. 採取用の単色 PNG を生成する(下記) + +### 単色 PNG の生成 (Generate Solid-Color Images) + +リポジトリ直下で次を実行する(生成先はリポジトリ外・作業用フォルダを推奨). + +```bash +uv run python scripts/generate_regression_images.py --out-dir <出力先フォルダ> +``` + +- 既定で 12 枚(代表的な舌色・境界値・域外色)を 1024×1280 px で生成する +- ファイル名は `rgb_156_102_098.png` のように **RGB がそのまま入る**(採取時の取り違え防止) +- 色を指定したい場合: `--color 156,102,98 --color 120,70,70`(複数指定可) +- 実行すると,採取表(CSV)の雛形が標準出力に表示される.そのままエディタに貼って使える + +## 採取手順 (Capture Steps) + +1 色(1 枚)につき次を 1 回行う. + +1. 旧 exe を起動する +1. **① 変換行列(.csv)読み込み** の参照ボタンで変換行列 CSV を選ぶ +1. **② 舌画像(.png)読み込み** の参照ボタンで単色 PNG を 1 枚選ぶ +1. 左の画像表示エリアの**中央付近を左クリックする(1 点だけ)** + - 「現在のクリック ●」が 1 つ点灯すれば OK + - 画像の端から 15 px 以上内側をクリックする(端では ROI がはみ出して旧 exe が落ちる) + - 誤ってクリックした場合は**右クリック**で取り消してから,読み込みからやり直す + (※ 旧実装は右クリックしてもクリック数の内部カウントが戻らない. + クリックし直したら一度 exe を再起動するか「診断続行」でリセットするのが安全) +1. **④ 舌色診断ボタン** をクリックする +1. **メイン画面の右下**に表示される次の 8 つの値を読み取り,採取表に記入する + - 上から `紫 xx%`・`暗赤 xx%`・`紅 xx%`・`淡紅 xx%`・`淡白 xx%` + - その下に `L* xx`・`a* xx`・`b* xx` + - いずれも**整数表示**(旧実装の書式が `%3.0lf` のため.小数は表示されない) +1. 同時に開く**結果画面**を見て,赤い帯の **「Diagnosis failed / Please try again.」が出ているか**を記録する + - 出ていれば `failed` 列に `yes`,出ていなければ `no` +1. 結果画面を閉じ,**診断続行** ボタンを押す + - 「同じ変換行列を使用するか」に **はい** と答えると,変換行列を選び直さずに次の画像へ進める +1. 次の色の画像を ② から読み込み,同じ手順を繰り返す + +### 採取すべきケース (Cases to Capture) + +`scripts/generate_regression_images.py` の既定 12 色がそのまま以下をカバーする. +最低でも「代表色 5 点 + 境界値 2 点 + 域外 2 点」を採取する. + +| 種類 | 色(R,G,B) | 意図 | +| --- | --- | --- | +| 代表 | 156,102,98 | 基準色(旧実装コメントの平均 BGR 由来) | +| 代表 | 180,120,115 | やや明るい舌色 | +| 代表 | 205,145,140 | 淡い舌色(淡紅・淡白寄り) | +| 代表 | 140,85,85 | やや暗い舌色 | +| 代表 | 115,62,68 | 暗い舌色(暗赤・紫寄り) | +| 代表 | 170,92,112 | 赤紫寄り | +| 代表 | 196,74,74 | 鮮やかな紅寄り | +| 代表 | 150,110,130 | 青み寄り | +| 境界 | 255,255,255 | 明るさの上限 | +| 境界 | 0,0,0 | XYZ が 0 にクリップされる(`L* = -16` になる想定) | +| 失敗 | 255,0,0 | 学習データ域外(「Diagnosis failed」が出るか確認する) | +| 失敗 | 0,0,255 | 学習データ域外 | + +※ 実際の舌画像で «上位 2 色が入れ替わる境目» に近い色が分かっている場合は, +その前後の色も追加すると回帰テストの価値が上がる. + +## 採取表の記入 (Recording) + +生成スクリプトが出力した雛形を使い,**2 つの CSV** を作る. +列名・列の意味・許容誤差は [tests/fixtures/regression/README.md](../../tests/fixtures/regression/README.md) が正. + +`tests/fixtures/regression/rgb_to_lab.csv` + +```csv +matrix,r,g,b,l_star,a_star,b_star,note +translateMatrix.csv,156,102,98,55,22,7,基準色 +``` + +`tests/fixtures/regression/rgb_to_scores.csv` + +```csv +matrix,r,g,b,purple,dark_red,crimson,pink,pale,failed,note +translateMatrix.csv,156,102,98,16,61,11,5,0,no,基準色 +``` + +記入のルール. + +- 数値は**画面表示のまま**(整数)書く.四捨五入や桁の追加をしない +- 負の値(`a*` や `b*`)はマイナス記号を付けて書く +- `matrix` 列には使った変換行列 CSV の**ファイル名**を書く +- 使った変換行列 CSV 自体を `tests/fixtures/regression/matrices/` にコピーする(必須) +- 文字コードは **UTF-8**(Excel で保存する場合は「CSV UTF-8」を選ぶ) +- `#` で始まる行はコメントとして無視される(採取日・環境のメモに使ってよい) + +## 照合の実行 (Run the Regression Test) + +CSV を置いたら,リポジトリ直下で次を実行する([ENV_04](../02_ENV/ENV_04_開発コマンド.md)). + +```bash +uv run pytest tests/test_regression.py -vv +``` + +- CSV が無い間は skip(理由に本手順書のパスが表示される) +- CSV を置くと 1 行 = 1 テストケースとして実行される +- 許容誤差(README と同じ) + - L\*a\*b\*: 絶対誤差 **0.51 以内**(旧 exe が整数しか表示しないため) + - 5 色スコア(%): **完全一致** + - 診断失敗の有無: **完全一致** + +## 不一致だったときの扱い (When Results Differ) + +**新実装(プロダクションコード)をその場で直さない**.次の順に切り分けて記録する. + +1. 採取ミスの確認: 変換行列は同じか,クリックは 1 点か,画像を取り違えていないか +1. 差の大きさを確認: L\*a\*b\* が 1 未満のずれなら表示丸めの範囲内の可能性がある +1. 原因の記録: どの入力で・どの値が・どれだけずれたかを [PROGRESS.md](../PROGRESS.md) に追記する +1. 仕様への反映: 旧実装の挙動が SPEC_02 の記述と異なると分かった場合は SPEC_02 を更新してから実装を直す + (PLAN_02 ステップ 5 の完了条件は「全ペアで結果一致(不一致時は原因を特定し記録)」) + +## 精度を上げたい場合 (Higher Precision, Optional) + +画面表示は整数までなので,L\*a\*b\* の照合はどうしても ±0.5 の幅を持つ. +より厳密に照合したい場合は,旧実装を Visual Studio で **Debug ビルド**して次を採取する. + +- `CalcLab()` の直後に `TRACE("L=%.6f a=%.6f b=%.6f\n", m_tongueL, m_tonguea, m_tongueb);` を追加し, + 出力ウィンドウの値を `rgb_to_lab.csv` に書く(この場合は小数のまま記入してよい. + テストの許容誤差 0.51 のままでも通るが,より厳しい照合が必要なら + `tests/test_regression.py` の `LAB_DISPLAY_TOLERANCE` を下げる) +- 同様に `m_tongueL/a/b` に任意の値を代入してから `CalcSVM()` を呼ぶ改造版を作れば, + 「Lab → スコア」の組(README の ③ `lab_to_scores.csv`)も採取できる + +※ 旧実装の改造は**参照専用の `org/` を書き換えない**(Git 管理外・原本保持). +コピーを別フォルダに作って行うこと. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ba81626..450a050 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -37,3 +37,6 @@ - **⚠ 要レビュー(無人運転中の仕様変更判断)**: 失敗検出を「直接検出のみ」とする /setup 時の設計判断を一部戻した.直接検出だけでは L\* が小さい正値の域外入力(旧実装は失敗表示)を取りこぼし「紫 42%」等の誤結果を表示してしまうことが tester の実測で判明したため,最上位ルール「診断結果は旧実装と一致」を優先し,**旧パターン判定(42/0/5/11/3 → OUT_OF_DOMAIN)を直接検出と併用する形で復活**させた.SPEC_02「診断失敗の判定」節を改訂済み.異議があれば `LEGACY_FAILURE_PERCENTAGES`(svm.py)と当該節を戻すこと - 設計判断: 集計の単精度丸め(float32 逐次加算・float32 100 倍 → 偶数丸め)と上位 2 色タイブレーク(旧 std::map キー順 crimson→dark_red→pink→purple→pale)まで旧実装を再現.ROI 範囲外は旧実装のクラッシュを ValueError に置換(GUI 側でガード前提) - 未実装・後段: TRAINING_FAILED / PREDICTION_FAILED は健全な入力から再現不能のためテスト未発火.GUI 実装時に 5 点目クリックのガードが必要(MAX_ROI_POINTS は core 側の定数のみ) +- [x] **PLAN_02 ステップ 5 準備完了(回帰テスト骨格・採取手順書)**: `tests/test_regression.py`(フィクスチャ CSV があれば照合,無ければ skip)・フィクスチャ形式定義・`docs/06_TEST/TEST_01_回帰テスト採取手順.md`・単色 PNG 生成スクリプト(`scripts/`)を作成(無人運転).**採取は旧 exe を使うユーザー作業**(TEST_01 参照)で,フィクスチャを置くと回帰テストが自動で有効化される + - 設計判断: 旧 exe は RGB/Lab 直接入力不可・クリック位置固定不能のため,全面単色 PNG(ROI がどこでも既知 RGB)で入力を厳密再現する方式.旧 exe の表示は Lab・スコアとも整数(%3.0lf)のため許容誤差は Lab ±0.51・スコア完全一致と定義 + - 採取時の注意(旧実装のバグ回避): 右クリックリセットはクリック数・平均色を戻さない/5 点目は加算済みのままエラー表示.手順書で「1 点のみクリック・やり直しは再起動」と規定 diff --git a/scripts/generate_regression_images.py b/scripts/generate_regression_images.py new file mode 100644 index 0000000..c16f505 --- /dev/null +++ b/scripts/generate_regression_images.py @@ -0,0 +1,147 @@ +"""回帰テスト採取用の単色 PNG を生成する(PLAN_02 ステップ 5). + +旧実装 exe は «画像を読み込んでクリックした ROI の平均色» しか入力にできず, +RGB / L*a*b* を直接入力する手段が無い.また,クリック位置は手作業では固定できない +(PLAN_02 の注記).そこで **画像全面を単一色で塗った PNG** を読み込ませることで, +クリック位置によらず ROI 平均色 = 画像の色(既知の RGB)となるようにする. + +出力サイズは既定で 1024×1280(旧実装のクロップ後サイズ)とし, +旧 exe のクロップ(中央基準の切り出し)が入っても色が変わらないようにする. + +使い方(詳細は docs/06_TEST/TEST_01_回帰テスト採取手順.md): + +```bash +uv run python scripts/generate_regression_images.py --out-dir out/regression_images +``` + +色を指定する場合(`R,G,B` を空白区切りで複数指定できる): + +```bash +uv run python scripts/generate_regression_images.py --out-dir out/images \ + --color 156,102,98 --color 0,0,0 +``` +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import cv2 +import numpy as np +from numpy.typing import NDArray + +#: 旧実装がクロップ後に扱う画像サイズ(SPEC_01「画像選択」) +DEFAULT_WIDTH = 1024 +DEFAULT_HEIGHT = 1280 + +#: 既定の採取ケース(R, G, B, 意図). +#: 「基準色」は旧実装のコメントに残る平均 BGR {98.4, 102.27, 156.4}(= RGB 156,102,98)に由来する. +#: 代表的な舌色をその周辺に散らし,加えて境界値・学習データ域外(診断失敗が出うる)色を含める +DEFAULT_COLORS: tuple[tuple[int, int, int, str], ...] = ( + (156, 102, 98, "基準色(旧実装コメントの平均 BGR 由来)"), + (180, 120, 115, "やや明るい舌色"), + (205, 145, 140, "淡い舌色(淡紅・淡白寄り)"), + (140, 85, 85, "やや暗い舌色"), + (115, 62, 68, "暗い舌色(暗赤・紫寄り)"), + (170, 92, 112, "赤紫寄り"), + (196, 74, 74, "鮮やかな紅寄り"), + (150, 110, 130, "青み寄り"), + (255, 255, 255, "境界値: 白(明るさの上限)"), + (0, 0, 0, "境界値: 黒(XYZ が 0 にクリップされる想定)"), + (255, 0, 0, "域外: 純赤"), + (0, 0, 255, "域外: 純青"), +) + + +def build_solid_image(color: tuple[int, int, int], width: int, height: int) -> NDArray[np.uint8]: + """指定 RGB の単色画像(OpenCV の BGR 配列)を作る.""" + red, green, blue = color + image = np.empty((height, width, 3), dtype=np.uint8) + image[:, :] = (blue, green, red) + return image + + +def parse_color(text: str) -> tuple[int, int, int]: + """`R,G,B` 形式の文字列を (R, G, B) に変換する.""" + parts = [part.strip() for part in text.split(",")] + if len(parts) != 3: + raise argparse.ArgumentTypeError(f"色は R,G,B の 3 要素で指定してください: {text!r}") + try: + values = tuple(int(part) for part in parts) + except ValueError as error: + raise argparse.ArgumentTypeError(f"色は整数で指定してください: {text!r}") from error + if any(not 0 <= value <= 255 for value in values): + raise argparse.ArgumentTypeError(f"色は 0〜255 の範囲で指定してください: {text!r}") + return values[0], values[1], values[2] + + +def image_name(color: tuple[int, int, int]) -> str: + """RGB からファイル名を作る(例: `rgb_156_102_098.png`).""" + red, green, blue = color + return f"rgb_{red:03d}_{green:03d}_{blue:03d}.png" + + +def write_images( + colors: list[tuple[int, int, int]], out_dir: Path, width: int, height: int +) -> list[Path]: + """単色 PNG を書き出し,書き出したパスの一覧を返す.""" + out_dir.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + for color in colors: + path = out_dir / image_name(color) + if not cv2.imwrite(str(path), build_solid_image(color, width, height)): + raise OSError(f"PNG を書き出せませんでした: {path}") + written.append(path) + return written + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """コマンドライン引数を解釈する.""" + parser = argparse.ArgumentParser(description="回帰テスト採取用の単色 PNG を生成する") + parser.add_argument( + "--out-dir", + type=Path, + required=True, + help="出力先ディレクトリ(リポジトリ外・作業用フォルダを推奨)", + ) + parser.add_argument( + "--color", + type=parse_color, + action="append", + dest="colors", + metavar="R,G,B", + help="生成する色(複数指定可).省略時は既定の採取ケースを生成する", + ) + parser.add_argument("--width", type=int, default=DEFAULT_WIDTH, help="画像の幅(px)") + parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT, help="画像の高さ(px)") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """単色 PNG を生成し,採取表の雛形(CSV の行)を標準出力に出す.""" + args = parse_args(argv) + colors = args.colors or [(red, green, blue) for red, green, blue, _ in DEFAULT_COLORS] + notes = {(red, green, blue): note for red, green, blue, note in DEFAULT_COLORS} + + written = write_images(colors, args.out_dir, args.width, args.height) + + print(f"{len(written)} 枚を生成しました: {args.out_dir}") + print("") + print("# 採取表の雛形(tests/fixtures/regression/rgb_to_lab.csv 用)") + print("matrix,r,g,b,l_star,a_star,b_star,note") + for color in colors: + red, green, blue = color + print(f"<変換行列ファイル名>,{red},{green},{blue},,,,{notes.get(color, '')}") + print("") + print("# 採取表の雛形(tests/fixtures/regression/rgb_to_scores.csv 用)") + print("matrix,r,g,b,purple,dark_red,crimson,pink,pale,failed,note") + for color in colors: + red, green, blue = color + print(f"<変換行列ファイル名>,{red},{green},{blue},,,,,,,{notes.get(color, '')}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/regression/README.md b/tests/fixtures/regression/README.md new file mode 100644 index 0000000..2964172 --- /dev/null +++ b/tests/fixtures/regression/README.md @@ -0,0 +1,116 @@ +# 回帰テスト用フィクスチャ形式 (Regression Fixture Format) + +旧実装 exe(`org/src` のビルド成果物)で採取した入出力ペアを置く場所. +PLAN_02 ステップ 5「回帰テスト」で使用し,`tests/test_regression.py` が本ディレクトリを読む. + +採取の手順(旧 exe の操作方法・採取すべきケース)は +[docs/06_TEST/TEST_01_回帰テスト採取手順.md](../../../docs/06_TEST/TEST_01_回帰テスト採取手順.md) を参照する. +**CSV が 1 件も無い間,回帰テストは skip される**(置いた瞬間に有効化される). + +## ディレクトリ構成 (Layout) + +```text +tests/fixtures/regression/ +├── README.md # 本ファイル(形式定義) +├── matrices/ # 採取時に使った変換行列 CSV(17 行 × 3 列・ヘッダなし) +│ └── <任意の名前>.csv +├── rgb_to_lab.csv # ① RGB → L*a*b* のペア +├── rgb_to_scores.csv # ② RGB → 5 色スコアのペア(パイプライン全体) +└── lab_to_scores.csv # ③ L*a*b* → 5 色スコアのペア(任意.下記の注意を参照) +``` + +## 共通ルール (Common Rules) + +- 文字コード: UTF-8(BOM なし).改行は CRLF / LF どちらでもよい +- 1 行目はヘッダ行とし,列名は本書のとおりに書く(列の順序は自由.列名で対応付ける) +- `#` で始まる行と空行は無視される(メモを書いてよい) +- 数値は半角.旧 exe の表示をそのまま転記する(表示は整数なので小数点は付かない) +- `matrix` 列には `matrices/` に置いた変換行列 CSV のファイル名を書く(例: `translateMatrix.csv`) +- `note` 列は任意.採取日・撮影環境・ケースの意図等を自由に書く + +## 旧 exe の表示精度 (Display Precision of the Legacy exe) + +`org/src/tonguediagnosisMFC2/tonguediagnosisMFC2Dlg.cpp` の `Text()` は, +L\*a\*b\* もスコアも **`Format(_T("%3.0lf"), ...)`** で整形している. +すなわち**どちらも小数点以下 0 桁(整数に丸めた値)しか画面に出ない**. +これを前提に,照合の許容誤差を次のように定める. + +| 項目 | 旧 exe の表示 | 照合方法 | 許容誤差 | +| --- | --- | --- | --- | +| L\*, a\*, b\* | `%3.0lf`(整数・四捨五入相当) | 新実装の計算値と表示値を直接比較 | 絶対値 0.51 以内(表示丸め幅 0.5 + 微小マージン) | +| 5 色スコア(%) | `%3.0lf`(整数) | 新実装の `percentages`(int)と**完全一致** | 0(不一致は即失敗) | +| 診断失敗 | 結果画面の「Diagnosis failed」表示 | 新実装の `DiagnosisResult.failed` と**完全一致** | 0 | + +※ スコアを完全一致で照合できる理由: スコアは `k / 38 × 100`(k は 0〜38 の整数)の値しか取らず, +`x.5` ちょうどになる k が存在しないため,丸め方(printf の偶数丸め / Python の `round`)の +違いで結果が変わらない.したがって整数どうしの厳密比較が安全である. + +※ L\*a\*b\* を 0.51 以内としているのは,旧 exe が整数しか表示しないためである. +より厳しい照合が必要になった場合は,旧実装をデバッグビルドして `TRACE` 出力(`%lf`)を +採取する必要がある(TEST_01「精度を上げたい場合」参照). + +## ① rgb_to_lab.csv + +ROI 平均 RGB(= 単色画像の色)と,旧 exe が表示した L\*a\*b\* の組. + +| 列名 | 内容 | +| --- | --- | +| `matrix` | 使用した変換行列 CSV のファイル名(`matrices/` 内) | +| `r`, `g`, `b` | 入力 RGB(0〜255.単色画像の画素値そのまま) | +| `l_star`, `a_star`, `b_star` | 旧 exe のメイン画面に表示された `L*` / `a*` / `b*` の値 | +| `note` | 任意のメモ | + +```csv +matrix,r,g,b,l_star,a_star,b_star,note +translateMatrix.csv,156,102,98,55,22,7,基準色(旧実装コメントの平均 BGR 由来) +``` + +## ② rgb_to_scores.csv + +同じ入力 RGB と,旧 exe が表示した 5 色スコア(%)・診断失敗の有無の組. +新実装では `rgb_to_lab` → `diagnose` の**パイプライン全体**を通して照合する +(ステップ 5 の完了条件はこのファイルで判定する). + +| 列名 | 内容 | +| --- | --- | +| `matrix` | 使用した変換行列 CSV のファイル名 | +| `r`, `g`, `b` | 入力 RGB(0〜255) | +| `purple` | 「紫」の表示値(%.整数) | +| `dark_red` | 「暗赤」の表示値(%) | +| `crimson` | 「紅」の表示値(%) | +| `pink` | 「淡紅」の表示値(%) | +| `pale` | 「淡白」の表示値(%) | +| `failed` | 結果画面に「Diagnosis failed」が出たら `yes`,出なければ `no` | +| `note` | 任意のメモ | + +```csv +matrix,r,g,b,purple,dark_red,crimson,pink,pale,failed,note +translateMatrix.csv,156,102,98,16,61,11,5,0,no,基準色 +``` + +※ `failed` は `yes` / `no`(`true` / `false`・`1` / `0` も可.空欄は `no` 扱い). + +## ③ lab_to_scores.csv(任意) + +L\*a\*b\* を直接入力としたスコアのペア.**旧 exe には Lab の直接入力手段が無い**ため, +通常は採取できない.次のような «L\*a\*b\* が厳密に分かっている» ケースがある場合にのみ使う. + +- 旧実装をデバッグビルドし,`CalcSVM()` へ渡る `m_tongueL/a/b` を直接与えて実行した場合 +- 旧実装のソースを改造して Lab 入力版を作った場合 + +| 列名 | 内容 | +| --- | --- | +| `l_star`, `a_star`, `b_star` | 入力 L\*a\*b\*(**整数丸めではない厳密値**) | +| `purple`, `dark_red`, `crimson`, `pink`, `pale` | 5 色スコアの表示値(%) | +| `failed` | 「Diagnosis failed」が出たら `yes` | +| `note` | 任意のメモ | + +※ 旧 exe の画面から読んだ**整数丸めの L\*a\*b\* をここに書いてはならない**. +丸めた Lab を SVM に入れると境界付近で予測が変わり,偽の不一致(または偽の一致)を招く. +RGB から採取した組は必ず ② に書く. + +## matrices/ + +採取時にユーザーが旧 exe に読ませた変換行列 CSV をそのまま置く(17 行 × 3 列・ヘッダなし). +撮影環境ごとに異なるファイルなので,複数置いて `matrix` 列で使い分けてよい. +変換行列が変われば Lab もスコアも変わるため,**採取に使ったファイルを必ず同梱する**こと. diff --git a/tests/fixtures/regression/matrices/.gitkeep b/tests/fixtures/regression/matrices/.gitkeep new file mode 100644 index 0000000..2f07a3f --- /dev/null +++ b/tests/fixtures/regression/matrices/.gitkeep @@ -0,0 +1,2 @@ +採取に使った変換行列 CSV(17 行 × 3 列・ヘッダなし)をこのディレクトリに置く. +形式は ../README.md ,採取手順は docs/06_TEST/TEST_01_回帰テスト採取手順.md を参照. diff --git a/tests/test_regression.py b/tests/test_regression.py new file mode 100644 index 0000000..b2c0341 --- /dev/null +++ b/tests/test_regression.py @@ -0,0 +1,255 @@ +"""旧実装 exe との回帰テスト(PLAN_02 ステップ 5). + +`tests/fixtures/regression/` に置かれた «旧 exe で採取した入出力ペア» を読み, +新実装のパイプライン(`color.rgb_to_lab` → `svm.diagnose`)の出力と照合する. + +フィクスチャ CSV が未採取(ファイルが無い・データ行が 0)の間はテストを skip する. +CSV を置いた瞬間に有効化される.形式は `tests/fixtures/regression/README.md`, +採取手順は `docs/06_TEST/TEST_01_回帰テスト採取手順.md` を参照. + +照合の許容誤差(README と同じ根拠): + +- L*a*b*: 旧 exe は `Format("%3.0lf", ...)` で整数しか表示しないため,絶対誤差 0.51 以内 +- 5 色スコア(%): 表示値どうしの整数比較(`k / 38 × 100` は x.5 にならないため厳密比較で安全) +- 診断失敗の有無: 完全一致 +""" + +from __future__ import annotations + +import csv +from collections.abc import Callable, Iterable +from pathlib import Path + +import numpy as np +import pytest +from numpy.typing import NDArray + +from tiasjudge.core.color import rgb_to_lab +from tiasjudge.core.csv_io import TrainingData, load_training_data, load_transform_matrix +from tiasjudge.core.svm import COLOR_KEYS, diagnose + +REPO_ROOT = Path(__file__).resolve().parents[1] +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "regression" +MATRIX_DIR = FIXTURE_DIR / "matrices" +FORMAT_DOC = FIXTURE_DIR / "README.md" +CAPTURE_DOC = "docs/06_TEST/TEST_01_回帰テスト採取手順.md" + +RGB_TO_LAB_CSV = FIXTURE_DIR / "rgb_to_lab.csv" +RGB_TO_SCORES_CSV = FIXTURE_DIR / "rgb_to_scores.csv" +LAB_TO_SCORES_CSV = FIXTURE_DIR / "lab_to_scores.csv" + +#: L*a*b* の許容誤差(旧 exe の表示丸め幅 0.5 + 微小マージン) +LAB_DISPLAY_TOLERANCE = 0.51 + +#: 真偽値として受け付ける表記(`failed` 列) +_TRUE_WORDS = frozenset({"yes", "y", "true", "1", "○"}) +_FALSE_WORDS = frozenset({"", "no", "n", "false", "0", "-", "×"}) + + +# --- フィクスチャ読込 (Fixture Loading) --- + + +def _read_fixture_rows(path: Path) -> list[dict[str, str]]: + """回帰フィクスチャ CSV を読む(未採取なら空リスト). + + `#` で始まる行と空行は無視する.値は前後の空白を除いて返す. + """ + if not path.exists(): + return [] + lines = [ + line + for line in path.read_text(encoding="utf-8-sig").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + reader = csv.DictReader(lines) + return [ + {(key or "").strip(): (value or "").strip() for key, value in row.items()} for row in reader + ] + + +def _skip_reason(path: Path) -> str: + """未採取時の skip 理由(採取手順への参照を含む)を組み立てる.""" + return ( + f"回帰フィクスチャ未採取: {path.relative_to(REPO_ROOT).as_posix()} が無い" + f"(または データ行が 0).旧 exe での採取手順は {CAPTURE_DOC}," + f"CSV の形式は {FORMAT_DOC.relative_to(REPO_ROOT).as_posix()} を参照" + ) + + +def _cases(path: Path, id_columns: Iterable[str]) -> list[object]: + """CSV の各行を parametrize 用のパラメータに変換する. + + 未採取の場合は «skip マーク付きの 1 件» を返し,理由に採取手順書を示す. + """ + rows = _read_fixture_rows(path) + if not rows: + return [pytest.param(None, marks=pytest.mark.skip(reason=_skip_reason(path)), id="未採取")] + columns = tuple(id_columns) + return [ + pytest.param(row, id="-".join(row.get(column, "?") for column in columns) + f"#{index}") + for index, row in enumerate(rows, start=1) + ] + + +# --- 値の取り出し (Value Access) --- + + +def _value(row: dict[str, str], column: str, path: Path) -> str: + """必須列の値を取り出す(列が無ければ形式エラーとして失敗させる).""" + if column not in row: + pytest.fail( + f"{path.name} に列 `{column}` がありません(列: {sorted(row)})." + f"形式は {FORMAT_DOC.name} を参照" + ) + return row[column] + + +def _number(row: dict[str, str], column: str, path: Path) -> float: + """必須列を実数として取り出す.""" + text = _value(row, column, path) + try: + return float(text) + except ValueError: + pytest.fail(f"{path.name} の列 `{column}` が数値ではありません: {text!r}") + + +def _percent(row: dict[str, str], column: str, path: Path) -> int: + """スコア列(%)を整数として取り出す.""" + value = _number(row, column, path) + if value != int(value): + pytest.fail( + f"{path.name} の列 `{column}` は整数(旧 exe の表示値)である必要があります: {value}" + ) + return int(value) + + +def _flag(row: dict[str, str], column: str, path: Path) -> bool: + """`failed` 列を真偽値として取り出す(列が無い・空欄なら False).""" + text = row.get(column, "").lower() + if text in _TRUE_WORDS: + return True + if text in _FALSE_WORDS: + return False + pytest.fail(f"{path.name} の列 `{column}` は yes / no で記入してください: {text!r}") + + +def _expected_percentages(row: dict[str, str], path: Path) -> dict[str, int]: + """5 色スコアの期待値(%)をまとめて取り出す.""" + return {key: _percent(row, key, path) for key in COLOR_KEYS} + + +# --- pytest フィクスチャ (Pytest Fixtures) --- + + +@pytest.fixture(scope="session") +def training_data(data_csv_path: Path) -> TrainingData: + """同梱の学習データ(`data/data.csv`)を読み込む.""" + return load_training_data(data_csv_path) + + +@pytest.fixture(scope="session") +def transform_matrix() -> Callable[[str], NDArray[np.float64]]: + """`matrices/` の変換行列 CSV を名前で読み込む関数を返す(読込結果を再利用する).""" + cache: dict[str, NDArray[np.float64]] = {} + + def load(name: str) -> NDArray[np.float64]: + if name not in cache: + path = MATRIX_DIR / name + if not path.exists(): + pytest.fail( + f"変換行列 CSV がありません: {path}." + f"採取に使った変換行列を {MATRIX_DIR.name}/ に置いてください" + f"({FORMAT_DOC.name} 参照)" + ) + cache[name] = load_transform_matrix(path) + return cache[name] + + return load + + +# --- 器の健全性 (Scaffold Sanity) --- + + +def test_fixture_scaffold_exists() -> None: + """フィクスチャの器(ディレクトリ・形式定義・変換行列置き場)が揃っている.""" + assert FIXTURE_DIR.is_dir(), f"回帰フィクスチャのディレクトリがありません: {FIXTURE_DIR}" + assert FORMAT_DOC.is_file(), f"フィクスチャ形式定義がありません: {FORMAT_DOC}" + assert MATRIX_DIR.is_dir(), f"変換行列の置き場がありません: {MATRIX_DIR}" + + +# --- ① RGB → L*a*b* --- + + +@pytest.mark.parametrize("row", _cases(RGB_TO_LAB_CSV, ("r", "g", "b"))) +def test_rgb_to_lab_matches_legacy( + row: dict[str, str], transform_matrix: Callable[[str], NDArray[np.float64]] +) -> None: + """旧 exe が表示した L*a*b* と新実装の色変換が一致する(SPEC_02 (1)(2)).""" + path = RGB_TO_LAB_CSV + coefficients = transform_matrix(_value(row, "matrix", path)) + rgb = (_number(row, "r", path), _number(row, "g", path), _number(row, "b", path)) + + lab = rgb_to_lab(rgb, coefficients) + + actual = {"l_star": lab.l_star, "a_star": lab.a_star, "b_star": lab.b_star} + for column, value in actual.items(): + expected = _number(row, column, path) + assert abs(value - expected) <= LAB_DISPLAY_TOLERANCE, ( + f"RGB={rgb} の {column} が旧実装と一致しません: " + f"旧 exe 表示={expected}・新実装={value}(許容 {LAB_DISPLAY_TOLERANCE})" + ) + + +# --- ② RGB → 5 色スコア(パイプライン全体)--- + + +@pytest.mark.parametrize("row", _cases(RGB_TO_SCORES_CSV, ("r", "g", "b"))) +def test_pipeline_scores_match_legacy( + row: dict[str, str], + transform_matrix: Callable[[str], NDArray[np.float64]], + training_data: TrainingData, +) -> None: + """RGB → Lab → SVM のパイプライン出力が旧 exe の 5 色スコアと一致する(SPEC_02).""" + path = RGB_TO_SCORES_CSV + coefficients = transform_matrix(_value(row, "matrix", path)) + rgb = (_number(row, "r", path), _number(row, "g", path), _number(row, "b", path)) + + result = diagnose(rgb_to_lab(rgb, coefficients), training_data) + + expected = _expected_percentages(row, path) + assert result.percentages == expected, ( + f"RGB={rgb} の 5 色スコアが旧実装と一致しません: " + f"旧 exe 表示={expected}・新実装={result.percentages}(Lab={result.lab})" + ) + assert result.failed == _flag(row, "failed", path), ( + f"RGB={rgb} の診断失敗判定が旧実装と一致しません: " + f"旧 exe={_flag(row, 'failed', path)}・新実装={result.failed}" + f"(理由: {result.failure_reason})" + ) + + +# --- ③ L*a*b* → 5 色スコア(任意)--- + + +@pytest.mark.parametrize("row", _cases(LAB_TO_SCORES_CSV, ("l_star", "a_star", "b_star"))) +def test_lab_to_scores_match_legacy(row: dict[str, str], training_data: TrainingData) -> None: + """厳密な L*a*b* を入力にしたときの 5 色スコアが旧実装と一致する(SPEC_02 (3)).""" + path = LAB_TO_SCORES_CSV + lab = ( + _number(row, "l_star", path), + _number(row, "a_star", path), + _number(row, "b_star", path), + ) + + result = diagnose(lab, training_data) + + expected = _expected_percentages(row, path) + assert result.percentages == expected, ( + f"Lab={lab} の 5 色スコアが旧実装と一致しません: " + f"旧 exe 表示={expected}・新実装={result.percentages}" + ) + assert result.failed == _flag(row, "failed", path), ( + f"Lab={lab} の診断失敗判定が旧実装と一致しません: " + f"旧 exe={_flag(row, 'failed', path)}・新実装={result.failed}" + f"(理由: {result.failure_reason})" + )