diff --git a/CLAUDE.md b/CLAUDE.md index 8cb2c68..6d0623a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ ## 開発進捗 -最新: CNN のハイパーパラメータを変えても性能はほぼ不変と判明(TEST_06).次は CNN と色特徴の併用・効いている特徴の分析 +最新: 判定モデルの書き出しと DeepTIAS のセグメンテーションの Python 再現(IoU 0.958)を完了し,引き渡し仕様 SPEC_04 を作成(Issue #9).次は TIASjudge 側への組み込み(TIASjudge #13) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) @@ -91,6 +91,7 @@ - アーキテクチャ設計: docs/04_SPEC/SPEC_01_アーキテクチャ設計.md - 学習・評価仕様: docs/04_SPEC/SPEC_02_学習・評価仕様.md - リモート実行仕様: docs/04_SPEC/SPEC_03_リモート実行仕様.md +- TIASjudge 引き渡し仕様: docs/04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md ### 05_TECH(技術設計) @@ -104,3 +105,4 @@ - クラス不均衡対策の実験: docs/06_TEST/TEST_04_クラス不均衡対策の実験.md - 色特徴分類器の実験: docs/06_TEST/TEST_05_色特徴分類器の実験.md - ハイパーパラメータの比較: docs/06_TEST/TEST_06_ハイパーパラメータの比較.md +- セグメンテーションの再現と判定モデルの書き出し: docs/06_TEST/TEST_07_セグメンテーションの再現と判定モデルの書き出し.md diff --git a/configs/export/color_logistic_with_mild.yaml b/configs/export/color_logistic_with_mild.yaml new file mode 100644 index 0000000..752bef2 --- /dev/null +++ b/configs/export/color_logistic_with_mild.yaml @@ -0,0 +1,23 @@ +# 判定モデルの書き出し(ややあり込み): 色特徴+ロジスティック回帰を全データで学習して color_model.json を書き出す(tias-train color-export) +# C は全データの患者単位 5-fold(split.seed)の held_out macro F1 の fold 平均で選ぶ(SPEC_02「判定モデルの書き出し」). +# 特徴・候補は configs/color/cv_color_logistic_with_mild.yaml と同じ. +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +train: + class_weighting: none + +color: + classifier: logistic # color-export は logistic のみ対応 + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 全データの 5-fold の fold 平均 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/export/color_logistic_without_mild.yaml b/configs/export/color_logistic_without_mild.yaml new file mode 100644 index 0000000..d66e56d --- /dev/null +++ b/configs/export/color_logistic_without_mild.yaml @@ -0,0 +1,23 @@ +# 判定モデルの書き出し(ややあり除外): 色特徴+ロジスティック回帰を全データで学習して color_model.json を書き出す(tias-train color-export) +# C は全データの患者単位 5-fold(split.seed)の held_out macro F1 の fold 平均で選ぶ(SPEC_02「判定モデルの書き出し」). +# 特徴・候補は configs/color/cv_color_logistic_without_mild.yaml と同じ. +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_DATA_ROOT が設定されていれば data.root より優先される. +data: + root: ~/data/TIASJudgeModel + manifest: manifest.csv # data.root からの相対パス + severities: [strong, normal] + +split: + seed: 42 + n_folds: 5 + +train: + class_weighting: none + +color: + classifier: logistic # color-export は logistic のみ対応 + max_side: 512 # 長辺がこれを超える画像は最近傍法で縮小してから特徴を取る(色は混ぜない) + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] # 全データの 5-fold の fold 平均 macro F1 で 1 つ選ぶ + max_iter: 5000 diff --git a/configs/export/color_synthetic.yaml b/configs/export/color_synthetic.yaml new file mode 100644 index 0000000..8d85127 --- /dev/null +++ b/configs/export/color_synthetic.yaml @@ -0,0 +1,20 @@ +# 手元確認用: 合成データ(scripts/make_synthetic_dataset.py の出力)で判定モデルの書き出し(tias-train color-export)を通す設定 +# GPU 不要.CPU で数秒.data.root の相対パスはカレントディレクトリ(リポジトリのルート)基準. +data: + root: data/synthetic + manifest: manifest.csv # data.root からの相対パス + severities: [strong, mild, normal] + +split: + seed: 42 + n_folds: 5 + +train: + class_weighting: none + +color: + classifier: logistic + max_side: 512 + logistic: + c_values: [0.01, 0.1, 1.0, 10.0, 100.0] + max_iter: 5000 diff --git a/configs/seg/eval_newton.yaml b/configs/seg/eval_newton.yaml new file mode 100644 index 0000000..38bccc4 --- /dev/null +++ b/configs/seg/eval_newton.yaml @@ -0,0 +1,46 @@ +# Python 版セグメンテーション(DeepTIAS 2.7 の再現)の評価(tias-seg eval): 計算サーバ上の正解マスク付きデータで IoU を測る +# 臨床データはサーバ上にのみ置く.手元ではこの設定で実行しない.GPU は使わない(tias-remote run --cpu). +# 環境変数 TIAS_SEG_DATA_ROOT・TIAS_SEG_MODEL_ROOT が設定されていれば data.root・models.root より優先される +# (tias-remote run が渡す TIAS_DATA_ROOT は判定モデルのデータを指すため使わない). +data: + root: ~/data/TongueSegmentation/input + sample_dirname: sample # 評価セット内の入力画像のディレクトリ + label_dirname: label # 正解マスク(入力画像と同じ stem) + label_threshold: 128 # 正解マスク(JPEG)のこの値以上を前景とする + sets: + - name: test_cropped + dir: test/cropped + kind: cropped # 切り出し済み: 画像全体に ResUNet(学習時と同じ条件) + - name: test_extra_cropped + dir: test_extra/cropped + kind: cropped + - name: train + dir: train + kind: cropped + - name: test_fullsize + dir: test/fullsize + kind: fullsize # 撮影画像: 検出なし・正解の外接矩形・検出モデル(ある場合)を比べる + +models: + root: ~/models/TongueSegmentation + resunet: ResUNet.onnx + # scripts/convert_detection_to_onnx.py で変換したもの.ファイルが無ければ detector の方式を省いて続ける + detection: Detection_Normal.onnx + +resunet: + input_size: 256 + foreground_channel: 0 # DeepTIAS はチャンネル 0 にシグモイドを掛ける + +postprocess: + min_area: 500 # 輪郭が 0 個または 2 個超のとき,前景・背景とも面積これ未満の領域を除く + mask_threshold: 127 # 枠のサイズに線形補間で戻したマスクのこの値より大きい画素を前景とする + +detection: + score_threshold: 0.5 # これを超える中で最高の枠を採用する(DeepTIAS と同じ) + input_color: rgb # 検出モデルへ渡す色の順(TF の物体検出 API の既定は RGB) + input_name: image_tensor + boxes_name: detection_boxes + scores_name: detection_scores + +evaluation: + iou_threshold: 0.8 # IoU がこれ未満の件数を数える diff --git a/configs/seg/eval_synthetic.yaml b/configs/seg/eval_synthetic.yaml new file mode 100644 index 0000000..8173ba3 --- /dev/null +++ b/configs/seg/eval_synthetic.yaml @@ -0,0 +1,46 @@ +# 手元確認用: 合成データ(scripts/make_synthetic_segmentation.py)とダミーの ONNX(scripts/make_dummy_seg_models.py)で tias-seg eval を通す設定 +# 相対パスはカレントディレクトリ(リポジトリのルート)基準.ダミーのモデルなので IoU の値に意味は無い. +# 環境変数 TIAS_SEG_DATA_ROOT・TIAS_SEG_MODEL_ROOT が設定されていれば data.root・models.root より優先される +# (tias-remote run が渡す TIAS_DATA_ROOT は判定モデルのデータを指すため使わない). +data: + root: data/synthetic_seg/input + sample_dirname: sample # 評価セット内の入力画像のディレクトリ + label_dirname: label # 正解マスク(入力画像と同じ stem) + label_threshold: 128 # 正解マスク(JPEG)のこの値以上を前景とする + sets: + - name: test_cropped + dir: test/cropped + kind: cropped # 切り出し済み: 画像全体に ResUNet(学習時と同じ条件) + - name: test_extra_cropped + dir: test_extra/cropped + kind: cropped + - name: train + dir: train + kind: cropped + - name: test_fullsize + dir: test/fullsize + kind: fullsize # 撮影画像: 検出なし・正解の外接矩形・検出モデル(ある場合)を比べる + +models: + root: data/synthetic_seg/models + resunet: ResUNet.onnx + # scripts/convert_detection_to_onnx.py で変換したもの.ファイルが無ければ detector の方式を省いて続ける + detection: Detection_Normal.onnx + +resunet: + input_size: 256 + foreground_channel: 0 # DeepTIAS はチャンネル 0 にシグモイドを掛ける + +postprocess: + min_area: 500 # 輪郭が 0 個または 2 個超のとき,前景・背景とも面積これ未満の領域を除く + mask_threshold: 127 # 枠のサイズに線形補間で戻したマスクのこの値より大きい画素を前景とする + +detection: + score_threshold: 0.5 # これを超える中で最高の枠を採用する(DeepTIAS と同じ) + input_color: rgb # 検出モデルへ渡す色の順(TF の物体検出 API の既定は RGB) + input_name: image_tensor + boxes_name: detection_boxes + scores_name: detection_scores + +evaluation: + iou_threshold: 0.8 # IoU がこれ未満の件数を数える diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index 0225d88..26d075c 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -31,6 +31,7 @@ | --- | --- | --- | | 画像 IO | Pillow | 学習時の読み込み.軽量 | | 数値計算 | NumPy | — | +| 舌のセグメンテーション | onnxruntime・opencv-python-headless | DeepTIAS 2.7 の検出モデル(ONNX に変換)と ResUNet(ONNX)を CPU で推論し,大津の 2 値化・連結成分などの後処理を行う(SPEC_04).開発用の依存に `onnx`(テスト用のダミーモデルの生成).検出モデルの変換だけは TensorFlow・tf2onnx を一時的に使う(本体の依存に入れない) | | 評価指標 | scikit-learn | 混同行列・macro F1・クラス別 recall・患者単位の層化分割 | | 図の出力 | matplotlib | 学習曲線・混同行列を PNG で出力する(サーバ上で描画し,画像ファイルだけ回収する) | | ルールベース比較 | TIASjudge の `core/` ロジックを移植または参照 | ベースライン算出用.OpenCV が必要になった場合はサーバに GUI が無いため `opencv-python-headless` を使う | diff --git "a/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 1443770..e343c27 100644 --- "a/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -161,6 +161,20 @@ ※ 研究室 PC から Newton への鍵が未登録の場合はパスワードを求められる. +## セグメンテーション用データの配置 (Segmentation Data) + +舌のセグメンテーションの検証([TEST_07](../06_TEST/TEST_07_セグメンテーションの再現と判定モデルの書き出し.md))に使う,ResUNet の開発時の正解マスク付きデータとモデルファイルの置き場所.2026-09-28 に実施. + +| もの | 置き場所 | +| --- | --- | +| 原本(画像・正解マスク・開発時の画像ごとの記録) | 研究室 PC の `C:\Users\Papin\clinical-data\TongueSegmentation\`(OneDrive の同期フォルダの外) | +| 作業用コピー(`input/` のみ.1804 ファイル・68 MB) | Newton・Archimedes の `~/data/TongueSegmentation/input/`(`drwx------`) | +| モデル(`ResUNet.onnx`・`Detection_Normal.pb`・変換した `Detection_Normal.onnx`) | Newton・Archimedes の `~/models/TongueSegmentation/`(`drwx------`.Newton で変換し,Archimedes には未配置なら同じ手順で変換する) | + +- 手元 PC の OneDrive にあった画像と画像ごとの記録は,研究室 PC にコピーして件数・サイズを照合した後に削除した(OneDrive の「大量削除の検出」で一度復元されたため,削除を確定し直した).コードとモデルは手元に残している +- サーバへの転送は研究室 PC で tar ファイルを作って `scp` で送り,サーバで展開した(手元 PC は経由しない).Windows の `tar` で作った tar は権限の情報のため展開に失敗することがあるので,`tar --delay-directory-restore -xf` で展開してから `chmod -R u+rwX,go-rwx` で整える.cmd のパイプ(`tar -cf - | ssh ... tar -xf -`)は研究室 PC 上で止まったため使わない +- 展開後の tar ファイルは研究室 PC・サーバとも削除した + ## 学外アクセス用の踏み台 (Off-campus Access) 研究室 PC を ProxyJump の踏み台にする設定は SmTIAS-LightSim で構築済みのため,本プロジェクトでの追加作業は不要(未設定の場合は同プロジェクトの `docs/02_ENV/ENV_02_環境構築手順.md`「学外から接続する(研究室 PC 経由)」に従う). diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index b36406b..69484cc 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -67,6 +67,7 @@ | 5-fold 交差検証 | `uv run tias-train cv --config <設定>` | 学習・評価して `runs/<実験ID>/` に書き出す.最後に実験 ID を出力する([SPEC_02](../04_SPEC/SPEC_02_学習・評価仕様.md)「交差検証の実行」) | | 手元での試運転 | `uv run tias-train cv --config configs/cv_synthetic.yaml` | 合成データ・小さなモデル・事前学習なし.CPU で十数秒 | | 色特徴分類器の交差検証 | `uv run tias-train color-cv --config <設定>` | CNN と同じ分割で色特徴+ロジスティック回帰/勾配ブースティングを評価する([SPEC_02](../04_SPEC/SPEC_02_学習・評価仕様.md)「色特徴分類器」).GPU 不要.手元の試運転は `configs/color_synthetic.yaml` | +| 判定モデルの書き出し | `uv run tias-train color-export --config <設定>` | 全データの患者単位 5-fold で C を選び,全データで学習した色特徴+ロジスティック回帰を `runs/<実験ID>/color_model.json` に書き出す(回収対象).GPU 不要.手元の試運転は `configs/export/color_synthetic.yaml` | | 回収した結果の集計 | `uv run python scripts/summarize_cv.py runs` | `runs/*_cv_*` を読み,モデル × 学習率 × 不均衡対策 × 条件ごとの表と,「除外 − 込み」「対策あり − 対策なし」の対応比較を Markdown で出す([TEST_03](../06_TEST/TEST_03_交差検証結果.md) の表) | 設定ファイル: @@ -80,6 +81,7 @@ | `configs/imbalance/*.yaml` | クラス不均衡の対策(`_cwinv`: 重み inverse・`_cwsqrt`: 重み sqrt_inverse・`_balanced`: バランス・サンプリング).元の設定(ConvNeXt-Tiny・ResNet-50 lr 1e-3 の各 6 本)から対策のキーだけを変えたもの | | `configs/hparam/*.yaml` | ハイパーパラメータの比較(`cv_hp__<変更>[_s43 \| _s44]`).基準の設定(ConvNeXt-Tiny・重み inverse/ResNet-50 lr 1e-3.ややあり除外)から 1 項目だけを変えたもの([TEST_06](../06_TEST/TEST_06_ハイパーパラメータの比較.md)) | | `configs/color/*.yaml` | 色特徴分類器(`cv_color__[_s43 \| _s44][_cwinv]`).分割・程度の設定は同名の CNN の設定と同一 | +| `configs/export/*.yaml` | 判定モデルの書き出し(`color_logistic_`.特徴・候補は `configs/color/` と同じ.`color_synthetic` は手元用) | **サーバでの実行例**: @@ -99,6 +101,26 @@ - 事前学習の重みは初回にサーバが Hugging Face からダウンロードする(`~/.cache/huggingface`) - ログに出る `lr_scheduler.step() before optimizer.step()` の警告は,混合精度で最初の数ステップの勾配がオーバーフローして捨てられたときに出るもので無害 +## セグメンテーションの評価 (Segmentation) + +DeepTIAS 2.7 の手順(検出 → ResUNet → 後処理)を Python(onnxruntime・OpenCV)で再現したものを,正解マスク付きデータで評価する.データ・モデルの場所は設定の `data.root`・`models.root`(環境変数 `TIAS_SEG_DATA_ROOT`・`TIAS_SEG_MODEL_ROOT` で上書き.`tias-remote run` が渡す `TIAS_DATA_ROOT` は使わない). + +| 目的 | コマンド | 備考 | +| --- | --- | --- | +| 合成データの生成 | `uv run python scripts/make_synthetic_segmentation.py` | 出力は `data/synthetic_seg/input/`(`train`・`test/cropped`・`test_extra/cropped`・`test/fullsize` の `sample/`・`label/`).作り直しは `--overwrite` | +| ダミーの ONNX の生成 | `uv run python scripts/make_dummy_seg_models.py` | 出力は `data/synthetic_seg/models/`(入出力の形だけを合わせた ResUNet・検出モデル.`onnx` は開発用の依存) | +| 評価 | `uv run tias-seg eval --config <設定>` | `runs/<実験ID>/` に `metrics.json`(集計値)・`figures/`(IoU のヒストグラム)を書く.手元の試運転は `configs/seg/eval_synthetic.yaml`(CPU で数秒) | +| 検出モデルの ONNX 変換 | `uv run --with tensorflow --with tf2onnx python scripts/convert_detection_to_onnx.py --pb --out ` | サーバで一時的な依存を入れて実行する(本体の依存には入れない).入出力の候補は `--list-nodes` | + +**サーバでの実行例**(モデルは `~/models/TongueSegmentation/` に `ResUNet.onnx`(と変換した `Detection_Normal.onnx`)を置く): + +```bash +uv run tias-remote run --name seg1 --cpu --server Newton -- tias-seg eval --config configs/seg/eval_newton.yaml +uv run tias-remote pull <実験ID> +``` + +- 検出モデルの ONNX が無ければ `detector` の方式を省いて(警告を出して)続ける + ## テスト (Test) | 目的 | コマンド | diff --git "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" index 82a45c8..35958e4 100644 --- "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" +++ "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" @@ -60,7 +60,7 @@ - **成果物**: 実験群(クラス不均衡対策・モデル比較・入力解像度・マスク外領域の埋め方)とその記録 - **完了条件**: 各要因の効果が**独立した実験として**切り分けて記録されている - **注意**: 共用サーバのため使用する GPU を明示的に指定する -- **進捗**: クラス不均衡対策([TEST_04](../06_TEST/TEST_04_クラス不均衡対策の実験.md))・色特徴分類器との比較([TEST_05](../06_TEST/TEST_05_色特徴分類器の実験.md))・ハイパーパラメータの比較([TEST_06](../06_TEST/TEST_06_ハイパーパラメータの比較.md))を実施 +- **進捗**: クラス不均衡対策([TEST_04](../06_TEST/TEST_04_クラス不均衡対策の実験.md))・色特徴分類器との比較([TEST_05](../06_TEST/TEST_05_色特徴分類器の実験.md))・ハイパーパラメータの比較([TEST_06](../06_TEST/TEST_06_ハイパーパラメータの比較.md))を実施.TIASjudge への組み込みに向けて,判定モデルの書き出しとセグメンテーションの再現を実施([TEST_07](../06_TEST/TEST_07_セグメンテーションの再現と判定モデルの書き出し.md)・[SPEC_04](../04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md)) ## Step 8: 結論 (Conclusion) diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index d782fe5..074c0bb 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -20,6 +20,8 @@ │ ├── evaluation/ # 指標・混同行列・レポート生成 │ ├── baseline/ # ルールベース比較(扱いは未定) │ ├── remote/ # リモート実行(tar over ssh・tmux・回収) +│ ├── segmentation/ # 舌のセグメンテーション(DeepTIAS 2.7 の手順の Python 版) +│ ├── deploy/ # 配布用の参照実装(TIASjudge にコピーする単一ファイル.NumPy のみ) │ └── cli/ # エントリポイント ├── scripts/ # 合成データ生成・manifest 生成 ├── tests/ @@ -135,11 +137,12 @@ | 回収する(`runs/<実験ID>/` 配下) | 回収しない | | --- | --- | -| `config.yaml`・`history.csv`・`metrics.json`・`confusion_matrix.csv`・`log.txt`・`dataset_summary.json` | 上記以外のすべて(`split.json`・個票の予測・画像・manifest 等) | +| `config.yaml`・`history.csv`・`metrics.json`・`confusion_matrix.csv`・`log.txt`・`dataset_summary.json`・`color_model.json` | 上記以外のすべて(`split.json`・個票の予測(`predictions.csv`)・画像・manifest 等) | | `figures/*.png` | `figures/` 以外にある `*.png` | | `checkpoints/*.pt`(**明示的に指定したときのみ**) | 指定が無いときの `checkpoints/` | - ジョブのログ(`runs/_remote_logs/<ジョブ名>.log`)はファイルとしては回収せず,`tias-remote logs` で表示して確認する(ログに個人情報を出さない規則が前提.`.claude/rules/coding-style.md`) +- `color_model.json` は `tias-train color-export` が書き出す判定モデル(特徴の定義・標準化の平均と尺度・係数・切片・クラス・学習条件)で,患者 ID・ファイル名・画像単位の値を含まない - データディレクトリ(`~/data/TIASJudgeModel/`)からは何も回収しない - 個票(画像ごとの予測)が必要な分析はサーバ上で行い,集計結果だけを上記の名前で書き出して持ち帰る @@ -150,6 +153,8 @@ | `tias-dataset build-manifest` | `images/` を走査して `manifest.csv` を書き出す(既存があれば `--overwrite` が必要) | サーバ | | `tias-train cv --config PATH` | 5-fold 交差検証で学習・評価し,`runs/<実験ID>/` に書き出す([SPEC_02「交差検証の実行」](SPEC_02_学習・評価仕様.md)).最後に実験 ID を出力する | サーバ(GPU) | | `tias-train color-cv --config PATH` | 色特徴分類器([SPEC_02「色特徴分類器」](SPEC_02_学習・評価仕様.md))を同じ分割で交差検証し,同じ形式で書き出す(学習曲線・重みは出さない).最後に実験 ID を出力する | サーバ(CPU) | +| `tias-train color-export --config PATH` | 色特徴+ロジスティック回帰の最終モデルを全データ(`data.severities` 適用後)で学習し,TIASjudge 用の `color_model.json` と C の選択の記録(`metrics.json`)を書き出す.NumPy だけの参照実装は `src/tias_judge_model/deploy/color_model.py` | サーバ(CPU) | +| `tias-seg eval --config PATH` | Python 版セグメンテーション(DeepTIAS 2.7 の再現.onnxruntime・OpenCV)を正解マスク付きデータで評価し,IoU・Dice の集計値を `metrics.json` に書き出す(画像ごとの値はサーバ上の `predictions.csv`) | サーバ(CPU) | | `tias-dataset survey` | データセットを集計して `runs/<実験ID>/` に集計値だけを書き出す(下記) | サーバ(結果の要約のみ手元へ) | | 学習 | 設定 YAML を受け取って学習する | サーバ | | 評価 | 学習済み run の評価・レポート生成 | サーバ | diff --git "a/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" index 21952d1..44910ed 100644 --- "a/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\345\255\246\347\277\222\343\203\273\350\251\225\344\276\241\344\273\225\346\247\230.md" @@ -224,6 +224,16 @@ - 学習用データに無いクラスの予測確率は 0 とし,5 クラスの形を保つ - 出力は CNN と同じ名前・同じ形式(「出力」).ただし `history.csv`・学習曲線・重み(`checkpoints/`)は出さない.`metrics.json` の `environment.model_name` は `color_logistic`・`color_hist_gb`,最良エポックは `null` で,fold ごとの採用した候補(`selected_params`)と候補ごとの検証 macro F1(`candidates`),特徴名(`color_classifier`),特徴抽出の所要秒数(`timing.feature_extraction_seconds`)を加える +### 判定モデルの書き出し (Model Export) + +TIASjudge に渡す最終モデルは `tias-train color-export` で作る(引き渡しの手順は [SPEC_04](SPEC_04_TIASjudge引き渡し仕様.md).結果は [TEST_07](../06_TEST/TEST_07_セグメンテーションの再現と判定モデルの書き出し.md)). + +- 当面は `color.classifier: logistic` のみ(`hist_gb` は停止する) +- C の選び方: 全データで交差検証と同じ患者単位 5-fold(`split.seed`)を組み,学習側と held_out の両方に `data.severities` を適用して,候補ごとの held_out macro F1 の fold 平均が最良の C を採る(厳密な改善で更新し,同点なら先の候補).この値は C を選ぶためのもので,性能の評価には交差検証(`color-cv`)の値を使う +- 最終モデルは `data.severities` 適用後の全データで学習する(乱数は `split.seed`,`train.class_weighting` は sample_weight).学習データに 1 枚も無いクラスがあれば停止する +- 出力は `color_model.json`(回収対象.患者 ID・ファイル名・画像単位の値を含まない)と,C の選択の記録(`metrics.json` の `c_selection`) +- NumPy だけの参照実装 `src/tias_judge_model/deploy/color_model.py` は,学習側と同じ特徴(ビット一致)と予測確率(差 1e-12 以下)を返す.本パッケージの他のモジュールに依存させない(TIASjudge にそのままコピーして使うため) + ## 評価指標 (Metrics) | 指標 | 役割 | diff --git "a/docs/04_SPEC/SPEC_04_TIASjudge\345\274\225\343\201\215\346\270\241\343\201\227\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_04_TIASjudge\345\274\225\343\201\215\346\270\241\343\201\227\344\273\225\346\247\230.md" new file mode 100644 index 0000000..4ee2596 --- /dev/null +++ "b/docs/04_SPEC/SPEC_04_TIASjudge\345\274\225\343\201\215\346\270\241\343\201\227\344\273\225\346\247\230.md" @@ -0,0 +1,95 @@ +# TIASjudge 引き渡し仕様 (TIASjudge Handoff) + +TIASjudge に「撮影画像 → 舌のセグメンテーション → 舌色 5 分類」の判定モードを組み込むために,本リポジトリが引き渡すものと,TIASjudge 側で守るべき処理の手順を定める(本リポジトリの Issue #9.TIASjudge 側は TIASjudge の Issue #13).検証結果は [TEST_07](../06_TEST/TEST_07_セグメンテーションの再現と判定モデルの書き出し.md). + +## 全体の流れ (Pipeline) + +```text +撮影画像(TIAS・色補正なし.cv2.imread の BGR) + → 1. 舌の検出(Detection_Normal.onnx): 舌の枠 + → 2. 舌の分割(ResUNet.onnx): 枠の中の舌マスク → 元の画像サイズのマスク + → 3. マスク外を黒 (0,0,0) にした RGB 画像 + → 4. 色特徴(107 次元)→ ロジスティック回帰(color_model.json): 5 クラスの確率 +``` + +1〜2 は DeepTIAS 2.7 の「画像のみ」モードの手順を再現したもの(DeepTIAS の実行ファイルの逆コンパイルで確認).3 は DeepTIAS の `2-segmentation/5-extraction` と同じ形で,判定モデルの学習データもこの形である. + +## 入力の前提 (Input Requirements) + +- **TIAS の機材で撮影した,色補正をしていない画像**に限る.判定モデルは補正なしの RGB で学習しているため,TIASjudge の変換行列(ROI 診断で使う多項式変換)を通した画像や,他の機材で撮った画像には使えない.画面にこの前提を明示する +- 既存のルールベース診断(ROI クリック → 変換行列 → SVM)は置き換えず,**別の判定モード**として追加する(TIASjudge の「診断結果は旧実装と一致させる」ルールのため) + +## 引き渡すもの (Deliverables) + +| もの | 内容 | 入手先 | +| --- | --- | --- | +| `color_model.json` | 判定モデル(特徴の定義・標準化の値・係数・切片・クラス・学習条件・来歴).約 28 KB | `tias-train color-export` の出力(サーバで実行し `tias-remote pull` で回収.[ENV_04](../02_ENV/ENV_04_開発コマンド.md)) | +| `deploy/color_model.py` | 判定モデルの参照実装(NumPy と標準ライブラリのみ.単一ファイル) | `src/tias_judge_model/deploy/color_model.py` をそのままコピーする | +| `segmentation/`(`detection.py`・`resunet.py`・`pipeline.py`) | セグメンテーションの参照実装(NumPy・OpenCV・onnxruntime のみ.パッケージ内では相互にだけ依存) | `src/tias_judge_model/segmentation/` から 3 ファイルをコピーし,import 文のパッケージ名を合わせる | +| `ResUNet.onnx` | 舌の分割モデル(約 71 MB.入力 `input.1` (1,3,256,256) float32,出力 (1,2,256,256)) | DeepTIAS 2.7 の `models/` に同梱のもの(開発元のファイルとハッシュが一致) | +| `Detection_Normal.onnx` | 舌の検出モデル(約 12 MB) | DeepTIAS 2.7 の `models/Detection_Normal.pb` を `scripts/convert_detection_to_onnx.py` で変換したもの(サーバの `~/models/TongueSegmentation/` にある) | + +- モデルファイル(`.onnx`・`color_model.json`)はリポジトリに入れない(臨床データから学習したもの.配布物への同梱の可否は「未決事項」) +- TIASjudge 側に必要な依存: `numpy`・`opencv-python`(既存)・`onnxruntime`(追加).TensorFlow・PyTorch・scikit-learn は不要 + +## 1. 舌の検出 (Detection) + +`segmentation/detection.py` の `OnnxDetector.detect(bgr)`. + +- 入力: 画像を **RGB に並べ替えた** uint8 の `(1, H, W, 3)`(入力名 `image_tensor:0`.TF の物体検出 API の既定.DeepTIAS も Bitmap 経由で RGB を渡している).画像サイズは任意 +- 出力: `detection_boxes`(正規化座標 [ymin, xmin, ymax, xmax])・`detection_scores` +- スコアが **0.5 を超える**枠のうち最高の 1 つを採用する(0.5 ちょうどは不採用.同点は先の枠) +- 正規化座標に画像の高さ・幅を掛けて **切り捨て**で画素の枠にし,画像の内側に収める +- 枠が無い(0.5 を超えない)場合は「舌を検出できなかった」として判定を行わず,利用者に撮り直し等を促す + +## 2. 舌の分割 (Segmentation) + +`segmentation/pipeline.py` の `segment_in_rect(bgr, rect, runner, params)`(`runner` は `resunet.py` の `ResUNetRunner`). + +1. 枠で切り出した画像を `cv2.resize`(線形補間)で 256×256 にする +2. **BGR のまま** 255 で割り,CHW の float32 にして ResUNet に入れる(学習時の `cv2.imread` の BGR /255 と同じ) +3. 出力の**チャンネル 0**(舌)にシグモイドを掛け,×255 を切り捨てて uint8 にする +4. 大津の方法で 2 値化する +5. 輪郭(Canny → 外側の輪郭)が 0 個または 2 個を超える場合だけ,面積 500 画素未満の前景の島を除き,次に同じく小さい背景の穴を埋める(8 近傍) +6. 枠のサイズに線形補間で戻し,127 を超える画素を舌とする +7. 元の画像サイズのマスクに配置する(枠の外は背景) + +設定値(`SegmentationParams`): `foreground_channel=0`・`min_area=500`・`mask_threshold=127`.[TEST_07](../06_TEST/TEST_07_セグメンテーションの再現と判定モデルの書き出し.md) の評価(IoU 0.958)はこの値で行った. + +- マスクが空(舌の画素が 0)なら判定を行わず,分割に失敗したことを示す +- 結果の確認のため,マスクの輪郭を撮影画像に重ねて表示する(TIASjudge の Issue #13) + +## 3. マスクの適用 (Masking) + +- 撮影画像(BGR)を **RGB に並べ替え**,マスクの外を (0, 0, 0) にする +- 判定モデルは「RGB がすべて 0 の画素」を背景とみなす.舌の中にもともと (0, 0, 0) の画素があれば背景扱いになるが,学習データも同じ扱いなので問題ない + +## 4. 舌色の判定 (Classification) + +`deploy/color_model.py`. + +```python +model = load_color_model("color_model.json") +probs = predict_proba(rgb_masked, model) # (5,) の確率.並びは model の classes の順 +``` + +- 長辺が `max_side`(512)を超える画像は最近傍法で縮小してから特徴を取る(学習時と同じ.`predict_proba` の中で行う) +- クラスの順は `color_model.json` の `classes`(0 淡白・1 淡紅・2 紅・3 暗赤紅・4 紫)に従う.最大の確率のクラスを判定結果とし,5 クラスの確率も表示する +- 参照実装は学習側と同じ予測を返す(特徴はビット一致,確率の差 1e-12 以下.本リポジトリのテストで確認).TIASjudge 側では,同じ画像に対して参照実装と同じ確率が出ることをテストで確かめる + +## 性能の目安 (Expected Performance) + +| 段階 | 指標 | 値 | 出典 | +| --- | --- | --- | --- | +| セグメンテーション | 撮影画像からの舌の IoU(検出+ResUNet.170 枚) | 平均 0.958・最小 0.864 | TEST_07 | +| 舌色の判定 | 「あり」と淡紅での macro F1(5-fold × 3 seed) | 0.630(ややあり込み)〜0.644(除外) | [TEST_05](../06_TEST/TEST_05_色特徴分類器の実験.md) | +| 舌色の判定 | 同・正解率 | 0.72〜0.75 | TEST_05 | + +判定の性能は医師のラベルとの一致がまだ 7 割程度であり,**当面は参考表示とする**.ルールベース診断との比較([PLAN_02](../03_PLAN/PLAN_02_開発ステップ.md) Step 6)の結果を見て扱いを決める. + +## 未決事項 (Open Items) + +- **どちらの判定モデルを渡すか**: ややあり込み(`configs/export/color_logistic_with_mild.yaml`)か除外(`color_logistic_without_mild.yaml`)か.除外のほうが「あり」と淡紅の成績がやや良く,ややありの画像を淡紅と判定しやすい(約 55 %.込みは約 33 %.TEST_05).臨床で「ややあり」を淡紅と判断することが多い点とは除外のほうが整合する +- **DeepTIAS のモデルを TIASjudge の配布物に含めてよいか**(権利) +- 配布サイズ(`ResUNet.onnx` 約 71 MB で単体 exe が大きくなる) +- 学習データ(舌色データセット)のマスクと,本実装のマスクがビット単位で一致するかは未確認(元の撮影画像が無いため.TEST_07「注意」) diff --git "a/docs/06_TEST/TEST_07_\343\202\273\343\202\260\343\203\241\343\203\263\343\203\206\343\203\274\343\202\267\343\203\247\343\203\263\343\201\256\345\206\215\347\217\276\343\201\250\345\210\244\345\256\232\343\203\242\343\203\207\343\203\253\343\201\256\346\233\270\343\201\215\345\207\272\343\201\227.md" "b/docs/06_TEST/TEST_07_\343\202\273\343\202\260\343\203\241\343\203\263\343\203\206\343\203\274\343\202\267\343\203\247\343\203\263\343\201\256\345\206\215\347\217\276\343\201\250\345\210\244\345\256\232\343\203\242\343\203\207\343\203\253\343\201\256\346\233\270\343\201\215\345\207\272\343\201\227.md" new file mode 100644 index 0000000..70d90da --- /dev/null +++ "b/docs/06_TEST/TEST_07_\343\202\273\343\202\260\343\203\241\343\203\263\343\203\206\343\203\274\343\202\267\343\203\247\343\203\263\343\201\256\345\206\215\347\217\276\343\201\250\345\210\244\345\256\232\343\203\242\343\203\207\343\203\253\343\201\256\346\233\270\343\201\215\345\207\272\343\201\227.md" @@ -0,0 +1,74 @@ +# セグメンテーションの再現と判定モデルの書き出し (Segmentation Reproduction & Model Export) + +TIASjudge への組み込み(Issue #9)に向けて,(1) DeepTIAS 2.7 の舌セグメンテーション(検出+ResUNet)を Python で再現して正解マスク付きデータで IoU を測り,(2) 色特徴+ロジスティック回帰の最終モデルを全データで学習して書き出した結果.引き渡し仕様は [SPEC_04](../04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md). + +## セグメンテーションの評価 (Segmentation Evaluation) + +| 項目 | 内容 | +| --- | --- | +| 実行日時 | 2026-09-28 23:28〜23:34(約 5 分) | +| 実行場所 | Newton の CPU(`tias-remote run --cpu`) | +| コマンド | `tias-seg eval --config configs/seg/eval_newton.yaml` | +| データ | ResUNet の開発時の正解マスク付きデータ(研究室 PC から両サーバの `~/data/TongueSegmentation/input/` に配置.ENV_03) | +| モデル | DeepTIAS 2.7 同梱の `ResUNet.onnx`(開発元フォルダの ONNX とハッシュが一致)と,`Detection_Normal.pb` を `scripts/convert_detection_to_onnx.py`(tf2onnx 1.17・opset 13)で変換した `Detection_Normal.onnx` | +| コード | `405ab01-dirty`(本ブランチの実装を未コミットのまま使用) | + +評価セットは,開発時に切り出し済みの画像(`cropped`.学習時と同じ入力条件)と,撮影画像そのもの(`fullsize`).`fullsize` では舌の枠の決め方を 3 通り比べた. + +| 評価セット | 枚数 | 枠の決め方 | IoU 平均 | 中央値 | 10 % 点 | 最小 | IoU 0.8 未満 | 1 枚の秒数 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| test(切り出し済み) | 170 | 画像全体 | **0.959** | 0.966 | 0.926 | 0.880 | 0 | 0.25 | +| test_extra(切り出し済み) | 180 | 画像全体 | 0.956 | 0.965 | 0.916 | 0.858 | 0 | 0.21 | +| train(切り出し済み.学習に使ったもの) | 382 | 画像全体 | 0.981 | 0.986 | 0.965 | 0.850 | 0 | 0.23 | +| test(撮影画像) | 170 | **検出モデル** | **0.958** | 0.967 | 0.918 | 0.864 | 0 | 0.39 | +| test(撮影画像) | 170 | 正解マスクの外接矩形 | 0.947 | 0.951 | 0.914 | 0.860 | 0 | 0.23 | +| test(撮影画像) | 170 | 検出なし(画像全体) | 0.458 | 0.444 | 0.238 | 0.103 | 162 | 0.24 | + +- IoU は舌(前景)の IoU.正解マスク(JPEG)は 128 以上を前景とした.小領域除去(後処理)の有無で平均は変わらなかった(後処理が働いたのは検出なしで 42 枚,それ以外は 0〜2 枚) +- 出力チャンネル 0 を舌として使うと IoU 0.96,チャンネル 1 を使うと 0.003〜0.04 で,**チャンネル 0 が舌**であることを確認した(DeepTIAS の C# の読み取りと一致) +- 画像ごとの IoU はサーバ上の `predictions.csv` にだけ置き,回収していない(回収したのは集計値と IoU のヒストグラムのみ) + +### 分かったこと (Findings) + +1. **DeepTIAS の手順を Python で再現できた**.撮影画像から検出モデル → ResUNet の流れで IoU 0.958(最小 0.864).DeepTIAS の開発時の報告値(ResUNet のモデル名に付いた 0.9518)と同じ水準で,切り出し済みの評価セット(0.959)とも差が無い +2. **検出は必須**.検出を省いて撮影画像全体に ResUNet を掛けると IoU 0.46 に落ちる(ResUNet は切り出し後の画像で学習されているため) +3. 変換した検出モデルは 170 枚すべてで舌を見つけた(スコア 0.5 超).正解マスクの外接矩形よりも IoU がやや高いのは,検出の枠に余白があり,学習時の切り出し方に近いためと考えられる +4. 1 枚あたり約 0.4 秒(Newton の CPU.検出+ResUNet+後処理).TIASjudge の手元 PC でも実用的な速さと見込まれる + +### 注意 (Caveats) + +- DeepTIAS の細部で確かめられていない値がある(検出モデルへ渡す色の順,枠のサイズに戻したマスクの 2 値化のしきい値,輪郭の数え方の Canny のしきい値など).いずれも設定で変えられ,IoU には大きく効かないと見られるが,学習データのマスクとビット単位で一致するかは確かめていない +- **学習データ(舌色データセット)のマスクとの一致は未確認**.舌色データセットはマスク適用後の画像しか無く,元の撮影画像が無いため.元画像が残っていれば,同じ画像で DeepTIAS の出力と比べられる +- OpenCV のバージョン(DeepTIAS は 3.x,本実装は 5.0)による resize・大津の 2 値化の微小な差はありうる + +## 判定モデルの書き出し (Model Export) + +| 項目 | 内容 | +| --- | --- | +| 実行日時 | 2026-09-28 23:26〜23:28 | +| 実行場所 | Archimedes の CPU | +| コマンド | `tias-train color-export --config configs/export/color_logistic_.yaml` | +| 分類器 | 色特徴(107 次元)+ロジスティック回帰(class_weighting なし).C は全データの患者単位 5-fold(seed 42)の held_out macro F1 の平均で選ぶ | + +| 設定 | 学習枚数 | 採用した C | C の候補ごとの fold 平均 macro F1(0.01 / 0.1 / 1 / 10 / 100) | +| --- | --- | --- | --- | +| ややあり除外 | 754(淡白 53・淡紅 337・紅 61・暗赤紅 247・紫 56) | 0.1 | 0.636 / **0.669** / 0.645 / 0.602 / 0.546 | +| ややあり込み | 1085(淡白 104・淡紅 337・紅 119・暗赤紅 380・紫 145) | 0.1 | 0.575 / **0.580** / 0.553 / 0.528 / 0.491 | + +- ここでの macro F1 は C を選ぶための値で,held_out にも `data.severities` を適用している(除外の設定では held_out にもややありが無い).評価指標としては TEST_05 の値(common macro F1 0.630〜0.644)を使う +- 書き出した `color_model.json`(約 28 KB)には,特徴の定義・標準化の値・係数・切片・クラス・学習条件・来歴が入り,患者 ID・ファイル名・画像単位の値は含まない(テストで確認) +- NumPy だけの参照実装 `src/tias_judge_model/deploy/color_model.py` で読み込み,学習側と同じ予測になることを確かめた(特徴はビット一致,予測確率の差は 1e-12 以下.合成データでのテスト) + +## 次の一手 (Next Steps) + +- TIASjudge 側(TIASjudge の Issue #13)で,SPEC_04 に従って判定モードを組み込む +- どちらの判定モデル(ややあり込み/除外)を使うかを決める(SPEC_04「未決事項」) +- 元の撮影画像が残っていれば,舌色データセットのマスクとの一致を確かめる + +## 実験 ID (Run IDs) + +手元の `runs/` に回収済み(`.gitignore` 対象). + +```bash +ls -d runs/20260928-232850_eval_newton_405ab01-dirty runs/*_color_logistic_*_405ab01-dirty +``` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d731f5a..0d7261d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -130,3 +130,16 @@ - 失敗パターン: seed 1 つ(5 fold)の比較は fold 間のばらつき(標準偏差 0.03〜0.10)に埋もれて当てにならない → 今後の比較は最低 3 seed で行う - 示唆: 性能の頭打ち(0.63〜0.65)はハイパーパラメータではなくデータ量・ラベルのあいまいさによる.伸ばすなら CNN と色特徴の併用など情報を増やす方向 - 対応済み: 本実験と TEST_04 の重み(計約 27 GB)が Archimedes の共用ディスク(残り 35 GB・98%)を圧迫していたため,ユーザーの判断で削除した(空き 52 GB に回復).重みを保存するかを設定で選べるようにする対応は未実施 + +## TIASjudge への組み込み準備(Issue #9) + +- [x] **判定モデルの書き出し(`tias-train color-export`・NumPy だけの参照実装)と,DeepTIAS 2.7 の舌セグメンテーションの Python 再現(`tias-seg eval`)を実装し,サーバで検証**: TIASjudge で「撮影画像 → セグメンテーション → 舌色判定」を行いたいというユーザーの要望から.TIASjudge 側の組み込みは TIASjudge の Issue #13.結果は [TEST_07](06_TEST/TEST_07_セグメンテーションの再現と判定モデルの書き出し.md),引き渡しの手順は [SPEC_04](04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md). + - 調査: 学習データの切り出しは DeepTIAS 2.7.ソースが無かったため実行ファイルを逆コンパイルし(ilspycmd.作業用の一時フォルダ),処理の順序を確認した.**切り出し画像はどちらのモードでも色補正されていない**(色補正は検出・分割に使う画像にだけかかる)→ 判定モデルは TIAS の補正なし画像専用 + - 調査: ResUNet の学習コードと正解マスク付きデータは別フォルダ(OneDrive)にあり,同梱の ONNX とハッシュが一致した.データは研究室 PC に移して手元から削除し,サーバへは研究室 PC から直送した(ENV_03「セグメンテーション用データの配置」) + - 設計判断 ①: 参照実装は TIASjudge にコピーして使う前提で,本パッケージに依存させない単一ファイル(`deploy/color_model.py`)にした.学習側との一致(特徴のビット一致・確率の差 1e-12 以下)をテストで保証する + - 設計判断 ②: 検出モデル(TF1 の .pb)は tf2onnx で ONNX に変換し,TIASjudge が onnxruntime だけで動くようにした.変換は一時的な依存で行い,本体の依存に TensorFlow を入れない + - 設計判断 ③: セグメンテーション用の環境変数は `TIAS_SEG_DATA_ROOT`・`TIAS_SEG_MODEL_ROOT` と別名にした(`tias-remote run` が舌色データを指す `TIAS_DATA_ROOT` を強制的に渡すため) + - 結果: 撮影画像から検出+ResUNet で IoU 0.958(検出の失敗 0/170).検出を省くと 0.46 で,検出は必須.出力チャンネル 0 が舌であることを確認.判定モデルは込み・除外とも C=0.1 + - 失敗パターン: 研究室 PC 上の cmd のパイプ(tar | ssh)が止まり,Windows の tar で作った tar は権限のため展開に失敗した → tar ファイルを scp で送り `--delay-directory-restore` で展開.展開失敗時のエラーにファイル名が大量に出たので,今後はエラー出力を件数で確かめる + - 失敗パターン: OneDrive で大量に削除すると「大量削除の検出」で復元される → 削除後に OneDrive の確認に「削除する」と答える必要がある + - 未確認: 舌色データセットのマスクとのビット単位の一致(元の撮影画像が無いため).どちらの判定モデルを渡すか(SPEC_04「未決事項」) diff --git a/pyproject.toml b/pyproject.toml index edbf234..1ed5288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,15 +14,19 @@ "matplotlib>=3.9", "pydantic>=2.9", "pyyaml>=6.0", + "onnxruntime>=1.30.0", + "opencv-python-headless>=5.0.0.93", ] [project.scripts] tias-dataset = "tias_judge_model.cli.dataset:main" tias-remote = "tias_judge_model.cli.remote:main" +tias-seg = "tias_judge_model.cli.seg:main" tias-train = "tias_judge_model.cli.train:main" [dependency-groups] dev = [ + "onnx>=1.23.0", "pytest>=8.0", "ruff>=0.8", ] diff --git a/scripts/convert_detection_to_onnx.py b/scripts/convert_detection_to_onnx.py new file mode 100644 index 0000000..0ac0ffd --- /dev/null +++ b/scripts/convert_detection_to_onnx.py @@ -0,0 +1,146 @@ +"""DeepTIAS 2.7 の検出モデル(TF1 物体検出の frozen graph `Detection_Normal.pb`)を ONNX にする. + +TensorFlow と tf2onnx は本体の依存に入れず,実行するときだけ一時的に入れる(サーバで実行する想定): + + uv run --with tensorflow --with tf2onnx python scripts/convert_detection_to_onnx.py \\ + --pb ~/models/TongueSegmentation/Detection_Normal.pb \\ + --out ~/models/TongueSegmentation/Detection_Normal.onnx + +入出力(TF1 物体検出 API の既定.tf2onnx はテンソル名に `:0` を付けたまま ONNX の名前にする): + +- 入力 `image_tensor:0`: `(1, H, W, 3)` の uint8 画像(TF の物体検出 API の既定は RGB) +- 出力 `detection_boxes:0`: `(1, N, 4)` の float32.正規化 `[ymin, xmin, ymax, xmax]` +- 出力 `detection_scores:0`: `(1, N)` の float32(降順) +- 出力 `detection_classes:0`・`num_detections:0`: 変換はするが評価では使わない + +`tias-seg eval` の `detection.input_name`・`boxes_name`・`scores_name` は `:0` の有無を問わずに +探すので,既定(`image_tensor`・`detection_boxes`・`detection_scores`)のままでよい. +名前が既定と違うグラフは `--list-nodes` で Placeholder と出力の候補を確かめ,`--inputs`・ +`--outputs` で指定する.変換後に onnxruntime で黒い画像を 1 枚推論し,出力の形状を表示する. +変換に失敗した場合でも `tias-seg eval` は検出なし・正解の外接矩形の方式だけで完走する. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +import numpy as np + +logger = logging.getLogger(__name__) + +DEFAULT_INPUTS = ("image_tensor:0",) +DEFAULT_OUTPUTS = ( + "detection_boxes:0", + "detection_scores:0", + "detection_classes:0", + "num_detections:0", +) +# TF の物体検出 API の NMS(ループ・制御フロー)を変換できる opset. +DEFAULT_OPSET = 13 +# 変換後の確認に使う黒い画像の大きさ(高さ・幅). +CHECK_IMAGE_SIZE = (480, 640) + + +def _load_graph_def(path: Path): # noqa: ANN202 TensorFlow の型は一時的な依存のため書かない + import tensorflow as tf + + graph_def = tf.compat.v1.GraphDef() + graph_def.ParseFromString(path.read_bytes()) + return graph_def + + +def _list_nodes(graph_def) -> None: # noqa: ANN001 + consumed = {i.split(":")[0].lstrip("^") for n in graph_def.node for i in n.input} + placeholders = [n.name for n in graph_def.node if n.op == "Placeholder"] + leaves = [n.name for n in graph_def.node if n.name not in consumed and n.op != "Const"] + logger.info("Placeholder(入力の候補): %s", placeholders) + logger.info("どこからも使われないノード(出力の候補): %s", leaves[:50]) + + +def _check_onnx(path: Path, input_name: str) -> None: + import onnxruntime as ort + + session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"]) + for i in session.get_inputs(): + logger.info("ONNX の入力: %s %s %s", i.name, i.shape, i.type) + for o in session.get_outputs(): + logger.info("ONNX の出力: %s %s %s", o.name, o.shape, o.type) + image = np.zeros((1, *CHECK_IMAGE_SIZE, 3), dtype=np.uint8) + outputs = session.run(None, {input_name: image}) + for o, value in zip(session.get_outputs(), outputs, strict=True): + logger.info("黒い画像での出力 %s: 形状 %s", o.name, np.asarray(value).shape) + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="TF1 物体検出の frozen graph を ONNX に変換する") + parser.add_argument("--pb", type=Path, required=True, help="Detection_Normal.pb のパス") + parser.add_argument("--out", type=Path, help="書き出す ONNX のパス(--list-nodes 以外で必須)") + parser.add_argument( + "--inputs", default=",".join(DEFAULT_INPUTS), help="入力テンソル名(カンマ区切り)" + ) + parser.add_argument( + "--outputs", default=",".join(DEFAULT_OUTPUTS), help="出力テンソル名(カンマ区切り)" + ) + parser.add_argument("--opset", type=int, default=DEFAULT_OPSET, help="ONNX の opset") + parser.add_argument( + "--list-nodes", action="store_true", help="入力・出力の候補を表示するだけで変換しない" + ) + args = parser.parse_args(argv) + if not args.list_nodes and args.out is None: + parser.error("--out を指定する") + return args + + +def main(argv: list[str] | None = None) -> int: + """エントリポイント. + + Args: + argv: コマンドライン引数(None なら `sys.argv[1:]`). + + Returns: + 終了コード(成功 0・失敗 1). + """ + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" + ) + args = _parse_args(argv) + pb = args.pb.expanduser() + if not pb.is_file(): + logger.error("frozen graph が無い: %s", pb) + return 1 + try: + graph_def = _load_graph_def(pb) + except ImportError: + logger.error( + "tensorflow が無い.uv run --with tensorflow --with tf2onnx python %s ... で実行する", + Path(__file__).as_posix(), + ) + return 1 + if args.list_nodes: + _list_nodes(graph_def) + return 0 + + import tf2onnx + + out = args.out.expanduser() + out.parent.mkdir(parents=True, exist_ok=True) + inputs = [s.strip() for s in args.inputs.split(",") if s.strip()] + outputs = [s.strip() for s in args.outputs.split(",") if s.strip()] + logger.info("変換する: 入力 %s・出力 %s・opset %d", inputs, outputs, args.opset) + tf2onnx.convert.from_graph_def( + graph_def, + input_names=inputs, + output_names=outputs, + opset=args.opset, + output_path=str(out), + ) + logger.info("書き出した: %s", out) + _check_onnx(out, inputs[0]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/make_dummy_seg_models.py b/scripts/make_dummy_seg_models.py new file mode 100644 index 0000000..d17dc37 --- /dev/null +++ b/scripts/make_dummy_seg_models.py @@ -0,0 +1,176 @@ +"""セグメンテーションの評価を手元で通すための小さなダミーの ONNX モデルを作る. + +本物の ResUNet・検出モデルと**入出力の名前・形状・型だけ**を合わせたもので,合成データ +(`scripts/make_synthetic_segmentation.py`)の赤い楕円を前景として返す.精度の評価には使えない. + +- `ResUNet.onnx`: 入力 `input.1`(`(1, 3, 256, 256)` float32.BGR・/255),出力 `output` + (`(1, 2, 256, 256)` float32 のロジット).1×1 畳み込みで ch0 = k・(R − B − 0.25),ch1 = −ch0 + (赤みの強い画素が前景.ch1 は出力チャンネルの取り違えを検出できるかの確認用) +- `Detection_Normal.onnx`: 入力 `image_tensor:0`(`(1, H, W, 3)` uint8.RGB),出力 + `detection_boxes:0`(`(1, 2, 4)`.正規化 `[ymin, xmin, ymax, xmax]`)・`detection_scores:0` + (`(1, 2)`).1 つ目は R − B が大きい画素の外接矩形(スコア 0.9),2 つ目は画像全体(スコア 0.3) + +ONNX のグラフは `onnx`(開発用の依存)で直接組み立てる. + +使い方: + uv run python scripts/make_dummy_seg_models.py --out data/synthetic_seg/models +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +import numpy as np +import onnx +from onnx import TensorProto, helper, numpy_helper + +logger = logging.getLogger(__name__) + +OPSET = 17 +IR_VERSION = 9 +RESUNET_SIZE = 256 +RESUNET_GAIN = 30.0 # ロジットの傾き(シグモイド後に 0/1 に近くなるよう大きめ) +RESUNET_OFFSET = 0.25 # R − B がこれを超える画素を前景とする(値域 0〜1) +DETECTION_RED_MINUS_BLUE = 60.0 # 検出で前景とみなす R − B(値域 0〜255) +DETECTION_SCORES = (0.9, 0.3) + + +def build_resunet() -> onnx.ModelProto: + """ダミーの ResUNet を作る. + + Returns: + ONNX モデル(`input.1` `(1, 3, 256, 256)` → `output` `(1, 2, 256, 256)`). + """ + # 入力のチャンネルは B・G・R の順(DeepTIAS は OpenCV の BGR のまま渡す). + fg = np.array([-RESUNET_GAIN, 0.0, RESUNET_GAIN], dtype=np.float32) + weight = np.stack([fg, -fg]).reshape(2, 3, 1, 1) + bias = np.array([-RESUNET_GAIN * RESUNET_OFFSET, RESUNET_GAIN * RESUNET_OFFSET], np.float32) + graph = helper.make_graph( + [helper.make_node("Conv", ["input.1", "W", "B"], ["output"])], + "dummy_resunet", + [ + helper.make_tensor_value_info( + "input.1", TensorProto.FLOAT, [1, 3, RESUNET_SIZE, RESUNET_SIZE] + ) + ], + [ + helper.make_tensor_value_info( + "output", TensorProto.FLOAT, [1, 2, RESUNET_SIZE, RESUNET_SIZE] + ) + ], + initializer=[numpy_helper.from_array(weight, "W"), numpy_helper.from_array(bias, "B")], + ) + return _finish(graph) + + +def build_detector() -> onnx.ModelProto: + """ダミーの検出モデルを作る. + + Returns: + ONNX モデル(`image_tensor:0` `(1, H, W, 3)` uint8 → `detection_boxes:0` `(1, 2, 4)`・ + `detection_scores:0` `(1, 2)`). + """ + + def const(name: str, value: np.ndarray) -> onnx.NodeProto: + return helper.make_node( + "Constant", [], [name], value=numpy_helper.from_array(value, name + "_v") + ) + + nodes = [ + helper.make_node("Cast", ["image_tensor:0"], ["xf"], to=TensorProto.FLOAT), + const("c0", np.array([0], np.int64)), + const("c1", np.array([1], np.int64)), + const("c2", np.array([2], np.int64)), + const("c3", np.array([3], np.int64)), + const("ax3", np.array([3], np.int64)), + helper.make_node("Slice", ["xf", "c0", "c1", "ax3"], ["red"]), + helper.make_node("Slice", ["xf", "c2", "c3", "ax3"], ["blue"]), + helper.make_node("Sub", ["red", "blue"], ["rb"]), + const("thr", np.array(DETECTION_RED_MINUS_BLUE, np.float32)), + helper.make_node("Greater", ["rb", "thr"], ["fg_bool"]), + helper.make_node("Cast", ["fg_bool"], ["fg"], to=TensorProto.FLOAT), + # 行・列ごとに前景があるか((1, H)・(1, W)). + helper.make_node("ReduceMax", ["fg"], ["rows"], axes=[2, 3], keepdims=0), + helper.make_node("ReduceMax", ["fg"], ["cols"], axes=[1, 3], keepdims=0), + helper.make_node("ArgMax", ["rows"], ["ymin_i"], axis=1, keepdims=0), + helper.make_node("ArgMax", ["rows"], ["ymax_i"], axis=1, keepdims=0, select_last_index=1), + helper.make_node("ArgMax", ["cols"], ["xmin_i"], axis=1, keepdims=0), + helper.make_node("ArgMax", ["cols"], ["xmax_i"], axis=1, keepdims=0, select_last_index=1), + helper.make_node("Shape", ["image_tensor:0"], ["shape"]), + helper.make_node("Gather", ["shape", "c1"], ["h_i"], axis=0), + helper.make_node("Gather", ["shape", "c2"], ["w_i"], axis=0), + helper.make_node("Cast", ["h_i"], ["h"], to=TensorProto.FLOAT), + helper.make_node("Cast", ["w_i"], ["w"], to=TensorProto.FLOAT), + const("one", np.array(1.0, np.float32)), + ] + for name in ("ymin", "ymax", "xmin", "xmax"): + nodes.append(helper.make_node("Cast", [f"{name}_i"], [f"{name}_f"], to=TensorProto.FLOAT)) + nodes += [ + helper.make_node("Add", ["ymax_f", "one"], ["ymax_e"]), + helper.make_node("Add", ["xmax_f", "one"], ["xmax_e"]), + helper.make_node("Div", ["ymin_f", "h"], ["ymin_n"]), + helper.make_node("Div", ["ymax_e", "h"], ["ymax_n"]), + helper.make_node("Div", ["xmin_f", "w"], ["xmin_n"]), + helper.make_node("Div", ["xmax_e", "w"], ["xmax_n"]), + helper.make_node("Concat", ["ymin_n", "xmin_n", "ymax_n", "xmax_n"], ["box1"], axis=0), + const("box_shape", np.array([1, 1, 4], np.int64)), + helper.make_node("Reshape", ["box1", "box_shape"], ["box1_r"]), + const("box2", np.array([[[0.0, 0.0, 1.0, 1.0]]], np.float32)), + helper.make_node("Concat", ["box1_r", "box2"], ["detection_boxes:0"], axis=1), + const("detection_scores:0", np.array([DETECTION_SCORES], np.float32)), + ] + graph = helper.make_graph( + nodes, + "dummy_detection", + [helper.make_tensor_value_info("image_tensor:0", TensorProto.UINT8, [1, None, None, 3])], + [ + helper.make_tensor_value_info("detection_boxes:0", TensorProto.FLOAT, [1, 2, 4]), + helper.make_tensor_value_info("detection_scores:0", TensorProto.FLOAT, [1, 2]), + ], + ) + return _finish(graph) + + +def _finish(graph: onnx.GraphProto) -> onnx.ModelProto: + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", OPSET)]) + model.ir_version = IR_VERSION + onnx.checker.check_model(model) + return model + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ダミーの ResUNet・検出モデル(ONNX)を作る") + parser.add_argument( + "--out", + type=Path, + default=Path("data/synthetic_seg/models"), + help="出力先(既定: data/synthetic_seg/models)", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """エントリポイント. + + Args: + argv: コマンドライン引数(None なら `sys.argv[1:]`). + + Returns: + 終了コード(成功 0). + """ + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" + ) + args = _parse_args(argv) + args.out.mkdir(parents=True, exist_ok=True) + onnx.save(build_resunet(), args.out / "ResUNet.onnx") + onnx.save(build_detector(), args.out / "Detection_Normal.onnx") + logger.info("ダミーのモデルを書き出した: %s", args.out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/make_synthetic_segmentation.py b/scripts/make_synthetic_segmentation.py new file mode 100644 index 0000000..b70f3d0 --- /dev/null +++ b/scripts/make_synthetic_segmentation.py @@ -0,0 +1,214 @@ +"""セグメンテーションの評価(`tias-seg eval`)を手元で通すための合成データを生成する. + +実データ(TongueSegmentation の `input/`)と同じ構成を作る: + + /input/train/{sample,label}/*.jpg + /input/test/cropped/{sample,label}/*.jpg + /input/test_extra/cropped/{sample,label}/*.jpg + /input/test/fullsize/{sample,label}/*.jpg + +撮影画像(fullsize)は青みがかった灰色の背景に舌の代わりの赤い楕円を描いたもの,正解マスクは +楕円の内側を 255 にしたグレースケール JPEG(実データと同じく JPEG のため境界に中間値を含む). +切り出し済み(cropped)は楕円の外接矩形に余白を付けて切り出したもの.一部の画像には楕円の外に +小さな赤い点を置き,小領域除去が働く場面を作る.ダミーの ONNX モデルは +`scripts/make_dummy_seg_models.py` で作る. + +使い方: + uv run python scripts/make_synthetic_segmentation.py --out data/synthetic_seg --per-set 8 +""" + +from __future__ import annotations + +import argparse +import logging +import shutil +import sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +logger = logging.getLogger(__name__) + +# 出力先がこのスクリプトの生成物であることを示す目印(--overwrite で消すのは目印のある所だけ). +MARKER_FILE = ".synthetic_segmentation" + +# (評価セットのディレクトリ, 撮影画像か). +SETS: tuple[tuple[str, bool], ...] = ( + ("train", False), + ("test/cropped", False), + ("test_extra/cropped", False), + ("test/fullsize", True), +) + +BACKGROUND_RGB = (110, 120, 150) # 青みがかった灰色(ダミーの ResUNet が背景と判定する色) +TONGUE_RGB = (200, 95, 105) # 赤みのある舌の色 +COLOR_JITTER = 10 +PIXEL_NOISE_STD = 5.0 +MIN_SIZE = 200 +MAX_SIZE = 360 +SPOT_PROBABILITY = 0.5 # 楕円の外に小さな赤い点を置く確率 +JPEG_QUALITY = 95 + + +def make_fullsize(rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]: + """撮影画像を模した画像と正解マスクを 1 組作る. + + Args: + rng: 乱数生成器. + + Returns: + (RGB 画像 `(H, W, 3)` uint8, 正解マスク `(H, W)` bool). + """ + width = int(rng.integers(MIN_SIZE, MAX_SIZE + 1)) + height = int(rng.integers(MIN_SIZE, MAX_SIZE + 1)) + ew = int(rng.integers(width // 4, width // 2)) + eh = int(rng.integers(height // 4, height // 2)) + cx = int(rng.integers(ew // 2 + 5, width - ew // 2 - 5)) + cy = int(rng.integers(eh // 2 + 5, height - eh // 2 - 5)) + mask_img = Image.new("L", (width, height), 0) + ImageDraw.Draw(mask_img).ellipse( + (cx - ew // 2, cy - eh // 2, cx + ew // 2, cy + eh // 2), fill=255 + ) + mask = np.asarray(mask_img) > 0 # (H, W) + + # 背景は横方向に少し明るさが変わる(一様な色だけにしない). + gradient = np.linspace(-15.0, 15.0, width)[None, :, None] + background = np.array(BACKGROUND_RGB, dtype=np.float64) + gradient + tongue = np.array(TONGUE_RGB, dtype=np.float64) + rng.integers( + -COLOR_JITTER, COLOR_JITTER + 1, size=3 + ) + pixels = np.where(mask[..., None], tongue, background) + pixels = pixels + rng.normal(0.0, PIXEL_NOISE_STD, size=(height, width, 3)) + + if rng.random() < SPOT_PROBABILITY: + # 舌と同じ色の小さな点(小領域除去で消えるべき誤検出の種).正解マスクには含めない. + spot = Image.new("L", (width, height), 0) + sx = int(rng.integers(3, width - 8)) + sy = int(rng.integers(3, height - 8)) + ImageDraw.Draw(spot).ellipse((sx, sy, sx + 4, sy + 4), fill=255) + spot_mask = (np.asarray(spot) > 0) & ~mask + pixels[spot_mask] = tongue + return np.clip(pixels, 0, 255).astype(np.uint8), mask + + +def crop_around( + rgb: np.ndarray, mask: np.ndarray, rng: np.random.Generator +) -> tuple[np.ndarray, np.ndarray]: + """正解マスクの外接矩形に余白を付けて切り出す(切り出し済みの画像を模す). + + Args: + rgb: RGB 画像(`(H, W, 3)`, uint8). + mask: 正解マスク(`(H, W)`, bool). + rng: 乱数生成器. + + Returns: + (切り出した RGB 画像, 切り出した正解マスク). + """ + ys, xs = np.nonzero(mask) + height, width = mask.shape + mx = int(rng.integers(2, max(3, (xs.max() - xs.min()) // 8))) + my = int(rng.integers(2, max(3, (ys.max() - ys.min()) // 8))) + x0, x1 = max(0, xs.min() - mx), min(width, xs.max() + 1 + mx) + y0, y1 = max(0, ys.min() - my), min(height, ys.max() + 1 + my) + return rgb[y0:y1, x0:x1], mask[y0:y1, x0:x1] + + +def generate(out_dir: Path, per_set: int, seed: int) -> int: + """合成データを生成する. + + Args: + out_dir: 出力先(空または存在しないディレクトリ). + per_set: 評価セットあたりの枚数. + seed: 乱数 seed. + + Returns: + 生成した組の数. + """ + rng = np.random.default_rng(seed) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / MARKER_FILE).write_text(f"seed={seed}\nper_set={per_set}\n", encoding="utf-8") + serial = 0 + for set_dir, fullsize in SETS: + sample_dir = out_dir / "input" / set_dir / "sample" + label_dir = out_dir / "input" / set_dir / "label" + sample_dir.mkdir(parents=True, exist_ok=True) + label_dir.mkdir(parents=True, exist_ok=True) + for _ in range(per_set): + rgb, mask = make_fullsize(rng) + if not fullsize: + rgb, mask = crop_around(rgb, mask, rng) + name = f"seg_{serial:06d}.jpg" + Image.fromarray(rgb).save(sample_dir / name, quality=JPEG_QUALITY) + Image.fromarray((mask * 255).astype(np.uint8)).save( + label_dir / name, quality=JPEG_QUALITY + ) + serial += 1 + logger.info("%s: %d 組", set_dir, per_set) + return serial + + +def _prepare_out_dir(out_dir: Path, overwrite: bool) -> None: + if not out_dir.exists(): + return + if not out_dir.is_dir(): + raise ValueError(f"出力先がディレクトリではない: {out_dir}") + if not any(out_dir.iterdir()): + return + if not overwrite: + raise ValueError(f"出力先が空でない: {out_dir}(作り直すには --overwrite を付ける)") + if not (out_dir / MARKER_FILE).is_file(): + raise ValueError( + f"出力先に合成データの目印 {MARKER_FILE} が無いため削除しない: {out_dir}" + "(合成データ以外を誤って消さないため)" + ) + logger.info("既存の合成データを削除する: %s", out_dir) + shutil.rmtree(out_dir) + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="セグメンテーションの評価用の合成データを生成する") + parser.add_argument( + "--out", + type=Path, + default=Path("data/synthetic_seg"), + help="出力先(既定: data/synthetic_seg)", + ) + parser.add_argument("--per-set", type=int, default=8, help="評価セットあたりの枚数(既定: 8)") + parser.add_argument("--seed", type=int, default=0, help="乱数 seed(既定: 0)") + parser.add_argument( + "--overwrite", + action="store_true", + help="既存の合成データを削除して作り直す(目印ファイルのある出力先のみ.models/ も消える)", + ) + args = parser.parse_args(argv) + if args.per_set < 1: + parser.error("--per-set は 1 以上を指定する") + return args + + +def main(argv: list[str] | None = None) -> int: + """エントリポイント. + + Args: + argv: コマンドライン引数(None なら `sys.argv[1:]`). + + Returns: + 終了コード(成功 0・入力の問題 1). + """ + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" + ) + args = _parse_args(argv) + try: + _prepare_out_dir(args.out, args.overwrite) + except ValueError as e: + logger.error("%s", e) + return 1 + count = generate(args.out, args.per_set, args.seed) + logger.info("合成データを生成した: %d 組 -> %s", count, args.out / "input") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tias_judge_model/cli/dataset.py b/src/tias_judge_model/cli/dataset.py index 0401091..2e9c8e5 100644 --- a/src/tias_judge_model/cli/dataset.py +++ b/src/tias_judge_model/cli/dataset.py @@ -13,6 +13,7 @@ from __future__ import annotations import argparse +import csv import json import logging import os @@ -25,7 +26,7 @@ from typing import Any import yaml -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from tias_judge_model.data.manifest import build_manifest_records, read_manifest, write_manifest from tias_judge_model.data.survey import ( @@ -107,12 +108,12 @@ def resolved_config_document( - config: ExperimentConfig, *, run_id: str, git_commit: str, command: str + config: BaseModel, *, run_id: str, git_commit: str, command: str ) -> dict[str, Any]: """`config.yaml` に書く内容(解決済み設定 + 実験 ID + git ハッシュ)を作る. Args: - config: 解決済みの設定(環境変数の上書き適用後). + config: 解決済みの設定(環境変数の上書き適用後.`ExperimentConfig`・`SegEvalConfig`). run_id: 実験 ID. git_commit: git の短縮ハッシュ(または `nogit`). command: 実行したサブコマンド名. @@ -127,6 +128,94 @@ # --------------------------------------------------------------------------- +# 実験ディレクトリを作る CLI(tias-dataset survey・tias-train・tias-seg)の共通部分 +# --------------------------------------------------------------------------- + + +def run_in_new_run_dir(out_root: Path, run_id: str, body: Callable[[Path], None]) -> int: + """実験ディレクトリ `out_root/run_id` を作り,`log.txt` にもログを残しながら `body` を実行する. + + 成功したら実験 ID を標準出力へ出す(`tias-remote pull` に使うため). + + Args: + out_root: 実験ディレクトリの親. + run_id: 実験 ID. + body: 実験ディレクトリを受け取って出力を書く処理. + + Returns: + 終了コード(成功 0.実験ディレクトリが既にある,または `body` が ValueError・OSError を + 送出した場合 1). + """ + run_dir = out_root / run_id + if run_dir.exists(): + logger.error("実験ディレクトリが既にある: %s(時間をおいて再実行する)", run_dir) + return 1 + run_dir.mkdir(parents=True) + + file_handler = logging.FileHandler(run_dir / LOG_FILENAME, encoding="utf-8") + file_handler.setFormatter(logging.Formatter(_LOG_FORMAT)) + root = logging.getLogger() + root.addHandler(file_handler) + try: + body(run_dir) + except (ValueError, OSError) as exc: + # log.txt にも失敗の理由を残すため,ハンドラを外す前にここで記録する. + logger.error("%s", exc) + return 1 + finally: + root.removeHandler(file_handler) + file_handler.close() + sys.stdout.write(f"{run_id}\n") + sys.stdout.flush() + return 0 + + +def configure_logging() -> None: + """ログを標準エラーへ出すよう設定する(標準出力は実験 ID の受け渡しに使うため汚さない).""" + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter(_LOG_FORMAT)) + logging.basicConfig(level=logging.INFO, handlers=[handler]) + + +def write_json(path: Path, data: Mapping[str, Any]) -> None: + """dict を UTF-8・インデント 2・改行 LF の JSON で書き出す. + + Args: + path: 書き出し先. + data: JSON に書き出せる dict. + """ + with path.open("w", encoding="utf-8", newline="\n") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + + +def write_yaml(path: Path, data: Mapping[str, Any]) -> None: + """dict をキーの順を保った UTF-8・改行 LF の YAML で書き出す. + + Args: + path: 書き出し先. + data: YAML に書き出せる dict. + """ + with path.open("w", encoding="utf-8", newline="\n") as f: + yaml.safe_dump(dict(data), f, allow_unicode=True, sort_keys=False) + + +def write_csv(path: Path, rows: Sequence[Mapping[str, Any]]) -> None: + """行の dict の並びを CSV で書き出す(列は先頭行のキーの順.行が無ければ空のファイル). + + Args: + path: 書き出し先. + rows: 行(すべて同じキーを持つ dict). + """ + with path.open("w", encoding="utf-8", newline="") as f: + if not rows: + return + writer = csv.DictWriter(f, fieldnames=list(rows[0]), lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + + +# --------------------------------------------------------------------------- # build-manifest # --------------------------------------------------------------------------- @@ -152,34 +241,17 @@ config = load_config(args.config) git_commit = resolve_git_commit(os.environ) run_id = make_run_id(datetime.now(), SURVEY_RUN_NAME, git_commit) - run_dir = args.out_root / run_id - if run_dir.exists(): - logger.error("実験ディレクトリが既にある: %s(時間をおいて再実行する)", run_dir) - return 1 - run_dir.mkdir(parents=True) - - file_handler = logging.FileHandler(run_dir / LOG_FILENAME, encoding="utf-8") - file_handler.setFormatter(logging.Formatter(_LOG_FORMAT)) - root = logging.getLogger() - root.addHandler(file_handler) - try: - _run_survey(config, run_dir, run_id=run_id, git_commit=git_commit) - except (ValueError, OSError) as exc: - # log.txt にも失敗の理由を残すため,ハンドラを外す前にここで記録する. - logger.error("%s", exc) - return 1 - finally: - root.removeHandler(file_handler) - file_handler.close() - sys.stdout.write(f"{run_id}\n") - sys.stdout.flush() - return 0 + return run_in_new_run_dir( + args.out_root, + run_id, + lambda run_dir: _run_survey(config, run_dir, run_id=run_id, git_commit=git_commit), + ) def _run_survey(config: ExperimentConfig, run_dir: Path, *, run_id: str, git_commit: str) -> None: started = time.monotonic() logger.info("データセット集計を開始する(実験 ID %s)", run_id) - _write_yaml( + write_yaml( run_dir / CONFIG_FILENAME, resolved_config_document(config, run_id=run_id, git_commit=git_commit, command="survey"), ) @@ -196,7 +268,7 @@ configured_severities=config.data.severities, ) summary = {"run_id": run_id, "git_commit": git_commit, **summary} - _write_json(run_dir / SUMMARY_FILENAME, summary) + write_json(run_dir / SUMMARY_FILENAME, summary) figures_dir = run_dir / FIGURES_DIRNAME plot_lab_by_group(lab_values_by_group(records, stats), figures_dir / LAB_BOXPLOT_FILENAME) @@ -237,17 +309,6 @@ return tuple(values) -def _write_json(path: Path, data: Mapping[str, Any]) -> None: - with path.open("w", encoding="utf-8", newline="\n") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - f.write("\n") - - -def _write_yaml(path: Path, data: Mapping[str, Any]) -> None: - with path.open("w", encoding="utf-8", newline="\n") as f: - yaml.safe_dump(dict(data), f, allow_unicode=True, sort_keys=False) - - # --------------------------------------------------------------------------- # エントリポイント # --------------------------------------------------------------------------- @@ -295,13 +356,6 @@ } -def _configure_logging() -> None: - # ログは標準エラーへ(標準出力は実験 ID の受け渡しに使うため汚さない). - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter(_LOG_FORMAT)) - logging.basicConfig(level=logging.INFO, handlers=[handler]) - - def main(argv: Sequence[str] | None = None) -> int: """tias-dataset コマンドのメインエントリポイント. @@ -312,7 +366,7 @@ 終了コード(成功 0・入力の問題 1). """ args = _parse_args(argv) - _configure_logging() + configure_logging() try: return _HANDLERS[args.subcommand](args) except ValidationError as exc: diff --git a/src/tias_judge_model/cli/seg.py b/src/tias_judge_model/cli/seg.py new file mode 100644 index 0000000..99642f8 --- /dev/null +++ b/src/tias_judge_model/cli/seg.py @@ -0,0 +1,184 @@ +"""`tias-seg` コマンド: Python 版セグメンテーション(DeepTIAS 2.7 の再現)の評価. + +サブコマンド: + +- `eval --config PATH [--out-root runs]`: 正解マスク付きデータで ResUNet(+検出)の IoU・Dice を + 測り,`runs/<実験ID>/` に書き出す.最後に実験 ID を標準出力へ出す(`tias-remote pull` 用) + +出力(SPEC_01「実験の出力」): + +- 回収対象: `config.yaml`・`metrics.json`(集計値のみ)・`log.txt`・`figures/*.png` + (IoU のヒストグラム.画像は描かない) +- サーバ上にだけ置く: `predictions.csv`(画像ごとの IoU.ファイル名の stem を含む) + +実データを読むためサーバで実行する(手元では合成データの設定 `configs/seg/eval_synthetic.yaml`). +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +import time +from collections.abc import Sequence +from dataclasses import asdict +from datetime import datetime +from pathlib import Path + +import cv2 +import onnxruntime +from pydantic import ValidationError + +from tias_judge_model.cli.dataset import ( + CONFIG_FILENAME, + FIGURES_DIRNAME, + configure_logging, + make_run_id, + resolve_git_commit, + resolved_config_document, + run_in_new_run_dir, + write_csv, + write_json, + write_yaml, +) +from tias_judge_model.evaluation.segmentation_eval import ( + ious_by_method, + log_summary, + run_segmentation_eval, + summarize_segmentation, +) +from tias_judge_model.evaluation.segmentation_figures import ( + iou_histogram_filename, + plot_iou_histograms, +) +from tias_judge_model.schema import SegEvalConfig, load_seg_eval_config + +logger = logging.getLogger(__name__) + +METRICS_FILENAME = "metrics.json" +PREDICTIONS_FILENAME = "predictions.csv" + +_EPILOG = """\ +使用例(サーバ上で実行する.手元では configs/seg/eval_synthetic.yaml で合成データを使う): + uv run tias-seg eval --config configs/seg/eval_synthetic.yaml + uv run tias-remote run --name seg1 --cpu -- tias-seg eval --config configs/seg/eval_newton.yaml + +環境変数: + TIAS_SEG_DATA_ROOT data.root を上書き(tias-remote run が渡す TIAS_DATA_ROOT は使わない) + TIAS_SEG_MODEL_ROOT models.root を上書き + TIAS_GIT_COMMIT 実験 ID に入れる git の短縮ハッシュ(tias-remote run が渡す.無ければ nogit) +""" + + +def _cmd_eval(args: argparse.Namespace) -> int: + started = time.monotonic() + config = load_seg_eval_config(args.config) + git_commit = resolve_git_commit(os.environ) + run_id = make_run_id(datetime.now(), args.config.stem, git_commit) + return run_in_new_run_dir( + args.out_root, + run_id, + lambda run_dir: _run_eval( + config, run_dir, run_id=run_id, git_commit=git_commit, started=started + ), + ) + + +def _run_eval( + config: SegEvalConfig, run_dir: Path, *, run_id: str, git_commit: str, started: float +) -> None: + logger.info("セグメンテーションの評価の実験 ID: %s", run_id) + write_yaml( + run_dir / CONFIG_FILENAME, + resolved_config_document(config, run_id=run_id, git_commit=git_commit, command="seg eval"), + ) + result = run_segmentation_eval(config) + # 画像ごとの値(個票.ファイル名の stem を含む.回収しない). + write_csv(run_dir / PREDICTIONS_FILENAME, [asdict(s) for s in result.scores]) + + sets = summarize_segmentation(result, config) + log_summary(sets) + for set_name in result.methods: + plot_iou_histograms( + ious_by_method(result, set_name), + f"{set_name}: IoU with postprocess", + run_dir / FIGURES_DIRNAME / iou_histogram_filename(set_name), + ) + + metrics = { + "run_id": run_id, + "git_commit": git_commit, + "detector": {"used": result.detector_used, "note": result.detector_note}, + "resunet": config.resunet.model_dump(mode="json"), + "postprocess": config.postprocess.model_dump(mode="json"), + "label_threshold": config.data.label_threshold, + "sets": sets, + "timing": { + "total_seconds": round(time.monotonic() - started, 2), + "loading_seconds": round(result.loading_seconds, 2), + }, + "environment": { + "onnxruntime_version": onnxruntime.__version__, + "opencv_version": cv2.__version__, + }, + } + write_json(run_dir / METRICS_FILENAME, metrics) + logger.info( + "出力先: %s(全体の所要 %.1f 秒・うち画像の読み込み %.1f 秒)", + run_dir, + metrics["timing"]["total_seconds"], + result.loading_seconds, + ) + + +def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="tias-seg", + description="Python 版セグメンテーション(DeepTIAS 2.7 の再現)を評価する", + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="subcommand", required=True) + p_eval = sub.add_parser( + "eval", + help="正解マスク付きデータで IoU・Dice を測って runs/<実験ID>/ に書き出す", + description=( + "設定の評価セットごとに ResUNet(切り出し済みは画像全体,撮影画像は検出なし・正解の" + "外接矩形・検出モデル)を掛け,前景の IoU・Dice を後処理の有無で集計する." + "最後に実験 ID を標準出力へ出す." + ), + ) + p_eval.add_argument("--config", type=Path, required=True, help="評価の設定 YAML.") + p_eval.add_argument( + "--out-root", + type=Path, + default=Path("runs"), + help="実験ディレクトリの親(既定: runs).", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """tias-seg コマンドのメインエントリポイント. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + 終了コード(成功 0・入力の問題 1). + """ + args = _parse_args(argv) + configure_logging() + try: + return _cmd_eval(args) + except ValidationError as exc: + logger.error("設定が不正である:\n%s", exc) + return 1 + except (ValueError, OSError) as exc: + logger.error("%s", exc) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tias_judge_model/cli/train.py b/src/tias_judge_model/cli/train.py index 374b7e1..13fc296 100644 --- a/src/tias_judge_model/cli/train.py +++ b/src/tias_judge_model/cli/train.py @@ -7,19 +7,20 @@ - `color-cv --config PATH [--out-root runs]`: 舌領域の色特徴量+古典的分類器(設定の `color` 節)で `cv` と同じ分割・同じ評価の交差検証を行う.出力は `cv` と同じ規則(ただし学習曲線・ `history.csv`・重みは出さない).GPU は使わない +- `color-export --config PATH [--out-root runs]`: 色特徴+ロジスティック回帰の最終モデルを全データ + (`data.severities` 適用後)で学習し,TIASjudge で使う判定モデル `color_model.json` と C の選択の + 記録(`metrics.json`)を書き出す.GPU は使わない 実データを読むためサーバで実行する(手元では合成データの設定 `configs/cv_synthetic.yaml`・ `configs/color_synthetic.yaml`). 回収対象(`config.yaml`・`metrics.json`・`history.csv`・`confusion_matrix.csv`・`log.txt`・ -`figures/*.png`)には患者 ID・ファイル名・画像単位の値を出さない.個票(`split.json`・ -`predictions.csv`)はサーバ上にだけ置く. +`figures/*.png`・`color_model.json`)には患者 ID・ファイル名・画像単位の値を出さない. +個票(`split.json`・`predictions.csv`)はサーバ上にだけ置く. """ from __future__ import annotations import argparse -import csv -import json import logging import os import sys @@ -32,16 +33,19 @@ import numpy as np import sklearn import torch -import yaml from pydantic import ValidationError from tias_judge_model.cli.dataset import ( CONFIG_FILENAME, FIGURES_DIRNAME, - LOG_FILENAME, + configure_logging, make_run_id, resolve_git_commit, resolved_config_document, + run_in_new_run_dir, + write_csv, + write_json, + write_yaml, ) from tias_judge_model.data.classes import CLASS_SLUGS, label_to_id from tias_judge_model.data.manifest import ManifestRecord, read_manifest @@ -65,6 +69,12 @@ check_color_config, run_color_cross_validation, ) +from tias_judge_model.training.color_export import ( + ColorExportResult, + build_color_model_document, + check_color_export_config, + train_final_color_model, +) from tias_judge_model.training.cross_validation import ( ClassBalance, CrossValidationResult, @@ -81,17 +91,19 @@ SPLIT_FILENAME = "split.json" PREDICTIONS_FILENAME = "predictions.csv" CHECKPOINTS_DIRNAME = "checkpoints" +# TIASjudge で使う判定モデル(回収ホワイトリストに含める.SPEC_01). +COLOR_MODEL_FILENAME = "color_model.json" # 混同行列(全 fold 合計)を書き出す部分集合(SPEC_02「評価の出し方」). CONFUSION_SUBSETS: tuple[str, ...] = ("common", "all") -_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s" - _EPILOG = """\ 使用例(サーバ上で実行する.手元では configs/cv_synthetic.yaml で合成データを使う): uv run tias-train cv --config configs/cv_with_mild.yaml uv run tias-remote run --name cv-with-mild -- tias-train cv --config configs/cv_with_mild.yaml uv run tias-train color-cv --config configs/color_synthetic.yaml uv run tias-remote run --name color1 --cpu -- tias-train color-cv --config + uv run tias-train color-export --config configs/export/color_synthetic.yaml + uv run tias-remote run --name ex1 --cpu -- tias-train color-export --config 環境変数: TIAS_DATA_ROOT data.root を上書き(tias-remote run が各サーバの data_dir を渡す) @@ -100,7 +112,7 @@ # --------------------------------------------------------------------------- -# cv・color-cv +# cv・color-cv・color-export # --------------------------------------------------------------------------- # サブコマンドの本体(設定・実験ディレクトリと,キーワード引数 run_id・git_commit・started). @@ -119,35 +131,20 @@ check(config) git_commit = resolve_git_commit(os.environ) run_id = make_run_id(datetime.now(), args.config.stem, git_commit) - run_dir = args.out_root / run_id - if run_dir.exists(): - logger.error("実験ディレクトリが既にある: %s(時間をおいて再実行する)", run_dir) - return 1 - run_dir.mkdir(parents=True) - - file_handler = logging.FileHandler(run_dir / LOG_FILENAME, encoding="utf-8") - file_handler.setFormatter(logging.Formatter(_LOG_FORMAT)) - root = logging.getLogger() - root.addHandler(file_handler) - try: - runner(config, run_dir, run_id=run_id, git_commit=git_commit, started=started) - except (ValueError, OSError) as exc: - # log.txt にも失敗の理由を残すため,ハンドラを外す前にここで記録する. - logger.error("%s", exc) - return 1 - finally: - root.removeHandler(file_handler) - file_handler.close() - sys.stdout.write(f"{run_id}\n") - sys.stdout.flush() - return 0 + return run_in_new_run_dir( + args.out_root, + run_id, + lambda run_dir: runner( + config, run_dir, run_id=run_id, git_commit=git_commit, started=started + ), + ) def _run_cv( config: ExperimentConfig, run_dir: Path, *, run_id: str, git_commit: str, started: float ) -> None: logger.info("交差検証の実験 ID: %s", run_id) - _write_yaml( + write_yaml( run_dir / CONFIG_FILENAME, _cv_config_document(config, run_id=run_id, git_commit=git_commit, command="train cv"), ) @@ -156,7 +153,7 @@ result = run_cross_validation(config, records, run_dir / CHECKPOINTS_DIRNAME) history = history_rows(result.folds) - _write_csv(run_dir / HISTORY_FILENAME, history) + write_csv(run_dir / HISTORY_FILENAME, history) plot_learning_curves(history, run_dir / FIGURES_DIRNAME / LEARNING_CURVES_FILENAME) metrics = build_metrics(config, records, result, run_id=run_id, git_commit=git_commit) @@ -167,7 +164,7 @@ config: ExperimentConfig, run_dir: Path, *, run_id: str, git_commit: str, started: float ) -> None: logger.info("色特徴分類器の交差検証の実験 ID: %s", run_id) - _write_yaml( + write_yaml( run_dir / CONFIG_FILENAME, _cv_config_document(config, run_id=run_id, git_commit=git_commit, command="train color-cv"), ) @@ -180,6 +177,103 @@ _write_evaluation_outputs(run_dir, config, records, result, metrics, started=started) +def _run_color_export( + config: ExperimentConfig, run_dir: Path, *, run_id: str, git_commit: str, started: float +) -> None: + logger.info("判定モデルの書き出しの実験 ID: %s", run_id) + write_yaml( + run_dir / CONFIG_FILENAME, + _cv_config_document( + config, run_id=run_id, git_commit=git_commit, command="train color-export" + ), + ) + + records = read_manifest(config.data.manifest_path) + result = train_final_color_model(config, records) + document = build_color_model_document(config, result, run_id=run_id, git_commit=git_commit) + write_json(run_dir / COLOR_MODEL_FILENAME, document) + + metrics = build_color_export_metrics(config, result, run_id=run_id, git_commit=git_commit) + metrics["timing"]["total_seconds"] = round(time.monotonic() - started, 2) + write_json(run_dir / METRICS_FILENAME, metrics) + logger.info( + "判定モデルを書き出した: %s(C=%s・画像 %d 件.全体の所要 %.1f 秒)", + run_dir / COLOR_MODEL_FILENAME, + result.selection.selected_c, + sum(result.class_counts), + metrics["timing"]["total_seconds"], + ) + + +def build_color_export_metrics( + config: ExperimentConfig, + result: ColorExportResult, + *, + run_id: str, + git_commit: str, +) -> dict[str, Any]: + """`tias-train color-export` の `metrics.json`(C の選択の記録と学習条件)を作る. + + 集計値だけを含み,患者 ID・ファイル名・画像単位の値は含まない. + + Args: + config: 実験設定(`color` 節が `logistic`). + result: 最終モデルの学習の結果. + run_id: 実験 ID. + git_commit: git の短縮ハッシュ(または `nogit`). + + Returns: + JSON に書き出せる dict(`timing.total_seconds` は呼び出し側で埋める). + """ + color = check_color_export_config(config) + selection = result.selection + return { + "run_id": run_id, + "git_commit": git_commit, + "classes": list(CLASS_SLUGS), + "severities_trained": list(config.data.severities), + "imbalance": {"class_weighting": config.train.class_weighting}, + "c_selection": { + "method": ( + "全データの患者単位 K-fold(split.seed)で,学習側と held_out の両方に " + "data.severities を適用し,held_out macro F1 の fold 平均が最良の C を採用" + "(同点は先の候補)" + ), + "n_folds": config.split.n_folds, + "split_seed": config.split.seed, + "fold_seeds": [fold_seed(config.split.seed, i) for i in range(config.split.n_folds)], + "candidates": selection.candidates, + "selected_C": selection.selected_c, + "fold_counts": selection.fold_counts, + }, + "final_model": { + "C": selection.selected_c, + "class_counts": dict(zip(CLASS_SLUGS, result.class_counts, strict=True)), + "class_weights": ( + None + if result.class_weights is None + else { + slug: round(float(w), 6) + for slug, w in zip(CLASS_SLUGS, result.class_weights, strict=True) + } + ), + "num_images": int(sum(result.class_counts)), + "seed": result.seed, + "max_iter": color.logistic.max_iter, + }, + "timing": { + "total_seconds": None, + **{k: round(v, 2) for k, v in result.timing.items()}, + }, + "environment": { + "model_name": "color_logistic", + "num_features": len(result.model.steps[0][1].mean_), + "max_side": color.max_side, + "sklearn_version": sklearn.__version__, + }, + } + + def _cv_config_document( config: ExperimentConfig, *, run_id: str, git_commit: str, command: str ) -> dict[str, Any]: @@ -218,7 +312,7 @@ total_seconds = time.monotonic() - started metrics["timing"]["total_seconds"] = round(total_seconds, 2) - _write_json(run_dir / METRICS_FILENAME, metrics) + write_json(run_dir / METRICS_FILENAME, metrics) for subset in SUBSETS: summary = metrics["mean_std"].get(subset) @@ -486,7 +580,7 @@ def _write_split(path: Path, config: ExperimentConfig, result: CrossValidationResult) -> None: # 患者 ID を含む(回収しない). - _write_json( + write_json( path, { "seed": config.split.seed, @@ -530,7 +624,7 @@ } ) rows.append(row) - _write_csv(path, rows) + write_csv(path, rows) def _write_confusion_csv(path: Path, matrices: Mapping[str, list[list[int]] | None]) -> None: @@ -542,27 +636,7 @@ row: dict[str, Any] = {"subset": subset, "true": slug} row.update({f"pred_{p}": c for p, c in zip(CLASS_SLUGS, counts, strict=True)}) rows.append(row) - _write_csv(path, rows) - - -def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]]) -> None: - with path.open("w", encoding="utf-8", newline="") as f: - if not rows: - return - writer = csv.DictWriter(f, fieldnames=list(rows[0]), lineterminator="\n") - writer.writeheader() - writer.writerows(rows) - - -def _write_json(path: Path, data: Mapping[str, Any]) -> None: - with path.open("w", encoding="utf-8", newline="\n") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - f.write("\n") - - -def _write_yaml(path: Path, data: Mapping[str, Any]) -> None: - with path.open("w", encoding="utf-8", newline="\n") as f: - yaml.safe_dump(dict(data), f, allow_unicode=True, sort_keys=False) + write_csv(path, rows) # --------------------------------------------------------------------------- @@ -595,7 +669,17 @@ "検証 macro F1 で選ぶ.GPU は使わない.最後に実験 ID を標準出力へ出す." ), ) - for p in (p_cv, p_color): + p_export = sub.add_parser( + "color-export", + help="色特徴+ロジスティック回帰の最終モデルを全データで学習し color_model.json に書き出す", + description=( + "全データ(data.severities 適用後)の患者単位 K-fold で C を選び,全データで学習した" + "判定モデルを runs/<実験ID>/color_model.json に書き出す(TIASjudge 用.NumPy だけの" + "参照実装 tias_judge_model/deploy/color_model.py で読める).最後に実験 ID を標準出力へ" + "出す." + ), + ) + for p in (p_cv, p_color, p_export): p.add_argument("--config", type=Path, required=True, help="実験設定 YAML.") p.add_argument( "--out-root", @@ -606,13 +690,6 @@ return parser.parse_args(argv) -def _configure_logging() -> None: - # ログは標準エラーへ(標準出力は実験 ID の受け渡しに使うため汚さない). - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter(_LOG_FORMAT)) - logging.basicConfig(level=logging.INFO, handlers=[handler]) - - def main(argv: Sequence[str] | None = None) -> int: """tias-train コマンドのメインエントリポイント. @@ -623,10 +700,12 @@ 終了コード(成功 0・入力の問題 1). """ args = _parse_args(argv) - _configure_logging() + configure_logging() try: if args.subcommand == "color-cv": return _cmd_run(args, _run_color_cv, check=check_color_config) + if args.subcommand == "color-export": + return _cmd_run(args, _run_color_export, check=check_color_export_config) return _cmd_run(args, _run_cv) except ValidationError as exc: logger.error("設定が不正である:\n%s", exc) diff --git a/src/tias_judge_model/data/color.py b/src/tias_judge_model/data/color.py index 2bb3221..d9f210a 100644 --- a/src/tias_judge_model/data/color.py +++ b/src/tias_judge_model/data/color.py @@ -18,6 +18,8 @@ ], dtype=np.float64, ) +# 配布用のモデルファイル(color_model.json)に行列を書き出すための公開名. +SRGB_TO_XYZ = _SRGB_TO_XYZ # D65 の白色点(Y = 1 に正規化).上の行列の行和と一致させ,sRGB 白が a* = b* = 0 になるようにする. D65_WHITE = _SRGB_TO_XYZ.sum(axis=1) diff --git a/src/tias_judge_model/deploy/__init__.py b/src/tias_judge_model/deploy/__init__.py new file mode 100644 index 0000000..cd0796a --- /dev/null +++ b/src/tias_judge_model/deploy/__init__.py @@ -0,0 +1 @@ +"""配布用の参照実装(TIASjudge にそのままコピーして使う単一ファイルのモジュール).""" diff --git a/src/tias_judge_model/deploy/color_model.py b/src/tias_judge_model/deploy/color_model.py new file mode 100644 index 0000000..e5af701 --- /dev/null +++ b/src/tias_judge_model/deploy/color_model.py @@ -0,0 +1,366 @@ +"""舌色 5 分類の判定モデル(色特徴+ロジスティック回帰)の NumPy だけの参照実装. + +`tias-train color-export` が書き出す `color_model.json` を読み,舌領域だけを残した画像から +舌色 5 分類の予測確率を求める.TIASjudge にこのファイルだけをコピーして使えるよう,依存は +NumPy と標準ライブラリだけにしている(scikit-learn・PyTorch・pydantic・本パッケージの他の +モジュールを import しない). + +入力の前提(`color_model.json` の `input` にも記録している): + +- RGB 順・uint8 の `(H, W, 3)` 配列(OpenCV で読んだ BGR のまま渡さない) +- 舌領域の外(背景)は RGB がすべて 0.舌領域の画素はどれかのチャンネルが 1 以上 +- TIAS の機材で撮影した色補正なしの画像から,舌のセグメンテーションで切り出したもの + (色補正後の画像や他の機材の画像には使えない) + +特徴量の定義(107 次元)は学習側の `tias_judge_model.data.color_features` と同じで,定数 +(ヒストグラムの範囲・ビン数・分位点・縮小の上限・Lab 変換の行列)は JSON から読む. + +使い方: + + model = load_color_model("color_model.json") + probs = predict_proba(rgb, model) # (5,) の予測確率(クラス ID 順) + label = model.classes[int(probs.argmax())] # {"id", "slug", "display_name"} +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +# 読める color_model.json の書式の版. +SUPPORTED_FORMAT_VERSIONS: tuple[int, ...] = (1,) +MODEL_TYPE = "color_logistic" + +# sRGB(IEC 61966-2-1)のガンマ補正を外す式の定数(規格で決まっているため JSON には持たない). +_SRGB_LINEAR_THRESHOLD = 0.04045 +_SRGB_LINEAR_SLOPE = 12.92 +_SRGB_OFFSET = 0.055 +_SRGB_GAMMA = 2.4 +# CIE L*a*b* の区分関数の定数(δ = 6/29.規格の定数). +_LAB_DELTA = 6.0 / 29.0 + + +@dataclass(frozen=True) +class ColorModel: + """読み込んだ判定モデル. + + Attributes: + feature_names: 特徴量の名前(長さ F.特徴ベクトルの並び). + channels: 統計量を取るチャンネル(`L`・`a`・`b`・`C`・`h`). + percentiles: 分位点(パーセント). + ab_bins: a*-b* の 2 次元ヒストグラムの 1 軸のビン数. + a_range: a* のヒストグラムの範囲. + b_range: b* のヒストグラムの範囲. + l_bins: L* のヒストグラムのビン数. + l_range: L* のヒストグラムの範囲. + max_side: 長辺の上限(これを超える画像は最近傍法で縮小する.None なら縮小しない). + srgb_to_xyz: `(3, 3)` の float64.線形 sRGB → XYZ(D65)の行列. + white_point: `(3,)` の float64.XYZ の白色点. + scaler_mean: `(F,)` の float64.標準化の平均. + scaler_scale: `(F,)` の float64.標準化の尺度(標準偏差.分散 0 の特徴は 1). + coef: `(K, F)` の float64.ロジスティック回帰の係数. + intercept: `(K,)` の float64.切片. + classes: クラス(ID 順).各要素は `{"id", "slug", "display_name"}`. + raw: 読み込んだ JSON の内容(学習条件などの記録を参照するため). + """ + + feature_names: tuple[str, ...] + channels: tuple[str, ...] + percentiles: tuple[float, ...] + ab_bins: int + a_range: tuple[float, float] + b_range: tuple[float, float] + l_bins: int + l_range: tuple[float, float] + max_side: int | None + srgb_to_xyz: np.ndarray + white_point: np.ndarray + scaler_mean: np.ndarray + scaler_scale: np.ndarray + coef: np.ndarray + intercept: np.ndarray + classes: tuple[dict[str, Any], ...] + raw: dict[str, Any] + + @property + def num_features(self) -> int: + """特徴量の次元数 F.""" + return len(self.feature_names) + + @property + def num_classes(self) -> int: + """クラス数 K.""" + return len(self.classes) + + +def _range(value: Any, key: str) -> tuple[float, float]: + if not isinstance(value, list | tuple) or len(value) != 2: + raise ValueError(f"color_model.json の {key} は [下限, 上限] でなければならない") + low, high = float(value[0]), float(value[1]) + if not low < high: + raise ValueError(f"color_model.json の {key} は下限 < 上限でなければならない") + return low, high + + +def _array(value: Any, key: str, shape: tuple[int, ...]) -> np.ndarray: + arr = np.asarray(value, dtype=np.float64) + if arr.shape != shape: + raise ValueError(f"color_model.json の {key} の形状が {arr.shape} である(期待 {shape})") + if not np.all(np.isfinite(arr)): + raise ValueError(f"color_model.json の {key} に有限でない値がある") + return arr + + +def load_color_model(source: str | os.PathLike[str] | Mapping[str, Any]) -> ColorModel: + """`color_model.json`(またはその内容の dict)から判定モデルを読み込む. + + Args: + source: `color_model.json` のパス,または JSON を読み込んだ dict. + + Returns: + 判定モデル. + + Raises: + ValueError: 未対応の書式の版・モデルの種類,必要なキーの欠落,配列の形状の不一致など. + OSError: ファイルを読めない場合. + """ + if isinstance(source, Mapping): + raw = dict(source) + else: + with Path(source).open(encoding="utf-8") as f: + raw = json.load(f) + if not isinstance(raw, dict): + raise ValueError("color_model.json のトップレベルはオブジェクトでなければならない") + + try: + version = raw["format_version"] + if version not in SUPPORTED_FORMAT_VERSIONS: + raise ValueError( + f"未対応の color_model.json の版である: {version!r}" + f"(対応: {', '.join(map(str, SUPPORTED_FORMAT_VERSIONS))})" + ) + if raw["model_type"] != MODEL_TYPE: + raise ValueError(f"未対応のモデルの種類である: {raw['model_type']!r}") + + features = raw["features"] + names = tuple(str(n) for n in features["names"]) + channels = tuple(str(c) for c in features["channels"]) + percentiles = tuple(float(q) for q in features["percentiles"]) + ab_hist = features["ab_hist"] + l_hist = features["l_hist"] + ab_bins = int(ab_hist["bins"]) + l_bins = int(l_hist["bins"]) + max_side = features["max_side"] + lab = features["lab"] + + n_features = len(names) + expected = len(channels) * (2 + len(percentiles)) + ab_bins**2 + l_bins + if n_features != expected or n_features != int(features["num_features"]): + raise ValueError( + f"color_model.json の特徴量の次元数が合わない(名前 {n_features}・" + f"num_features {features['num_features']}・定義から {expected})" + ) + if max_side is not None and int(max_side) < 1: + raise ValueError("color_model.json の features.max_side は 1 以上か null") + + classes = tuple(dict(c) for c in raw["classes"]) + if [int(c["id"]) for c in classes] != list(range(len(classes))): + raise ValueError("color_model.json の classes は ID 0 から順に並んでいなければならない") + n_classes = len(classes) + + scaler = raw["scaler"] + classifier = raw["classifier"] + return ColorModel( + feature_names=names, + channels=channels, + percentiles=percentiles, + ab_bins=ab_bins, + a_range=_range(ab_hist["a_range"], "features.ab_hist.a_range"), + b_range=_range(ab_hist["b_range"], "features.ab_hist.b_range"), + l_bins=l_bins, + l_range=_range(l_hist["range"], "features.l_hist.range"), + max_side=None if max_side is None else int(max_side), + srgb_to_xyz=_array(lab["srgb_to_xyz"], "features.lab.srgb_to_xyz", (3, 3)), + white_point=_array(lab["white_point"], "features.lab.white_point", (3,)), + scaler_mean=_array(scaler["mean"], "scaler.mean", (n_features,)), + scaler_scale=_array(scaler["scale"], "scaler.scale", (n_features,)), + coef=_array(classifier["coef"], "classifier.coef", (n_classes, n_features)), + intercept=_array(classifier["intercept"], "classifier.intercept", (n_classes,)), + classes=classes, + raw=raw, + ) + except KeyError as exc: + raise ValueError(f"color_model.json に必要なキーが無い: {exc.args[0]!r}") from None + + +def downscale_nearest(rgb: np.ndarray, max_side: int) -> np.ndarray: + """長辺が `max_side` を超える画像を,アスペクト比を保って最近傍法で縮小する. + + 学習側(Pillow の `Image.resize(..., NEAREST)`)と同じ画素を選ぶ.Pillow は出力の + i 番目の画素に,入力の `floor(s/2 + s + s + ...)`(s = 入力の長さ / 出力の長さを i 回 + 足したもの.浮動小数点で逐次加算)番目を使うため,同じ順で足して丸めを一致させる. + 最近傍法は画素の色を混ぜないため,背景(黒)と舌の画素が縁で混ざらない. + + Args: + rgb: RGB 画像(`(H, W, 3)`, uint8). + max_side: 縮小後の長辺の上限(1 以上). + + Returns: + RGB 画像(`(H', W', 3)`, uint8.max(H', W') ≤ max_side).長辺が `max_side` 以下なら + 入力をそのまま返す. + + Raises: + ValueError: `max_side` が 1 未満の場合. + """ + if max_side < 1: + raise ValueError(f"max_side は 1 以上でなければならない(現在 {max_side})") + height, width = rgb.shape[:2] + if max(height, width) <= max_side: + return rgb + scale = max_side / max(height, width) + # 学習側と同じく Python の round(偶数丸め)で出力サイズを決める. + new_w = max(1, round(width * scale)) + new_h = max(1, round(height * scale)) + return rgb[_nearest_indices(height, new_h)][:, _nearest_indices(width, new_w)] + + +def _nearest_indices(n_in: int, n_out: int) -> np.ndarray: + step = n_in / n_out + steps = np.full(n_out, step, dtype=np.float64) + steps[0] = step * 0.5 + # np.cumsum は先頭から順に足すため,Pillow の逐次加算と同じ丸めになる. + return np.minimum(np.cumsum(steps).astype(np.int64), n_in - 1) + + +def _srgb8_to_lab(rgb: np.ndarray, model: ColorModel) -> np.ndarray: + # uint8 の 256 階調を先に線形化する(学習側と同じ計算順にして値を一致させる). + c = np.arange(256, dtype=np.float64) / 255.0 + lut = np.where( + c <= _SRGB_LINEAR_THRESHOLD, + c / _SRGB_LINEAR_SLOPE, + ((c + _SRGB_OFFSET) / (1.0 + _SRGB_OFFSET)) ** _SRGB_GAMMA, + ) + xyz = lut[rgb] @ model.srgb_to_xyz.T # (P, 3) + t = xyz / model.white_point + f = np.where(t > _LAB_DELTA**3, np.cbrt(t), t / (3.0 * _LAB_DELTA**2) + 4.0 / 29.0) + lightness = 116.0 * f[..., 1] - 16.0 + a = 500.0 * (f[..., 0] - f[..., 1]) + b = 200.0 * (f[..., 1] - f[..., 2]) + return np.stack([lightness, a, b], axis=-1) + + +def _check_rgb(rgb: np.ndarray) -> np.ndarray: + arr = np.asarray(rgb) + if arr.ndim != 3 or arr.shape[-1] != 3: + raise ValueError(f"画像の形状は (H, W, 3) でなければならない(形状 {arr.shape})") + if arr.dtype != np.uint8: + raise ValueError(f"画像の dtype は uint8 でなければならない({arr.dtype})") + return arr + + +def extract_features(rgb: np.ndarray, model: ColorModel, *, downscale: bool = True) -> np.ndarray: + """舌領域の画素から色特徴量を求める(学習側の `extract_color_features` と同じ値). + + Args: + rgb: RGB 画像(`(H, W, 3)`, uint8).背景(舌領域の外)は RGB がすべて 0. + model: 判定モデル(特徴量の定数を使う). + downscale: True なら `model.max_side` に従って最近傍法で縮小してから計算する + (学習時と同じ).False なら与えた画像のまま計算する. + + Returns: + `(F,)` の float64 の特徴ベクトル(並びは `model.feature_names`). + + Raises: + ValueError: 形状・dtype が不正,または舌領域の画素が 0 の場合. + """ + arr = _check_rgb(rgb) + if downscale and model.max_side is not None: + arr = downscale_nearest(arr, model.max_side) + mask = np.any(arr != 0, axis=-1) + n_pixels = int(mask.sum()) + if n_pixels == 0: + raise ValueError("舌領域の画素が 0 である(全画素が背景の黒)") + + lab = _srgb8_to_lab(arr[mask], model) # (P, 3) + lightness, a, b = lab[:, 0], lab[:, 1], lab[:, 2] + chroma = np.hypot(a, b) + hue = np.degrees(np.arctan2(b, a)) # (-180, 180] の度 + by_name = {"L": lightness, "a": a, "b": b, "C": chroma, "h": hue} + channels = np.stack([by_name[ch] for ch in model.channels], axis=1) # (P, n_channels) + + means = channels.mean(axis=0) + stds = channels.std(axis=0) # 母標準偏差(ddof=0) + quantiles = np.percentile(channels, model.percentiles, axis=0) # (n_percentiles, n_channels) + stats = np.concatenate([means[None, :], stds[None, :], quantiles], axis=0) + channel_features = stats.T.reshape(-1) # チャンネルごとに統計量を並べる + + # 範囲外の画素は端のビンに入れる(割合の合計を 1 に保つ). + ab_hist, _, _ = np.histogram2d( + np.clip(a, *model.a_range), + np.clip(b, *model.b_range), + bins=model.ab_bins, + range=[model.a_range, model.b_range], + ) + l_hist, _ = np.histogram( + np.clip(lightness, *model.l_range), bins=model.l_bins, range=model.l_range + ) + features = np.concatenate( + [ + channel_features, + ab_hist.reshape(-1) / n_pixels, + l_hist.astype(np.float64) / n_pixels, + ] + ).astype(np.float64) + if features.shape != (model.num_features,): + raise ValueError( + f"特徴量の次元数 {features.shape[0]} がモデルの {model.num_features} と一致しない" + ) + return features + + +def predict_proba_from_features(features: np.ndarray, model: ColorModel) -> np.ndarray: + """特徴量から予測確率を求める(標準化 → 線形 → softmax). + + Args: + features: `(F,)` または `(N, F)` の float の特徴量. + model: 判定モデル. + + Returns: + `(K,)` または `(N, K)` の float64 の予測確率(クラス ID 順.和は 1). + + Raises: + ValueError: 特徴量の次元数がモデルと一致しない場合. + """ + x = np.asarray(features, dtype=np.float64) + if x.shape[-1:] != (model.num_features,) or x.ndim not in (1, 2): + raise ValueError( + f"特徴量の形状は (F,) か (N, F)(F = {model.num_features})でなければならない" + f"(形状 {x.shape})" + ) + z = (x - model.scaler_mean) / model.scaler_scale + logits = z @ model.coef.T + model.intercept + logits = logits - logits.max(axis=-1, keepdims=True) # exp の桁あふれを防ぐ + exp = np.exp(logits) + return exp / exp.sum(axis=-1, keepdims=True) + + +def predict_proba(rgb: np.ndarray, model: ColorModel) -> np.ndarray: + """舌領域だけを残した RGB 画像から舌色 5 分類の予測確率を求める. + + Args: + rgb: RGB 画像(`(H, W, 3)`, uint8).背景(舌領域の外)は RGB がすべて 0. + model: 判定モデル. + + Returns: + `(K,)` の float64 の予測確率(クラス ID 順.`model.classes` と対応). + + Raises: + ValueError: 形状・dtype が不正,または舌領域の画素が 0 の場合. + """ + return predict_proba_from_features(extract_features(rgb, model), model) diff --git a/src/tias_judge_model/evaluation/segmentation_eval.py b/src/tias_judge_model/evaluation/segmentation_eval.py new file mode 100644 index 0000000..71338cb --- /dev/null +++ b/src/tias_judge_model/evaluation/segmentation_eval.py @@ -0,0 +1,388 @@ +"""Python 版セグメンテーション(DeepTIAS 2.7 の再現)を正解マスク付きデータで評価する. + +`tias-seg eval` の本体.評価セットの種類(`data.sets[].kind`)ごとに次の方式(枠の決め方)で +ResUNet を掛け,前景の IoU・Dice を求める. + +- `cropped`(切り出し済み): `whole`(画像全体に掛ける.ResUNet の学習時と同じ条件) +- `fullsize`(撮影画像): `whole`(検出なしで画像全体に掛ける)・`gt_bbox`(正解マスクの外接矩形で + 切り出す.検出が完璧な場合の上限)・`detector`(検出モデルの枠で切り出す.モデルがある場合のみ) + +どの方式も,後処理(小領域除去)あり(DeepTIAS と同じ)・なし,もう一方の出力チャンネル +(後処理あり)の 3 通りのマスクを 1 回の推論から作る.画像ごとの値(`ImageScore`)は個票として +サーバ上にだけ書き,回収する metrics.json には集計値(`summarize_segmentation`)だけを書く. +ログ・例外には件数と評価セット内の何件目かだけを出し,ファイル名は出さない. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +from tias_judge_model.evaluation.segmentation_metrics import iou_and_dice, summarize_scores +from tias_judge_model.schema import SegEvalConfig, SegEvalSetConfig +from tias_judge_model.segmentation.detection import ( + OnnxDetector, + Rect, + full_rect, + mask_bounding_rect, +) +from tias_judge_model.segmentation.images import list_image_pairs, read_bgr, read_label_mask +from tias_judge_model.segmentation.pipeline import SegmentationParams, empty_masks, segment_in_rect +from tias_judge_model.segmentation.resunet import CropMasks, ResUNetRunner + +logger = logging.getLogger(__name__) + +METHOD_WHOLE = "whole" +METHOD_GT_BBOX = "gt_bbox" +METHOD_DETECTOR = "detector" +# 方式の説明(metrics.json に書く). +METHOD_DESCRIPTIONS: dict[str, str] = { + METHOD_WHOLE: "検出なし.画像全体を ResUNet の入力サイズにして掛ける", + METHOD_GT_BBOX: "正解マスクの外接矩形で切り出して掛ける(検出が完璧な場合の上限)", + METHOD_DETECTOR: "検出モデル(スコアがしきい値を超える最高の枠)で切り出して掛ける", +} + + +@dataclass(frozen=True) +class ImageScore: + """画像 1 枚・方式 1 つの評価(個票.サーバ上の predictions.csv にだけ書く). + + Attributes: + set_name: 評価セットの名前. + index: 評価セット内の位置(stem の昇順で 1 始まり). + stem: ファイル名の stem. + method: 方式. + iou: IoU(後処理あり). + dice: Dice(後処理あり). + iou_no_post: IoU(後処理なし). + dice_no_post: Dice(後処理なし). + iou_other_channel: もう一方の出力チャンネルを使った IoU(後処理あり). + postprocess_applied: 小領域除去をしたか. + detected: 検出できたか(`detector` 以外は None). + detection_score: 検出の最高スコア(`detector` 以外は None). + seconds: 検出・推論・後処理の所要秒数. + """ + + set_name: str + index: int + stem: str + method: str + iou: float + dice: float + iou_no_post: float + dice_no_post: float + iou_other_channel: float + postprocess_applied: bool + detected: bool | None + detection_score: float | None + seconds: float + + +@dataclass +class SegEvalResult: + """評価全体の結果. + + Attributes: + scores: 画像・方式ごとの評価(個票). + methods: 評価セットの名前 → 評価した方式(順序つき). + detector_used: 検出モデルを使ったか. + detector_note: 検出モデルを使わなかった理由(使った場合は None). + loading_seconds: 画像の読み込みの合計秒数. + """ + + scores: list[ImageScore] = field(default_factory=list) + methods: dict[str, list[str]] = field(default_factory=dict) + detector_used: bool = False + detector_note: str | None = None + loading_seconds: float = 0.0 + + +def methods_for(kind: str, has_detector: bool) -> list[str]: + """評価セットの種類から評価する方式を返す. + + Args: + kind: `cropped` または `fullsize`. + has_detector: 検出モデルがあるか. + + Returns: + 方式の一覧. + """ + if kind == "cropped": + return [METHOD_WHOLE] + methods = [METHOD_WHOLE, METHOD_GT_BBOX] + if has_detector: + methods.append(METHOD_DETECTOR) + return methods + + +def _load_detector(config: SegEvalConfig) -> tuple[OnnxDetector | None, str | None]: + path = config.models.detection_path + if path is None: + return None, "設定で検出モデルを指定していない(models.detection: null)" + if not path.is_file(): + # 変換できなかった場合でも検出なし・正解の外接矩形の評価は完走させるため,止めずに省く. + logger.warning("検出モデルの ONNX が無いため,detector の方式を省く: %s", path) + return None, "検出モデルの ONNX が無い" + d = config.detection + detector = OnnxDetector( + path, + score_threshold=d.score_threshold, + input_color=d.input_color, + input_name=d.input_name, + boxes_name=d.boxes_name, + scores_name=d.scores_name, + ) + return detector, None + + +def _segment_by_method( + method: str, + bgr: np.ndarray, + gt_rect: Rect, + runner: ResUNetRunner, + detector: OnnxDetector | None, + params: SegmentationParams, +) -> tuple[CropMasks, bool | None, float | None]: + # 方式ごとに枠を決めて ResUNet を掛ける.戻り値は(`(H, W)` の bool のマスク,検出できたか, + # 検出の最高スコア).検出の 2 つは detector 以外では None. + height, width = bgr.shape[:2] + if method == METHOD_WHOLE: + return segment_in_rect(bgr, full_rect(height, width), runner, params), None, None + if method == METHOD_GT_BBOX: + return segment_in_rect(bgr, gt_rect, runner, params), None, None + if detector is None: + raise RuntimeError("検出モデルが無いのに detector の方式を評価しようとした") + detection = detector.detect(bgr) + masks = ( + segment_in_rect(bgr, detection.rect, runner, params) + if detection.rect is not None + else empty_masks(height, width) + ) + return masks, detection.rect is not None, detection.score + + +def _evaluate_set( + set_config: SegEvalSetConfig, + config: SegEvalConfig, + runner: ResUNetRunner, + detector: OnnxDetector | None, + params: SegmentationParams, + result: SegEvalResult, +) -> None: + set_dir = config.data.root / set_config.dir + pairs = list_image_pairs(set_dir, config.data.sample_dirname, config.data.label_dirname) + methods = methods_for(set_config.kind, detector is not None) + result.methods[set_config.name] = methods + logger.info( + "評価セット %s(%s): 画像 %d 件・方式 %s", + set_config.name, + set_config.kind, + len(pairs), + methods, + ) + progress_every = max(1, len(pairs) // 10) + for number, pair in enumerate(pairs, start=1): + where = f"評価セット {set_config.name} の {number} 件目" + started = time.monotonic() + bgr = read_bgr(pair.sample, where) + target = read_label_mask(pair.label, config.data.label_threshold, where) + result.loading_seconds += time.monotonic() - started + if target.shape != bgr.shape[:2]: + raise ValueError( + f"{where}: 入力画像 {bgr.shape[1]}×{bgr.shape[0]} と正解マスク " + f"{target.shape[1]}×{target.shape[0]} のサイズが違う" + ) + gt_rect = mask_bounding_rect(target) + if gt_rect is None: + raise ValueError(f"{where}: 正解マスクに前景が無い") + + for method in methods: + started = time.monotonic() + masks, detected, det_score = _segment_by_method( + method, bgr, gt_rect, runner, detector, params + ) + iou, dice = iou_and_dice(masks.post, target) + iou_np, dice_np = iou_and_dice(masks.no_post, target) + iou_other, _ = iou_and_dice(masks.other_channel, target) + result.scores.append( + ImageScore( + set_name=set_config.name, + index=number, + stem=pair.stem, + method=method, + iou=iou, + dice=dice, + iou_no_post=iou_np, + dice_no_post=dice_np, + iou_other_channel=iou_other, + postprocess_applied=masks.postprocess_applied, + detected=detected, + detection_score=det_score, + seconds=time.monotonic() - started, + ) + ) + if number % progress_every == 0 or number == len(pairs): + logger.info("評価セット %s: %d / %d 件", set_config.name, number, len(pairs)) + + +def run_segmentation_eval(config: SegEvalConfig) -> SegEvalResult: + """設定の評価セットをすべて評価する. + + Args: + config: 評価の設定. + + Returns: + 評価の結果(画像ごとの値を含む). + + Raises: + ValueError: ResUNet の ONNX が無い・形状が合わない,評価セットのディレクトリが無い, + 画像を読めない,入力画像と正解マスクの対応が取れない・サイズが違う,正解マスクに + 前景が無い等の入力の問題. + """ + runner = ResUNetRunner(config.models.resunet_path, config.resunet.input_size) + detector, note = _load_detector(config) + params = SegmentationParams( + foreground_channel=config.resunet.foreground_channel, + min_area=config.postprocess.min_area, + mask_threshold=config.postprocess.mask_threshold, + ) + result = SegEvalResult(detector_used=detector is not None, detector_note=note) + for set_config in config.data.sets: + _evaluate_set(set_config, config, runner, detector, params, result) + return result + + +def _scores_of(scores: Sequence[ImageScore], set_name: str, method: str) -> list[ImageScore]: + return [s for s in scores if s.set_name == set_name and s.method == method] + + +def ious_by_method(result: SegEvalResult, set_name: str) -> dict[str, list[float]]: + """評価セットの画像ごとの IoU(後処理あり)を方式ごとに返す(図を描くため). + + Args: + result: 評価の結果. + set_name: 評価セットの名前. + + Returns: + 方式(評価した順)→ 画像ごとの IoU(評価セット内の順). + """ + return { + method: [s.iou for s in _scores_of(result.scores, set_name, method)] + for method in result.methods.get(set_name, []) + } + + +def _summarize_method( + method: str, scores: Sequence[ImageScore], config: SegEvalConfig +) -> dict[str, Any]: + threshold = config.evaluation.iou_threshold + entry: dict[str, Any] = { + "description": METHOD_DESCRIPTIONS[method], + "iou": summarize_scores([s.iou for s in scores], threshold), + "dice": summarize_scores([s.dice for s in scores], threshold), + "iou_no_post": summarize_scores([s.iou_no_post for s in scores], threshold), + "dice_no_post": summarize_scores([s.dice_no_post for s in scores], threshold), + "iou_other_channel": summarize_scores([s.iou_other_channel for s in scores], threshold), + "n_postprocess_applied": sum(s.postprocess_applied for s in scores), + "mean_seconds": round(float(np.mean([s.seconds for s in scores])), 4) if scores else None, + } + if method == METHOD_DETECTOR: + det_scores = [s.detection_score for s in scores if s.detection_score is not None] + entry["detection"] = { + "n_detected": sum(bool(s.detected) for s in scores), + "n_not_detected": sum(not s.detected for s in scores), + "score_threshold": config.detection.score_threshold, + "top_score": summarize_scores(det_scores, config.detection.score_threshold), + } + return entry + + +def summarize_segmentation(result: SegEvalResult, config: SegEvalConfig) -> dict[str, Any]: + """評価セット・方式ごとの集計値を作る(metrics.json の `sets`.画像単位の値は含めない). + + Args: + result: 評価の結果. + config: 評価の設定. + + Returns: + 評価セットの名前 → `{dir, kind, n_images, methods: {方式: 集計}}`.方式ごとの集計は + `iou`・`dice`(後処理あり)・`iou_no_post`・`dice_no_post`・`iou_other_channel` の + `summarize_scores`,小領域除去をした件数,所要秒数の平均,`detector` では検出の件数. + """ + sets: dict[str, Any] = {} + for set_config in config.data.sets: + methods: dict[str, Any] = {} + n_images = 0 + for method in result.methods.get(set_config.name, []): + scores = _scores_of(result.scores, set_config.name, method) + n_images = len(scores) + methods[method] = _summarize_method(method, scores, config) + sets[set_config.name] = { + "dir": set_config.dir.as_posix(), + "kind": set_config.kind, + "n_images": n_images, + "methods": methods, + } + return sets + + +def log_summary(sets: dict[str, Any]) -> None: + """集計値をログに出す(出力チャンネルの取り違えに気づけるよう,もう一方のチャンネルも出す). + + Args: + sets: `summarize_segmentation` の戻り値. + """ + for set_name, entry in sets.items(): + for method, m in entry["methods"].items(): + iou, iou_np, other = m["iou"], m["iou_no_post"], m["iou_other_channel"] + if not iou["n"]: + continue + logger.info( + "%s / %s: IoU 平均 %.4f・中央値 %.4f・p10 %.4f・最小 %.4f・%.2f 未満 %d / %d 件" + "(後処理なし: 平均 %.4f・%.2f 未満 %d 件.小領域除去 %d 件)・Dice 平均 %.4f", + set_name, + method, + iou["mean"], + iou["median"], + iou["p10"], + iou["min"], + iou["below_threshold"], + iou["n_below"], + iou["n"], + iou_np["mean"], + iou_np["below_threshold"], + iou_np["n_below"], + m["n_postprocess_applied"], + m["dice"]["mean"], + ) + logger.info( + "%s / %s: もう一方の出力チャンネルを使った場合の IoU 平均 %.4f・中央値 %.4f", + set_name, + method, + other["mean"], + other["median"], + ) + if other["mean"] > iou["mean"]: + logger.warning( + "%s / %s: もう一方の出力チャンネルのほうが IoU が高い(%.4f > %.4f)." + "resunet.foreground_channel の取り違えを確認する", + set_name, + method, + other["mean"], + iou["mean"], + ) + detection = m.get("detection") + if detection: + logger.info( + "%s / %s: 検出できた %d 件・できなかった %d 件(しきい値 %.2f)", + set_name, + method, + detection["n_detected"], + detection["n_not_detected"], + detection["score_threshold"], + ) diff --git a/src/tias_judge_model/evaluation/segmentation_figures.py b/src/tias_judge_model/evaluation/segmentation_figures.py new file mode 100644 index 0000000..60b5d22 --- /dev/null +++ b/src/tias_judge_model/evaluation/segmentation_figures.py @@ -0,0 +1,59 @@ +"""セグメンテーションの評価の図(IoU の分布). + +`figures/` は回収対象であるため,集計値の図だけを描き,画像(入力・マスク・重ね合わせ)は +描かない(SPEC_01「実験の出力」).`evaluation/figures.py` と同じく pyplot を使わず `Figure` + +`FigureCanvasAgg` で描き,ラベルは英字で書く. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path + +import numpy as np +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure + +_DPI = 120 +# IoU のヒストグラムのビン(0〜1 を 0.02 刻み). +_IOU_BINS = np.linspace(0.0, 1.0, 51) + + +def iou_histogram_filename(set_name: str) -> str: + """評価セットの IoU のヒストグラムのファイル名を返す. + + Args: + set_name: 評価セットの名前. + + Returns: + ファイル名(`seg_iou_hist_.png`). + """ + return f"seg_iou_hist_{set_name}.png" + + +def plot_iou_histograms( + ious_by_method: Mapping[str, Sequence[float]], title: str, path: Path +) -> None: + """方式ごとの IoU のヒストグラムを縦に並べた図を保存する. + + Args: + ious_by_method: 方式名 → 画像ごとの IoU(空の方式は描かない). + title: 図のタイトル. + path: 保存先(親ディレクトリは作る). + """ + methods = [m for m, v in ious_by_method.items() if len(v) > 0] + n = max(1, len(methods)) + fig = Figure(figsize=(6.0, 2.2 * n + 0.6)) + FigureCanvasAgg(fig) + axes = fig.subplots(n, 1, squeeze=False, sharex=True)[:, 0] + for ax, method in zip(axes, methods, strict=False): + values = np.asarray(ious_by_method[method], dtype=np.float64) + ax.hist(np.clip(values, 0.0, 1.0), bins=_IOU_BINS, color="tab:blue") + ax.set_ylabel("count") + ax.set_title(f"{method} (n={values.size}, mean={values.mean():.3f})", fontsize=9) + axes[-1].set_xlabel("IoU (foreground)") + axes[-1].set_xlim(0.0, 1.0) + fig.suptitle(title, fontsize=10) + fig.tight_layout() + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, dpi=_DPI) diff --git a/src/tias_judge_model/evaluation/segmentation_metrics.py b/src/tias_judge_model/evaluation/segmentation_metrics.py new file mode 100644 index 0000000..50c370e --- /dev/null +++ b/src/tias_judge_model/evaluation/segmentation_metrics.py @@ -0,0 +1,76 @@ +"""セグメンテーションの指標(前景の IoU・Dice)と画像ごとの値の集計. + +集計値(平均・中央値・分位点・最小・しきい値未満の件数)だけを metrics.json に書くための +純関数.画像ごとの値はサーバ上の個票(predictions.csv)にだけ書く. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import numpy as np + + +def iou_and_dice(pred: np.ndarray, target: np.ndarray) -> tuple[float, float]: + """前景の IoU と Dice を求める. + + 両方とも前景が無い場合は一致とみなして 1.0 とする. + + Args: + pred: `(H, W)` の bool(予測の前景). + target: `(H, W)` の bool(正解の前景). + + Returns: + (IoU, Dice). + + Raises: + ValueError: 形状が一致しない場合. + """ + pred = np.asarray(pred, dtype=bool) + target = np.asarray(target, dtype=bool) + if pred.shape != target.shape: + raise ValueError(f"予測と正解の形状が一致しない({pred.shape} と {target.shape})") + intersection = int(np.count_nonzero(pred & target)) + union = int(np.count_nonzero(pred | target)) + total = int(np.count_nonzero(pred)) + int(np.count_nonzero(target)) + if union == 0: + return 1.0, 1.0 + return intersection / union, 2.0 * intersection / total + + +def summarize_scores(values: Sequence[float], threshold: float) -> dict[str, Any]: + """画像ごとの指標を集計する. + + Args: + values: 画像ごとの値. + threshold: この値未満の件数を数える. + + Returns: + `n`・`mean`・`median`・`p10`・`p25`・`min`・`max`・`n_below`・`below_threshold`. + 値が 0 件なら `n` が 0 で他は None. + """ + arr = np.asarray(values, dtype=np.float64) + if arr.size == 0: + return { + "n": 0, + "mean": None, + "median": None, + "p10": None, + "p25": None, + "min": None, + "max": None, + "n_below": None, + "below_threshold": threshold, + } + return { + "n": int(arr.size), + "mean": float(arr.mean()), + "median": float(np.median(arr)), + "p10": float(np.percentile(arr, 10)), + "p25": float(np.percentile(arr, 25)), + "min": float(arr.min()), + "max": float(arr.max()), + "n_below": int(np.count_nonzero(arr < threshold)), + "below_threshold": threshold, + } diff --git a/src/tias_judge_model/remote/whitelist.py b/src/tias_judge_model/remote/whitelist.py index 6bcf58b..4b5ff2b 100644 --- a/src/tias_judge_model/remote/whitelist.py +++ b/src/tias_judge_model/remote/whitelist.py @@ -26,6 +26,8 @@ "confusion_matrix.csv", "log.txt", "dataset_summary.json", + # TIASjudge で使う判定モデル(tias-train color-export.係数と集計値のみ) + "color_model.json", } ) PULL_FIGURES_DIR = "figures" diff --git a/src/tias_judge_model/schema.py b/src/tias_judge_model/schema.py index 2e8475e..94c86e5 100644 --- a/src/tias_judge_model/schema.py +++ b/src/tias_judge_model/schema.py @@ -286,3 +286,200 @@ raw = {**raw, "data": {**data, "root": env_root}} return ExperimentConfig.model_validate(raw) + + +# --------------------------------------------------------------------------- +# セグメンテーションの評価(tias-seg eval) +# --------------------------------------------------------------------------- + +# セグメンテーションのデータ・モデルの場所を上書きする環境変数.tias-remote run が渡す +# TIAS_DATA_ROOT は判定モデルのデータ(~/data/TIASJudgeModel)を指すため,別の名前にする. +SEG_DATA_ROOT_ENV_VAR = "TIAS_SEG_DATA_ROOT" +SEG_MODEL_ROOT_ENV_VAR = "TIAS_SEG_MODEL_ROOT" + + +class SegEvalSetConfig(_StrictModel): + """評価セット 1 つ(`//sample/*` と `//label/*` の組). + + Attributes: + name: 評価セットの名前(metrics.json のキー・図のファイル名に使う.英数字・`_`・`-`). + dir: `data.root` からの相対パス. + kind: `cropped`(舌の周りで切り出し済み.ResUNet の学習時と同じ条件で画像全体に掛ける) + または `fullsize`(撮影画像そのもの.検出の扱いを比べる). + """ + + name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + dir: Path + kind: Literal["cropped", "fullsize"] + + @field_validator("dir", mode="after") + @classmethod + def _check_relative(cls, value: Path) -> Path: + if value.is_absolute() or ".." in value.parts: + raise ValueError("data.sets[].dir は data.root からの相対パス(.. を含まない)") + return value + + +class SegDataConfig(_StrictModel): + """セグメンテーションの評価データの設定. + + Attributes: + root: データのルート(`~` を展開する.相対パスはカレントディレクトリ基準). + sample_dirname: 評価セット内の入力画像のディレクトリ名. + label_dirname: 評価セット内の正解マスクのディレクトリ名(入力画像と同じ stem で対応). + label_threshold: 正解マスク(グレースケールで読む.JPEG のため中間値を含む)を前景と + みなす下限の画素値(この値以上が前景). + sets: 評価セット(記載順に評価する). + """ + + root: Path + sample_dirname: str = Field(default="sample", min_length=1) + label_dirname: str = Field(default="label", min_length=1) + label_threshold: int = Field(default=128, ge=1, le=255) + sets: tuple[SegEvalSetConfig, ...] + + @field_validator("root", mode="after") + @classmethod + def _expand_user(cls, value: Path) -> Path: + return value.expanduser() + + @field_validator("sets", mode="after") + @classmethod + def _check_sets(cls, value: tuple[SegEvalSetConfig, ...]) -> tuple[SegEvalSetConfig, ...]: + if not value: + raise ValueError("data.sets が空である(評価セットを 1 つ以上)") + names = [s.name for s in value] + if len(set(names)) != len(names): + raise ValueError("data.sets の name に重複がある") + return value + + +class SegModelsConfig(_StrictModel): + """ONNX モデルの場所. + + Attributes: + root: モデルを置いたディレクトリ(`~` を展開する.相対パスはカレントディレクトリ基準). + resunet: ResUNet の ONNX(`root` からの相対パス). + detection: 検出モデルの ONNX(`root` からの相対パス.`scripts/convert_detection_to_onnx.py` + で変換したもの).null なら検出モデルを使わない.ファイルが無い場合も警告して省く. + """ + + root: Path + resunet: Path = Path("ResUNet.onnx") + detection: Path | None = None + + @field_validator("root", mode="after") + @classmethod + def _expand_user(cls, value: Path) -> Path: + return value.expanduser() + + @property + def resunet_path(self) -> Path: + """ResUNet の ONNX の解決済みパス.""" + return self.resunet if self.resunet.is_absolute() else self.root / self.resunet + + @property + def detection_path(self) -> Path | None: + """検出モデルの ONNX の解決済みパス(使わないなら None).""" + if self.detection is None: + return None + return self.detection if self.detection.is_absolute() else self.root / self.detection + + +class ResUNetConfig(_StrictModel): + """ResUNet の入出力の設定(DeepTIAS 2.7 と同じ値が既定). + + Attributes: + input_size: 入力の一辺(切り出した画像をこのサイズに線形補間で縮小・拡大する). + foreground_channel: 出力 `(1, 2, S, S)` のうち前景として使うチャンネル(DeepTIAS は 0). + """ + + input_size: int = Field(default=256, ge=16) + foreground_channel: Literal[0, 1] = 0 + + +class SegPostprocessConfig(_StrictModel): + """ResUNet の出力の後処理の設定. + + Attributes: + min_area: 小領域除去で取り除く領域の面積の上限(未満を除く.前景・背景とも. + ResUNet の出力サイズ(input_size × input_size)での画素数). + mask_threshold: 枠のサイズに線形補間で戻したマスク(0〜255)を前景とみなす下限を超える値 + (この値より大きい画素を前景とする). + """ + + min_area: int = Field(default=500, ge=0) + mask_threshold: int = Field(default=127, ge=0, le=254) + + +class SegDetectionConfig(_StrictModel): + """検出モデル(TF1 物体検出を ONNX に変換したもの)の設定. + + Attributes: + score_threshold: 採用するスコアの下限(これを超える中で最高の 1 つを採用する). + input_color: 検出モデルへ渡す画像の色の順(`rgb`・`bgr`). + input_name: 入力名(`:0` の有無は問わない). + boxes_name: 枠(正規化 `[ymin, xmin, ymax, xmax]`)の出力名. + scores_name: スコアの出力名. + """ + + score_threshold: float = Field(default=0.5, ge=0.0, lt=1.0) + input_color: Literal["rgb", "bgr"] = "rgb" + input_name: str = "image_tensor" + boxes_name: str = "detection_boxes" + scores_name: str = "detection_scores" + + +class SegEvaluationConfig(_StrictModel): + """評価の設定. + + Attributes: + iou_threshold: IoU がこれ未満の件数を数える. + """ + + iou_threshold: float = Field(default=0.8, gt=0.0, le=1.0) + + +class SegEvalConfig(_StrictModel): + """セグメンテーションの評価(`tias-seg eval`)の設定のルート.""" + + data: SegDataConfig + models: SegModelsConfig + resunet: ResUNetConfig = Field(default_factory=ResUNetConfig) + postprocess: SegPostprocessConfig = Field(default_factory=SegPostprocessConfig) + detection: SegDetectionConfig = Field(default_factory=SegDetectionConfig) + evaluation: SegEvaluationConfig = Field(default_factory=SegEvaluationConfig) + + +def load_seg_eval_config(path: Path, environ: Mapping[str, str] | None = None) -> SegEvalConfig: + """セグメンテーションの評価の設定 YAML を読み込む. + + 環境変数 `TIAS_SEG_DATA_ROOT`・`TIAS_SEG_MODEL_ROOT` が空でない値で設定されていれば, + それぞれ `data.root`・`models.root` より優先する. + + Args: + path: 設定 YAML のパス. + environ: 参照する環境変数.None なら `os.environ` を使う. + + Returns: + 検証済みの設定. + + Raises: + ValueError: YAML のトップレベル・`data`・`models` がマッピングでない場合. + pydantic.ValidationError: 設定値が不正な場合. + """ + env = os.environ if environ is None else environ + with path.open(encoding="utf-8") as f: + raw: Any = yaml.safe_load(f) + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise ValueError(f"設定 YAML のトップレベルはマッピングでなければならない: {path}") + for section, var in (("data", SEG_DATA_ROOT_ENV_VAR), ("models", SEG_MODEL_ROOT_ENV_VAR)): + value = env.get(var, "") + if value: + node = raw.get(section) or {} + if not isinstance(node, dict): + raise ValueError(f"設定 YAML の {section} はマッピングでなければならない: {path}") + raw = {**raw, section: {**node, "root": value}} + return SegEvalConfig.model_validate(raw) diff --git a/src/tias_judge_model/segmentation/__init__.py b/src/tias_judge_model/segmentation/__init__.py new file mode 100644 index 0000000..6fc02e8 --- /dev/null +++ b/src/tias_judge_model/segmentation/__init__.py @@ -0,0 +1 @@ +"""舌のセグメンテーション(DeepTIAS 2.7 の手順の Python 版.onnxruntime・OpenCV).""" diff --git a/src/tias_judge_model/segmentation/detection.py b/src/tias_judge_model/segmentation/detection.py new file mode 100644 index 0000000..df6e424 --- /dev/null +++ b/src/tias_judge_model/segmentation/detection.py @@ -0,0 +1,197 @@ +"""舌の検出(DeepTIAS 2.7 の `Detection_Normal.pb` を ONNX に変換したもの)と枠の扱い. + +DeepTIAS 2.7 は TF1 の物体検出 API の frozen graph を使う.入力 `image_tensor`(`(1, H, W, 3)` +の uint8 画像),出力 `detection_boxes`(`(1, N, 4)`.正規化 `[ymin, xmin, ymax, xmax]`)と +`detection_scores`(`(1, N)`).スコアが 0.5 を超える中で最高の 1 つを採用し,その枠で切り出す. +ONNX への変換は `scripts/convert_detection_to_onnx.py`(tf2onnx は出力名に `:0` を付けるため, +名前は `:0` の有無を問わずに探す). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +import onnxruntime as ort + + +@dataclass(frozen=True) +class Rect: + """画像上の矩形(画素.`x1`・`y1` は含まない). + + Attributes: + x0: 左端. + y0: 上端. + x1: 右端 + 1. + y1: 下端 + 1. + """ + + x0: int + y0: int + x1: int + y1: int + + @property + def width(self) -> int: + """幅.""" + return self.x1 - self.x0 + + @property + def height(self) -> int: + """高さ.""" + return self.y1 - self.y0 + + +def full_rect(height: int, width: int) -> Rect: + """画像全体の矩形を返す. + + Args: + height: 画像の高さ. + width: 画像の幅. + + Returns: + 画像全体の矩形. + """ + return Rect(0, 0, width, height) + + +def mask_bounding_rect(mask: np.ndarray) -> Rect | None: + """マスクの前景の外接矩形を返す. + + Args: + mask: `(H, W)` の bool. + + Returns: + 外接矩形.前景が無ければ None. + """ + ys, xs = np.nonzero(mask) + if ys.size == 0: + return None + return Rect(int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) + + +def normalized_box_to_rect(box: Sequence[float], height: int, width: int) -> Rect | None: + """正規化 `[ymin, xmin, ymax, xmax]` の枠を画素の矩形にする(画像の内側に収める). + + 画素への変換は切り捨て(C# の `(int)` のキャストと同じ). + + Args: + box: 正規化座標の枠(値域 0〜1). + height: 画像の高さ. + width: 画像の幅. + + Returns: + 矩形.幅または高さが 0 になる場合は None. + """ + ymin, xmin, ymax, xmax = (float(v) for v in box) + x0 = min(max(int(xmin * width), 0), width) + y0 = min(max(int(ymin * height), 0), height) + x1 = min(max(int(xmax * width), 0), width) + y1 = min(max(int(ymax * height), 0), height) + if x1 <= x0 or y1 <= y0: + return None + return Rect(x0, y0, x1, y1) + + +def select_best_box( + boxes: np.ndarray, scores: np.ndarray, threshold: float +) -> tuple[np.ndarray, float] | None: + """スコアが `threshold` を超える中で最高の枠を選ぶ. + + Args: + boxes: `(N, 4)` の float(正規化 `[ymin, xmin, ymax, xmax]`). + scores: `(N,)` の float. + threshold: スコアの下限(これを超えるもののみ). + + Returns: + (枠 `(4,)`, スコア).該当が無ければ None. + """ + scores = np.asarray(scores, dtype=np.float64).reshape(-1) + boxes = np.asarray(boxes, dtype=np.float64).reshape(-1, 4) + if scores.size == 0: + return None + best = int(np.argmax(scores)) + if not scores[best] > threshold: + return None + return boxes[best], float(scores[best]) + + +def _resolve_name(available: Sequence[str], wanted: str, what: str) -> str: + for name in (wanted, f"{wanted}:0"): + if name in available: + return name + raise ValueError(f"検出モデルに{what} {wanted!r} が無い(あるもの: {', '.join(available)})") + + +@dataclass(frozen=True) +class Detection: + """検出の結果. + + Attributes: + rect: 採用した枠(画素).検出できなければ None. + score: 最高のスコア(検出できなくても記録する.出力が空なら None). + """ + + rect: Rect | None + score: float | None + + +class OnnxDetector: + """検出モデルの ONNX を onnxruntime(CPU)で推論する薄いラッパ. + + Args: + path: 検出モデルの ONNX のパス. + score_threshold: 採用するスコアの下限. + input_color: モデルへ渡す画像の色の順(`rgb`・`bgr`). + input_name: 入力名(`:0` の有無は問わない). + boxes_name: 枠の出力名. + scores_name: スコアの出力名. + + Raises: + ValueError: ファイルが無い,または入出力名が見つからない場合. + """ + + def __init__( + self, + path: Path, + *, + score_threshold: float, + input_color: str, + input_name: str, + boxes_name: str, + scores_name: str, + ) -> None: + if not path.is_file(): + raise ValueError(f"検出モデルの ONNX が無い: {path}") + self._session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"]) + inputs = [i.name for i in self._session.get_inputs()] + outputs = [o.name for o in self._session.get_outputs()] + self._input = _resolve_name(inputs, input_name, "入力") + self._boxes = _resolve_name(outputs, boxes_name, "出力") + self._scores = _resolve_name(outputs, scores_name, "出力") + self._threshold = score_threshold + self._rgb = input_color == "rgb" + + def detect(self, bgr: np.ndarray) -> Detection: + """撮影画像から舌の枠を 1 つ検出する. + + Args: + bgr: BGR 画像(`(H, W, 3)`, uint8.OpenCV で読んだまま). + + Returns: + 検出の結果. + """ + image = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) if self._rgb else bgr + boxes, scores = self._session.run( + [self._boxes, self._scores], {self._input: np.ascontiguousarray(image)[None]} + ) + scores = np.asarray(scores).reshape(-1) + top = float(scores.max()) if scores.size else None + picked = select_best_box(boxes, scores, self._threshold) + if picked is None: + return Detection(rect=None, score=top) + height, width = bgr.shape[:2] + return Detection(rect=normalized_box_to_rect(picked[0], height, width), score=picked[1]) diff --git a/src/tias_judge_model/segmentation/images.py b/src/tias_judge_model/segmentation/images.py new file mode 100644 index 0000000..450dc55 --- /dev/null +++ b/src/tias_judge_model/segmentation/images.py @@ -0,0 +1,120 @@ +"""セグメンテーションの評価データ(`/sample/*` と `/label/*`)の列挙と読み込み. + +入力画像と正解マスクはファイル名の stem で対応づける.OpenCV の `imread` は Windows で日本語を +含むパスを読めないため,`np.fromfile` → `cv2.imdecode` で読む.例外メッセージにはファイル名を +出さず,評価セット内の何件目か(stem の昇順で 1 始まり)と件数で示す. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + +# 評価データとして読む画像の拡張子(小文字で比べる). +IMAGE_SUFFIXES: frozenset[str] = frozenset({".jpg", ".jpeg", ".png", ".bmp"}) + + +@dataclass(frozen=True) +class ImagePair: + """入力画像と正解マスクの組. + + Attributes: + stem: ファイル名の stem(サーバ上の個票にだけ書く.ログ・例外には出さない). + sample: 入力画像のパス. + label: 正解マスクのパス. + """ + + stem: str + sample: Path + label: Path + + +def _images_by_stem(directory: Path, what: str) -> dict[str, Path]: + if not directory.is_dir(): + raise ValueError(f"{what}のディレクトリが無い: {directory}") + by_stem: dict[str, Path] = {} + duplicates = 0 + for path in directory.iterdir(): + if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES: + if path.stem in by_stem: + duplicates += 1 + by_stem[path.stem] = path + if duplicates: + raise ValueError(f"{what}に stem が同じ画像が {duplicates} 件ある(拡張子違いの重複)") + return by_stem + + +def list_image_pairs(set_dir: Path, sample_dirname: str, label_dirname: str) -> list[ImagePair]: + """評価セットの入力画像と正解マスクの組を stem の昇順で列挙する. + + Args: + set_dir: 評価セットのディレクトリ. + sample_dirname: 入力画像のディレクトリ名. + label_dirname: 正解マスクのディレクトリ名. + + Returns: + 組の一覧(stem の昇順). + + Raises: + ValueError: ディレクトリが無い,画像が 0 枚,stem の重複,または対応の取れない画像が + ある場合(件数のみを示す). + """ + samples = _images_by_stem(set_dir / sample_dirname, "入力画像") + labels = _images_by_stem(set_dir / label_dirname, "正解マスク") + only_sample = len(samples.keys() - labels.keys()) + only_label = len(labels.keys() - samples.keys()) + if only_sample or only_label: + raise ValueError( + f"入力画像と正解マスクの対応が取れない(正解マスクの無い入力画像 {only_sample} 件・" + f"入力画像の無い正解マスク {only_label} 件)" + ) + if not samples: + raise ValueError(f"評価セットに画像が 0 枚である: {set_dir}") + return [ImagePair(stem, samples[stem], labels[stem]) for stem in sorted(samples)] + + +def _decode(path: Path, flags: int, where: str) -> np.ndarray: + try: + data = np.fromfile(path, dtype=np.uint8) + except OSError as exc: + raise ValueError(f"{where}: 画像を読めない({type(exc).__name__})") from None + image = cv2.imdecode(data, flags) + if image is None: + raise ValueError(f"{where}: 画像を復号できない") + return image + + +def read_bgr(path: Path, where: str) -> np.ndarray: + """カラー画像を OpenCV の BGR で読む. + + Args: + path: 画像のパス. + where: 例外メッセージに使う位置の表示(ファイル名を含めない). + + Returns: + `(H, W, 3)` の uint8(BGR). + + Raises: + ValueError: 読めない場合. + """ + return _decode(path, cv2.IMREAD_COLOR, where) + + +def read_label_mask(path: Path, threshold: int, where: str) -> np.ndarray: + """正解マスクをグレースケールで読み,`threshold` 以上を前景とする. + + Args: + path: 正解マスクのパス. + threshold: 前景とみなす下限の画素値. + where: 例外メッセージに使う位置の表示(ファイル名を含めない). + + Returns: + `(H, W)` の bool. + + Raises: + ValueError: 読めない場合. + """ + return _decode(path, cv2.IMREAD_GRAYSCALE, where) >= threshold diff --git a/src/tias_judge_model/segmentation/pipeline.py b/src/tias_judge_model/segmentation/pipeline.py new file mode 100644 index 0000000..cc89ac3 --- /dev/null +++ b/src/tias_judge_model/segmentation/pipeline.py @@ -0,0 +1,100 @@ +"""画像 1 枚のセグメンテーション(枠で切り出す → ResUNet → 元の画像の位置へ戻す). + +枠の決め方は呼び出し側が選ぶ(`detection.py`): + +- 検出なし: 画像全体(`full_rect`)を ResUNet に掛ける.切り出し済み画像はこれ +- 正解マスクの外接矩形(`mask_bounding_rect`): 検出が完璧な場合の上限を見るため +- 検出モデル(`OnnxDetector`): DeepTIAS 2.7 と同じ +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from tias_judge_model.segmentation.detection import Rect +from tias_judge_model.segmentation.resunet import CropMasks, ResUNetRunner, output_to_masks + + +@dataclass(frozen=True) +class SegmentationParams: + """後処理の設定(`SegEvalConfig` の該当部分). + + Attributes: + foreground_channel: 前景として使う ResUNet の出力チャンネル. + min_area: 小領域除去の面積の上限. + mask_threshold: 枠のサイズに戻したマスクの 2 値化のしきい値. + """ + + foreground_channel: int + min_area: int + mask_threshold: int + + +def place_masks(crop: CropMasks, rect: Rect, height: int, width: int) -> CropMasks: + """枠のサイズのマスクを元の画像サイズのマスクに配置する(枠の外は背景). + + Args: + crop: 枠のサイズのマスク. + rect: 枠. + height: 元の画像の高さ. + width: 元の画像の幅. + + Returns: + `(height, width)` の bool のマスク. + """ + + def place(mask: np.ndarray) -> np.ndarray: + out = np.zeros((height, width), dtype=bool) + out[rect.y0 : rect.y1, rect.x0 : rect.x1] = mask + return out + + return CropMasks( + post=place(crop.post), + no_post=place(crop.no_post), + other_channel=place(crop.other_channel), + postprocess_applied=crop.postprocess_applied, + ) + + +def empty_masks(height: int, width: int) -> CropMasks: + """前景の無いマスク(検出できなかった場合)を返す. + + Args: + height: 画像の高さ. + width: 画像の幅. + + Returns: + すべて背景のマスク. + """ + empty = np.zeros((height, width), dtype=bool) + return CropMasks(post=empty, no_post=empty, other_channel=empty, postprocess_applied=False) + + +def segment_in_rect( + bgr: np.ndarray, rect: Rect, runner: ResUNetRunner, params: SegmentationParams +) -> CropMasks: + """枠で切り出して ResUNet に掛け,元の画像サイズのマスクを返す. + + Args: + bgr: BGR 画像(`(H, W, 3)`, uint8). + rect: 切り出す枠(画像の内側.幅・高さは 1 以上). + runner: ResUNet の推論器. + params: 後処理の設定. + + Returns: + `(H, W)` の bool のマスク(後処理あり・なし・もう一方のチャンネル). + """ + height, width = bgr.shape[:2] + crop = bgr[rect.y0 : rect.y1, rect.x0 : rect.x1] + output = runner.run(crop) + masks = output_to_masks( + output, + rect.width, + rect.height, + foreground_channel=params.foreground_channel, + min_area=params.min_area, + mask_threshold=params.mask_threshold, + ) + return place_masks(masks, rect, height, width) diff --git a/src/tias_judge_model/segmentation/resunet.py b/src/tias_judge_model/segmentation/resunet.py new file mode 100644 index 0000000..02b2e09 --- /dev/null +++ b/src/tias_judge_model/segmentation/resunet.py @@ -0,0 +1,264 @@ +"""ResUNet の前処理・後処理(DeepTIAS 2.7 の手順の再現)と onnxruntime の薄いラッパ. + +DeepTIAS 2.7(C#)の逆コンパイルと ResUNet の学習コードで確認した手順: + +1. 切り出した画像(OpenCV で読んだ **BGR のまま**)を `cv2.resize` の既定(線形補間)で + 256×256 にし,/255 して CHW に並べる(学習時も `cv2.imread` の BGR を /255 している) +2. ResUNet(入力 `(1, 3, 256, 256)`・出力 `(1, 2, 256, 256)`)で推論する +3. 出力の**チャンネル 0** にシグモイドを掛けて ×255 し uint8 にする +4. 大津の方法で 2 値化する +5. 輪郭(Canny → findContours)が 0 個または 2 個を超えるときだけ小領域除去(前景・背景とも + 面積 `min_area` 未満の領域を除く.元は C++ の DLL なので連結成分で再実装した) +6. 切り出した枠のサイズに戻す + +前処理・後処理は純関数とし,推論だけを `ResUNetRunner` に閉じ込める. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +import onnxruntime as ort + +# Canny のしきい値.入力は 0/255 の 2 値画像なので,境界の勾配は常にこれを大きく超え,値に +# よって輪郭の数は変わらない(DeepTIAS の値は不明のため OpenCV の例でよく使う値にした). +_CANNY_LOW = 100 +_CANNY_HIGH = 200 +# 連結成分の近傍(前景・背景とも 8 近傍). +_CONNECTIVITY = 8 + + +def preprocess_resunet(bgr: np.ndarray, input_size: int) -> np.ndarray: + """切り出した画像を ResUNet の入力にする(BGR のまま /255・CHW). + + Args: + bgr: BGR 画像(`(H, W, 3)`, uint8.OpenCV で読んだまま). + input_size: 入力の一辺 S. + + Returns: + `(1, 3, S, S)` の float32(値域 0.0〜1.0.チャンネルは B・G・R の順). + + Raises: + ValueError: 形状・dtype が不正,または画像が空の場合. + """ + arr = np.asarray(bgr) + if arr.ndim != 3 or arr.shape[-1] != 3 or arr.dtype != np.uint8: + raise ValueError( + f"画像は (H, W, 3) の uint8 でなければならない({arr.shape}, {arr.dtype})" + ) + if arr.shape[0] == 0 or arr.shape[1] == 0: + raise ValueError("画像が空である(幅または高さが 0)") + resized = cv2.resize(arr, (input_size, input_size)) # 既定は線形補間(DeepTIAS と同じ) + return (resized.astype(np.float32) / 255.0).transpose(2, 0, 1)[None] + + +def probability_map_u8(output: np.ndarray, channel: int) -> np.ndarray: + """ResUNet の出力の 1 チャンネルにシグモイドを掛けて 0〜255 の uint8 にする. + + Args: + output: ResUNet の出力(`(1, 2, S, S)`, float.ロジット). + channel: 使うチャンネル(DeepTIAS は 0). + + Returns: + `(S, S)` の uint8(シグモイド × 255 の小数部を切り捨て). + """ + logits = np.asarray(output, dtype=np.float64)[0, channel] + # ロジットが約 -709 未満だと exp が inf に桁あふれするが,1 / (1 + inf) = 0 で値は正しい. + # DeepTIAS と同じ式のまま(切り捨ての結果を変えないため)警告だけを抑える. + with np.errstate(over="ignore"): + prob = 1.0 / (1.0 + np.exp(-logits)) + return (prob * 255.0).astype(np.uint8) + + +def binarize_otsu(prob_u8: np.ndarray) -> np.ndarray: + """大津の方法で 2 値化する. + + Args: + prob_u8: `(S, S)` の uint8. + + Returns: + `(S, S)` の uint8(前景 255・背景 0). + """ + _, binary = cv2.threshold(prob_u8, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) + return binary + + +def count_contours(binary: np.ndarray) -> int: + """2 値画像の輪郭の数を数える(Canny でエッジを取ってから外側の輪郭を数える). + + DeepTIAS の方式の再現であり,連結成分の数とは一致しない.Canny のエッジにならない数画素の + 小さな島は数えないことがあり,外側の輪郭だけを数えるため領域の内側の穴(とその中の島)も + 数えない.全面が前景・全面が背景ならエッジが無く 0 になる. + + Args: + binary: `(H, W)` の uint8(0/255). + + Returns: + 輪郭の数. + """ + edges = cv2.Canny(binary, _CANNY_LOW, _CANNY_HIGH) + contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + return len(contours) + + +def needs_small_region_removal(n_contours: int) -> bool: + """輪郭の数から小領域除去が必要かを返す(0 個または 2 個を超えるとき.DeepTIAS と同じ条件). + + Args: + n_contours: `count_contours` の戻り値. + + Returns: + 小領域除去をするなら True. + """ + return n_contours == 0 or n_contours > 2 + + +def remove_small_regions(binary: np.ndarray, min_area: int) -> np.ndarray: + """前景・背景とも面積 `min_area` 未満の連結成分を取り除く. + + 先に前景の小さな島を背景にし,次に背景の小さな穴を前景で埋める. + + Args: + binary: `(H, W)` の uint8(0/255). + min_area: 取り除く面積の上限(未満を除く.画素数). + + Returns: + `(H, W)` の uint8(0/255). + """ + out = np.where(binary > 0, 255, 0).astype(np.uint8) + for value, fill in ((255, 0), (0, 255)): + region = (out == value).astype(np.uint8) + n, labels, stats, _ = cv2.connectedComponentsWithStats(region, connectivity=_CONNECTIVITY) + small = [i for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] < min_area] + if small: + out[np.isin(labels, small)] = fill + return out + + +def resize_mask(mask: np.ndarray, width: int, height: int, threshold: int) -> np.ndarray: + """ResUNet の出力サイズのマスクを枠のサイズに線形補間で戻し,2 値にする. + + Args: + mask: `(S, S)` の uint8(0/255). + width: 枠の幅. + height: 枠の高さ. + threshold: この値より大きい画素を前景とする. + + Returns: + `(height, width)` の bool. + """ + resized = cv2.resize(mask, (width, height)) + return resized > threshold + + +@dataclass(frozen=True) +class CropMasks: + """切り出した画像 1 枚のセグメンテーションの結果(枠のサイズ). + + Attributes: + post: `(H, W)` の bool.前景チャンネル・後処理あり(DeepTIAS と同じ手順). + no_post: `(H, W)` の bool.前景チャンネル・後処理(小領域除去)なし. + other_channel: `(H, W)` の bool.もう一方のチャンネル・後処理あり + (出力チャンネルの取り違えに気づくため). + postprocess_applied: 前景チャンネルで小領域除去をしたか(輪郭が 0 個または 2 個超). + """ + + post: np.ndarray + no_post: np.ndarray + other_channel: np.ndarray + postprocess_applied: bool + + +def output_to_masks( + output: np.ndarray, + width: int, + height: int, + *, + foreground_channel: int, + min_area: int, + mask_threshold: int, +) -> CropMasks: + """ResUNet の出力から,後処理の有無・チャンネルの違いによるマスクを作る. + + Args: + output: ResUNet の出力(`(1, 2, S, S)`, float). + width: 切り出した枠の幅. + height: 切り出した枠の高さ. + foreground_channel: 前景として使うチャンネル. + min_area: 小領域除去の面積の上限. + mask_threshold: 枠のサイズに戻したマスクの 2 値化のしきい値. + + Returns: + マスク(枠のサイズ). + """ + + def binary_of(channel: int) -> tuple[np.ndarray, np.ndarray, bool]: + binary = binarize_otsu(probability_map_u8(output, channel)) + applied = needs_small_region_removal(count_contours(binary)) + post = remove_small_regions(binary, min_area) if applied else binary + return binary, post, applied + + raw, post, applied = binary_of(foreground_channel) + _, other_post, _ = binary_of(1 - foreground_channel) + return CropMasks( + post=resize_mask(post, width, height, mask_threshold), + no_post=resize_mask(raw, width, height, mask_threshold), + other_channel=resize_mask(other_post, width, height, mask_threshold), + postprocess_applied=applied, + ) + + +class ResUNetRunner: + """ResUNet の ONNX を onnxruntime(CPU)で推論する薄いラッパ. + + Args: + path: ResUNet の ONNX のパス. + input_size: 入力の一辺 S(モデルの入力形状と照合する). + + Raises: + ValueError: ファイルが無い,入力・出力の数や形状が想定(`(1, 3, S, S)` → `(1, 2, S, S)`) + と違う場合. + """ + + def __init__(self, path: Path, input_size: int) -> None: + if not path.is_file(): + raise ValueError(f"ResUNet の ONNX が無い: {path}") + self._session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"]) + inputs = self._session.get_inputs() + outputs = self._session.get_outputs() + if len(inputs) != 1 or len(outputs) < 1: + raise ValueError( + f"ResUNet の入力は 1 つ・出力は 1 つ以上のはずである(入力 {len(inputs)}・" + f"出力 {len(outputs)})" + ) + shape = inputs[0].shape + static = [d for d in shape[2:] if isinstance(d, int)] + if len(shape) != 4 or any(d != input_size for d in static): + raise ValueError( + f"ResUNet の入力形状 {shape} が設定の input_size {input_size} と合わない" + ) + self._input_name = inputs[0].name + self._output_name = outputs[0].name + self.input_size = input_size + + def run(self, bgr: np.ndarray) -> np.ndarray: + """切り出した画像 1 枚を推論する. + + Args: + bgr: BGR 画像(`(H, W, 3)`, uint8). + + Returns: + `(1, 2, S, S)` の float32 の出力(ロジット). + + Raises: + ValueError: 画像の形状・dtype が不正,または出力の形状が想定と違う場合. + """ + x = preprocess_resunet(bgr, self.input_size) + (output,) = self._session.run([self._output_name], {self._input_name: x}) + output = np.asarray(output) + if output.ndim != 4 or output.shape[:2] != (1, 2): + raise ValueError(f"ResUNet の出力形状 {output.shape} が (1, 2, S, S) でない") + return output diff --git a/src/tias_judge_model/training/color_export.py b/src/tias_judge_model/training/color_export.py new file mode 100644 index 0000000..081c002 --- /dev/null +++ b/src/tias_judge_model/training/color_export.py @@ -0,0 +1,414 @@ +"""色特徴+ロジスティック回帰の最終モデルの学習と書き出し(`tias-train color-export`). + +交差検証(`color_cross_validation.py`)で性能を確かめた方式を,全データ(`data.severities` +適用後)で学習し直して TIASjudge で使う判定モデルにする. + +1. 正則化の強さ C を選ぶ: 全データで交差検証と同じ患者単位 K-fold(`make_kfold_splits`. + `split.seed`)を作り,各 fold で学習側(`data.severities` 適用後)で候補ごとに学習して + held_out(`data.severities` 適用後)の macro F1 を求める.fold 平均が最良の候補を採用する + (厳密な改善でだけ更新し,同点なら先の候補) +2. 採用した C で全データ(`data.severities` 適用後)を学習する +3. 標準化の平均・尺度,係数・切片,特徴量の定義,クラス,学習条件を JSON に書ける dict にする + (`build_color_model_document`.NumPy だけの参照実装 `deploy/color_model.py` が読む) + +書き出す内容に患者 ID・ファイル名・画像単位の値は含めない(標準化の平均・尺度は全画像の集計値). +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +import sklearn +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + +from tias_judge_model.data.class_balance import count_classes +from tias_judge_model.data.classes import CLASS_DISPLAY_NAMES, CLASS_SLUGS, NUM_CLASSES, label_to_id +from tias_judge_model.data.color import D65_WHITE, SRGB_TO_XYZ +from tias_judge_model.data.color_features import ( + A_RANGE, + AB_HIST_BINS, + B_RANGE, + CHANNEL_STATS, + COLOR_CHANNELS, + L_HIST_BINS, + L_RANGE, + NUM_COLOR_FEATURES, + PERCENTILES, + color_feature_names, +) +from tias_judge_model.data.manifest import ManifestRecord +from tias_judge_model.data.split import filter_by_severity, make_kfold_splits, split_records +from tias_judge_model.evaluation.metrics import macro_f1 +from tias_judge_model.schema import ColorClassifierConfig, ExperimentConfig +from tias_judge_model.training.color_cross_validation import ( + candidate_params, + check_color_config, + extract_features_for_records, + fit_classifier, + make_classifier, + predict_proba_all_classes, +) +from tias_judge_model.training.cross_validation import fold_seed, make_class_balance + +logger = logging.getLogger(__name__) + +# color_model.json の書式の版(deploy/color_model.py の SUPPORTED_FORMAT_VERSIONS と対応させる). +COLOR_MODEL_FORMAT_VERSION = 1 +COLOR_MODEL_TYPE = "color_logistic" + +# 入力画像の前提(TIASjudge 側の実装者が読む説明.JSON にそのまま書く). +INPUT_SPEC: dict[str, Any] = { + "color_order": "RGB", + "dtype": "uint8", + "shape": "(H, W, 3)", + "background_rgb": [0, 0, 0], + "background_rule": ( + "RGB がすべて 0 の画素を背景(舌領域の外)とし,それ以外の画素を舌領域とする" + "(舌領域の画素に (0, 0, 0) を含めない)" + ), + "color_correction": "none", + "source": ( + "TIAS の機材で撮影した色補正なしの画像を,舌のセグメンテーションで切り出したもの" + "(DeepTIAS 2.7 の 2-segmentation/5-extraction と同じ形).色補正後の画像や他の機材の" + "画像には使えない" + ), +} + + +@dataclass +class CSelection: + """C の選択の記録. + + Attributes: + candidates: 候補ごとの結果(候補の順).各要素は `{"C", "fold_macro_f1", "mean_macro_f1"}`. + selected_index: 採用した候補の位置. + fold_counts: fold ごとの学習・held_out の画像数(`data.severities` 適用後). + """ + + candidates: list[dict[str, Any]] + selected_index: int + fold_counts: list[dict[str, int]] + + @property + def selected_c(self) -> float: + """採用した C.""" + return float(self.candidates[self.selected_index]["C"]) + + +@dataclass +class ColorExportResult: + """最終モデルの学習の結果. + + Attributes: + model: 全データで学習した StandardScaler → LogisticRegression のパイプライン. + selection: C の選択の記録. + class_counts: 学習に使ったクラス別枚数(クラス ID 順). + class_weights: 学習に使ったクラス重み(クラス ID 順.`class_weighting` が `none` なら + None). + seed: 最終モデルの学習の seed(`split.seed`). + timing: 所要秒数(`feature_extraction_seconds`・`selection_seconds`・`final_fit_seconds`). + """ + + model: Pipeline + selection: CSelection + class_counts: tuple[int, ...] + class_weights: tuple[float, ...] | None + seed: int + timing: dict[str, float] = field(default_factory=dict) + + +def check_color_export_config(config: ExperimentConfig) -> ColorClassifierConfig: + """設定が最終モデルの書き出しに使えるか確かめ,`color` 節を返す. + + Args: + config: 実験設定. + + Returns: + `color` 節. + + Raises: + ValueError: `color` 節が無い,`train.sampler` が `random` でない,または + `color.classifier` が `logistic` でない場合(書き出しは当面ロジスティック回帰のみ). + """ + color = check_color_config(config) + if color.classifier != "logistic": + raise ValueError( + f"color-export は color.classifier: {color.classifier} に対応していない" + "(当面 logistic のみ)" + ) + return color + + +def _sample_weight(labels: np.ndarray, config: ExperimentConfig) -> np.ndarray | None: + _, class_weights, _ = make_class_balance(labels, config.train) + return None if class_weights is None else class_weights[labels] + + +def select_c_by_cv( + config: ExperimentConfig, + records: Sequence[ManifestRecord], + features: np.ndarray, + labels: np.ndarray, +) -> CSelection: + """全データの患者単位 K-fold で候補の C ごとの fold 平均 macro F1 を求め,最良の C を選ぶ. + + 分割は交差検証(`tias-train color-cv`)と同じ `make_kfold_splits`(manifest 全体・ + `split.seed`)で作り,`data.severities` を学習側と held_out の両方に適用する(最終モデルの + 学習データと同じ分布で比べるため).内側の検証は取らない. + + Args: + config: 実験設定(`color` 節が `logistic`). + records: manifest の全レコード(除外前). + features: `(N, F)` の float64.`records` の順の特徴量. + labels: `(N,)` の int64.`records` の順のクラス ID. + + Returns: + C の選択の記録. + + Raises: + ValueError: 分割できない,または fold の学習用・held_out が 0 件になる場合. + """ + color = check_color_export_config(config) + candidates = candidate_params(color) + position = {r.image_path: i for i, r in enumerate(records)} + folds = make_kfold_splits(records, config.split.n_folds, config.split.seed) + + scores = np.zeros((len(candidates), len(folds)), dtype=np.float64) + fold_counts: list[dict[str, int]] = [] + for k, fold in enumerate(folds): + parts = split_records(records, fold, config.data.severities) + train_idx = np.array([position[r.image_path] for r in parts["train"]], dtype=np.int64) + held_idx = np.array([position[r.image_path] for r in parts["held_out"]], dtype=np.int64) + if train_idx.size == 0 or held_idx.size == 0: + raise ValueError( + f"C の選択の fold{fold.index}: 学習用 {train_idx.size} 件・held_out " + f"{held_idx.size} 件のいずれかが 0 件である" + ) + fold_counts.append({"train": int(train_idx.size), "held_out": int(held_idx.size)}) + train_labels = labels[train_idx] + weight = _sample_weight(train_labels, config) + seed = fold_seed(config.split.seed, fold.index) + for c_index, params in enumerate(candidates): + model = make_classifier(color, params, seed) + fit_classifier(model, features[train_idx], train_labels, weight) + pred = predict_proba_all_classes(model, features[held_idx]).argmax(axis=1) + scores[c_index, k] = macro_f1(labels[held_idx], pred) + logger.info( + "C の選択 fold%d(%d/%d): 学習 %d 件・held_out %d 件・macro F1 %s", + fold.index, + k + 1, + len(folds), + train_idx.size, + held_idx.size, + {p["C"]: round(float(s), 4) for p, s in zip(candidates, scores[:, k], strict=True)}, + ) + + entries: list[dict[str, Any]] = [] + best_index = 0 + for c_index, params in enumerate(candidates): + mean = float(scores[c_index].mean()) + entries.append( + { + "C": float(params["C"]), + "fold_macro_f1": [float(s) for s in scores[c_index]], + "mean_macro_f1": mean, + } + ) + # 厳密な改善でだけ更新し,同点なら先の候補を残す(color-cv の候補選びと同じ規則). + if mean > entries[best_index]["mean_macro_f1"]: + best_index = c_index + for entry in entries: + logger.info("C=%s: fold 平均 macro F1 %.4f", entry["C"], entry["mean_macro_f1"]) + logger.info("採用した C: %s", entries[best_index]["C"]) + return CSelection(candidates=entries, selected_index=best_index, fold_counts=fold_counts) + + +def train_final_color_model( + config: ExperimentConfig, records: Sequence[ManifestRecord] +) -> ColorExportResult: + """C を選び,全データ(`data.severities` 適用後)で最終モデルを学習する. + + Args: + config: 実験設定(`color` 節が `logistic`). + records: manifest の全レコード(検証済み.除外前). + + Returns: + 最終モデルの学習の結果. + + Raises: + ValueError: 設定が書き出しに対応していない,画像を読めない,舌領域の無い画像がある, + 学習データに 1 枚も無いクラスがある(5 クラスの係数が作れない)等の入力の問題. + """ + color = check_color_export_config(config) + records = list(records) + timing: dict[str, float] = {} + logger.info( + "最終モデルの学習を開始する: 画像 %d 件(除外前)・程度 %s・class_weighting=%s・" + "C の候補 %s・長辺の上限 %s", + len(records), + list(config.data.severities), + config.train.class_weighting, + list(color.logistic.c_values), + color.max_side, + ) + + started = time.monotonic() + features = extract_features_for_records(records, config.data.images_dir, color.max_side) + timing["feature_extraction_seconds"] = time.monotonic() - started + labels = np.array([label_to_id(r.label) for r in records], dtype=np.int64) + + started = time.monotonic() + selection = select_c_by_cv(config, records, features, labels) + timing["selection_seconds"] = time.monotonic() - started + + position = {r.image_path: i for i, r in enumerate(records)} + train_records = filter_by_severity(records, config.data.severities) + train_idx = np.array([position[r.image_path] for r in train_records], dtype=np.int64) + train_labels = labels[train_idx] + counts = count_classes(train_labels) + missing = [CLASS_SLUGS[i] for i, n in enumerate(counts) if n == 0] + if missing: + raise ValueError( + "学習データに 1 枚も無いクラスがあるため 5 クラスのモデルを作れない: " + f"{', '.join(missing)}" + ) + balance, class_weights, _ = make_class_balance(train_labels, config.train) + weight = None if class_weights is None else class_weights[train_labels] + + started = time.monotonic() + seed = config.split.seed + model = make_classifier(color, {"C": selection.selected_c}, seed) + fit_classifier(model, features[train_idx], train_labels, weight) + timing["final_fit_seconds"] = time.monotonic() - started + logger.info( + "最終モデルを学習した: 画像 %d 件(クラス別 %s)・C=%s", + train_idx.size, + dict(zip(CLASS_SLUGS, balance.class_counts, strict=True)), + selection.selected_c, + ) + return ColorExportResult( + model=model, + selection=selection, + class_counts=balance.class_counts, + class_weights=balance.class_weights, + seed=seed, + timing=timing, + ) + + +def build_color_model_document( + config: ExperimentConfig, + result: ColorExportResult, + *, + run_id: str, + git_commit: str, +) -> dict[str, Any]: + """`color_model.json` の内容を作る(画像単位の値・患者 ID・ファイル名は含めない). + + Args: + config: 実験設定(`color` 節が `logistic`). + result: 最終モデルの学習の結果. + run_id: 実験 ID. + git_commit: git の短縮ハッシュ(または `nogit`). + + Returns: + JSON に書き出せる dict(`deploy/color_model.py` の `load_color_model` が読む). + + Raises: + RuntimeError: 学習済みモデルの形が想定と違う場合(5 クラスの係数・107 次元でない). + """ + color = check_color_export_config(config) + scaler = result.model.steps[0][1] + classifier = result.model.steps[-1][1] + if not isinstance(scaler, StandardScaler) or not isinstance(classifier, LogisticRegression): + raise RuntimeError("最終モデルが StandardScaler → LogisticRegression でない") + coef = np.asarray(classifier.coef_, dtype=np.float64) + intercept = np.asarray(classifier.intercept_, dtype=np.float64) + if coef.shape != (NUM_CLASSES, NUM_COLOR_FEATURES) or intercept.shape != (NUM_CLASSES,): + raise RuntimeError( + f"係数の形状が想定と違う(coef {coef.shape}・intercept {intercept.shape})" + ) + if list(classifier.classes_) != list(range(NUM_CLASSES)): + raise RuntimeError("分類器のクラスが 0〜4 の順でない") + + return { + "format_version": COLOR_MODEL_FORMAT_VERSION, + "model_type": COLOR_MODEL_TYPE, + "description": ( + "舌領域の色特徴(CIE L*a*b* 系の統計量・ヒストグラム 107 次元)+標準化+多クラスの" + "ロジスティック回帰による舌色 5 分類.予測確率は softmax(((x - mean) / scale) @ coef.T" + " + intercept)" + ), + "input": INPUT_SPEC, + "features": { + "num_features": NUM_COLOR_FEATURES, + "names": list(color_feature_names()), + "channels": list(COLOR_CHANNELS), + "channel_stats": list(CHANNEL_STATS), + "percentiles": list(PERCENTILES), + "percentile_method": "linear(numpy.percentile の既定)", + "std_ddof": 0, + "hue": "atan2(b*, a*) の度((-180, 180])", + "chroma": "sqrt(a*^2 + b*^2)", + "ab_hist": {"bins": AB_HIST_BINS, "a_range": list(A_RANGE), "b_range": list(B_RANGE)}, + "l_hist": {"bins": L_HIST_BINS, "range": list(L_RANGE)}, + "hist_normalization": "舌領域の画素数に対する割合.範囲外の画素は端のビンに入れる", + "max_side": color.max_side, + "downscale": ( + "長辺が max_side を超える画像は,出力サイズ round(辺 × max_side / 長辺) に " + "Pillow の NEAREST で縮小する(色を混ぜない)" + ), + "lab": { + "rgb_space": "sRGB(IEC 61966-2-1.ガンマ補正を外して線形化)", + "illuminant": "D65", + "srgb_to_xyz": SRGB_TO_XYZ.tolist(), + "white_point": D65_WHITE.tolist(), + }, + }, + "scaler": { + "mean": np.asarray(scaler.mean_, dtype=np.float64).tolist(), + "scale": np.asarray(scaler.scale_, dtype=np.float64).tolist(), + }, + "classifier": { + "type": "multinomial_logistic_regression", + "coef": coef.tolist(), + "intercept": intercept.tolist(), + }, + "classes": [ + {"id": i, "slug": slug, "display_name": CLASS_DISPLAY_NAMES[slug]} + for i, slug in enumerate(CLASS_SLUGS) + ], + "training": { + "severities": list(config.data.severities), + "class_weighting": config.train.class_weighting, + "class_weights": ( + None + if result.class_weights is None + else dict(zip(CLASS_SLUGS, result.class_weights, strict=True)) + ), + "C": result.selection.selected_c, + "c_candidates": [e["C"] for e in result.selection.candidates], + "c_selection": ( + f"全データの患者単位 {config.split.n_folds}-fold(split.seed " + f"{config.split.seed})の" + "held_out macro F1 の fold 平均が最良の C" + ), + "max_iter": color.logistic.max_iter, + "class_counts": dict(zip(CLASS_SLUGS, result.class_counts, strict=True)), + "num_images": int(sum(result.class_counts)), + "seed": result.seed, + }, + "provenance": { + "run_id": run_id, + "git_commit": git_commit, + "sklearn_version": sklearn.__version__, + "numpy_version": np.__version__, + }, + } diff --git a/tests/cli/test_seg.py b/tests/cli/test_seg.py new file mode 100644 index 0000000..c093943 --- /dev/null +++ b/tests/cli/test_seg.py @@ -0,0 +1,221 @@ +"""tias-seg eval の結合テスト. + +仕様(実装サマリーを仕様として扱う.出力の規則は SPEC_01「実験の出力」「回収ホワイトリスト」): + +- 出力: `metrics.json`・`log.txt`・`config.yaml`・`figures/seg_iou_hist_<評価セット>.png` + (回収対象)と `predictions.csv`(stem を含む.回収対象外) +- 最後に実験 ID を標準出力へ出す +- 回収対象・標準出力・ログにファイル名(stem)を出さない +- 環境変数 `TIAS_SEG_DATA_ROOT`・`TIAS_SEG_MODEL_ROOT` で `data.root`・`models.root` を上書きする +- 入力の問題(正解マスクに前景が無い等)は終了コード 1(ログに評価セット名と何件目かを出し, + ファイル名は出さない) +""" + +from __future__ import annotations + +import contextlib +import csv +import io +import json +import logging +import re +import shutil +from pathlib import Path + +import numpy as np +import pytest +import yaml +from PIL import Image + +import tias_judge_model.cli.seg as cli +from tias_judge_model.remote.whitelist import is_pull_allowed + +SECRET = "SECRETPT" +GIT = "abc1234" +PER_SET = 3 +NAME = "seg_eval_tiny" +SETS = ["train", "test_cropped", "test_extra_cropped", "test_fullsize"] +RECOVERABLE = {"metrics.json", "log.txt", "config.yaml"} | { + f"figures/seg_iou_hist_{s}.png" for s in SETS +} + + +def _invoke(argv: list[str]) -> tuple[int, str, str]: + root_logger = logging.getLogger() + old_level = root_logger.level + root_logger.setLevel(logging.INFO) + out, err = io.StringIO(), io.StringIO() + handler = logging.StreamHandler(err) + root_logger.addHandler(handler) + try: + with contextlib.redirect_stdout(out): + rc = cli.main(argv) + finally: + root_logger.removeHandler(handler) + root_logger.setLevel(old_level) + return rc, out.getvalue(), err.getvalue() + + +def _config_doc() -> dict: + # data.root・models.root は環境変数で上書きする(設定には存在しない場所を書く) + return { + "data": { + "root": "does/not/exist", + "sets": [ + {"name": "train", "dir": "train", "kind": "cropped"}, + {"name": "test_cropped", "dir": "test/cropped", "kind": "cropped"}, + {"name": "test_extra_cropped", "dir": "test_extra/cropped", "kind": "cropped"}, + {"name": "test_fullsize", "dir": "test/fullsize", "kind": "fullsize"}, + ], + }, + "models": {"root": "does/not/exist", "detection": "Detection_Normal.onnx"}, + } + + +def _rename_stems(root: Path) -> None: + n = 0 + for label_dir in sorted(root.rglob("label")): + sample_dir = label_dir.parent / "sample" + for label in sorted(label_dir.iterdir()): + sample = sample_dir / label.name + new = f"{SECRET}{n:04d}{label.suffix}" + label.rename(label_dir / new) + sample.rename(sample_dir / new) + n += 1 + + +@pytest.fixture(scope="module") +def run(tmp_path_factory: pytest.TempPathFactory, dummy_seg_models: dict, script_loader) -> dict: + tmp = tmp_path_factory.mktemp("seg_cli") + script_loader("make_synthetic_segmentation").generate(tmp / "syn", PER_SET, seed=1) + data_root = tmp / "syn" / "input" + _rename_stems(data_root) + config = tmp / f"{NAME}.yaml" + config.write_text(yaml.safe_dump(_config_doc()), encoding="utf-8") + out_root = tmp / "runs" + mp = pytest.MonkeyPatch() + mp.setenv("TIAS_SEG_DATA_ROOT", str(data_root)) + mp.setenv("TIAS_SEG_MODEL_ROOT", str(dummy_seg_models["root"])) + mp.setenv("TIAS_GIT_COMMIT", GIT) + try: + rc, stdout, stderr = _invoke(["eval", "--config", str(config), "--out-root", str(out_root)]) + finally: + mp.undo() + run_dirs = [p for p in out_root.iterdir() if p.is_dir()] if out_root.exists() else [] + return { + "tmp": tmp, + "data_root": data_root, + "models": dummy_seg_models, + "rc": rc, + "stdout": stdout, + "stderr": stderr, + "run_dirs": run_dirs, + "run_dir": run_dirs[0] if run_dirs else None, + } + + +def test_exit_code_and_single_run_dir(run: dict) -> None: + assert run["rc"] == 0, run["stderr"][-2000:] + assert len(run["run_dirs"]) == 1 + + +def test_run_id_is_last_stdout_line(run: dict) -> None: + run_id = run["run_dir"].name + assert re.fullmatch(rf"\d{{8}}-\d{{6}}_{NAME}_{GIT}", run_id) + lines = [ln for ln in run["stdout"].splitlines() if ln.strip()] + assert lines == [run_id] + + +def test_outputs_and_pull_whitelist(run: dict) -> None: + d = run["run_dir"] + files = {p.relative_to(d).as_posix() for p in d.rglob("*") if p.is_file()} + assert files == RECOVERABLE | {"predictions.csv"} + for name in RECOVERABLE: + assert is_pull_allowed(name, with_checkpoints=False), name + assert not is_pull_allowed("predictions.csv", with_checkpoints=True) + + +def test_no_file_names_in_recoverable_outputs_stdout_or_log(run: dict) -> None: + d = run["run_dir"] + for name in RECOVERABLE: + data = (d / name).read_bytes() + assert SECRET.encode() not in data, name + assert SECRET not in run["stdout"] + assert SECRET not in run["stderr"] + + +def test_predictions_csv_has_per_image_rows(run: dict) -> None: + with (run["run_dir"] / "predictions.csv").open(encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + # cropped 3 セット × 1 方式 + fullsize × 3 方式 + assert len(rows) == PER_SET * (3 + 3) + assert all(r["stem"].startswith(SECRET) for r in rows) + + +def test_metrics_structure(run: dict) -> None: + m = json.loads((run["run_dir"] / "metrics.json").read_text(encoding="utf-8")) + assert m["run_id"] == run["run_dir"].name + assert m["git_commit"] == GIT + assert m["detector"]["used"] is True + assert set(m["sets"]) == set(SETS) + for name in SETS: + s = m["sets"][name] + assert s["n_images"] == PER_SET + expected = ["whole", "gt_bbox", "detector"] if name == "test_fullsize" else ["whole"] + assert list(s["methods"]) == expected + for method in s["methods"].values(): + for key in ("iou", "dice", "iou_no_post", "dice_no_post", "iou_other_channel"): + summary = method[key] + assert summary["n"] == PER_SET + assert {"mean", "median", "p10", "p25", "min", "max", "n_below"} <= set(summary) + assert summary["below_threshold"] == 0.8 + det = m["sets"]["test_fullsize"]["methods"]["detector"]["detection"] + assert det["n_detected"] + det["n_not_detected"] == PER_SET + assert {"total_seconds", "loading_seconds"} <= set(m["timing"]) + + +def test_config_yaml_records_resolved_roots(run: dict) -> None: + doc = yaml.safe_load((run["run_dir"] / "config.yaml").read_text(encoding="utf-8")) + assert doc["run"]["command"] == "seg eval" + assert doc["run"]["git_commit"] == GIT + assert Path(doc["config"]["data"]["root"]) == run["data_root"] + + +def test_bad_label_fails_with_position_but_no_file_name(run: dict) -> None: + tmp = run["tmp"] + root = tmp / "bad" / "input" + shutil.copytree(run["data_root"] / "train", root / "train") + label = sorted((root / "train" / "label").iterdir())[0] + with Image.open(label) as img: + w, h = img.size + Image.fromarray(np.zeros((h, w), np.uint8)).save(label) + doc = _config_doc() + doc["data"]["root"] = root.as_posix() + doc["data"]["sets"] = [{"name": "train", "dir": "train", "kind": "cropped"}] + doc["models"]["root"] = run["models"]["root"].as_posix() + config = tmp / "seg_bad.yaml" + config.write_text(yaml.safe_dump(doc), encoding="utf-8") + out_root = tmp / "runs_bad" + mp = pytest.MonkeyPatch() + mp.delenv("TIAS_SEG_DATA_ROOT", raising=False) + mp.delenv("TIAS_SEG_MODEL_ROOT", raising=False) + try: + rc, stdout, stderr = _invoke(["eval", "--config", str(config), "--out-root", str(out_root)]) + finally: + mp.undo() + assert rc == 1 + assert stdout.strip() == "" + assert "1 件目" in stderr + assert SECRET not in stderr + (run_dir,) = [p for p in out_root.iterdir() if p.is_dir()] + log = (run_dir / "log.txt").read_text(encoding="utf-8") + assert "1 件目" in log + assert SECRET not in log + + +def test_invalid_config_returns_1(run: dict) -> None: + config = run["tmp"] / "seg_invalid.yaml" + config.write_text(yaml.safe_dump({"data": {"root": "x", "sets": []}}), encoding="utf-8") + rc, stdout, _ = _invoke(["eval", "--config", str(config), "--out-root", str(run["tmp"] / "r")]) + assert rc == 1 + assert stdout.strip() == "" diff --git a/tests/cli/test_train_color_export.py b/tests/cli/test_train_color_export.py new file mode 100644 index 0000000..5503dcd --- /dev/null +++ b/tests/cli/test_train_color_export.py @@ -0,0 +1,208 @@ +"""tias-train color-export の結合テスト. + +仕様(実装サマリーの設計を仕様として扱う.出力の規則は SPEC_01「実験の出力」 +「回収ホワイトリスト」): + +- 出力: `runs//color_model.json`・`metrics.json`(`c_selection` を含む)・`config.yaml`・ + `log.txt`.すべて回収対象で,患者 ID・ファイル名・画像単位の値を含まない +- 最後に実験 ID を標準出力へ出す +- `color_model.json` は NumPy だけの参照実装(`deploy/color_model.py`)で読め,予測確率が出る +- `color.classifier: hist_gb` は実験ディレクトリを作る前に終了コード 1 で止まる +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import logging +import re +import sys +from pathlib import Path +from types import ModuleType + +import numpy as np +import pytest +import yaml +from PIL import Image + +import tias_judge_model.cli.train as cli +from tias_judge_model.data.manifest import build_manifest_records, write_manifest +from tias_judge_model.deploy.color_model import load_color_model, predict_proba +from tias_judge_model.remote.whitelist import is_pull_allowed + +pytestmark = pytest.mark.slow + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py" +SECRET = "SECRETPT" +GIT = "abc1234" +N_FOLDS = 5 +PER_DIR = 5 +SPLIT_SEED = 3 +NAME = "color_export_tiny" +C_VALUES = [0.1, 1.0, 10.0] +EXPECTED_FILES = {"color_model.json", "metrics.json", "config.yaml", "log.txt"} + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("make_synthetic_dataset_export_cli", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _doc(data_root: Path, classifier: str = "logistic") -> dict: + return { + "data": {"root": data_root.as_posix(), "severities": ["strong", "normal"]}, + "split": {"seed": SPLIT_SEED, "n_folds": N_FOLDS}, + "train": {"class_weighting": "inverse"}, + "color": { + "classifier": classifier, + "max_side": 64, + "logistic": {"c_values": C_VALUES, "max_iter": 3000}, + }, + } + + +def _invoke(argv: list[str]) -> tuple[int, str, str]: + root_logger = logging.getLogger() + old_level = root_logger.level + root_logger.setLevel(logging.INFO) + out, err = io.StringIO(), io.StringIO() + handler = logging.StreamHandler(err) + root_logger.addHandler(handler) + try: + with contextlib.redirect_stdout(out): + rc = cli.main(argv) + finally: + root_logger.removeHandler(handler) + root_logger.setLevel(old_level) + return rc, out.getvalue(), err.getvalue() + + +@pytest.fixture(scope="module") +def run(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("train_color_export_cli") + data_root = tmp / "dataset" + _load_script().generate(data_root, per_dir=PER_DIR, seed=0) + pngs = sorted((data_root / "images").rglob("*.png")) + for n, png in enumerate(pngs): + png.rename(png.with_name(f"{SECRET}{n:04d}.png")) + write_manifest(build_manifest_records(data_root / "images"), data_root / "manifest.csv") + + config = tmp / f"{NAME}.yaml" + config.write_text(yaml.safe_dump(_doc(data_root)), encoding="utf-8") + out_root = tmp / "runs" + mp = pytest.MonkeyPatch() + mp.delenv("TIAS_DATA_ROOT", raising=False) + mp.setenv("TIAS_GIT_COMMIT", GIT) + try: + rc, stdout, stderr = _invoke( + ["color-export", "--config", str(config), "--out-root", str(out_root)] + ) + finally: + mp.undo() + run_dirs = [p for p in out_root.iterdir() if p.is_dir()] if out_root.exists() else [] + return { + "tmp": tmp, + "data_root": data_root, + "rc": rc, + "stdout": stdout, + "stderr": stderr, + "run_dirs": run_dirs, + "run_dir": run_dirs[0] if run_dirs else None, + } + + +def test_exit_code_and_single_run_dir(run: dict) -> None: + assert run["rc"] == 0, run["stderr"][-2000:] + assert len(run["run_dirs"]) == 1 + + +def test_run_id_is_last_stdout_line(run: dict) -> None: + run_id = run["run_dir"].name + assert re.fullmatch(rf"\d{{8}}-\d{{6}}_{NAME}_{GIT}", run_id) + lines = [ln for ln in run["stdout"].splitlines() if ln.strip()] + assert lines[-1] == run_id + + +def test_outputs_are_exactly_the_recoverable_files(run: dict) -> None: + d = run["run_dir"] + files = {p.relative_to(d).as_posix() for p in d.rglob("*") if p.is_file()} + assert files == EXPECTED_FILES + for name in files: + assert is_pull_allowed(name, with_checkpoints=False), name + assert (d / "log.txt").stat().st_size > 0 + + +def test_no_patient_ids_or_file_names_in_outputs(run: dict) -> None: + d = run["run_dir"] + for name in EXPECTED_FILES: + assert SECRET not in (d / name).read_text(encoding="utf-8"), name + assert SECRET not in run["stdout"] + assert SECRET not in run["stderr"] + + +def test_metrics_record_c_selection(run: dict) -> None: + m = json.loads((run["run_dir"] / "metrics.json").read_text(encoding="utf-8")) + sel = m["c_selection"] + assert sel["n_folds"] == N_FOLDS + assert sel["split_seed"] == SPLIT_SEED + assert [c["C"] for c in sel["candidates"]] == C_VALUES + means = [c["mean_macro_f1"] for c in sel["candidates"]] + for c in sel["candidates"]: + assert len(c["fold_macro_f1"]) == N_FOLDS + assert c["mean_macro_f1"] == pytest.approx(np.mean(c["fold_macro_f1"])) + # 最良の平均の中で最初の候補 + assert sel["selected_C"] == C_VALUES[means.index(max(means))] + assert len(sel["fold_counts"]) == N_FOLDS + n_kept = 5 * PER_DIR # strong 4 クラス+ normal 1 クラス(mild は除外) + assert sum(fc["held_out"] for fc in sel["fold_counts"]) == n_kept + final = m["final_model"] + assert final["C"] == sel["selected_C"] + assert final["num_images"] == n_kept + assert final["class_weights"] is not None + assert m["severities_trained"] == ["strong", "normal"] + + +def test_config_yaml_records_command(run: dict) -> None: + doc = yaml.safe_load((run["run_dir"] / "config.yaml").read_text(encoding="utf-8")) + text = json.dumps(doc, ensure_ascii=False) + assert "color-export" in text + assert GIT in text + + +def test_color_model_json_is_usable_by_reference_implementation(run: dict) -> None: + path = run["run_dir"] / "color_model.json" + raw = json.loads(path.read_text(encoding="utf-8")) + assert ( + raw["training"]["C"] + == json.loads((run["run_dir"] / "metrics.json").read_text(encoding="utf-8"))["c_selection"][ + "selected_C" + ] + ) + assert raw["provenance"]["run_id"] == run["run_dir"].name + model = load_color_model(path) + png = next((run["data_root"] / "images" / "red_strong").glob("*.png")) + with Image.open(png) as img: + rgb = np.asarray(img.convert("RGB"), dtype=np.uint8) + probs = predict_proba(rgb, model) + assert probs.shape == (5,) + assert probs.sum() == pytest.approx(1.0) + + +def test_hist_gb_stops_before_creating_run_dir(run: dict) -> None: + tmp = run["tmp"] + config = tmp / "export_hist_gb.yaml" + config.write_text(yaml.safe_dump(_doc(run["data_root"], "hist_gb")), encoding="utf-8") + out_root = tmp / "runs_hist_gb" + rc, stdout, stderr = _invoke( + ["color-export", "--config", str(config), "--out-root", str(out_root)] + ) + assert rc == 1 + assert not out_root.exists() or not any(out_root.iterdir()) + assert stdout.strip() == "" + assert SECRET not in stderr diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1bb8ed3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +"""テスト全体で共有するフィクスチャ. + +セグメンテーションのテスト(segmentation・evaluation・cli)で使うダミーの ONNX モデル +(`scripts/make_dummy_seg_models.py`.入出力の形だけを本物に合わせたもの)と, +合成データ生成スクリプト(`scripts/make_synthetic_segmentation.py`)の読み込み. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" + + +def load_script(name: str) -> ModuleType: + """`scripts/.py` をモジュールとして読み込む.""" + module_name = f"_test_script_{name}" + if module_name in sys.modules: + return sys.modules[module_name] + spec = importlib.util.spec_from_file_location(module_name, SCRIPTS_DIR / f"{name}.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="session") +def dummy_seg_models(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Path]: + """ダミーの ResUNet・検出モデルの ONNX を書き出し,そのパスを返す.""" + onnx = pytest.importorskip("onnx") + script = load_script("make_dummy_seg_models") + out = tmp_path_factory.mktemp("dummy_seg_models") + onnx.save(script.build_resunet(), out / "ResUNet.onnx") + onnx.save(script.build_detector(), out / "Detection_Normal.onnx") + return { + "root": out, + "resunet": out / "ResUNet.onnx", + "detector": out / "Detection_Normal.onnx", + } + + +@pytest.fixture(scope="session") +def script_loader(): + """`scripts/.py` を読み込む関数(`load_script`)を返す.""" + return load_script diff --git a/tests/deploy/test_color_model.py b/tests/deploy/test_color_model.py new file mode 100644 index 0000000..cfbe792 --- /dev/null +++ b/tests/deploy/test_color_model.py @@ -0,0 +1,394 @@ +"""配布用の参照実装(`deploy/color_model.py`)のテスト. + +仕様(実装サマリーの設計を仕様として扱う): + +- NumPy と標準ライブラリだけに依存する(本パッケージの他モジュール・sklearn・torch・pydantic・ + Pillow 等を import しない.TIASjudge にこのファイルだけをコピーして使うため) +- `downscale_nearest` は Pillow の `Image.resize(..., NEAREST)` による縮小と画素単位で完全一致する +- `extract_features` は学習側の `extract_color_features(downscale_nearest(...))` と完全一致する +- `predict_proba` は学習側の sklearn パイプラインの `predict_proba` と差 1e-12 以内 +- JSON に書いて読み直しても予測は変わらない +- 形状・dtype の不正,舌領域が 0 画素,未対応の版・種類・キー欠落・形状不一致は ValueError +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import numpy as np +import pytest +from PIL import Image + +from tias_judge_model.data.classes import CLASS_DISPLAY_NAMES, CLASS_SLUGS +from tias_judge_model.data.color_features import ( + NUM_COLOR_FEATURES, + color_feature_names, + extract_color_features, +) +from tias_judge_model.data.color_features import downscale_nearest as train_downscale +from tias_judge_model.deploy import color_model as deploy +from tias_judge_model.schema import ExperimentConfig +from tias_judge_model.training.color_cross_validation import fit_classifier, make_classifier +from tias_judge_model.training.color_export import ( + ColorExportResult, + CSelection, + build_color_model_document, +) + +DEPLOY_PATH = Path(deploy.__file__) +ALLOWED_TOP_LEVEL_IMPORTS = { + "__future__", + "json", + "os", + "collections", + "dataclasses", + "pathlib", + "typing", + "numpy", + "math", +} +MAX_SIDE = 48 +SEED = 7 + + +# --- 依存の制約 --- + + +def _imported_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + assert node.level == 0, "相対 import をしてはならない(単一ファイルで使うため)" + assert node.module is not None + modules.add(node.module) + return modules + + +def test_deploy_module_imports_only_numpy_and_stdlib() -> None: + modules = _imported_modules(DEPLOY_PATH) + top = {m.split(".")[0] for m in modules} + forbidden = {"tias_judge_model", "sklearn", "torch", "pydantic", "PIL", "cv2", "scipy"} + assert not (top & forbidden), top & forbidden + assert top <= ALLOWED_TOP_LEVEL_IMPORTS, top - ALLOWED_TOP_LEVEL_IMPORTS + + +def test_deploy_module_has_no_dynamic_imports() -> None: + src = DEPLOY_PATH.read_text(encoding="utf-8") + assert "importlib" not in src + assert "__import__" not in src + + +# --- テスト用のモデル --- + + +def _config(max_side: int | None = MAX_SIDE, class_weighting: str = "none") -> ExperimentConfig: + return ExperimentConfig.model_validate( + { + "data": {"root": "unused", "severities": ["strong", "normal"]}, + "split": {"seed": SEED, "n_folds": 5}, + "train": {"class_weighting": class_weighting}, + "color": { + "classifier": "logistic", + "max_side": max_side, + "logistic": {"c_values": [0.5, 2.0], "max_iter": 2000}, + }, + } + ) + + +def _random_tongue(rng: np.random.Generator, height: int, width: int) -> np.ndarray: + """楕円の舌領域(ランダムな赤み)+背景の黒の RGB 画像.""" + yy, xx = np.mgrid[0:height, 0:width] + inside = ((yy - height / 2) / (height * 0.45)) ** 2 + ((xx - width / 2) / (width * 0.45)) ** 2 + mask = inside <= 1.0 + base = rng.integers(60, 230, size=3) + img = np.clip(base + rng.normal(0, 25, size=(height, width, 3)), 1, 255).astype(np.uint8) + img[~mask] = 0 + return img + + +@pytest.fixture(scope="module") +def trained() -> dict: + rng = np.random.default_rng(SEED) + images = [ + _random_tongue(rng, int(rng.integers(30, 90)), int(rng.integers(30, 90))) for _ in range(40) + ] + config = _config() + features = np.stack([extract_color_features(train_downscale(img, MAX_SIDE)) for img in images]) + labels = np.arange(len(images)) % 5 + assert config.color is not None + pipeline = make_classifier(config.color, {"C": 1.0}, SEED) + fit_classifier(pipeline, features, labels, None) + result = ColorExportResult( + model=pipeline, + selection=CSelection( + candidates=[ + {"C": 0.5, "fold_macro_f1": [0.1], "mean_macro_f1": 0.1}, + {"C": 1.0, "fold_macro_f1": [0.2], "mean_macro_f1": 0.2}, + ], + selected_index=1, + fold_counts=[{"train": 32, "held_out": 8}], + ), + class_counts=(8, 8, 8, 8, 8), + class_weights=None, + seed=SEED, + ) + doc = build_color_model_document(config, result, run_id="RUN", git_commit="abc1234") + return {"doc": doc, "pipeline": pipeline, "images": images, "rng": rng} + + +# --- downscale_nearest の一致 --- + + +@pytest.mark.parametrize( + ("height", "width", "max_side"), + [ + (100, 60, 48), + (60, 100, 48), + (1000, 999, 512), + (513, 512, 512), + (37, 1001, 50), + (1001, 37, 50), + (7, 3, 2), + (3, 7, 1), + (997, 991, 131), + (1500, 2000, 512), + (255, 255, 254), + ], +) +def test_downscale_matches_pillow_nearest(height: int, width: int, max_side: int) -> None: + rng = np.random.default_rng(height * 31 + width) + img = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8) + got = deploy.downscale_nearest(img, max_side) + scale = max_side / max(height, width) + size = (max(1, round(width * scale)), max(1, round(height * scale))) + expected = np.asarray(Image.fromarray(img).resize(size, Image.Resampling.NEAREST)) + assert got.shape == expected.shape + assert max(got.shape[:2]) <= max_side + np.testing.assert_array_equal(got, expected) + np.testing.assert_array_equal(got, train_downscale(img, max_side)) + + +def test_downscale_returns_input_when_small_enough() -> None: + img = np.arange(4 * 5 * 3, dtype=np.uint8).reshape(4, 5, 3) + np.testing.assert_array_equal(deploy.downscale_nearest(img, 5), img) + np.testing.assert_array_equal(deploy.downscale_nearest(img, 100), img) + + +def test_downscale_rejects_non_positive_max_side() -> None: + img = np.zeros((4, 4, 3), dtype=np.uint8) + with pytest.raises(ValueError): + deploy.downscale_nearest(img, 0) + + +def test_downscale_matches_pillow_over_many_sizes() -> None: + rng = np.random.default_rng(123) + for _ in range(200): + height, width = int(rng.integers(2, 700)), int(rng.integers(2, 700)) + max_side = int(rng.integers(1, max(height, width) + 1)) + img = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8) + np.testing.assert_array_equal( + deploy.downscale_nearest(img, max_side), + train_downscale(img, max_side), + err_msg=f"{height}x{width} -> {max_side}", + ) + + +# --- 特徴量の一致 --- + + +def test_extract_features_matches_training_side(trained: dict) -> None: + model = deploy.load_color_model(trained["doc"]) + rng = np.random.default_rng(99) + for size in [(20, 30), (48, 48), (120, 70), (70, 300), (1, 1)]: + img = _random_tongue(rng, *size) if min(size) > 1 else np.full((1, 1, 3), 9, np.uint8) + got = deploy.extract_features(img, model) + expected = extract_color_features(train_downscale(img, MAX_SIDE)) + np.testing.assert_array_equal(got, expected) + assert got.dtype == np.float64 + assert got.shape == (NUM_COLOR_FEATURES,) + + +def test_extract_features_without_downscale_uses_full_image(trained: dict) -> None: + model = deploy.load_color_model(trained["doc"]) + img = _random_tongue(np.random.default_rng(5), 200, 150) + np.testing.assert_array_equal( + deploy.extract_features(img, model, downscale=False), extract_color_features(img) + ) + + +def test_extract_features_with_null_max_side_does_not_downscale(trained: dict) -> None: + doc = json.loads(json.dumps(trained["doc"])) + doc["features"]["max_side"] = None + model = deploy.load_color_model(doc) + img = _random_tongue(np.random.default_rng(6), 200, 150) + np.testing.assert_array_equal(deploy.extract_features(img, model), extract_color_features(img)) + + +def test_extract_features_covers_all_rgb_values(trained: dict) -> None: + # 256 階調すべてを含む画像(線形化の LUT の全要素)でも学習側と一致する + model = deploy.load_color_model(trained["doc"]) + rng = np.random.default_rng(11) + img = np.stack([rng.permutation(256), rng.permutation(256), np.arange(256)], axis=-1).astype( + np.uint8 + )[None] # (1, 256, 3).(0,0,0) の画素を含みうる + img = np.repeat(img, 3, axis=0) + np.testing.assert_array_equal( + deploy.extract_features(img, model, downscale=False), extract_color_features(img) + ) + + +@pytest.mark.parametrize( + "img", + [ + np.zeros((4, 4), dtype=np.uint8), + np.zeros((4, 4, 4), dtype=np.uint8), + np.ones((4, 4, 3), dtype=np.float32), + np.ones((4, 4, 3), dtype=np.int64), + ], + ids=["2d", "4ch", "float32", "int64"], +) +def test_extract_features_rejects_bad_input(trained: dict, img: np.ndarray) -> None: + model = deploy.load_color_model(trained["doc"]) + with pytest.raises(ValueError): + deploy.extract_features(img, model) + + +def test_extract_features_rejects_all_background(trained: dict) -> None: + model = deploy.load_color_model(trained["doc"]) + with pytest.raises(ValueError): + deploy.extract_features(np.zeros((10, 10, 3), dtype=np.uint8), model) + + +# --- 予測確率の一致 --- + + +def test_predict_proba_matches_sklearn(trained: dict) -> None: + model = deploy.load_color_model(trained["doc"]) + rng = np.random.default_rng(1234) + for i in range(30): + img = _random_tongue(rng, int(rng.integers(10, 150)), int(rng.integers(10, 150))) + got = deploy.predict_proba(img, model) + feats = extract_color_features(train_downscale(img, MAX_SIDE)) + expected = trained["pipeline"].predict_proba(feats[None])[0] + assert got.shape == (5,) + assert np.max(np.abs(got - expected)) <= 1e-12, i + assert got.sum() == pytest.approx(1.0, abs=1e-12) + + +def test_predict_proba_from_features_batch_matches_sklearn(trained: dict) -> None: + model = deploy.load_color_model(trained["doc"]) + rng = np.random.default_rng(3) + feats = np.stack([extract_color_features(_random_tongue(rng, 40, 40)) for _ in range(10)]) + got = deploy.predict_proba_from_features(feats, model) + expected = trained["pipeline"].predict_proba(feats) + assert got.shape == (10, 5) + assert np.max(np.abs(got - expected)) <= 1e-12 + + +def test_predict_proba_from_features_is_stable_for_extreme_values(trained: dict) -> None: + model = deploy.load_color_model(trained["doc"]) + feats = np.full(NUM_COLOR_FEATURES, 1e6) + probs = deploy.predict_proba_from_features(feats, model) + assert np.all(np.isfinite(probs)) + assert probs.sum() == pytest.approx(1.0) + + +@pytest.mark.parametrize("shape", [(NUM_COLOR_FEATURES - 1,), (2, 3, NUM_COLOR_FEATURES), (3,)]) +def test_predict_proba_from_features_rejects_bad_shape(trained: dict, shape: tuple) -> None: + model = deploy.load_color_model(trained["doc"]) + with pytest.raises(ValueError): + deploy.predict_proba_from_features(np.zeros(shape), model) + + +def test_json_round_trip_keeps_predictions(trained: dict, tmp_path: Path) -> None: + path = tmp_path / "color_model.json" + path.write_text(json.dumps(trained["doc"], ensure_ascii=False, indent=2), encoding="utf-8") + from_file = deploy.load_color_model(path) + from_str_path = deploy.load_color_model(str(path)) + from_dict = deploy.load_color_model(trained["doc"]) + rng = np.random.default_rng(77) + for _ in range(10): + img = _random_tongue(rng, 64, 50) + p = deploy.predict_proba(img, from_dict) + np.testing.assert_array_equal(deploy.predict_proba(img, from_file), p) + np.testing.assert_array_equal(deploy.predict_proba(img, from_str_path), p) + + +# --- モデルの読み込み --- + + +def test_loaded_model_exposes_classes_and_features(trained: dict) -> None: + model = deploy.load_color_model(trained["doc"]) + assert model.num_features == NUM_COLOR_FEATURES + assert model.num_classes == 5 + assert model.feature_names == color_feature_names() + assert [c["slug"] for c in model.classes] == list(CLASS_SLUGS) + assert [c["display_name"] for c in model.classes] == [ + CLASS_DISPLAY_NAMES[s] for s in CLASS_SLUGS + ] + assert model.max_side == MAX_SIDE + + +def _broken(doc: dict, mutate) -> dict: + d = json.loads(json.dumps(doc)) + mutate(d) + return d + + +@pytest.mark.parametrize( + "mutate", + [ + lambda d: d.update(format_version=2), + lambda d: d.update(format_version="1"), + lambda d: d.update(model_type="color_hist_gb"), + lambda d: d.pop("scaler"), + lambda d: d.pop("classes"), + lambda d: d["features"].pop("lab"), + lambda d: d["classifier"].update(coef=d["classifier"]["coef"][:4]), + lambda d: d["classifier"].update(intercept=d["classifier"]["intercept"][:4]), + lambda d: d["scaler"].update(mean=d["scaler"]["mean"][:-1]), + lambda d: d["scaler"].update(scale=[float("nan")] * NUM_COLOR_FEATURES), + lambda d: d["features"].update(names=d["features"]["names"][:-1]), + lambda d: d["features"].update(num_features=106), + lambda d: d["features"].update(max_side=0), + lambda d: d["features"]["ab_hist"].update(a_range=[50.0, 0.0]), + lambda d: d["features"]["l_hist"].update(range=[20.0]), + lambda d: d["features"]["lab"].update(srgb_to_xyz=[[1.0, 0.0], [0.0, 1.0]]), + lambda d: d.update(classes=list(reversed(d["classes"]))), + ], + ids=[ + "future_version", + "string_version", + "model_type", + "no_scaler", + "no_classes", + "no_lab", + "coef_rows", + "intercept_len", + "mean_len", + "nan_scale", + "names_len", + "num_features", + "max_side_0", + "a_range_reversed", + "l_range_len", + "matrix_shape", + "class_order", + ], +) +def test_load_rejects_invalid_documents(trained: dict, mutate) -> None: + with pytest.raises(ValueError): + deploy.load_color_model(_broken(trained["doc"], mutate)) + + +def test_load_rejects_non_object_json(tmp_path: Path) -> None: + path = tmp_path / "color_model.json" + path.write_text("[1, 2, 3]", encoding="utf-8") + with pytest.raises(ValueError): + deploy.load_color_model(path) diff --git a/tests/evaluation/test_segmentation_eval.py b/tests/evaluation/test_segmentation_eval.py new file mode 100644 index 0000000..158715e --- /dev/null +++ b/tests/evaluation/test_segmentation_eval.py @@ -0,0 +1,250 @@ +"""セグメンテーションの評価(`evaluation/segmentation_eval.py`)のテスト. + +仕様(実装サマリーを仕様として扱う): + +- 評価セットの種類ごとの方式: `cropped` は `whole`,`fullsize` は `whole`・`gt_bbox`・`detector` + (検出モデルが無ければ警告して `detector` を省く.`models.detection: null` も省く) +- 方式ごとに IoU・Dice(後処理あり)・後処理なし・もう一方のチャンネルの IoU を集計する +- 検出できない画像は空マスク(IoU 0)として数え,`n_not_detected` に入れる +- 正解マスクに前景が無い・入力画像とサイズが違うと ValueError(評価セット名と何件目かで示し, + ファイル名を出さない) + +合成データ(scripts/make_synthetic_segmentation.py)とダミーの ONNX で動かす. +""" + +from __future__ import annotations + +import json +import logging +import shutil +from pathlib import Path + +import numpy as np +import pytest +from PIL import Image + +from tias_judge_model.evaluation.segmentation_eval import ( + METHOD_DETECTOR, + METHOD_GT_BBOX, + METHOD_WHOLE, + ImageScore, + SegEvalResult, + methods_for, + run_segmentation_eval, + summarize_segmentation, +) +from tias_judge_model.schema import SegEvalConfig + +PER_SET = 4 +SECRET = "SECRETPT" + + +def _config(data_root: Path, model_root: Path, **overrides) -> SegEvalConfig: + raw = { + "data": { + "root": data_root.as_posix(), + "sets": [ + {"name": "test_cropped", "dir": "test/cropped", "kind": "cropped"}, + {"name": "test_fullsize", "dir": "test/fullsize", "kind": "fullsize"}, + ], + }, + "models": {"root": model_root.as_posix(), "detection": "Detection_Normal.onnx"}, + } + for key, value in overrides.items(): + raw[key] = {**raw.get(key, {}), **value} + return SegEvalConfig.model_validate(raw) + + +@pytest.fixture(scope="module") +def seg_data(tmp_path_factory: pytest.TempPathFactory, script_loader) -> Path: + out = tmp_path_factory.mktemp("seg_eval") / "synthetic_seg" + script_loader("make_synthetic_segmentation").generate(out, PER_SET, seed=0) + return out / "input" + + +# --- 方式 --- + + +def test_methods_for_each_kind() -> None: + assert methods_for("cropped", True) == [METHOD_WHOLE] + assert methods_for("cropped", False) == [METHOD_WHOLE] + assert methods_for("fullsize", True) == [METHOD_WHOLE, METHOD_GT_BBOX, METHOD_DETECTOR] + assert methods_for("fullsize", False) == [METHOD_WHOLE, METHOD_GT_BBOX] + + +# --- 集計(手で作った個票) --- + + +def _score(method: str, iou: float, **kw) -> ImageScore: + base = { + "set_name": "s", + "index": 1, + "stem": SECRET, + "method": method, + "iou": iou, + "dice": 2 * iou / (1 + iou), + "iou_no_post": iou, + "dice_no_post": 2 * iou / (1 + iou), + "iou_other_channel": 0.0, + "postprocess_applied": False, + "detected": None, + "detection_score": None, + "seconds": 0.01, + } + base.update(kw) + return ImageScore(**base) + + +def test_summarize_counts_not_detected_as_zero_iou() -> None: + config = SegEvalConfig.model_validate( + { + "data": {"root": "r", "sets": [{"name": "s", "dir": "d", "kind": "fullsize"}]}, + "models": {"root": "m"}, + } + ) + result = SegEvalResult( + scores=[ + _score(METHOD_WHOLE, 0.9, postprocess_applied=True), + _score(METHOD_WHOLE, 0.7), + _score(METHOD_DETECTOR, 0.85, detected=True, detection_score=0.9), + _score(METHOD_DETECTOR, 0.0, detected=False, detection_score=0.3), + _score(METHOD_DETECTOR, 0.0, detected=False, detection_score=None), + ], + methods={"s": [METHOD_WHOLE, METHOD_DETECTOR]}, + detector_used=True, + ) + sets = summarize_segmentation(result, config) + s = sets["s"] + assert s["kind"] == "fullsize" + assert s["dir"] == "d" + whole = s["methods"][METHOD_WHOLE] + assert whole["iou"]["n"] == 2 + assert whole["iou"]["mean"] == pytest.approx(0.8) + assert whole["iou"]["n_below"] == 1 + assert whole["n_postprocess_applied"] == 1 + det = s["methods"][METHOD_DETECTOR] + assert det["iou"]["n"] == 3 + assert det["iou"]["min"] == 0.0 + assert det["detection"]["n_detected"] == 1 + assert det["detection"]["n_not_detected"] == 2 + assert det["detection"]["top_score"]["n"] == 2 + assert det["detection"]["score_threshold"] == 0.5 + assert "detection" not in whole + # 集計に stem を含めない + assert SECRET not in json.dumps(sets, ensure_ascii=False) + + +# --- 合成データでの評価 --- + + +def test_run_on_synthetic_data_with_detector(seg_data: Path, dummy_seg_models: dict) -> None: + config = _config(seg_data, dummy_seg_models["root"]) + result = run_segmentation_eval(config) + assert result.detector_used + assert result.methods == { + "test_cropped": [METHOD_WHOLE], + "test_fullsize": [METHOD_WHOLE, METHOD_GT_BBOX, METHOD_DETECTOR], + } + assert len(result.scores) == PER_SET * (1 + 3) + # 同じセット内の index は stem の昇順で 1 始まり + idx = [s.index for s in result.scores if s.set_name == "test_cropped"] + assert idx == list(range(1, PER_SET + 1)) + for s in result.scores: + assert 0.0 <= s.iou <= 1.0 and 0.0 <= s.dice <= 1.0 + if s.method == METHOD_DETECTOR: + assert s.detected is True + else: + assert s.detected is None and s.detection_score is None + # ダミーのモデルは赤い楕円を前景とするため,gt_bbox・cropped はよく一致する + for s in result.scores: + if s.method in (METHOD_GT_BBOX,) or s.set_name == "test_cropped": + assert s.iou > 0.8 + # もう一方のチャンネル(ダミーは反転)はほぼ一致しない + assert s.iou_other_channel < 0.5 + sets = summarize_segmentation(result, config) + assert sets["test_fullsize"]["n_images"] == PER_SET + assert sets["test_fullsize"]["methods"][METHOD_DETECTOR]["detection"]["n_not_detected"] == 0 + + +def test_not_detected_images_are_empty_masks(seg_data: Path, dummy_seg_models: dict) -> None: + # ダミーの検出スコアは 0.9 → しきい値 0.95 では検出できない + config = _config(seg_data, dummy_seg_models["root"], detection={"score_threshold": 0.95}) + result = run_segmentation_eval(config) + det = [s for s in result.scores if s.method == METHOD_DETECTOR] + assert len(det) == PER_SET + assert all(s.detected is False for s in det) + assert all(s.iou == 0.0 and s.dice == 0.0 and s.iou_no_post == 0.0 for s in det) + assert all(s.detection_score == pytest.approx(0.9) for s in det) + sets = summarize_segmentation(result, config) + d = sets["test_fullsize"]["methods"][METHOD_DETECTOR]["detection"] + assert d["n_not_detected"] == PER_SET + assert d["n_detected"] == 0 + + +def test_missing_detector_file_is_skipped_with_warning( + seg_data: Path, dummy_seg_models: dict, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + models = tmp_path / "models" + models.mkdir() + shutil.copy(dummy_seg_models["resunet"], models / "ResUNet.onnx") + config = _config(seg_data, models) + with caplog.at_level(logging.WARNING): + result = run_segmentation_eval(config) + assert not result.detector_used + assert result.detector_note + assert result.methods["test_fullsize"] == [METHOD_WHOLE, METHOD_GT_BBOX] + assert any(r.levelno == logging.WARNING for r in caplog.records) + + +def test_detection_null_skips_detector(seg_data: Path, dummy_seg_models: dict) -> None: + config = _config(seg_data, dummy_seg_models["root"], models={"detection": None}) + result = run_segmentation_eval(config) + assert not result.detector_used + assert result.detector_note + assert METHOD_DETECTOR not in result.methods["test_fullsize"] + + +def test_missing_resunet_raises(seg_data: Path, tmp_path: Path) -> None: + with pytest.raises(ValueError): + run_segmentation_eval(_config(seg_data, tmp_path)) + + +def _copy_set(seg_data: Path, tmp_path: Path) -> Path: + root = tmp_path / "input" + shutil.copytree(seg_data / "test" / "cropped", root / "test" / "cropped") + shutil.copytree(seg_data / "test" / "fullsize", root / "test" / "fullsize") + # stem を秘密の文字列にする(例外に出ないことを確かめる) + for sub in ("sample", "label"): + for n, p in enumerate(sorted((root / "test" / "cropped" / sub).iterdir())): + p.rename(p.with_name(f"{SECRET}{n:02d}{p.suffix}")) + return root + + +def test_label_without_foreground_raises_with_position( + seg_data: Path, dummy_seg_models: dict, tmp_path: Path +) -> None: + root = _copy_set(seg_data, tmp_path) + labels = sorted((root / "test" / "cropped" / "label").iterdir()) + with Image.open(labels[1]) as img: + size = img.size + Image.fromarray(np.zeros((size[1], size[0]), np.uint8)).save(labels[1]) + with pytest.raises(ValueError) as exc: + run_segmentation_eval(_config(root, dummy_seg_models["root"])) + msg = str(exc.value) + assert "test_cropped" in msg + assert "2 件目" in msg + assert SECRET not in msg + + +def test_label_size_mismatch_raises_with_position( + seg_data: Path, dummy_seg_models: dict, tmp_path: Path +) -> None: + root = _copy_set(seg_data, tmp_path) + labels = sorted((root / "test" / "cropped" / "label").iterdir()) + Image.fromarray(np.full((10, 12), 255, np.uint8)).save(labels[0]) + with pytest.raises(ValueError) as exc: + run_segmentation_eval(_config(root, dummy_seg_models["root"])) + msg = str(exc.value) + assert "test_cropped" in msg + assert "1 件目" in msg + assert SECRET not in msg diff --git a/tests/evaluation/test_segmentation_figures.py b/tests/evaluation/test_segmentation_figures.py new file mode 100644 index 0000000..ef33855 --- /dev/null +++ b/tests/evaluation/test_segmentation_figures.py @@ -0,0 +1,27 @@ +"""セグメンテーションの評価の図(`evaluation/segmentation_figures.py`)の smoke テスト.""" + +from __future__ import annotations + +from pathlib import Path + +from tias_judge_model.evaluation.segmentation_figures import ( + iou_histogram_filename, + plot_iou_histograms, +) + + +def test_filename() -> None: + assert iou_histogram_filename("test_cropped") == "seg_iou_hist_test_cropped.png" + + +def test_plot_creates_png(tmp_path: Path) -> None: + path = tmp_path / "figures" / iou_histogram_filename("s") + plot_iou_histograms({"whole": [0.9, 0.8, 1.2, -0.1], "gt_bbox": [0.95]}, "s", path) + assert path.is_file() + assert path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" + + +def test_plot_with_empty_methods_does_not_fail(tmp_path: Path) -> None: + path = tmp_path / "empty.png" + plot_iou_histograms({"whole": [], "detector": []}, "empty", path) + assert path.is_file() diff --git a/tests/evaluation/test_segmentation_metrics.py b/tests/evaluation/test_segmentation_metrics.py new file mode 100644 index 0000000..a10f210 --- /dev/null +++ b/tests/evaluation/test_segmentation_metrics.py @@ -0,0 +1,110 @@ +"""セグメンテーションの指標(`evaluation/segmentation_metrics.py`)のテスト. + +仕様(実装サマリーを仕様として扱う): + +- IoU = |P ∩ T| / |P ∪ T|,Dice = 2|P ∩ T| / (|P| + |T|)(前景について) +- 予測が空で正解に前景があれば IoU = Dice = 0(検出できなかった画像の扱い) +- 両方とも前景が無い場合は一致とみなして 1.0(評価では正解に前景が無い画像を ValueError で + 止めるため,評価の集計には現れない) +- 形状が違えば ValueError +- 集計: n・平均・中央値・p10・p25・最小・最大・しきい値**未満**の件数.0 件なら n = 0 で他は None +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from tias_judge_model.evaluation.segmentation_metrics import iou_and_dice, summarize_scores + + +def _mask(shape: tuple[int, int], *slices: tuple[slice, slice]) -> np.ndarray: + m = np.zeros(shape, bool) + for s in slices: + m[s] = True + return m + + +def test_identical_masks() -> None: + m = _mask((10, 10), (slice(2, 6), slice(2, 6))) + assert iou_and_dice(m, m) == (1.0, 1.0) + + +def test_known_overlap() -> None: + # P: 4×4 = 16,T: 4×4 = 16,重なり 2×4 = 8 → IoU 8/24,Dice 16/32 + p = _mask((10, 10), (slice(0, 4), slice(0, 4))) + t = _mask((10, 10), (slice(2, 6), slice(0, 4))) + iou, dice = iou_and_dice(p, t) + assert iou == pytest.approx(8 / 24) + assert dice == pytest.approx(0.5) + + +def test_dice_iou_relation_holds() -> None: + rng = np.random.default_rng(0) + for _ in range(20): + p = rng.random((16, 16)) > 0.5 + t = rng.random((16, 16)) > 0.5 + iou, dice = iou_and_dice(p, t) + assert dice == pytest.approx(2 * iou / (1 + iou)) + + +def test_disjoint_masks_are_zero() -> None: + p = _mask((6, 6), (slice(0, 2), slice(0, 2))) + t = _mask((6, 6), (slice(4, 6), slice(4, 6))) + assert iou_and_dice(p, t) == (0.0, 0.0) + + +def test_empty_prediction_is_zero() -> None: + t = _mask((6, 6), (slice(1, 3), slice(1, 3))) + assert iou_and_dice(np.zeros((6, 6), bool), t) == (0.0, 0.0) + + +def test_empty_target_with_prediction_is_zero() -> None: + p = _mask((6, 6), (slice(1, 3), slice(1, 3))) + assert iou_and_dice(p, np.zeros((6, 6), bool)) == (0.0, 0.0) + + +def test_both_empty_counts_as_match() -> None: + z = np.zeros((6, 6), bool) + assert iou_and_dice(z, z) == (1.0, 1.0) + + +def test_non_bool_inputs_are_treated_as_masks() -> None: + p = np.array([[0, 255], [255, 0]], np.uint8) + t = np.array([[0, 1], [0, 0]], np.int64) + iou, dice = iou_and_dice(p, t) + assert iou == pytest.approx(0.5) + assert dice == pytest.approx(2 / 3) + + +def test_shape_mismatch_raises() -> None: + with pytest.raises(ValueError): + iou_and_dice(np.zeros((4, 4), bool), np.zeros((4, 5), bool)) + + +def test_summarize_scores_known_values() -> None: + values = [0.9, 0.5, 0.8, 0.95, 0.7] + s = summarize_scores(values, 0.8) + assert s["n"] == 5 + assert s["mean"] == pytest.approx(np.mean(values)) + assert s["median"] == pytest.approx(0.8) + assert s["p10"] == pytest.approx(np.percentile(values, 10)) + assert s["p25"] == pytest.approx(0.7) + assert s["min"] == 0.5 + assert s["max"] == 0.95 + assert s["n_below"] == 2 # 0.5・0.7(0.8 ちょうどは数えない) + assert s["below_threshold"] == 0.8 + + +def test_summarize_scores_empty() -> None: + s = summarize_scores([], 0.8) + assert s["n"] == 0 + for key in ("mean", "median", "p10", "p25", "min", "max", "n_below"): + assert s[key] is None + assert s["below_threshold"] == 0.8 + + +def test_summarize_scores_values_are_json_native() -> None: + s = summarize_scores(np.array([0.1, 0.2], np.float32), 0.5) + assert isinstance(s["n"], int) and isinstance(s["n_below"], int) + assert all(isinstance(s[k], float) for k in ("mean", "median", "p10", "p25", "min", "max")) diff --git a/tests/remote/test_whitelist.py b/tests/remote/test_whitelist.py index 811874d..dbd1eb8 100644 --- a/tests/remote/test_whitelist.py +++ b/tests/remote/test_whitelist.py @@ -26,6 +26,7 @@ "confusion_matrix.csv", "log.txt", "dataset_summary.json", # データセット集計の結果(集計値のみ) + "color_model.json", # tias-train color-export の判定モデル(係数と集計値のみ) ] # 列挙外(回収してはならない)ファイル.患者 ID・画像名を含みうる名前も混ぜる @@ -56,6 +57,11 @@ "figures/dataset_summary.json", "sub/dataset_summary.json", "dataset_details.json", + # 判定モデルの名前に似ているが列挙外のもの + "color_model.json.bak", + "color_model.pkl", + "sub/color_model.json", + "figures/color_model.json", ] diff --git a/tests/scripts/test_make_seg_scripts.py b/tests/scripts/test_make_seg_scripts.py new file mode 100644 index 0000000..f059387 --- /dev/null +++ b/tests/scripts/test_make_seg_scripts.py @@ -0,0 +1,113 @@ +"""セグメンテーション用のスクリプト(ダミーの ONNX・合成データ)のテスト. + +仕様(実装サマリーを仕様として扱う): + +- `make_dummy_seg_models.build_resunet()`: 入力 `(1, 3, 256, 256)` float32 → 出力 + `(1, 2, 256, 256)` float32(本物の ResUNet と入出力の形を合わせる) +- `make_dummy_seg_models.build_detector()`: 入力 `image_tensor:0`(`(1, H, W, 3)` uint8)→ + `detection_boxes:0`(`(1, N, 4)`)・`detection_scores:0`(`(1, N)`) +- `make_synthetic_segmentation.generate()`: 実データと同じ構成(`train`・`test/cropped`・ + `test_extra/cropped`・`test/fullsize` の `sample/`・`label/`).同じ seed で同じ出力. + 出力先が空でなければ `--overwrite` が必要で,目印の無い出力先は削除しない +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import onnx +import onnxruntime as ort +import pytest +from PIL import Image + +SET_DIRS = ["train", "test/cropped", "test_extra/cropped", "test/fullsize"] + + +def _session(model: onnx.ModelProto) -> ort.InferenceSession: + return ort.InferenceSession(model.SerializeToString(), providers=["CPUExecutionProvider"]) + + +def test_dummy_resunet_io_shapes(script_loader) -> None: + script = script_loader("make_dummy_seg_models") + sess = _session(script.build_resunet()) + (inp,) = sess.get_inputs() + assert inp.shape == [1, 3, 256, 256] + x = np.random.default_rng(0).random((1, 3, 256, 256), dtype=np.float32) + (out,) = sess.run(None, {inp.name: x}) + assert out.shape == (1, 2, 256, 256) + assert out.dtype == np.float32 + np.testing.assert_allclose(out[0, 1], -out[0, 0], atol=1e-5) + + +def test_dummy_detector_io_shapes(script_loader) -> None: + script = script_loader("make_dummy_seg_models") + sess = _session(script.build_detector()) + assert [i.name for i in sess.get_inputs()] == ["image_tensor:0"] + names = [o.name for o in sess.get_outputs()] + assert {"detection_boxes:0", "detection_scores:0"} <= set(names) + img = np.zeros((1, 50, 70, 3), np.uint8) + img[0, 10:20, 30:40] = (200, 90, 100) # RGB の赤 + boxes, scores = sess.run(["detection_boxes:0", "detection_scores:0"], {"image_tensor:0": img}) + assert boxes.shape == (1, 2, 4) + assert scores.shape == (1, 2) + np.testing.assert_allclose(boxes[0, 0], [10 / 50, 30 / 70, 20 / 50, 40 / 70], rtol=1e-6) + + +def test_main_writes_both_models(script_loader, tmp_path: Path) -> None: + script = script_loader("make_dummy_seg_models") + assert script.main(["--out", str(tmp_path / "m")]) == 0 + assert (tmp_path / "m" / "ResUNet.onnx").is_file() + assert (tmp_path / "m" / "Detection_Normal.onnx").is_file() + + +@pytest.fixture(scope="module") +def synthetic(script_loader, tmp_path_factory: pytest.TempPathFactory) -> tuple: + script = script_loader("make_synthetic_segmentation") + out = tmp_path_factory.mktemp("synseg") / "a" + n = script.generate(out, 3, seed=5) + return script, out, n + + +def test_generate_layout(synthetic: tuple) -> None: + _, out, n = synthetic + assert n == 3 * len(SET_DIRS) + for set_dir in SET_DIRS: + samples = sorted((out / "input" / set_dir / "sample").iterdir()) + labels = sorted((out / "input" / set_dir / "label").iterdir()) + assert len(samples) == len(labels) == 3 + assert [p.stem for p in samples] == [p.stem for p in labels] + for s, lb in zip(samples, labels, strict=True): + with Image.open(s) as si, Image.open(lb) as li: + assert si.size == li.size + assert si.mode == "RGB" + assert li.mode == "L" + assert (np.asarray(li) >= 128).any() + + +def test_generate_is_reproducible(synthetic: tuple, tmp_path: Path) -> None: + script, out, _ = synthetic + other = tmp_path / "b" + script.generate(other, 3, seed=5) + for p in (out / "input").rglob("*.jpg"): + q = other / "input" / p.relative_to(out / "input") + assert p.read_bytes() == q.read_bytes() + + +def test_main_refuses_non_empty_without_overwrite(script_loader, tmp_path: Path) -> None: + script = script_loader("make_synthetic_segmentation") + out = tmp_path / "x" + out.mkdir() + (out / "keep.txt").write_text("x", encoding="utf-8") + assert script.main(["--out", str(out), "--per-set", "1"]) == 1 + # 目印が無ければ --overwrite でも消さない + assert script.main(["--out", str(out), "--per-set", "1", "--overwrite"]) == 1 + assert (out / "keep.txt").is_file() + + +def test_main_overwrite_regenerates_marked_output(script_loader, tmp_path: Path) -> None: + script = script_loader("make_synthetic_segmentation") + out = tmp_path / "y" + assert script.main(["--out", str(out), "--per-set", "1"]) == 0 + assert script.main(["--out", str(out), "--per-set", "2", "--overwrite"]) == 0 + assert len(list((out / "input" / "train" / "sample").iterdir())) == 2 diff --git a/tests/segmentation/test_detection.py b/tests/segmentation/test_detection.py new file mode 100644 index 0000000..7c56907 --- /dev/null +++ b/tests/segmentation/test_detection.py @@ -0,0 +1,185 @@ +"""舌の検出と枠の扱い(`segmentation/detection.py`)のテスト. + +仕様(DeepTIAS 2.7 の再現.実装サマリーを仕様として扱う): + +- スコアがしきい値(0.5)を**超える**中で最高の枠を採用する(該当なしは None) +- 正規化 `[ymin, xmin, ymax, xmax]` を画素の枠(`x1`・`y1` は含まない)に変換する.画素への + 変換は切り捨て.画像の範囲にクリップし,幅・高さが 0 以下なら None +- `mask_bounding_rect` は前景の外接矩形(前景が無ければ None) +- `OnnxDetector` は入出力名を `:0` の有無を問わずに探す.ファイルが無い・名前が無いと ValueError +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from tias_judge_model.segmentation.detection import ( + OnnxDetector, + Rect, + full_rect, + mask_bounding_rect, + normalized_box_to_rect, + select_best_box, +) + +# --- Rect --- + + +def test_rect_width_height_and_full_rect() -> None: + r = full_rect(30, 50) + assert r == Rect(0, 0, 50, 30) + assert (r.width, r.height) == (50, 30) + + +# --- mask_bounding_rect --- + + +def test_mask_bounding_rect_is_exclusive_on_right_and_bottom() -> None: + mask = np.zeros((20, 30), bool) + mask[5:9, 10:17] = True + mask[12, 3] = True + r = mask_bounding_rect(mask) + assert r == Rect(3, 5, 17, 13) + + +def test_mask_bounding_rect_single_pixel_and_full() -> None: + mask = np.zeros((4, 4), bool) + mask[2, 1] = True + assert mask_bounding_rect(mask) == Rect(1, 2, 2, 3) + assert mask_bounding_rect(np.ones((4, 6), bool)) == Rect(0, 0, 6, 4) + + +def test_mask_bounding_rect_empty_is_none() -> None: + assert mask_bounding_rect(np.zeros((5, 5), bool)) is None + + +# --- normalized_box_to_rect --- + + +def test_normalized_box_to_rect_basic() -> None: + r = normalized_box_to_rect([0.1, 0.2, 0.5, 0.6], height=100, width=200) + assert r == Rect(40, 10, 120, 50) + + +def test_normalized_box_to_rect_truncates() -> None: + # 0.29 × 100 = 28.999999999999996 → 28(切り捨て),0.999 × 10 = 9.99 → 9 + r = normalized_box_to_rect([0.29, 0.0, 0.999, 0.999], height=100, width=10) + assert r == Rect(0, 28, 9, 99) + + +def test_normalized_box_to_rect_clips_outside_values() -> None: + r = normalized_box_to_rect([-0.2, -0.1, 1.3, 1.5], height=40, width=60) + assert r == Rect(0, 0, 60, 40) + + +@pytest.mark.parametrize( + "box", + [ + [0.5, 0.5, 0.5, 0.8], # 高さ 0 + [0.2, 0.5, 0.6, 0.5], # 幅 0 + [0.6, 0.2, 0.3, 0.8], # 上下が逆 + [0.2, 0.8, 0.6, 0.3], # 左右が逆 + [1.2, 0.1, 1.5, 0.5], # 全体が画像の外(下) + [0.1, -0.5, 0.5, -0.1], # 全体が画像の外(左) + [0.10, 0.1, 0.105, 0.5], # 切り捨てで高さ 0(10 → 10) + ], +) +def test_normalized_box_to_rect_degenerate_is_none(box: list[float]) -> None: + assert normalized_box_to_rect(box, height=100, width=100) is None + + +# --- select_best_box --- + + +def test_select_best_box_picks_highest_above_threshold() -> None: + boxes = np.array([[0, 0, 0.5, 0.5], [0.1, 0.1, 0.9, 0.9], [0.2, 0.2, 0.3, 0.3]]) + scores = np.array([0.6, 0.95, 0.7]) + box, score = select_best_box(boxes, scores, 0.5) + np.testing.assert_array_equal(box, boxes[1]) + assert score == pytest.approx(0.95) + + +def test_select_best_box_threshold_is_strict() -> None: + boxes = np.array([[0, 0, 1, 1]]) + assert select_best_box(boxes, np.array([0.5]), 0.5) is None + assert select_best_box(boxes, np.array([0.5000001]), 0.5) is not None + + +def test_select_best_box_none_when_all_below_or_empty() -> None: + assert select_best_box(np.array([[0, 0, 1, 1]] * 2), np.array([0.1, 0.3]), 0.5) is None + assert select_best_box(np.zeros((0, 4)), np.zeros(0), 0.5) is None + + +def test_select_best_box_accepts_batched_shapes() -> None: + # 検出モデルの出力の形((1, N, 4)・(1, N))のままでも扱える + boxes = np.array([[[0, 0, 0.5, 0.5], [0.1, 0.2, 0.3, 0.4]]]) + scores = np.array([[0.3, 0.8]]) + box, _ = select_best_box(boxes, scores, 0.5) + np.testing.assert_allclose(box, [0.1, 0.2, 0.3, 0.4]) + + +def test_select_best_box_tie_takes_first() -> None: + boxes = np.array([[0, 0, 0.5, 0.5], [0.5, 0.5, 1, 1]]) + box, _ = select_best_box(boxes, np.array([0.9, 0.9]), 0.5) + np.testing.assert_array_equal(box, boxes[0]) + + +# --- OnnxDetector(ダミーの ONNX) --- + + +def _detector(path: Path, **kw) -> OnnxDetector: + args = { + "score_threshold": 0.5, + "input_color": "rgb", + "input_name": "image_tensor", + "boxes_name": "detection_boxes", + "scores_name": "detection_scores", + } + args.update(kw) + return OnnxDetector(path, **args) + + +def _red_patch_bgr() -> np.ndarray: + bgr = np.full((80, 120, 3), (150, 120, 110), np.uint8) # 青みがかった背景(BGR) + bgr[20:50, 30:90] = (105, 95, 200) # 赤い領域(BGR) + return bgr + + +def test_detector_finds_red_region_with_colon_zero_names(dummy_seg_models: dict) -> None: + det = _detector(dummy_seg_models["detector"]) + result = det.detect(_red_patch_bgr()) + assert result.rect == Rect(30, 20, 90, 50) + assert result.score == pytest.approx(0.9) + + +def test_detector_below_threshold_is_not_detected_but_records_score( + dummy_seg_models: dict, +) -> None: + det = _detector(dummy_seg_models["detector"], score_threshold=0.95) + result = det.detect(_red_patch_bgr()) + assert result.rect is None + assert result.score == pytest.approx(0.9) + + +def test_detector_bgr_input_color_changes_what_model_sees(dummy_seg_models: dict) -> None: + # input_color: bgr ならそのまま渡す → ダミーは R−B を見るため,赤(BGR の B が小さい)を + # 青と取り違え,背景(BGR の B が大きい)を前景とみなす + det = _detector(dummy_seg_models["detector"], input_color="bgr") + result = det.detect(_red_patch_bgr()) + assert result.rect != Rect(30, 20, 90, 50) + + +def test_detector_rejects_missing_file(tmp_path: Path) -> None: + with pytest.raises(ValueError): + _detector(tmp_path / "none.onnx") + + +@pytest.mark.parametrize( + "kw", [{"input_name": "images"}, {"boxes_name": "boxes"}, {"scores_name": "scores"}] +) +def test_detector_rejects_unknown_names(dummy_seg_models: dict, kw: dict) -> None: + with pytest.raises(ValueError): + _detector(dummy_seg_models["detector"], **kw) diff --git a/tests/segmentation/test_pipeline.py b/tests/segmentation/test_pipeline.py new file mode 100644 index 0000000..98f56cf --- /dev/null +++ b/tests/segmentation/test_pipeline.py @@ -0,0 +1,74 @@ +"""画像 1 枚のセグメンテーション(`segmentation/pipeline.py`)のテスト. + +仕様(実装サマリーを仕様として扱う): + +- 枠で切り出して ResUNet に掛け,枠のサイズに戻したマスクを元の画像サイズのマスクに配置する + (枠の外は背景) +- 検出できない場合は前景の無いマスク(空マスク) +""" + +from __future__ import annotations + +import numpy as np + +from tias_judge_model.segmentation.detection import Rect, full_rect +from tias_judge_model.segmentation.pipeline import ( + SegmentationParams, + empty_masks, + place_masks, + segment_in_rect, +) +from tias_judge_model.segmentation.resunet import CropMasks, ResUNetRunner + +PARAMS = SegmentationParams(foreground_channel=0, min_area=500, mask_threshold=127) + + +def test_place_masks_puts_crop_at_rect() -> None: + crop = np.ones((3, 4), bool) + masks = CropMasks(post=crop, no_post=crop, other_channel=~crop, postprocess_applied=True) + placed = place_masks(masks, Rect(2, 1, 6, 4), height=6, width=8) + assert placed.post.shape == (6, 8) + assert placed.post.sum() == 12 + assert placed.post[1:4, 2:6].all() + assert not placed.other_channel.any() + assert placed.postprocess_applied is True + + +def test_empty_masks_have_no_foreground() -> None: + m = empty_masks(5, 7) + for arr in (m.post, m.no_post, m.other_channel): + assert arr.shape == (5, 7) + assert not arr.any() + assert m.postprocess_applied is False + + +def _scene() -> tuple[np.ndarray, np.ndarray]: + bgr = np.full((120, 160, 3), (150, 120, 110), np.uint8) # 青みがかった背景 + yy, xx = np.mgrid[0:120, 0:160] + mask = ((yy - 60) / 30) ** 2 + ((xx - 90) / 40) ** 2 <= 1 + bgr[mask] = (105, 95, 200) # 赤(BGR) + return bgr, mask + + +def _iou(a: np.ndarray, b: np.ndarray) -> float: + return (a & b).sum() / (a | b).sum() + + +def test_segment_whole_image_with_dummy_model(dummy_seg_models: dict) -> None: + runner = ResUNetRunner(dummy_seg_models["resunet"], 256) + bgr, mask = _scene() + m = segment_in_rect(bgr, full_rect(120, 160), runner, PARAMS) + assert m.post.shape == mask.shape + assert _iou(m.post, mask) > 0.9 + + +def test_segment_in_sub_rect_keeps_outside_background(dummy_seg_models: dict) -> None: + runner = ResUNetRunner(dummy_seg_models["resunet"], 256) + bgr, mask = _scene() + rect = Rect(40, 20, 140, 100) + m = segment_in_rect(bgr, rect, runner, PARAMS) + outside = np.ones_like(mask) + outside[20:100, 40:140] = False + assert not m.post[outside].any() + assert not m.no_post[outside].any() + assert _iou(m.post, mask) > 0.9 diff --git a/tests/segmentation/test_resunet.py b/tests/segmentation/test_resunet.py new file mode 100644 index 0000000..2b6d7f6 --- /dev/null +++ b/tests/segmentation/test_resunet.py @@ -0,0 +1,328 @@ +"""ResUNet の前処理・後処理(`segmentation/resunet.py`)のテスト. + +仕様(DeepTIAS 2.7 の再現.実装サマリーを仕様として扱う): + +- 前処理: 切り出し画像を `cv2.resize`(線形)で S×S → BGR のまま /255 → `(1, 3, S, S)` float32 +- 後処理: 出力 ch(既定 0)にシグモイド → ×255 を uint8(切り捨て)→ 大津の 2 値化 → + 輪郭が 0 個または 2 個超なら小領域除去(面積 min_area 未満の前景の島を消し,次に同じく小さい + 背景の穴を埋める.8 近傍)→ 枠のサイズに線形で戻して `> mask_threshold` で 2 値化 +- `ResUNetRunner` は入力 `(1, 3, S, S)` → 出力 `(1, 2, S, S)` の ONNX を CPU で推論する. + ファイルが無い・入力サイズが設定と違うと ValueError +""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from tias_judge_model.segmentation.resunet import ( + ResUNetRunner, + binarize_otsu, + count_contours, + needs_small_region_removal, + output_to_masks, + preprocess_resunet, + probability_map_u8, + remove_small_regions, + resize_mask, +) + +BIG = 30.0 + + +# --- 前処理 --- + + +def test_preprocess_shape_dtype_and_range() -> None: + rng = np.random.default_rng(0) + bgr = rng.integers(0, 256, size=(37, 53, 3), dtype=np.uint8) + x = preprocess_resunet(bgr, 64) + assert x.shape == (1, 3, 64, 64) + assert x.dtype == np.float32 + assert 0.0 <= x.min() and x.max() <= 1.0 + + +def test_preprocess_matches_linear_resize_divided_by_255() -> None: + rng = np.random.default_rng(1) + bgr = rng.integers(0, 256, size=(90, 70, 3), dtype=np.uint8) + x = preprocess_resunet(bgr, 32) + expected = cv2.resize(bgr, (32, 32), interpolation=cv2.INTER_LINEAR).astype(np.float32) / 255 + np.testing.assert_array_equal(x[0], expected.transpose(2, 0, 1)) + + +def test_preprocess_keeps_bgr_channel_order() -> None: + bgr = np.zeros((10, 10, 3), dtype=np.uint8) + bgr[..., 0] = 255 # B + bgr[..., 2] = 51 # R + x = preprocess_resunet(bgr, 16) + assert np.all(x[0, 0] == 1.0) # ch0 = B + assert np.all(x[0, 1] == 0.0) + assert np.allclose(x[0, 2], 51 / 255) # ch2 = R + + +@pytest.mark.parametrize( + "bad", + [ + np.zeros((10, 10), dtype=np.uint8), + np.zeros((10, 10, 4), dtype=np.uint8), + np.zeros((10, 10, 3), dtype=np.float32), + np.zeros((0, 10, 3), dtype=np.uint8), + np.zeros((10, 0, 3), dtype=np.uint8), + ], + ids=["2d", "4ch", "float", "empty_h", "empty_w"], +) +def test_preprocess_rejects_bad_input(bad: np.ndarray) -> None: + with pytest.raises(ValueError): + preprocess_resunet(bad, 16) + + +# --- シグモイドと丸め --- + + +def _output(ch0: np.ndarray, ch1: np.ndarray | None = None) -> np.ndarray: + ch1 = -ch0 if ch1 is None else ch1 + return np.stack([ch0, ch1])[None].astype(np.float32) + + +def test_probability_map_truncates_sigmoid_times_255() -> None: + # sigmoid(0) = 0.5 → 127.5 → 127(切り捨て) + logits = np.zeros((2, 2), dtype=np.float32) + assert np.all(probability_map_u8(_output(logits), 0) == 127) + + +@pytest.mark.parametrize("target", [1.9, 64.99, 128.5, 200.2, 254.7]) +def test_probability_map_floor_for_various_values(target: float) -> None: + p = target / 255.0 + logit = np.log(p / (1 - p)) + out = probability_map_u8(_output(np.full((1, 1), logit, np.float64)), 0) + assert out.dtype == np.uint8 + assert int(out[0, 0]) == int(np.floor(target)) + + +def test_probability_map_extremes_do_not_overflow() -> None: + logits = np.array([[1000.0, -1000.0, 50.0, -50.0]], dtype=np.float32) + out = probability_map_u8(_output(logits), 0) + assert out.tolist() == [[255, 0, 255, 0]] + + +def test_probability_map_uses_requested_channel() -> None: + ch0 = np.full((2, 2), 100.0, np.float32) + out = _output(ch0) + assert np.all(probability_map_u8(out, 0) == 255) + assert np.all(probability_map_u8(out, 1) == 0) + + +def test_binarize_otsu_separates_bimodal_map() -> None: + prob = np.full((10, 10), 20, np.uint8) + prob[3:7, 3:7] = 230 + binary = binarize_otsu(prob) + assert set(np.unique(binary)) == {0, 255} + assert np.all((binary == 255) == (prob == 230)) + + +# --- 輪郭の数と分岐 --- + + +def _canvas(size: int = 64) -> np.ndarray: + return np.zeros((size, size), np.uint8) + + +def test_count_contours_blank_is_zero() -> None: + assert count_contours(_canvas()) == 0 + + +def test_count_contours_counts_separate_blobs() -> None: + img = _canvas() + cv2.circle(img, (20, 20), 8, 255, -1) + assert count_contours(img) == 1 + cv2.circle(img, (45, 45), 6, 255, -1) + assert count_contours(img) == 2 + cv2.rectangle(img, (5, 50), (12, 58), 255, -1) + assert count_contours(img) == 3 + + +def test_sigmoid_of_moderate_logit_is_truncated_below_255() -> None: + # sigmoid(30) × 255 = 254.99999… → 254(四捨五入ではなく切り捨て) + out = probability_map_u8(_output(np.full((1, 1), 30.0, np.float32)), 0) + assert int(out[0, 0]) == 254 + + +def test_count_contours_full_foreground_is_zero() -> None: + # 全面が前景ならエッジが無く輪郭 0 個(DeepTIAS の条件では小領域除去の対象) + assert count_contours(np.full((32, 32), 255, np.uint8)) == 0 + + +@pytest.mark.parametrize( + ("n", "expected"), [(0, True), (1, False), (2, False), (3, True), (10, True)] +) +def test_needs_small_region_removal(n: int, expected: bool) -> None: + assert needs_small_region_removal(n) is expected + + +# --- 小領域除去 --- + + +def test_removes_small_island_and_keeps_boundary_area() -> None: + img = _canvas(40) + img[2:12, 2:12] = 255 # 面積 100(大) + img[20:22, 20:24] = 255 # 面積 8(min_area 未満) + img[30:33, 30:33] = 255 # 面積 9(ちょうど min_area) + out = remove_small_regions(img, 9) + assert np.all(out[2:12, 2:12] == 255) + assert np.all(out[20:22, 20:24] == 0) + assert np.all(out[30:33, 30:33] == 255) # 未満だけを除く + + +def test_fills_small_hole_and_keeps_boundary_hole() -> None: + img = np.full((40, 40), 255, np.uint8) + img[5:7, 5:9] = 0 # 穴 8(未満) + img[20:23, 20:23] = 0 # 穴 9(ちょうど) + out = remove_small_regions(img, 9) + assert np.all(out[5:7, 5:9] == 255) + assert np.all(out[20:23, 20:23] == 0) + + +def test_islands_use_8_connectivity() -> None: + img = _canvas(20) + img[5, 5] = 255 + img[6, 6] = 255 # 斜めに隣接 → 8 近傍で 1 つの成分(面積 2) + out = remove_small_regions(img, 2) + assert out[5, 5] == 255 and out[6, 6] == 255 + out = remove_small_regions(img, 3) + assert out[5, 5] == 0 and out[6, 6] == 0 + + +def test_holes_use_8_connectivity() -> None: + img = np.full((20, 20), 255, np.uint8) + img[5, 5] = 0 + img[6, 6] = 0 # 斜めに隣接する穴 → 面積 2 の 1 成分 + assert np.all(remove_small_regions(img, 2) == img) + assert np.all(remove_small_regions(img, 3) == 255) + + +def test_islands_are_removed_before_holes_are_filled() -> None: + # 細い輪(前景 16 画素)が穴(背景 9 画素)を囲む.min_area 20: + # 島の除去が先なら輪(16 < 20)が消えて穴は外の背景とつながり,全面が背景になる. + # 穴埋めが先だと輪+穴 = 25 ≥ 20 で残ってしまう. + img = _canvas(30) + img[10:15, 10:15] = 255 + img[11:14, 11:14] = 0 + out = remove_small_regions(img, 20) + assert np.all(out == 0) + + +def test_min_area_zero_changes_nothing() -> None: + rng = np.random.default_rng(0) + img = (rng.random((30, 30)) > 0.5).astype(np.uint8) * 255 + np.testing.assert_array_equal(remove_small_regions(img, 0), img) + + +def test_nonzero_values_are_treated_as_foreground() -> None: + img = _canvas(20) + img[2:18, 2:18] = 1 + out = remove_small_regions(img, 5) + assert out.dtype == np.uint8 + assert set(np.unique(out)) <= {0, 255} + assert np.all(out[2:18, 2:18] == 255) + + +# --- 枠のサイズへの戻し --- + + +def test_resize_mask_shape_and_threshold() -> None: + mask = np.zeros((8, 8), np.uint8) + mask[:, 4:] = 255 + out = resize_mask(mask, 16, 4, 127) + assert out.shape == (4, 16) + assert out.dtype == bool + assert not out[:, :7].any() + assert out[:, 9:].all() + + +def test_resize_mask_threshold_is_strictly_greater() -> None: + mask = np.full((4, 4), 127, np.uint8) + assert not resize_mask(mask, 4, 4, 127).any() + assert resize_mask(mask, 4, 4, 126).all() + + +# --- output_to_masks --- + + +def _disk_logits(size: int, spots: int) -> np.ndarray: + img = np.zeros((size, size), np.uint8) + cv2.circle(img, (size // 2, size // 2), size // 4, 255, -1) + for k in range(spots): + img[4 + 8 * k : 8 + 8 * k, 4:8] = 255 # 面積 16 の点 + return np.where(img > 0, BIG, -BIG).astype(np.float32) + + +def test_output_to_masks_without_spots_skips_postprocess() -> None: + logits = _disk_logits(64, spots=0) + m = output_to_masks( + _output(logits), 64, 64, foreground_channel=0, min_area=50, mask_threshold=127 + ) + assert not m.postprocess_applied + np.testing.assert_array_equal(m.post, m.no_post) + np.testing.assert_array_equal(m.post, logits > 0) + np.testing.assert_array_equal(m.other_channel, ~(logits > 0)) + + +def test_output_to_masks_one_spot_keeps_it_since_two_contours() -> None: + # 輪郭 2 個(舌+点 1 つ)では小領域除去をしない(DeepTIAS と同じ条件) + logits = _disk_logits(64, spots=1) + m = output_to_masks( + _output(logits), 64, 64, foreground_channel=0, min_area=50, mask_threshold=127 + ) + assert not m.postprocess_applied + assert m.post[4:8, 4:8].all() + + +def test_output_to_masks_many_spots_are_removed() -> None: + logits = _disk_logits(64, spots=2) + m = output_to_masks( + _output(logits), 64, 64, foreground_channel=0, min_area=50, mask_threshold=127 + ) + assert m.postprocess_applied + assert not m.post[4:16, 4:8].any() # 点は消える + assert m.no_post[4:8, 4:8].all() # 後処理なしには残る + disk = _disk_logits(64, spots=0) > 0 + np.testing.assert_array_equal(m.post, disk) + + +def test_output_to_masks_resizes_to_box_and_respects_foreground_channel() -> None: + logits = _disk_logits(64, spots=0) + m0 = output_to_masks( + _output(logits), 30, 20, foreground_channel=0, min_area=10, mask_threshold=127 + ) + m1 = output_to_masks( + _output(logits), 30, 20, foreground_channel=1, min_area=10, mask_threshold=127 + ) + assert m0.post.shape == (20, 30) + np.testing.assert_array_equal(m1.post, m0.other_channel) + np.testing.assert_array_equal(m1.other_channel, m0.post) + + +# --- ResUNetRunner(ダミーの ONNX) --- + + +def test_runner_runs_dummy_model(dummy_seg_models: dict) -> None: + runner = ResUNetRunner(dummy_seg_models["resunet"], 256) + bgr = np.zeros((50, 80, 3), np.uint8) + bgr[..., 2] = 200 # 赤 + out = runner.run(bgr) + assert out.shape == (1, 2, 256, 256) + assert np.all(out[0, 0] > 0) # ダミーは赤みを前景(ch0 > 0)とする + assert np.all(out[0, 1] < 0) + + +def test_runner_rejects_missing_file(tmp_path: Path) -> None: + with pytest.raises(ValueError): + ResUNetRunner(tmp_path / "none.onnx", 256) + + +def test_runner_rejects_input_size_mismatch(dummy_seg_models: dict) -> None: + with pytest.raises(ValueError): + ResUNetRunner(dummy_seg_models["resunet"], 128) diff --git a/tests/segmentation/test_seg_images.py b/tests/segmentation/test_seg_images.py new file mode 100644 index 0000000..5f5b87a --- /dev/null +++ b/tests/segmentation/test_seg_images.py @@ -0,0 +1,125 @@ +"""セグメンテーションの評価データの列挙と読み込み(`segmentation/images.py`)のテスト. + +(`tests/data/test_images.py` と basename が衝突しないよう `test_seg_images.py` とする) + +仕様(実装サマリーを仕様として扱う): + +- `/sample/*` と `/label/*` をファイル名の stem で対応づけ,stem の昇順で返す +- 拡張子は jpg・jpeg・png・bmp(大文字小文字を問わない).それ以外は無視する +- 対応の取れない画像・stem の重複・0 枚・ディレクトリが無いと ValueError. + 例外メッセージにファイル名(stem)を出さない(件数のみ) +- 正解マスクはグレースケールで読み,`threshold` 以上を前景とする +- 日本語を含むパスでも読める(`np.fromfile` → `cv2.imdecode`) +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +from PIL import Image + +from tias_judge_model.segmentation.images import list_image_pairs, read_bgr, read_label_mask + +SECRET = "SECRETPT" + + +def _save(path: Path, arr: np.ndarray) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(arr).save(path) + + +def _pair(set_dir: Path, stem: str, ext: str = ".png") -> None: + _save(set_dir / "sample" / f"{stem}{ext}", np.zeros((4, 4, 3), np.uint8)) + _save(set_dir / "label" / f"{stem}.png", np.zeros((4, 4), np.uint8)) + + +def test_pairs_are_matched_by_stem_and_sorted(tmp_path: Path) -> None: + for stem in ["b", "a", "c"]: + _pair(tmp_path, stem, ".jpg" if stem == "a" else ".png") + (tmp_path / "sample" / "notes.txt").write_text("x", encoding="utf-8") + pairs = list_image_pairs(tmp_path, "sample", "label") + assert [p.stem for p in pairs] == ["a", "b", "c"] + assert pairs[0].sample.suffix == ".jpg" + assert pairs[0].label.name == "a.png" + + +def test_uppercase_extension_is_accepted(tmp_path: Path) -> None: + _save(tmp_path / "sample" / "x.JPG", np.zeros((4, 4, 3), np.uint8)) + _save(tmp_path / "label" / "x.BMP", np.zeros((4, 4), np.uint8)) + assert [p.stem for p in list_image_pairs(tmp_path, "sample", "label")] == ["x"] + + +def test_unmatched_images_raise_without_names(tmp_path: Path) -> None: + _pair(tmp_path, "ok") + _save(tmp_path / "sample" / f"{SECRET}1.png", np.zeros((4, 4, 3), np.uint8)) + _save(tmp_path / "label" / f"{SECRET}2.png", np.zeros((4, 4), np.uint8)) + _save(tmp_path / "label" / f"{SECRET}3.png", np.zeros((4, 4), np.uint8)) + with pytest.raises(ValueError) as exc: + list_image_pairs(tmp_path, "sample", "label") + msg = str(exc.value) + assert SECRET not in msg + assert "1 件" in msg and "2 件" in msg + + +def test_duplicate_stems_raise_without_names(tmp_path: Path) -> None: + _pair(tmp_path, SECRET) + _save(tmp_path / "sample" / f"{SECRET}.jpg", np.zeros((4, 4, 3), np.uint8)) + with pytest.raises(ValueError) as exc: + list_image_pairs(tmp_path, "sample", "label") + assert SECRET not in str(exc.value) + + +def test_empty_set_raises(tmp_path: Path) -> None: + (tmp_path / "sample").mkdir() + (tmp_path / "label").mkdir() + with pytest.raises(ValueError): + list_image_pairs(tmp_path, "sample", "label") + + +def test_missing_directory_raises(tmp_path: Path) -> None: + (tmp_path / "sample").mkdir() + with pytest.raises(ValueError): + list_image_pairs(tmp_path, "sample", "label") + + +def test_read_bgr_returns_bgr_order(tmp_path: Path) -> None: + rgb = np.zeros((3, 5, 3), np.uint8) + rgb[..., 0] = 200 # R + path = tmp_path / "日本語ディレクトリ" / "画像.png" + _save(path, rgb) + bgr = read_bgr(path, "評価セット x の 1 件目") + assert bgr.shape == (3, 5, 3) + assert np.all(bgr[..., 2] == 200) and np.all(bgr[..., 0] == 0) + + +def test_read_label_mask_threshold_is_inclusive(tmp_path: Path) -> None: + mask = np.array([[0, 127, 128, 255]], np.uint8) + path = tmp_path / "ラベル.png" + _save(path, mask) + got = read_label_mask(path, 128, "w") + assert got.dtype == bool + assert got.tolist() == [[False, False, True, True]] + + +def test_read_label_mask_converts_color_to_gray(tmp_path: Path) -> None: + rgb = np.zeros((2, 2, 3), np.uint8) + rgb[0, 0] = 255 + path = tmp_path / "m.png" + _save(path, rgb) + got = read_label_mask(path, 128, "w") + assert got.shape == (2, 2) + assert got.tolist() == [[True, False], [False, False]] + + +def test_unreadable_image_raises_with_position_only(tmp_path: Path) -> None: + path = tmp_path / f"{SECRET}.png" + path.write_bytes(b"not an image") + with pytest.raises(ValueError) as exc: + read_bgr(path, "評価セット s の 3 件目") + assert "3 件目" in str(exc.value) + assert SECRET not in str(exc.value) + with pytest.raises(ValueError) as exc: + read_bgr(tmp_path / f"{SECRET}_missing.png", "評価セット s の 4 件目") + assert SECRET not in str(exc.value) diff --git a/tests/test_color_config.py b/tests/test_color_config.py index 41ff20a..9249477 100644 --- a/tests/test_color_config.py +++ b/tests/test_color_config.py @@ -22,7 +22,14 @@ CONFIGS_DIR = Path(__file__).resolve().parents[1] / "configs" COLOR_CONFIGS = sorted((CONFIGS_DIR / "color").glob("*.yaml")) -ALL_CONFIGS = sorted(p for p in CONFIGS_DIR.rglob("*.yaml") if p.name != "remote.yaml") +# configs/seg/ はセグメンテーションの評価(別スキーマ `load_seg_eval_config`)のため除く. +SEG_CONFIGS_DIR = CONFIGS_DIR / "seg" +ALL_CONFIGS = sorted( + p + for p in CONFIGS_DIR.rglob("*.yaml") + if p.name != "remote.yaml" and SEG_CONFIGS_DIR not in p.parents +) +EXPORT_CONFIGS = sorted((CONFIGS_DIR / "export").glob("*.yaml")) NO_ENV: dict[str, str] = {} @@ -167,3 +174,28 @@ assert c.data == b.data assert c.split == b.split assert c.train.inner_val_ratio == b.train.inner_val_ratio + + +# --- 判定モデルの書き出し(configs/export/)--- + + +def test_export_configs_exist() -> None: + names = {p.stem for p in EXPORT_CONFIGS} + assert { + "color_logistic_with_mild", + "color_logistic_without_mild", + "color_synthetic", + } <= names + + +@pytest.mark.parametrize("path", EXPORT_CONFIGS, ids=lambda p: p.name) +def test_every_export_config_loads_as_logistic(path: Path) -> None: + # color-export は ExperimentConfig で読み,logistic のみ対応する + c = load_config(path, environ=NO_ENV) + assert c.color is not None + assert c.color.classifier == "logistic" + assert c.train.sampler == "random" + + +def test_seg_configs_are_excluded_from_experiment_configs() -> None: + assert not [p for p in ALL_CONFIGS if SEG_CONFIGS_DIR in p.parents] diff --git a/tests/test_seg_config.py b/tests/test_seg_config.py new file mode 100644 index 0000000..332bcdb --- /dev/null +++ b/tests/test_seg_config.py @@ -0,0 +1,178 @@ +"""セグメンテーションの評価の設定(`SegEvalConfig`・`load_seg_eval_config`)のテスト. + +仕様(実装サマリーの設計を仕様として扱う): + +- `configs/seg/*.yaml` はすべて `load_seg_eval_config` で読める(`ExperimentConfig` とは別スキーマ) +- 環境変数 `TIAS_SEG_DATA_ROOT`・`TIAS_SEG_MODEL_ROOT` が空でなければ `data.root`・`models.root` + を上書きする(`TIAS_DATA_ROOT` は使わない) +- 既定値は DeepTIAS 2.7 と同じ(入力 256・前景チャンネル 0・検出のしきい値 0.5・IoU 0.8) +- 評価セットの空・名前の重複・絶対パス・`..`・未知の kind・未知のキーは拒否 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from tias_judge_model.schema import SegEvalConfig, load_seg_eval_config + +CONFIGS_DIR = Path(__file__).resolve().parents[1] / "configs" +SEG_CONFIGS = sorted((CONFIGS_DIR / "seg").glob("*.yaml")) +NO_ENV: dict[str, str] = {} + + +def _raw(**overrides) -> dict: + raw: dict = { + "data": {"root": "data_root", "sets": [{"name": "s1", "dir": "a/b", "kind": "cropped"}]}, + "models": {"root": "model_root"}, + } + raw.update(overrides) + return raw + + +def _write(tmp_path: Path, raw: dict) -> Path: + path = tmp_path / "seg.yaml" + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + return path + + +# --- リポジトリの設定ファイル --- + + +def test_seg_configs_exist() -> None: + assert {p.stem for p in SEG_CONFIGS} >= {"eval_synthetic", "eval_newton"} + + +@pytest.mark.parametrize("path", SEG_CONFIGS, ids=lambda p: p.name) +def test_every_seg_config_loads(path: Path) -> None: + c = load_seg_eval_config(path, environ=NO_ENV) + assert c.data.sets + assert c.resunet.foreground_channel == 0 + assert c.resunet.input_size == 256 + assert c.detection.score_threshold == 0.5 + kinds = {s.kind for s in c.data.sets} + assert kinds <= {"cropped", "fullsize"} + + +# --- 既定値 --- + + +def test_defaults_follow_deeptias() -> None: + c = SegEvalConfig.model_validate(_raw()) + assert c.resunet.input_size == 256 + assert c.resunet.foreground_channel == 0 + assert c.detection.score_threshold == 0.5 + assert c.evaluation.iou_threshold == 0.8 + assert c.data.sample_dirname == "sample" + assert c.data.label_dirname == "label" + assert c.models.resunet_path == Path("model_root") / "ResUNet.onnx" + assert c.models.detection_path is None + + +def test_detection_path_resolves_relative_to_models_root() -> None: + c = SegEvalConfig.model_validate( + _raw(models={"root": "mroot", "detection": "Detection_Normal.onnx"}) + ) + assert c.models.detection_path == Path("mroot") / "Detection_Normal.onnx" + + +# --- 環境変数による上書き --- + + +def test_env_overrides_data_and_model_roots(tmp_path: Path) -> None: + path = _write(tmp_path, _raw()) + env = {"TIAS_SEG_DATA_ROOT": "/srv/seg_data", "TIAS_SEG_MODEL_ROOT": "/srv/seg_models"} + c = load_seg_eval_config(path, environ=env) + assert c.data.root == Path("/srv/seg_data") + assert c.models.root == Path("/srv/seg_models") + + +def test_empty_env_does_not_override(tmp_path: Path) -> None: + path = _write(tmp_path, _raw()) + c = load_seg_eval_config(path, environ={"TIAS_SEG_DATA_ROOT": "", "TIAS_SEG_MODEL_ROOT": ""}) + assert c.data.root == Path("data_root") + assert c.models.root == Path("model_root") + + +def test_tias_data_root_is_not_used(tmp_path: Path) -> None: + path = _write(tmp_path, _raw()) + c = load_seg_eval_config(path, environ={"TIAS_DATA_ROOT": "/other"}) + assert c.data.root == Path("data_root") + assert c.models.root == Path("model_root") + + +def test_non_mapping_top_level_is_rejected(tmp_path: Path) -> None: + path = tmp_path / "bad.yaml" + path.write_text("- a\n- b\n", encoding="utf-8") + with pytest.raises(ValueError): + load_seg_eval_config(path, environ=NO_ENV) + + +# --- 不正な設定 --- + + +@pytest.mark.parametrize( + "mutate", + [ + lambda r: r["data"].update(sets=[]), + lambda r: r["data"].update( + sets=[ + {"name": "x", "dir": "a", "kind": "cropped"}, + {"name": "x", "dir": "b", "kind": "fullsize"}, + ] + ), + lambda r: r["data"].update(sets=[{"name": "x", "dir": "../a", "kind": "cropped"}]), + lambda r: r["data"].update(sets=[{"name": "x", "dir": "a/../../b", "kind": "cropped"}]), + lambda r: r["data"].update(sets=[{"name": "x", "dir": "a", "kind": "whole"}]), + lambda r: r["data"].update(sets=[{"name": "x/y", "dir": "a", "kind": "cropped"}]), + lambda r: r["data"].update(sets=[{"name": "", "dir": "a", "kind": "cropped"}]), + lambda r: r["data"].update(label_threshold=0), + lambda r: r["data"].update(label_threshold=256), + lambda r: r["data"].update(unknown=1), + lambda r: r.pop("models"), + lambda r: r["data"].pop("sets"), + lambda r: r.update(resunet={"foreground_channel": 2}), + lambda r: r.update(postprocess={"min_area": -1}), + lambda r: r.update(postprocess={"mask_threshold": 255}), + lambda r: r.update(detection={"score_threshold": 1.0}), + lambda r: r.update(detection={"input_color": "gray"}), + lambda r: r.update(evaluation={"iou_threshold": 0.0}), + lambda r: r.update(unknown={}), + ], + ids=[ + "empty_sets", + "duplicate_names", + "dotdot_dir", + "nested_dotdot_dir", + "unknown_kind", + "name_with_slash", + "empty_name", + "label_threshold_0", + "label_threshold_256", + "unknown_data_key", + "missing_models", + "missing_sets", + "bad_channel", + "negative_min_area", + "mask_threshold_255", + "score_threshold_1", + "bad_input_color", + "iou_threshold_0", + "unknown_top_key", + ], +) +def test_invalid_seg_settings_are_rejected(mutate) -> None: + raw = _raw() + mutate(raw) + with pytest.raises(ValidationError): + SegEvalConfig.model_validate(raw) + + +def test_absolute_set_dir_is_rejected(tmp_path: Path) -> None: + raw = _raw() + raw["data"]["sets"] = [{"name": "x", "dir": tmp_path.as_posix(), "kind": "cropped"}] + with pytest.raises(ValidationError): + SegEvalConfig.model_validate(raw) diff --git a/tests/training/test_color_export.py b/tests/training/test_color_export.py new file mode 100644 index 0000000..780730c --- /dev/null +++ b/tests/training/test_color_export.py @@ -0,0 +1,390 @@ +"""判定モデルの書き出し(`training/color_export.py`)のテスト. + +仕様(実装サマリーの設計を仕様として扱う): + +- `color.classifier: logistic` のみ対応(`hist_gb` は ValueError).`color` 節の欠落・ + `sampler: balanced` も ValueError +- C の選択: 全データで患者単位 K-fold(`split.seed`.`make_kfold_splits`)を組み,学習側・held_out + の両方に `data.severities` を適用する.候補 C ごとの held_out macro F1 の fold 平均が最良の C を + 採用する(厳密に上回ったときだけ更新・同点は先の候補) +- 最終モデルは severities 適用後の全データで学習する(random_state = split.seed, + class_weighting はクラス重みを sample_weight に使う) +- 学習データに 1 枚も無いクラスがあれば ValueError +- `color_model.json` の内容: format_version 1・input の前提・特徴の定義・scaler・coef (5×107)・ + intercept (5)・classes・training・provenance.患者 ID・ファイル名・画像単位の値を含まない + +C の選択規則は macro F1 を差し替えて検証し(学習はそのまま回す),最終モデルは合成データで +学習する(slow). +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +import numpy as np +import pytest +from sklearn.linear_model import LogisticRegression +from sklearn.preprocessing import StandardScaler + +import tias_judge_model.training.color_export as ce +from tias_judge_model.data.class_balance import compute_class_weights +from tias_judge_model.data.classes import CLASS_SLUGS, VALID_SEVERITIES, label_to_id +from tias_judge_model.data.color_features import ( + NUM_COLOR_FEATURES, + color_feature_names, + downscale_nearest, + extract_color_features, +) +from tias_judge_model.data.manifest import ( + ManifestRecord, + read_manifest, + resolve_image_path, +) +from tias_judge_model.data.split import make_kfold_splits, split_records +from tias_judge_model.schema import ExperimentConfig + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py" +N_FOLDS = 5 +SEED = 11 +SECRET = "SECRETPT" +C_VALUES = [0.1, 1.0, 10.0] + + +def _config( + root: str = "unused", + *, + severities: list[str] | None = None, + color: dict | None = None, + train: dict | None = None, + with_color: bool = True, +) -> ExperimentConfig: + raw: dict = { + "data": {"root": root, "severities": severities or ["strong", "mild", "normal"]}, + "split": {"seed": SEED, "n_folds": N_FOLDS}, + "train": train or {}, + } + if with_color: + raw["color"] = color or { + "classifier": "logistic", + "max_side": 64, + "logistic": {"c_values": C_VALUES, "max_iter": 3000}, + } + return ExperimentConfig.model_validate(raw) + + +def _records(per_group: int = N_FOLDS) -> list[ManifestRecord]: + """全グループ(クラス × 程度)に per_group 人(1 人 1 枚)のレコード.""" + records = [] + n = 0 + for label in CLASS_SLUGS: + for severity in VALID_SEVERITIES[label]: + for _ in range(per_group): + records.append( + ManifestRecord( + image_path=f"{label}_{severity}/{SECRET}{n:04d}.png", + patient_id=f"{SECRET}{n:04d}", + label=label, + severity=severity, + ) + ) + n += 1 + return records + + +def _separable_features(records: list[ManifestRecord], seed: int = 0) -> np.ndarray: + rng = np.random.default_rng(seed) + labels = np.array([label_to_id(r.label) for r in records]) + # クラスの one-hot(+わずかな揺らぎ)だけが情報を持つ.他の次元は定数(標準化で 0 になる) + x = np.zeros((len(records), NUM_COLOR_FEATURES)) + x[:, :5] = rng.normal(0, 0.01, size=(len(records), 5)) + x[np.arange(len(records)), labels] += 10.0 + return x + + +def _labels(records: list[ManifestRecord]) -> np.ndarray: + return np.array([label_to_id(r.label) for r in records], dtype=np.int64) + + +# --- 設定の検査 --- + + +def test_hist_gb_is_rejected() -> None: + config = _config(color={"classifier": "hist_gb"}) + with pytest.raises(ValueError): + ce.check_color_export_config(config) + + +def test_hist_gb_is_rejected_by_selection_and_training() -> None: + config = _config(color={"classifier": "hist_gb"}) + records = _records() + with pytest.raises(ValueError): + ce.select_c_by_cv(config, records, _separable_features(records), _labels(records)) + with pytest.raises(ValueError): + ce.train_final_color_model(config, records) + + +def test_missing_color_section_is_rejected() -> None: + with pytest.raises(ValueError): + ce.check_color_export_config(_config(with_color=False)) + + +def test_balanced_sampler_is_rejected() -> None: + with pytest.raises(ValueError): + ce.check_color_export_config(_config(train={"sampler": "balanced"})) + + +def test_logistic_config_is_accepted() -> None: + color = ce.check_color_export_config(_config()) + assert color.classifier == "logistic" + + +# --- C の選択 --- + + +def _scripted_macro_f1(monkeypatch: pytest.MonkeyPatch, table: list[list[float]]) -> list: + """macro F1 を (候補, fold) の表の値に差し替える(呼び出し順は fold → 候補).""" + calls: list[tuple[int, int]] = [] + n_candidates = len(table) + + def fake(y_true, y_pred) -> float: + k = len(calls) + fold, cand = divmod(k, n_candidates) + calls.append((cand, fold)) + return table[cand][fold] + + monkeypatch.setattr(ce, "macro_f1", fake) + return calls + + +def test_selection_uses_fold_mean_not_single_fold(monkeypatch: pytest.MonkeyPatch) -> None: + records = _records() + table = [ + [1.0, 0.0, 0.0, 0.0, 0.0], # 平均 0.2(1 fold だけ最良) + [0.3, 0.3, 0.3, 0.3, 0.3], # 平均 0.3(最良) + [0.25, 0.25, 0.25, 0.25, 0.25], + ] + calls = _scripted_macro_f1(monkeypatch, table) + sel = ce.select_c_by_cv(_config(), records, _separable_features(records), _labels(records)) + assert len(calls) == len(C_VALUES) * N_FOLDS + assert sel.selected_index == 1 + assert sel.selected_c == 1.0 + assert [c["C"] for c in sel.candidates] == C_VALUES + for c, row in zip(sel.candidates, table, strict=True): + assert c["fold_macro_f1"] == row + assert c["mean_macro_f1"] == pytest.approx(np.mean(row)) + + +def test_selection_tie_keeps_earlier_candidate(monkeypatch: pytest.MonkeyPatch) -> None: + records = _records() + table = [[0.4] * 5, [0.6] * 5, [0.6] * 5] + _scripted_macro_f1(monkeypatch, table) + sel = ce.select_c_by_cv(_config(), records, _separable_features(records), _labels(records)) + assert sel.selected_index == 1 + + +def test_selection_all_equal_keeps_first(monkeypatch: pytest.MonkeyPatch) -> None: + records = _records() + _scripted_macro_f1(monkeypatch, [[0.5] * 5] * 3) + sel = ce.select_c_by_cv(_config(), records, _separable_features(records), _labels(records)) + assert sel.selected_index == 0 + assert sel.selected_c == C_VALUES[0] + + +def test_selection_last_candidate_when_strictly_best(monkeypatch: pytest.MonkeyPatch) -> None: + records = _records() + _scripted_macro_f1(monkeypatch, [[0.5] * 5, [0.5] * 5, [0.5, 0.5, 0.5, 0.5, 0.51]]) + sel = ce.select_c_by_cv(_config(), records, _separable_features(records), _labels(records)) + assert sel.selected_index == 2 + assert sel.selected_c == 10.0 + + +def test_selection_with_real_scores_on_separable_data_picks_first() -> None: + # 分離可能なデータではどの C も macro F1 1.0 → 同点なので先の候補 + records = _records() + sel = ce.select_c_by_cv(_config(), records, _separable_features(records), _labels(records)) + for c in sel.candidates: + assert c["fold_macro_f1"] == [1.0] * N_FOLDS + assert sel.selected_index == 0 + + +@pytest.mark.parametrize( + "severities", [["strong", "mild", "normal"], ["strong", "normal"]], ids=["with", "without"] +) +def test_selection_applies_severities_to_both_sides(severities: list[str]) -> None: + records = _records() + config = _config(severities=severities) + sel = ce.select_c_by_cv(config, records, _separable_features(records), _labels(records)) + kept = [r for r in records if r.severity in severities] + assert len(sel.fold_counts) == N_FOLDS + assert sum(fc["held_out"] for fc in sel.fold_counts) == len(kept) + for fc in sel.fold_counts: + assert fc["train"] + fc["held_out"] == len(kept) + + +def test_selection_uses_patient_kfold_with_split_seed(monkeypatch: pytest.MonkeyPatch) -> None: + # 各 fold の学習・held_out が make_kfold_splits(records, n_folds, split.seed) と一致する + records = _records() + config = _config(severities=["strong", "normal"]) + folds = make_kfold_splits(records, N_FOLDS, SEED) + expected = [split_records(records, f, config.data.severities) for f in folds] + seen_train: list[int] = [] + real_fit = ce.fit_classifier + + def spy(model, features, labels, weight): + seen_train.append(len(labels)) + return real_fit(model, features, labels, weight) + + monkeypatch.setattr(ce, "fit_classifier", spy) + sel = ce.select_c_by_cv(config, records, _separable_features(records), _labels(records)) + assert [fc["train"] for fc in sel.fold_counts] == [len(e["train"]) for e in expected] + assert [fc["held_out"] for fc in sel.fold_counts] == [len(e["held_out"]) for e in expected] + assert seen_train == [len(e["train"]) for e in expected for _ in C_VALUES] + + +def test_selection_fails_when_too_few_patients() -> None: + records = _records(per_group=N_FOLDS - 1) + with pytest.raises(ValueError): + ce.select_c_by_cv(_config(), records, _separable_features(records), _labels(records)) + + +# --- 合成データでの最終モデル(slow) --- + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("make_synthetic_dataset_export", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def dataset(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, list[ManifestRecord]]: + root = tmp_path_factory.mktemp("color_export") / "dataset" + _load_script().generate(root, per_dir=N_FOLDS, seed=0) + return root, read_manifest(root / "manifest.csv") + + +def _features_of(root: Path, records: list[ManifestRecord], max_side: int) -> np.ndarray: + from PIL import Image + + out = [] + for r in records: + with Image.open(resolve_image_path(root / "images", r)) as img: + rgb = np.asarray(img.convert("RGB"), dtype=np.uint8) + out.append(extract_color_features(downscale_nearest(rgb, max_side))) + return np.stack(out) + + +@pytest.mark.slow +@pytest.mark.parametrize("class_weighting", ["none", "inverse"]) +def test_final_model_is_trained_on_filtered_data(dataset, class_weighting: str) -> None: + root, records = dataset + severities = ["strong", "normal"] + config = _config(str(root), severities=severities, train={"class_weighting": class_weighting}) + result = ce.train_final_color_model(config, records) + + kept = [r for r in records if r.severity in severities] + labels = _labels(kept) + assert result.class_counts == tuple(int((labels == k).sum()) for k in range(5)) + assert result.seed == SEED + assert result.selection.selected_c in C_VALUES + + # 同じ条件で sklearn を直接学習したものと一致する(random_state = split.seed・sample_weight) + features = _features_of(root, kept, 64) + scaler = StandardScaler().fit(features) + clf = LogisticRegression(C=result.selection.selected_c, max_iter=3000, random_state=SEED) + weight = None + if class_weighting != "none": + cw = compute_class_weights(labels, class_weighting) + weight = cw[labels] + assert result.class_weights == pytest.approx(tuple(cw)) + else: + assert result.class_weights is None + clf.fit(scaler.transform(features), labels, sample_weight=weight) + fitted = result.model.steps[-1][1] + np.testing.assert_allclose(result.model.steps[0][1].mean_, scaler.mean_) + np.testing.assert_allclose(fitted.coef_, clf.coef_, rtol=1e-9, atol=1e-9) + np.testing.assert_allclose(fitted.intercept_, clf.intercept_, rtol=1e-9, atol=1e-9) + + +@pytest.mark.slow +def test_missing_class_in_training_data_stops(dataset) -> None: + root, records = dataset + # 紫を manifest から除く(分割はできるが,最終モデルの学習データに紫が 1 枚も無い) + without_purple = [r for r in records if r.label != "purple"] + with pytest.raises(ValueError, match="purple"): + ce.train_final_color_model(_config(str(root)), without_purple) + + +@pytest.mark.slow +def test_missing_class_after_severity_filter_stops(dataset) -> None: + root, records = dataset + # 紫は mild だけにする → severities で mild を除くと学習データに紫が無い + records = [r for r in records if not (r.label == "purple" and r.severity == "strong")] + config = _config(str(root), severities=["strong", "normal"]) + with pytest.raises(ValueError): + ce.train_final_color_model(config, records) + + +# --- color_model.json の内容 --- + + +@pytest.fixture(scope="module") +def document(dataset) -> dict: + root, records = dataset + config = _config(str(root), severities=["strong", "normal"]) + result = ce.train_final_color_model(config, records) + doc = ce.build_color_model_document(config, result, run_id="RUNID", git_commit="abc1234") + return {"doc": doc, "result": result, "records": records} + + +@pytest.mark.slow +def test_document_structure(document: dict) -> None: + doc = document["doc"] + assert doc["format_version"] == 1 + assert doc["model_type"] == "color_logistic" + inp = doc["input"] + assert inp["color_order"] == "RGB" + assert inp["dtype"] == "uint8" + assert inp["background_rgb"] == [0, 0, 0] + assert inp["color_correction"] == "none" + f = doc["features"] + assert f["num_features"] == NUM_COLOR_FEATURES == 107 + assert f["names"] == list(color_feature_names()) + assert f["percentiles"] == [10, 25, 50, 75, 90] + assert f["ab_hist"] == {"bins": 8, "a_range": [0.0, 50.0], "b_range": [-10.0, 30.0]} + assert f["l_hist"] == {"bins": 8, "range": [20.0, 100.0]} + assert f["max_side"] == 64 + assert np.asarray(f["lab"]["srgb_to_xyz"]).shape == (3, 3) + assert len(f["lab"]["white_point"]) == 3 + assert len(doc["scaler"]["mean"]) == len(doc["scaler"]["scale"]) == 107 + assert np.asarray(doc["classifier"]["coef"]).shape == (5, 107) + assert len(doc["classifier"]["intercept"]) == 5 + assert [c["id"] for c in doc["classes"]] == [0, 1, 2, 3, 4] + assert [c["slug"] for c in doc["classes"]] == list(CLASS_SLUGS) + t = doc["training"] + assert t["severities"] == ["strong", "normal"] + assert t["C"] == document["result"].selection.selected_c + assert t["c_candidates"] == C_VALUES + assert t["seed"] == SEED + assert t["num_images"] == sum(document["result"].class_counts) + p = doc["provenance"] + assert p["run_id"] == "RUNID" + assert p["git_commit"] == "abc1234" + # JSON に書ける + json.dumps(doc, ensure_ascii=False, allow_nan=False) + + +@pytest.mark.slow +def test_document_has_no_patient_ids_or_file_names(document: dict) -> None: + text = json.dumps(document["doc"], ensure_ascii=False) + assert SECRET not in text + for r in document["records"][:5]: + assert r.patient_id not in text + assert Path(r.image_path).stem not in text diff --git a/uv.lock b/uv.lock index 0c8df43..93ca2ed 100644 --- a/uv.lock +++ b/uv.lock @@ -156,6 +156,14 @@ ] [[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] name = "fonttools" version = "4.66.0" source = { registry = "https://pypi.org/simple" } @@ -379,6 +387,22 @@ ] [[package]] +name = "ml-dtypes" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/72/307d7c4bd0600601c7133fba5cb78af7db968152951c1cd473abb1cda782/ml_dtypes-0.6.0.tar.gz", hash = "sha256:5e60251d32ced5598972e4d5e06a2f044341f9291402551a3f6f0ec44f9299b0", size = 3032327, upload-time = "2026-08-13T14:14:40.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/6a/441eb053b078954f7fea284dfb288701884d0a1404d39babb858e1649023/ml_dtypes-0.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5359c588cc62de6f78d7430f06b65853d884955494d86d6ad90b6dd64a3f3a08", size = 565447, upload-time = "2026-08-13T14:14:01.737Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cf/87e8a6c57eed63a91782a0d229856ddf73e138ce004dd71e2799a9dcdb33/ml_dtypes-0.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37da32aa97749251025666d62372775019594577b9c9e9cfda83bed48d778fdb", size = 360227, upload-time = "2026-08-13T14:14:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f9/7d76c1eae866f5d4636401b31b6d6dd90e4b4ced1fa7cfdfcca9c60e4bd3/ml_dtypes-0.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b4a480aa8fd54a1805b8ac10f3f91763926a74f73c0c364c10f9231854f4170", size = 409890, upload-time = "2026-08-13T14:14:04.248Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/9c61ec2760b5cbfb1c6558d5c991a6d8fd3271053c32db20506a9a90272b/ml_dtypes-0.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a3e9d53925597fbffafd2a37048dadeddd0bdaba58058f6ae0869ed709a184d", size = 439333, upload-time = "2026-08-13T14:14:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/6a/57/780ca3e5ab135b9fbdd8e5441abf5f801b30398371b691291e05ab9834c0/ml_dtypes-0.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:6eaed129a4afe90694b8685e2f9b6294849f5eda4af9a15be83a4326eeebd775", size = 552268, upload-time = "2026-08-13T14:14:06.866Z" }, +] + +[[package]] name = "mpmath" version = "1.3.0" source = { registry = "https://pypi.org/simple" } @@ -588,6 +612,64 @@ ] [[package]] +name = "onnx" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/3a/b73bfb8f506a7b5be43eebf71dbdd351c4116f8100e1103a91bfcbcd3502/onnx-1.23.0.tar.gz", hash = "sha256:3f4b7323ea3397c63aa6ff5d43abe16013ffefc0a96cb8a0be6527ff1e1add40", size = 6022619, upload-time = "2026-09-18T19:30:37.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/f9/54d8047c2f409cfc314a0df520c6e5703afe78b1395e8c44287724c4b37e/onnx-1.23.0-cp312-abi3-macosx_13_0_universal2.whl", hash = "sha256:6cc93aa89502acbf453a16d79b465d47d9c454f3a3cb3fc5507a8cb59ab53208", size = 9725329, upload-time = "2026-09-18T19:30:07.486Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ff/a7d3fe41debd5b8947182a632b4e001bd3a7a103212e58ef6ca0c3daab91/onnx-1.23.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95b77466b4f6bc7c2c63d6004b8db7d963fb17bf24aff13007752e9d2ebf9390", size = 8640232, upload-time = "2026-09-18T19:30:10.058Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/63cdd5740f566fd9de2cadc106529adee0566f2fcafe69a65767eedc1681/onnx-1.23.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f336004196a22fbdc16c62e7f26f20635af1826db6147c80ff3b4b8d428fc7ef", size = 8881347, upload-time = "2026-09-18T19:30:12.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/7844929ef08aee3f9cb857d5ce34bb60d44496c9cc5c66cdfd8255917283/onnx-1.23.0-cp312-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:05ad734997f09fcc9633fc2e3715033239792fc9f12aec6ec0c48e32b214e47e", size = 7314556, upload-time = "2026-09-18T19:30:15.456Z" }, + { url = "https://files.pythonhosted.org/packages/52/68/ee1ad44118d0031e28a1cabb1cc02a4c9e30273bdc25571c5847aec5e4e8/onnx-1.23.0-cp312-abi3-win32.whl", hash = "sha256:221037cb9ffe67b701fb180ca871f1ee9089f431d944f3d0313ffae68c356a80", size = 7736116, upload-time = "2026-09-18T19:30:18.06Z" }, + { url = "https://files.pythonhosted.org/packages/81/5a/766abb1411b9f9b06dc4e8fe9ff195d3b715535677c362ab3d950fa0e93f/onnx-1.23.0-cp312-abi3-win_amd64.whl", hash = "sha256:70a2f930b221f9dbdff62704838ce8bf81442787b25048f8b9cdc9118168799e", size = 7872197, upload-time = "2026-09-18T19:30:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/94/34/483fa56b4d47a5210d64dccb522a60b46e0953ca3f72d75b3c4759266f5f/onnx-1.23.0-cp312-abi3-win_arm64.whl", hash = "sha256:dcd4d269818a13c48444c96b7f17c7539d8cda7810ef802431393b6cf5df794b", size = 8037224, upload-time = "2026-09-18T19:30:22.682Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" }, + { url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" }, + { url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" }, + { url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7c/8c8097891c509d98cd128493835c95631c80be6a8f37ed9d25716c2e16f1/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f", size = 48322581, upload-time = "2026-07-02T05:50:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/eab2ad388c3cbab2a350c10c2ef19ce6bd099240afc31789032c996bab52/opencv_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00", size = 34782894, upload-time = "2026-07-02T05:51:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/afca939f40ffe2b2380bfa86f812b2f7d4acc5a27b27dc41b49cad7ce7b4/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f", size = 36521085, upload-time = "2026-07-02T06:55:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/2b/97/8170e9819764c47e436c130d3ff6cfb73b58f923eae9d3a03d8982b04aec/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4", size = 56563598, upload-time = "2026-07-02T06:55:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/1a28a7101e31801042b3098871a74b76c61581d328ef40774ff4edb53a56/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4", size = 39648433, upload-time = "2026-07-02T06:56:04.255Z" }, + { url = "https://files.pythonhosted.org/packages/9b/21/f6ef335f6e65724aa78b8d792b48d40a48c381715f1e62f5a5049e09d07e/opencv_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37", size = 61204038, upload-time = "2026-07-02T06:56:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/b8756467ea991449a293797f6b3fa80fcfdd29598a0a60d1cd5715b96e61/opencv_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9", size = 35411237, upload-time = "2026-07-02T05:50:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" }, +] + +[[package]] name = "packaging" version = "26.3" source = { registry = "https://pypi.org/simple" } @@ -623,6 +705,21 @@ ] [[package]] +name = "protobuf" +version = "7.36.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, +] + +[[package]] name = "pydantic" version = "2.13.5" source = { registry = "https://pypi.org/simple" } @@ -868,6 +965,8 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy" }, + { name = "onnxruntime" }, + { name = "opencv-python-headless" }, { name = "pillow" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -881,6 +980,7 @@ [package.dev-dependencies] dev = [ + { name = "onnx" }, { name = "pytest" }, { name = "ruff" }, ] @@ -889,6 +989,8 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.9" }, { name = "numpy", specifier = ">=2.0" }, + { name = "onnxruntime", specifier = ">=1.30.0" }, + { name = "opencv-python-headless", specifier = ">=5.0.0.93" }, { name = "pillow", specifier = ">=11.0" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -902,6 +1004,7 @@ [package.metadata.requires-dev] dev = [ + { name = "onnx", specifier = ">=1.23.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "ruff", specifier = ">=0.8" }, ]