diff --git a/.gitattributes b/.gitattributes index 6313b56..19302cf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ * text=auto eol=lf +data/models/*.onnx filter=lfs diff=lfs merge=lfs -text +data/models/*.json filter=lfs diff=lfs merge=lfs -text diff --git a/TIASjudge.spec b/TIASjudge.spec index 9234f90..c3f1c3b 100644 --- a/TIASjudge.spec +++ b/TIASjudge.spec @@ -9,6 +9,10 @@ アイコン `packaging/TIASjudge.ico`(旧 MFC 実装 `IDR_MAINFRAME` と同一ファイル)は exe のファイルアイコンに設定しつつ同梱もし,実行時のウィンドウアイコンにも使う (`gui/main_window.py` の `default_icon_path()` が解決する). +舌色判定(参考.Issue #13)のモデル `data/models/`(`Detection_Normal.onnx`・`ResUNet.onnx`・ +`color_model.json`.Git LFS 管理)も同じく `sys._MEIPASS/data/models/` へ展開され, +`gui/main_window.py` の `default_model_directory()` が解決する.ビルド前に `git lfs pull` で +実体を取得しておくこと(ポインタファイルのまま同梱すると,そのモードだけがエラー表示になる). パスはすべて spec の位置(`SPECPATH`)からの相対で組み立てる(絶対パスを埋め込まない). """ @@ -16,6 +20,9 @@ project_root = Path(SPECPATH) # noqa: F821 - PyInstaller が注入する icon_path = project_root / "packaging" / "TIASjudge.ico" +# 舌色判定(参考)のモデル(展開先は `sys._MEIPASS/data/models/`) +model_dir = project_root / "data" / "models" +model_files = ["Detection_Normal.onnx", "ResUNet.onnx", "color_model.json"] a = Analysis( # noqa: F821 [str(project_root / "packaging" / "entry.py")], @@ -25,6 +32,7 @@ (str(project_root / "data" / "data.csv"), "data"), # ウィンドウ・タスクバーのアイコン用に同梱する(展開先は `sys._MEIPASS/packaging/`) (str(icon_path), "packaging"), + *[(str(model_dir / name), "data/models") for name in model_files], ], hiddenimports=["tiasjudge", "tiasjudge.gui.main_window", "tiasjudge.gui.result_window"], hookspath=[], diff --git a/data/models/Detection_Normal.onnx b/data/models/Detection_Normal.onnx new file mode 100644 index 0000000..109e0c3 --- /dev/null +++ b/data/models/Detection_Normal.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b9831e61889e010f4330c5a685864b114b36146d4969e271a1470facc56e3bb +size 12325192 diff --git a/data/models/README.md b/data/models/README.md new file mode 100644 index 0000000..8caf35b --- /dev/null +++ b/data/models/README.md @@ -0,0 +1,15 @@ +# 舌色判定(参考)のモデル (Models) + +「AI 舌色判定(参考)」モード(Issue #13)で使うモデルファイル.いずれも Git LFS で管理する(`.gitattributes`).LFS を取得しない checkout ではポインタファイルが置かれ,アプリはこのモードだけを使用不可としてエラー表示する(`tiasjudge.core.ml.judge.is_model_file_available`). + +処理の手順・閾値・クラス順は TIASJudgeModel リポジトリの `docs/04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md` に従う. + +| ファイル | 内容 | 出所 | sha256 | +| --- | --- | --- | --- | +| `Detection_Normal.onnx` | 舌の検出モデル | DeepTIAS 2.7 の `models/Detection_Normal.pb` を TIASJudgeModel の `scripts/convert_detection_to_onnx.py` で ONNX に変換したもの | `4b9831e61889e010f4330c5a685864b114b36146d4969e271a1470facc56e3bb` | +| `ResUNet.onnx` | 舌の分割モデル(入力 `(1, 3, 256, 256)`・出力 `(1, 2, 256, 256)`) | DeepTIAS 2.7.1 に同梱のもの(そのまま) | `eb701b8c42bdd35cb9e7b58cb2f95273160a9dc7d0b7fcf67313602e239dea79` | +| `color_model.json` | 舌色 5 分類の判定モデル(色特徴 107 次元+ロジスティック回帰.ややあり除外) | TIASJudgeModel の `tias-train color-export --config configs/export/color_logistic_without_mild.yaml` の出力.実験 ID `20260928-232605_color_logistic_without_mild_405ab01-dirty` | `31e3d9c28e738e725dce19dc8ac9520fbcc8b0c9fa94c1b4e541e117be2dfc1e` | + +- DeepTIAS のモデル(`Detection_Normal.onnx`・`ResUNet.onnx`)は TIASjudge の配布物に含めてよい(2026-09-29 にユーザーが確認) +- 配布用 exe には `TIASjudge.spec` の `datas` で同梱し,実行時は `sys._MEIPASS/data/models/` から読む.置き場所は環境変数 `TIASJUDGE_MODEL_DIR` で上書きできる +- 入力は TIAS の機材で撮影した色補正なしの画像に限る(変換行列は通さない) diff --git a/data/models/ResUNet.onnx b/data/models/ResUNet.onnx new file mode 100644 index 0000000..fbba675 --- /dev/null +++ b/data/models/ResUNet.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb701b8c42bdd35cb9e7b58cb2f95273160a9dc7d0b7fcf67313602e239dea79 +size 71189535 diff --git a/data/models/color_model.json b/data/models/color_model.json new file mode 100644 index 0000000..7b94a1b --- /dev/null +++ b/data/models/color_model.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31e3d9c28e738e725dce19dc8ac9520fbcc8b0c9fa94c1b4e541e117be2dfc1e +size 27743 diff --git a/pyproject.toml b/pyproject.toml index 68feb95..5987f28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ requires-python = ">=3.12,<3.13" dependencies = [ "numpy>=2.0", + # 舌色判定(参考.Issue #13)の ONNX モデルの推論に使う + "onnxruntime>=1.30.0", # ENV_01 の選定どおり OpenCV 4.x 系に固定する(cv2.ml.SVM の挙動同一性のため) "opencv-python>=4.10,<5", "pyside6>=6.7", diff --git a/src/tiasjudge/__main__.py b/src/tiasjudge/__main__.py index 87aed7e..69d63bf 100644 --- a/src/tiasjudge/__main__.py +++ b/src/tiasjudge/__main__.py @@ -12,6 +12,8 @@ プレビューでは画面内で言語を切り替えられない) - `--verify-data`: 同梱学習データ(`data/data.csv`)を読み込み,ダミー L*a*b* で診断を 1 回実行して終了する(画面を出さない.配布 exe の動作確認用.PLAN_02 ステップ 8) +- `--verify-models`: 舌色判定(Issue #13)の同梱モデル(`data/models/`)を読み込み, + ダミー画像で検出を 1 回実行して終了する(画面を出さない.配布 exe の動作確認用) """ from __future__ import annotations @@ -91,6 +93,11 @@ action="store_true", help="同梱学習データを読み込みダミー値で診断して終了する(配布 exe の確認用)", ) + parser.add_argument( + "--verify-models", + action="store_true", + help="舌色判定の同梱モデルを読込みダミー画像で実行して終了する(exe の確認用)", + ) return parser @@ -176,6 +183,35 @@ return 0 +def _verify_bundled_models() -> int: + """舌色判定の同梱モデルを読み込み,ダミー画像で 1 回実行する(`--verify-models`). + + PyInstaller で固めた exe がモデルを同梱できているか,onnxruntime が動作するかを + 画面なしで確認するためのオプション.ダミー画像(単色)では舌は検出されないため, + 判定の結果は「舌を検出できなかった」になるのが正常である. + + Returns: + モデルを読み込めて実行できれば 0,モデルが無い・読み込めなければ 1. + """ + from tiasjudge.core.ml.judge import MlModelError, ModelPaths, TongueColorJudge + + # パス解決は GUI 側の同梱データ解決(frozen 対応)を再利用する + from tiasjudge.gui.main_window import default_model_directory + + directory = default_model_directory() + logger.info("舌色判定の同梱モデルを確認します: %s", directory) + judge = TongueColorJudge(ModelPaths.in_directory(directory)) + try: + result = judge.judge(_preview_image()) + except MlModelError as error: + logger.error("舌色判定のモデルを使えません: %s", error) + return 1 + logger.info( + "動作確認の判定結果: %s(検出スコア %s)", result.status.value, result.detection_score + ) + return 0 + + def main(argv: Sequence[str] | None = None) -> int: """アプリを起動する.戻り値はプロセスの終了コード. @@ -192,6 +228,10 @@ # 画面を作らずに同梱データと診断の動作だけを確認する return _verify_bundled_data() + if arguments.verify_models: + # 画面を作らずに舌色判定のモデルの同梱と onnxruntime の動作だけを確認する + return _verify_bundled_models() + # GUI ツールキットの import は起動時のみに限定する(`core` は GUI 非依存) from PySide6.QtWidgets import QApplication diff --git a/src/tiasjudge/core/ml/__init__.py b/src/tiasjudge/core/ml/__init__.py new file mode 100644 index 0000000..9cc2ec1 --- /dev/null +++ b/src/tiasjudge/core/ml/__init__.py @@ -0,0 +1,12 @@ +"""セグメンテーション+機械学習モデルによる舌色判定(TIASjudge Issue #13). + +既存のルールベース診断(ROI → 変換行列 → SVM.`core.roi`・`core.color`・`core.svm`)とは +独立した別の判定モードである.入力は TIAS の機材で撮影した色補正なしの画像に限る +(変換行列は通さない). + +- `color_model.py`・`detection.py`・`resunet.py`・`pipeline.py`: TIASJudgeModel の参照実装の + コピー(ロジックは改変しない.コピー元は各モジュールの docstring に記す) +- `judge.py`: 検出 → 分割 → マスク適用 → 判定をまとめた入口(GUI はこれを呼ぶだけにする) + +本パッケージも `core` の一部であり,PySide6・GUI に依存してはならない. +""" diff --git a/src/tiasjudge/core/ml/color_model.py b/src/tiasjudge/core/ml/color_model.py new file mode 100644 index 0000000..4adb64d --- /dev/null +++ b/src/tiasjudge/core/ml/color_model.py @@ -0,0 +1,373 @@ +"""舌色 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"} + +コピー元(TIASjudge Issue #13.ロジックは改変しない): TIASJudgeModel リポジトリの +`src/tias_judge_model/deploy/color_model.py` +(コミット 95cd9a2e18f3dabfc06718a8577666fa9c6ca106.本ファイルの最終変更は +6d332cfa1e637a4a824aada9972f7c6812f72acb). +内容はそのまま(本注記の追加のみ). +引き渡し仕様は TIASJudgeModel の `docs/04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md`. +""" + +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/tiasjudge/core/ml/detection.py b/src/tiasjudge/core/ml/detection.py new file mode 100644 index 0000000..b5f4683 --- /dev/null +++ b/src/tiasjudge/core/ml/detection.py @@ -0,0 +1,204 @@ +"""舌の検出(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` の有無を問わずに探す). + +コピー元(TIASjudge Issue #13.ロジックは改変しない): TIASJudgeModel リポジトリの +`src/tias_judge_model/segmentation/detection.py` +(コミット 95cd9a2e18f3dabfc06718a8577666fa9c6ca106.本ファイルの最終変更は +6d332cfa1e637a4a824aada9972f7c6812f72acb). +内容はそのまま(本注記の追加のみ). +引き渡し仕様は TIASJudgeModel の `docs/04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md`. +""" + +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/tiasjudge/core/ml/diagnosis.py b/src/tiasjudge/core/ml/diagnosis.py new file mode 100644 index 0000000..7f3a74d --- /dev/null +++ b/src/tiasjudge/core/ml/diagnosis.py @@ -0,0 +1,78 @@ +"""AI 舌色判定の確率を,既存の診断結果と同じ形(5 色の割合・上位 2 色)にする. + +画面(メイン画面の「診断結果」欄・結果画面)はルールベース診断と共通にし,推論だけを +差し替えるため(Issue #13).判定モデル(`color_model.json`)のクラスを既存の 5 色のキー +(`core.svm.COLOR_KEYS`)に割り当てる. + +本モジュールは onnxruntime を import しない(結果画面から型として参照するため軽く保つ). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass + +from tiasjudge.core.svm import COLOR_KEYS + +#: 判定モデルのクラス(`color_model.json` の `classes` の `slug`)→ 既存の 5 色のキー. +#: 表示名は 淡白→淡白・淡紅→淡紅・紅→紅・暗赤紅→暗赤・紫→紫 に対応する +CLASS_SLUG_TO_COLOR_KEY: dict[str, str] = { + "pale": "pale", + "light_red": "pink", + "red": "crimson", + "dark_red": "dark_red", + "purple": "purple", +} + +#: 確率(0〜1)を百分率にする倍率 +PERCENT_SCALE = 100 + + +@dataclass(frozen=True) +class MlDiagnosis: + """AI 舌色判定の結果を既存の診断結果と同じ形にしたもの. + + `core.svm.DiagnosisResult` と同じ名前の属性(`scores`・`percentages`・`top_labels`・ + `failed`)を持ち,結果画面・メイン画面の表示にそのまま渡せる.L*a*b* は持たない + (変換行列を通さないため). + + Attributes: + scores: 5 色の確率(0.0〜1.0.和は 1).キーは `COLOR_KEYS`. + percentages: 表示用の百分率(整数.色ごとに四捨五入するため和が 100 にならないことが + ある.ルールベース診断の表示と同じ扱い). + top_labels: 確率の降順の上位 2 色のキー(同点は `COLOR_KEYS` の順). + failed: 常に False(判定できなかった場合は本結果を作らない). + """ + + scores: dict[str, float] + percentages: dict[str, int] + top_labels: tuple[str, str] + failed: bool = False + + +def build_ml_diagnosis(probabilities_by_slug: Mapping[str, float]) -> MlDiagnosis: + """判定モデルのクラスごとの確率から,既存の形の結果を作る. + + Args: + probabilities_by_slug: クラスの `slug` → 確率(5 クラスすべて). + + Returns: + 5 色のキーに割り当てた結果. + + Raises: + ValueError: 未知のクラス,またはクラスの過不足がある場合. + """ + unknown = set(probabilities_by_slug) - set(CLASS_SLUG_TO_COLOR_KEY) + if unknown: + raise ValueError(f"判定モデルに未知のクラスがある: {sorted(unknown)}") + scores = { + CLASS_SLUG_TO_COLOR_KEY[slug]: float(value) for slug, value in probabilities_by_slug.items() + } + if set(scores) != set(COLOR_KEYS): + raise ValueError(f"判定モデルのクラスが 5 色と対応しない: {sorted(scores)}") + # 表示順を既存に揃える(dict の並びは `COLOR_KEYS`) + scores = {key: scores[key] for key in COLOR_KEYS} + percentages = {key: round(value * PERCENT_SCALE) for key, value in scores.items()} + # 安定ソートなので,同点は `COLOR_KEYS` の順になる + ordered = sorted(COLOR_KEYS, key=lambda key: scores[key], reverse=True) + return MlDiagnosis(scores=scores, percentages=percentages, top_labels=(ordered[0], ordered[1])) diff --git a/src/tiasjudge/core/ml/judge.py b/src/tiasjudge/core/ml/judge.py new file mode 100644 index 0000000..222094c --- /dev/null +++ b/src/tiasjudge/core/ml/judge.py @@ -0,0 +1,450 @@ +"""撮影画像 → 舌の検出 → 舌の分割 → マスク適用 → 舌色 5 分類をまとめた入口(Issue #13). + +処理手順・閾値・クラス順は TIASJudgeModel の引き渡し仕様 +(`docs/04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md`)に従う.各段の計算は参照実装のコピー +(`detection.py`・`resunet.py`・`pipeline.py`・`color_model.py`)を呼ぶだけで,本モジュールは +設定値の固定・モデルの遅延ロード・失敗の判定・結果の組み立てだけを持つ. + +1. 舌の検出(`Detection_Normal.onnx`): スコアが 0.5 を超える枠のうち最高の 1 つ +2. 舌の分割(`ResUNet.onnx`): 枠の中の舌マスク → 元の画像サイズのマスク +3. 撮影画像(BGR)を RGB に並べ替え,マスクの外を (0, 0, 0) にする +4. 色特徴(107 次元)→ ロジスティック回帰(`color_model.json`): 5 クラスの確率 + +入力は **TIAS の機材で撮影した色補正なしの画像**に限る(変換行列は通さない). +検出できない・マスクが空の場合は判定せず,その旨を結果の `status` で返す(例外にしない). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +import cv2 +import numpy as np +from numpy.typing import NDArray +from onnxruntime.capi import onnxruntime_pybind11_state as ort_errors + +from tiasjudge.core.ml.color_model import ( + ColorModel, + downscale_nearest, + load_color_model, + predict_proba, +) +from tiasjudge.core.ml.detection import OnnxDetector, Rect +from tiasjudge.core.ml.diagnosis import MlDiagnosis, build_ml_diagnosis +from tiasjudge.core.ml.pipeline import SegmentationParams, segment_in_rect +from tiasjudge.core.ml.resunet import ResUNetRunner + +logger = logging.getLogger(__name__) + +#: onnxruntime がモデルの読込失敗(壊れた ONNX・非対応の演算等)で送出する例外. +#: いずれも `Exception` の直接の派生で `RuntimeError` ではないため,列挙して捕捉する +_ORT_LOAD_ERRORS: tuple[type[Exception], ...] = ( + ort_errors.Fail, + ort_errors.InvalidArgument, + ort_errors.InvalidGraph, + ort_errors.InvalidProtobuf, + ort_errors.NoModel, + ort_errors.NoSuchFile, + ort_errors.NotImplemented, + ort_errors.RuntimeException, +) + +# --- モデルファイル名(`data/models/` に置く.来歴は `data/models/README.md`)--- + +#: 舌の検出モデル(DeepTIAS 2.7 の `Detection_Normal.pb` を ONNX に変換したもの) +DETECTION_MODEL_FILE_NAME = "Detection_Normal.onnx" +#: 舌の分割モデル(DeepTIAS 2.7 同梱の ResUNet) +SEGMENTATION_MODEL_FILE_NAME = "ResUNet.onnx" +#: 舌色の判定モデル(色特徴+ロジスティック回帰) +COLOR_MODEL_FILE_NAME = "color_model.json" + +# --- 設定値(引き渡し仕様「1. 舌の検出」「2. 舌の分割」.DeepTIAS 2.7 と同じ値)--- + +#: 検出の採用スコアの下限(これを **超える** 枠のみ採用.0.5 ちょうどは不採用) +DETECTION_SCORE_THRESHOLD = 0.5 +#: 検出モデルへ渡す画像の色の順(TF の物体検出 API の既定は RGB) +DETECTION_INPUT_COLOR = "rgb" +#: 検出モデルの入出力名(`:0` の有無は参照実装が吸収する) +DETECTION_INPUT_NAME = "image_tensor" +DETECTION_BOXES_NAME = "detection_boxes" +DETECTION_SCORES_NAME = "detection_scores" +#: ResUNet の入力の一辺 +RESUNET_INPUT_SIZE = 256 +#: 分割の後処理の設定(TEST_07 の評価 IoU 0.958 はこの値) +SEGMENTATION_PARAMS = SegmentationParams(foreground_channel=0, min_area=500, mask_threshold=127) + +# --- Git LFS のポインタファイルの判定(LFS を取得しない checkout ではモデルの代わりに置かれる)--- + +#: LFS のポインタファイルの先頭(git-lfs の仕様 v1) +LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1" +#: ポインタファイルとみなす大きさの上限(実物は 130 バイト前後.git-lfs は 1024 未満を扱う) +LFS_POINTER_MAX_BYTES = 1024 + + +class MlModelError(Exception): + """判定モデルを使えないことを知らせるエラーの基底(GUI がエラーダイアログで案内する).""" + + +class ModelUnavailableError(MlModelError): + """モデルファイルが無い,または Git LFS のポインタファイルのままである.""" + + +class ModelLoadError(MlModelError): + """モデルファイルはあるが読み込めない(壊れている・形式が合わない等).""" + + +def is_lfs_pointer(path: Path) -> bool: + """ファイルが Git LFS のポインタファイル(実体を取得していない状態)かを返す. + + Args: + path: 調べるファイル. + + Returns: + ポインタファイルなら True(ファイルが無い・読めない場合は False). + """ + try: + if path.stat().st_size >= LFS_POINTER_MAX_BYTES: + return False + with path.open("rb") as file: + head = file.read(len(LFS_POINTER_PREFIX)) + except OSError: + return False + return head == LFS_POINTER_PREFIX + + +def is_model_file_available(path: Path) -> bool: + """モデルファイルの実体があるか(存在し,LFS のポインタファイルでない)を返す. + + CI 等で LFS を取得しない checkout ではポインタファイルが置かれるため,存在だけでは + 判定できない.テストのスキップ条件にも使える. + + Args: + path: モデルファイルのパス. + + Returns: + 実体があれば True. + """ + return path.is_file() and not is_lfs_pointer(path) + + +@dataclass(frozen=True) +class ModelPaths: + """判定に使う 3 つのモデルファイルのパス. + + Attributes: + detection: 舌の検出モデル(`Detection_Normal.onnx`). + segmentation: 舌の分割モデル(`ResUNet.onnx`). + color: 舌色の判定モデル(`color_model.json`). + """ + + detection: Path + segmentation: Path + color: Path + + @classmethod + def in_directory(cls, directory: Path) -> ModelPaths: + """既定のファイル名で `directory` 直下のパスを組み立てる. + + Args: + directory: モデルファイルを置いたディレクトリ(`data/models` 等). + + Returns: + 3 つのモデルファイルのパス. + """ + return cls( + detection=directory / DETECTION_MODEL_FILE_NAME, + segmentation=directory / SEGMENTATION_MODEL_FILE_NAME, + color=directory / COLOR_MODEL_FILE_NAME, + ) + + def all_paths(self) -> tuple[Path, Path, Path]: + """3 つのパスを(検出・分割・判定)の順で返す.""" + return (self.detection, self.segmentation, self.color) + + +#: 使えない理由(利用者向け) +UNAVAILABLE_REASON_MISSING = "ファイルが無い" +UNAVAILABLE_REASON_LFS_POINTER = "Git LFS のポインタファイルのままで,実体が無い" + + +@dataclass(frozen=True) +class UnavailableModel: + """使えないモデルファイル 1 つ. + + Attributes: + path: モデルファイルのパス. + reason: 使えない理由(利用者向け.`UNAVAILABLE_REASON_*`). + """ + + path: Path + reason: str + + def __str__(self) -> str: + """「<パス>(理由)」の形の説明.""" + return f"{self.path}({self.reason})" + + +def find_unavailable_models(paths: ModelPaths) -> list[UnavailableModel]: + """使えないモデルファイル(無い・LFS のポインタのまま)を返す(すべて使えれば空). + + ファイルの存在と先頭の数十バイトだけを調べる(ONNX は読み込まない.起動時の判定用). + + Args: + paths: モデルファイルのパス. + + Returns: + 使えないモデルファイルの並び(検出・分割・判定の順). + """ + problems: list[UnavailableModel] = [] + for path in paths.all_paths(): + if not path.is_file(): + problems.append(UnavailableModel(path, UNAVAILABLE_REASON_MISSING)) + elif is_lfs_pointer(path): + problems.append(UnavailableModel(path, UNAVAILABLE_REASON_LFS_POINTER)) + return problems + + +class JudgeStatus(Enum): + """判定の結果の種類.""" + + #: 判定できた + SUCCEEDED = "succeeded" + #: 舌を検出できなかった(スコアが閾値を超える枠が無い) + NOT_DETECTED = "not_detected" + #: 舌の分割に失敗した(マスクの舌の画素が 0) + EMPTY_MASK = "empty_mask" + + +@dataclass(frozen=True) +class ClassProbability: + """1 クラスの予測確率. + + Attributes: + class_id: クラス ID(`color_model.json` の `classes` の `id`.0 淡白〜4 紫). + slug: クラスの識別子(`pale`・`light_red`・`red`・`dark_red`・`purple`). + display_name: 表示名(日本語.`color_model.json` の `display_name`). + probability: 予測確率(0〜1.5 クラスの和は 1). + """ + + class_id: int + slug: str + display_name: str + probability: float + + +@dataclass(frozen=True) +class MlJudgeResult: + """判定の結果. + + Attributes: + status: 結果の種類. + detection_score: 検出の最高スコア(検出モデルの出力が空なら None). + rect: 採用した舌の枠(画素.検出できなければ None). + mask: `(H, W)` の bool の舌マスク(元の画像サイズ.検出できなければ None). + probabilities: 5 クラスの予測確率(クラス ID 順.判定しなかった場合は空). + """ + + status: JudgeStatus + detection_score: float | None + rect: Rect | None + mask: NDArray[np.bool_] | None + probabilities: tuple[ClassProbability, ...] + + @property + def succeeded(self) -> bool: + """判定できたか.""" + return self.status is JudgeStatus.SUCCEEDED + + @property + def top(self) -> ClassProbability | None: + """最大の確率のクラス(判定結果).判定しなかった場合は None. + + 同点は ID の小さいクラス(`np.argmax` と同じ.引き渡し仕様「4. 舌色の判定」). + """ + if not self.probabilities: + return None + values = np.array([item.probability for item in self.probabilities]) + return self.probabilities[int(np.argmax(values))] + + @property + def mask_pixel_count(self) -> int: + """舌マスクの画素数(マスクが無ければ 0).""" + return 0 if self.mask is None else int(np.count_nonzero(self.mask)) + + def to_diagnosis(self) -> MlDiagnosis: + """既存の診断結果と同じ形(5 色の割合・上位 2 色)にする(画面表示用). + + Raises: + ValueError: 判定しなかった結果(検出できない・マスクが空)の場合. + """ + if not self.succeeded: + raise ValueError(f"判定していない結果は変換できない({self.status.value})") + return build_ml_diagnosis({item.slug: item.probability for item in self.probabilities}) + + +def apply_mask(bgr: NDArray[np.uint8], mask: NDArray[np.bool_]) -> NDArray[np.uint8]: + """撮影画像を RGB に並べ替え,マスクの外を (0, 0, 0) にする(引き渡し仕様「3.」). + + Args: + bgr: 撮影画像(`(H, W, 3)`, uint8.OpenCV で読んだ BGR のまま). + mask: `(H, W)` の bool の舌マスク. + + Returns: + `(H, W, 3)` の uint8 の RGB 画像(舌の外は黒). + """ + rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + rgb[~mask] = 0 + return rgb + + +def _has_tongue_pixels(rgb_masked: NDArray[np.uint8], model: ColorModel) -> bool: + """判定モデルが特徴を取れる(背景の黒以外の画素が残る)かを返す. + + `predict_proba` は内部で `max_side` へ縮小してから黒以外の画素を舌とみなすため, + 同じ縮小をしてから確かめる(ごく小さなマスクが縮小で消える場合も判定しない). + """ + arr = rgb_masked + if model.max_side is not None: + arr = downscale_nearest(arr, model.max_side) + return bool(np.any(arr != 0)) + + +class TongueColorJudge: + """撮影画像から舌色 5 分類の確率を求める(検出 → 分割 → マスク適用 → 判定). + + モデル(ONNX セッション・判定モデル)は生成時には読み込まず,初回の `judge`(または + `load`)で読み込んで以後は再利用する(起動を遅くしないため). + + Args: + paths: モデルファイルのパス. + """ + + def __init__(self, paths: ModelPaths) -> None: + self._paths = paths + self._detector: OnnxDetector | None = None + self._runner: ResUNetRunner | None = None + self._color_model: ColorModel | None = None + + @property + def paths(self) -> ModelPaths: + """モデルファイルのパス.""" + return self._paths + + @property + def is_loaded(self) -> bool: + """モデルを読み込み済みか.""" + return self._color_model is not None + + def load(self) -> None: + """モデルを読み込む(読み込み済みなら何もしない). + + Raises: + ModelUnavailableError: モデルファイルが無い,または LFS のポインタファイルの場合. + ModelLoadError: モデルファイルを読み込めない場合. + """ + if self.is_loaded: + return + problems = find_unavailable_models(self._paths) + if problems: + raise ModelUnavailableError( + "使えないモデルファイルがある:\n" + "\n".join(map(str, problems)) + ) + + logger.info("舌色判定のモデルを読み込みます: %s", self._paths) + try: + detector = OnnxDetector( + self._paths.detection, + score_threshold=DETECTION_SCORE_THRESHOLD, + input_color=DETECTION_INPUT_COLOR, + input_name=DETECTION_INPUT_NAME, + boxes_name=DETECTION_BOXES_NAME, + scores_name=DETECTION_SCORES_NAME, + ) + runner = ResUNetRunner(self._paths.segmentation, RESUNET_INPUT_SIZE) + color_model = load_color_model(self._paths.color) + except (ValueError, OSError, *_ORT_LOAD_ERRORS) as error: + # 参照実装の入出力名・形状の不一致と JSON の形式不正は ValueError(JSON の構文誤りの + # `json.JSONDecodeError` も ValueError の派生).壊れた ONNX は onnxruntime の例外 + logger.warning("舌色判定のモデルを読み込めません: %s", error) + raise ModelLoadError(f"モデルファイルを読み込めない: {error}") from error + + self._detector = detector + self._runner = runner + self._color_model = color_model + + def judge(self, bgr: NDArray[np.uint8]) -> MlJudgeResult: + """撮影画像 1 枚の舌色を判定する. + + Args: + bgr: 撮影画像(`(H, W, 3)`, uint8.OpenCV で読んだ BGR のまま.色補正なし. + TIASjudge の変換行列は通さない). + + Returns: + 判定の結果(検出できない・マスクが空なら `status` でそれを示し,確率は空). + + Raises: + ModelUnavailableError: モデルファイルが無い,または LFS のポインタファイルの場合. + ModelLoadError: モデルファイルを読み込めない場合. + """ + self.load() + detector, runner, color_model = self._detector, self._runner, self._color_model + if detector is None or runner is None or color_model is None: # load() 後は常に揃う + raise ModelLoadError("モデルが読み込まれていない") + + detection = detector.detect(bgr) + if detection.rect is None: + logger.info("舌を検出できませんでした(最高スコア %s)", detection.score) + return MlJudgeResult( + status=JudgeStatus.NOT_DETECTED, + detection_score=detection.score, + rect=None, + mask=None, + probabilities=(), + ) + + masks = segment_in_rect(bgr, detection.rect, runner, SEGMENTATION_PARAMS) + mask = masks.post + rgb_masked = apply_mask(bgr, mask) + if not mask.any() or not _has_tongue_pixels(rgb_masked, color_model): + logger.info( + "舌の領域が空でした(検出スコア %.4f・枠 %s)", detection.score, detection.rect + ) + return MlJudgeResult( + status=JudgeStatus.EMPTY_MASK, + detection_score=detection.score, + rect=detection.rect, + mask=mask, + probabilities=(), + ) + + proba = predict_proba(rgb_masked, color_model) + probabilities = tuple( + ClassProbability( + class_id=int(item["id"]), + slug=str(item["slug"]), + display_name=str(item["display_name"]), + probability=float(value), + ) + for item, value in zip(color_model.classes, proba, strict=True) + ) + result = MlJudgeResult( + status=JudgeStatus.SUCCEEDED, + detection_score=detection.score, + rect=detection.rect, + mask=mask, + probabilities=probabilities, + ) + # 研究用途での再確認のため,入力(枠・マスク)と出力(確率)を INFO で残す + # (error-handling.md「ログ」) + logger.info( + "舌色判定: 検出スコア %.4f・枠 %s・舌の画素 %d・確率 %s・判定 %s", + detection.score, + detection.rect, + result.mask_pixel_count, + {item.slug: item.probability for item in probabilities}, + result.top.slug if result.top is not None else None, + ) + return result diff --git a/src/tiasjudge/core/ml/pipeline.py b/src/tiasjudge/core/ml/pipeline.py new file mode 100644 index 0000000..f7da301 --- /dev/null +++ b/src/tiasjudge/core/ml/pipeline.py @@ -0,0 +1,107 @@ +"""画像 1 枚のセグメンテーション(枠で切り出す → ResUNet → 元の画像の位置へ戻す). + +枠の決め方は呼び出し側が選ぶ(`detection.py`): + +- 検出なし: 画像全体(`full_rect`)を ResUNet に掛ける.切り出し済み画像はこれ +- 正解マスクの外接矩形(`mask_bounding_rect`): 検出が完璧な場合の上限を見るため +- 検出モデル(`OnnxDetector`): DeepTIAS 2.7 と同じ + +コピー元(TIASjudge Issue #13.ロジックは改変しない): TIASJudgeModel リポジトリの +`src/tias_judge_model/segmentation/pipeline.py` +(コミット 95cd9a2e18f3dabfc06718a8577666fa9c6ca106.本ファイルの最終変更は +6d332cfa1e637a4a824aada9972f7c6812f72acb). +import のパッケージ名だけを `tiasjudge.core.ml` に合わせた. +引き渡し仕様は TIASJudgeModel の `docs/04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from tiasjudge.core.ml.detection import Rect +from tiasjudge.core.ml.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/tiasjudge/core/ml/resunet.py b/src/tiasjudge/core/ml/resunet.py new file mode 100644 index 0000000..98b058f --- /dev/null +++ b/src/tiasjudge/core/ml/resunet.py @@ -0,0 +1,271 @@ +"""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` に閉じ込める. + +コピー元(TIASjudge Issue #13.ロジックは改変しない): TIASJudgeModel リポジトリの +`src/tias_judge_model/segmentation/resunet.py` +(コミット 95cd9a2e18f3dabfc06718a8577666fa9c6ca106.本ファイルの最終変更は +6d332cfa1e637a4a824aada9972f7c6812f72acb). +内容はそのまま(本注記の追加のみ). +引き渡し仕様は TIASJudgeModel の `docs/04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md`. +""" + +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/tiasjudge/gui/main_window.py b/src/tiasjudge/gui/main_window.py index 7dbf3c2..61dd4b5 100644 --- a/src/tiasjudge/gui/main_window.py +++ b/src/tiasjudge/gui/main_window.py @@ -8,6 +8,13 @@ 4. [舌色診断] で `core` のパイプライン(ROI 平均色 → Lab → SVM)を実行する 5. [診断続行] で入力をリセットし,次の診断へ進む +①の上の「判定方法」で,推論だけを「AI」に切り替えられる(Issue #13.既定は +ルールベース).AI モードでは①変換行列と③クリックを使わず,②で読み込んだ撮影画像 +(クロップ前.変換行列は通さない)を④で `core.ml.judge.TongueColorJudge` に渡し,検出 → +舌の分割 → 色特徴 → ロジスティック回帰で 5 色の確率を求める.結果は既存と同じ「診断結果」欄 +(整数 %)と結果画面に出し,結果画面のタイトルに AI 判定である旨を付ける.ルールベース +モードの挙動・表示・診断結果は従来と同一.判定モデルは初回の AI 診断時に遅延ロードする. + 依存方向は `gui` → `core` の一方向とする(project-structure.md).診断計算は本モジュールに 実装せず,必ず `core` を呼ぶ.画面に依存しないロジック(クリック点の管理・入力ガード・ クロップ・表示座標の変換・パイプライン実行)は QWidget 非依存の関数・クラスに分離してある. @@ -26,6 +33,7 @@ import sys from collections.abc import Callable, Sequence from dataclasses import dataclass, field +from enum import Enum from pathlib import Path import cv2 @@ -35,6 +43,7 @@ from PySide6.QtGui import QColor, QFont, QIcon, QMouseEvent, QPainter, QPixmap from PySide6.QtWidgets import ( QApplication, + QButtonGroup, QFileDialog, QFrame, QGridLayout, @@ -45,6 +54,7 @@ QMainWindow, QMessageBox, QPushButton, + QRadioButton, QVBoxLayout, QWidget, ) @@ -56,9 +66,20 @@ load_training_data, load_transform_matrix, ) +from tiasjudge.core.ml.judge import ( + COLOR_MODEL_FILE_NAME, + JudgeStatus, + MlJudgeResult, + MlModelError, + ModelPaths, + TongueColorJudge, + UnavailableModel, + find_unavailable_models, +) from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose from tiasjudge.gui.result_window import ( IMAGE_DISPLAY_DIVISOR, + DisplayResult, Language, ResultWindow, next_language, @@ -106,6 +127,19 @@ #: 学習データを置くディレクトリ名(リポジトリ・PyInstaller 展開先で共通) TRAINING_DATA_DIR_NAME = "data" +#: 舌色判定のモデルを置くディレクトリを上書きする環境変数 +MODEL_DIR_ENV_VAR = "TIASJUDGE_MODEL_DIR" +#: 舌色判定のモデルを置くディレクトリ(リポジトリ・PyInstaller 展開先で共通. +#: `data/` の下の `models/`) +MODEL_DIR_NAME = f"{TRAINING_DATA_DIR_NAME}/models" + +#: 舌色判定の重ね描き: 舌マスクの輪郭の色(BGR.緑)と太さ(1/2 表示でも見える太さ) +ML_CONTOUR_COLOR = (0, 255, 0) +ML_CONTOUR_THICKNESS = 4 +#: 舌色判定の重ね描き: 舌の検出枠の色(BGR.水色)と太さ +ML_RECT_COLOR = (255, 255, 0) +ML_RECT_THICKNESS = 2 + #: アプリのアイコンファイル名(旧 MFC 実装 `IDR_MAINFRAME` と同一のアイコン) ICON_FILE_NAME = "TIASjudge.ico" #: アイコンを置くディレクトリ名(リポジトリ・PyInstaller 展開先で共通) @@ -126,6 +160,57 @@ MESSAGE_MATRIX_EXTENSION = "csv ファイルを選択しなおしてください" MESSAGE_IMAGE_EXTENSION = "png ファイルを選択しなおしてください" +# --- 舌色判定の文言(Issue #13)--- + +ML_OVERLAY_NOTE_TEXT = "\n".join( + ( + "画像上の緑線は検出した舌の輪郭,水色の枠は", + "舌の検出枠です.輪郭が舌からずれている場合は", + "結果を参考にしないでください.", + ) +) +MESSAGE_ML_NOT_DETECTED = ( + "舌を検出できませんでした(最高スコア {score}).\n" + "舌全体が写るように撮り直した画像で,もう一度判定してください." +) +MESSAGE_ML_EMPTY_MASK = ( + "舌の領域を切り出せませんでした.\n" + "舌全体が写るように撮り直した画像で,もう一度判定してください." +) +MESSAGE_ML_UNAVAILABLE = ( + "AI のモデルを使えないため,AI での判定はできません.\n判定方法をルールベースに戻しました." +) +#: モデルを使えないため AI を選べないときに「判定方法」内へ出す案内の見出し(2 行) +ML_DISABLED_HEADING = "AI は使用できません\n(モデルファイルを確認してください):" +#: 実行時にモデルを読み込めなかった場合のラベルの文言(詳細はツールチップに出す) +ML_LOAD_FAILED_LINES: tuple[str, ...] = ( + "・モデルファイルを読み込めません", + " (詳細は AI にマウスを載せると表示)", +) + +#: 判定方法の見出しと選択肢 +MODE_GROUP_TITLE = "判定方法" +MODE_LABEL_RULE_BASED = "ルールベース(ROI)" +MODE_LABEL_AI = "AI" + + +def ml_disabled_label_text(problems: Sequence[UnavailableModel]) -> str: + """モデルを使えない理由をラベル用の短い文言(ファイル名と理由を別の行)にする. + + 完全なパスは折り返せず画面が横に広がるため,ラベルにはファイル名だけを出す + (完全なパスは `ml_disabled_tooltip_text` でツールチップに出す). + """ + lines = [ML_DISABLED_HEADING] + for problem in problems: + lines += [f"・{problem.path.name}", f" {problem.reason}"] + return "\n".join(lines) + + +def ml_disabled_tooltip_text(details: Sequence[object]) -> str: + """モデルを使えない理由をツールチップ用の文言(完全なパス・エラー文)にする.""" + return "\n".join([ML_DISABLED_HEADING, *(f"・{detail}" for detail in details)]) + + #: 画像未読込時のプレースホルダ文言(旧実装の `nofile.bmp` に相当.画像アセットに依存しない) PLACEHOLDER_TEXT = "舌画像(.png)を読み込んでください" @@ -223,6 +308,24 @@ Raises: ImageLoadError: ファイルを読めない,または画像として解釈できない場合. """ + return crop_center(read_image_file(path)) + + +def read_image_file(path: Path | str) -> NDArray[np.uint8]: + """画像を BGR で読み込んで返す(クロップしない.`cv2.imread` の既定と同じ 8 bit BGR). + + 舌色判定は撮影画像そのもの(引き渡し仕様「全体の流れ」)を使うため, + クロップ前の画像も取り出せるようにしてある. + + Args: + path: 画像ファイルのパス. + + Returns: + BGR 画像(形状 (高さ, 幅, 3),uint8). + + Raises: + ImageLoadError: ファイルを読めない,または画像として解釈できない場合. + """ image_path = Path(path) try: buffer = np.frombuffer(image_path.read_bytes(), dtype=np.uint8) @@ -240,7 +343,7 @@ raise ImageLoadError(f"画像として読み込めません: {image_path}") from error if image is None: raise ImageLoadError(f"画像として読み込めません: {image_path}") - return crop_center(image) + return image def draw_roi_markers( @@ -262,6 +365,46 @@ return annotated +def draw_ml_overlay(image: NDArray[np.uint8], result: MlJudgeResult) -> NDArray[np.uint8]: + """舌色判定の舌マスクの輪郭と検出枠を描いた画像(コピー)を返す. + + セグメンテーションが正しく行われたかを利用者が確かめるための表示(Issue #13). + + Args: + image: 判定に用いた撮影画像(クロップ前の BGR). + result: 判定の結果(マスク・枠が無ければ描かない). + + Returns: + 重ね描きした BGR 画像. + """ + annotated = image.copy() + if result.rect is not None: + rect = result.rect + cv2.rectangle( + annotated, + (rect.x0, rect.y0), + (rect.x1 - 1, rect.y1 - 1), + ML_RECT_COLOR, + ML_RECT_THICKNESS, + ) + if result.mask is not None and result.mask.any(): + contours, _ = cv2.findContours( + result.mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + cv2.drawContours(annotated, contours, -1, ML_CONTOUR_COLOR, ML_CONTOUR_THICKNESS) + return annotated + + +def ml_failure_message(result: MlJudgeResult) -> str | None: + """判定しなかった場合に利用者へ出す案内を返す(判定できた場合は None).""" + if result.status is JudgeStatus.NOT_DETECTED: + score = "-" if result.detection_score is None else f"{result.detection_score:.2f}" + return MESSAGE_ML_NOT_DETECTED.format(score=score) + if result.status is JudgeStatus.EMPTY_MASK: + return MESSAGE_ML_EMPTY_MASK + return None + + def is_roi_inside_image(image: NDArray[np.uint8], point: tuple[int, int]) -> bool: """ROI(51x51 px)が画像内に収まるクリック点かどうかを返す. @@ -333,6 +476,22 @@ return found if found is not None else candidates[-1] +def default_model_directory() -> Path: + """舌色判定のモデルを置くディレクトリ(`data/models`)を返す. + + 環境変数 `TIASJUDGE_MODEL_DIR` があればそれを優先する.PyInstaller の onefile exe では + 展開先(`sys._MEIPASS`)配下の `data/models` を使う.判定モデル(`color_model.json`)が + 実在する候補を選び,無ければ最後の候補(リポジトリ直下)を返す(絶対パスは埋め込まない). + """ + override = os.environ.get(MODEL_DIR_ENV_VAR) + if override: + return Path(override) + + candidates = _resource_candidates(MODEL_DIR_NAME, COLOR_MODEL_FILE_NAME) + found = _first_existing(candidates) + return (found if found is not None else candidates[-1]).parent + + def default_icon_path() -> Path | None: """アプリのアイコン(`packaging/TIASjudge.ico`)のパスを返す(無ければ None). @@ -397,6 +556,15 @@ return diagnose(lab, training) +class DiagnosisMode(Enum): + """判定方法(推論の種類).表示・操作の流れは共通で,推論だけが異なる.""" + + #: ROI クリック → 変換行列 → SVM(従来どおり.PLAN_01 の成功基準の対象) + RULE_BASED = "rule_based" + #: 撮影画像 → 舌の検出・分割 → 色特徴 → ロジスティック回帰(Issue #13) + AI = "ai" + + @dataclass class MainWindowState: """メイン画面の入力状態(画面部品に依存しない). @@ -411,6 +579,12 @@ image_path: Path | None = None points: list[tuple[int, int]] = field(default_factory=list) diagnosed: bool = False + #: クロップ前の撮影画像(舌色判定の入力.変換行列は通さない) + raw_image: NDArray[np.uint8] | None = None + #: 舌色判定の結果(画像を読み替える・診断続行・判定方法の切替で消える) + ml_result: MlJudgeResult | None = None + #: 判定方法(既定はルールベース.診断後は [診断続行] まで切り替えない) + mode: DiagnosisMode = DiagnosisMode.RULE_BASED @property def has_transform(self) -> bool: @@ -428,10 +602,27 @@ return len(self.points) @property + def is_ai_mode(self) -> bool: + """判定方法が AI か.""" + return self.mode is DiagnosisMode.AI + + @property def can_diagnose(self) -> bool: - """[舌色診断] を有効化できるか(1 点以上クリック済み).""" + """[舌色診断] を有効化できるか. + + ルールベース: 変換行列・画像を読込済みで 1 点以上クリック済み(従来どおり). + AI: 画像を読込済みで未診断(入力が画像だけで,同じ画像を再判定しても結果は同じため). + """ + if self.is_ai_mode: + return self.has_image and not self.diagnosed return self.has_transform and self.has_image and self.point_count > 0 + def set_mode(self, mode: DiagnosisMode) -> None: + """判定方法を切り替え,クリック点と舌色判定の結果を初期化する.""" + self.mode = mode + self.ml_result = None + self.clear_points() + def set_transform(self, path: Path, coefficients: NDArray[np.float64]) -> None: """変換行列を設定する.""" self.transform_path = path @@ -442,16 +633,31 @@ self.transform_path = None self.coefficients = None - def set_image(self, path: Path, image: NDArray[np.uint8]) -> None: - """画像を設定し,クリック点を初期化する.""" + def set_image( + self, + path: Path, + image: NDArray[np.uint8], + raw_image: NDArray[np.uint8] | None = None, + ) -> None: + """画像を設定し,クリック点と舌色判定の結果を初期化する. + + Args: + path: 画像ファイルのパス. + image: クロップ後の BGR 画像(ルールベース診断・表示に使う). + raw_image: クロップ前の BGR 画像(舌色判定に使う).省略時は `image`. + """ self.image_path = path self.image = image + self.raw_image = image if raw_image is None else raw_image + self.ml_result = None self.clear_points() def clear_image(self) -> None: """画像の選択を解除する([診断続行]).""" self.image_path = None self.image = None + self.raw_image = None + self.ml_result = None self.clear_points() def clear_points(self) -> None: @@ -487,9 +693,15 @@ def ensure_diagnosable(self) -> None: """診断を実行できる状態かを検証する(SPEC_01「入力ガード」). + AI モードは画像だけを要る(変換行列・クリックは使わない). + Raises: InputGuardError: 未読込,またはクリック 0 点の場合. """ + if self.is_ai_mode: + if not self.has_image: + raise InputGuardError(MESSAGE_IMAGE_REQUIRED) + return self.ensure_clickable() if self.point_count == 0: raise InputGuardError(MESSAGE_CLICK_REQUIRED) @@ -524,7 +736,7 @@ #: 結果画面へ結果を渡すための差し込み口(未設定なら `ResultWindow` を開く) -ResultPresenter = Callable[[DiagnosisResult, NDArray[np.uint8]], None] +ResultPresenter = Callable[[DisplayResult, NDArray[np.uint8]], None] class MainWindow(QMainWindow): @@ -537,6 +749,7 @@ self, training_data_path: Path | str | None = None, parent: QWidget | None = None, + model_directory: Path | str | None = None, ) -> None: super().__init__(parent) self._training_data_path = ( @@ -544,6 +757,26 @@ if training_data_path is not None else default_training_data_path() ) + # 舌色判定.モデルは初回の判定で読み込む(起動を遅くしない) + self._ml_judge = TongueColorJudge( + ModelPaths.in_directory( + Path(model_directory) if model_directory is not None else default_model_directory() + ) + ) + # 起動時はファイルの存在と LFS のポインタかどうかだけを調べる(ONNX は読み込まない). + # 使えないモデルがあれば,その説明を保持して判定方法の「AI」を選べなくする + unavailable = find_unavailable_models(self._ml_judge.paths) + #: AI を選べない理由(ラベル用・ツールチップ用).None なら AI を選べる + self._ml_disabled_texts: tuple[str, str] | None = None + if unavailable: + self._ml_disabled_texts = ( + ml_disabled_label_text(unavailable), + ml_disabled_tooltip_text(unavailable), + ) + logger.warning( + "舌色判定のモデルを使えないため,AI を選べなくします: %s", + " / ".join(map(str, unavailable)), + ) self._training: TrainingData | None = None self._state = MainWindowState() self._last_directory = Path.home() @@ -584,9 +817,13 @@ panel = QWidget(parent) layout = QVBoxLayout(panel) + layout.addWidget(self._build_mode_group(panel)) + matrix_row, self._matrix_edit, self._matrix_button = self._build_file_row( panel, "① 変換行列 (.csv) 読み込み", self._on_select_matrix ) + # AI モードでは変換行列を使わないため,グループごと無効(灰色)にする + self._matrix_group = matrix_row layout.addWidget(matrix_row) image_row, self._image_edit, self._image_button = self._build_file_row( @@ -594,7 +831,8 @@ ) layout.addWidget(image_row) - layout.addWidget(self._build_click_group(panel)) + self._click_group = self._build_click_group(panel) + layout.addWidget(self._click_group) self._diagnose_button = QPushButton("④ 舌色診断", panel) self._diagnose_button.clicked.connect(self._on_diagnose) @@ -618,6 +856,40 @@ layout.addStretch(1) return panel + def _build_mode_group(self, parent: QWidget) -> QGroupBox: + """判定方法(ルールベース/AI)の切替を作る(Issue #13). + + モデルを使えない場合は AI を選べなくし,理由(ファイル名)をラベルに,完全なパスを + ツールチップに出す. + """ + group = QGroupBox(MODE_GROUP_TITLE, parent) + layout = QVBoxLayout(group) + + row = QHBoxLayout() + self._rule_radio = QRadioButton(MODE_LABEL_RULE_BASED, group) + self._ai_radio = QRadioButton(MODE_LABEL_AI, group) + self._rule_radio.setChecked(True) + self._mode_buttons = QButtonGroup(group) + self._mode_buttons.addButton(self._rule_radio) + self._mode_buttons.addButton(self._ai_radio) + self._ai_radio.toggled.connect(self._on_mode_toggled) + row.addWidget(self._rule_radio) + row.addWidget(self._ai_radio) + row.addStretch(1) + layout.addLayout(row) + + # 使えない理由(モデルファイル).画像の読み直しや [診断続行] でも消さない + self._ml_disabled_label = QLabel("", group) + self._ml_disabled_label.setStyleSheet("color: #b00000;") + layout.addWidget(self._ml_disabled_label) + + # 輪郭の重ね描きの説明(AI の判定で枠・輪郭を描いたときだけ表示) + self._ml_overlay_label = QLabel(ML_OVERLAY_NOTE_TEXT, group) + layout.addWidget(self._ml_overlay_label) + + self._show_ml_disabled_reason() + return group + def _build_result_control_group(self, parent: QWidget) -> QGroupBox: """結果画面(患者側モニタ)を操作するボタン群を作る. @@ -721,19 +993,27 @@ return try: check_extension(path, IMAGE_EXTENSION, MESSAGE_IMAGE_EXTENSION) - image = load_image_file(path) + raw_image = read_image_file(path) except (InputGuardError, ImageLoadError) as error: self._show_error(str(error)) return + # ルールベース診断・表示用はクロップ後(`load_image_file` と同じ), + # 舌色判定はクロップ前の撮影画像を使う + image = crop_center(raw_image) - self._state.set_image(path, image) + self._state.set_image(path, image, raw_image) self._image_edit.setText(str(path)) logger.info("画像を選択しました: %s(%dx%d)", path, image.shape[1], image.shape[0]) self._refresh_image() self._update_controls() def _on_image_clicked(self, view_x: int, view_y: int) -> None: - """画像上の左クリックで ROI を追加する(表示は 1/2 縮小).""" + """画像上の左クリックで ROI を追加する(表示は 1/2 縮小). + + AI モードは ROI を使わないため,クリックを無視する(案内も出さない). + """ + if self._state.is_ai_mode: + return point = view_to_image_point(view_x, view_y) try: self._state.add_point(point) @@ -762,6 +1042,10 @@ self._show_error(str(error)) return + if self._state.is_ai_mode: + self._diagnose_with_ai() + return + training = self._load_training_data() if training is None: return @@ -791,18 +1075,68 @@ self._update_controls() self._present_result(result, image) + def _diagnose_with_ai(self) -> None: + """AI で診断する(推論だけを差し替え,表示・状態遷移はルールベースと同じ). + + 検出失敗・マスクが空の場合は撮り直しを促し,結果画面は開かず診断済みにもしない. + モデルを読み込めない場合はルールベースに戻し,AI を選べなくする. + """ + image = self._state.image + raw_image = self._state.raw_image + if image is None or raw_image is None: + return + + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + try: + # 初回はここでモデルを読み込む(以後は再利用する) + judge_result = self._ml_judge.judge(raw_image) + except MlModelError as error: + # 壊れた ONNX 等(起動時の存在・ポインタ判定では分からない).再度試しても同じ結果に + # なるため,ルールベースに戻して AI を選べなくする(再起動すれば再び試せる) + self._ml_disabled_texts = ( + "\n".join((ML_DISABLED_HEADING, *ML_LOAD_FAILED_LINES)), + ml_disabled_tooltip_text([error]), + ) + self._show_ml_disabled_reason() + self._rule_radio.setChecked(True) + self._update_controls() + self._show_error(f"{MESSAGE_ML_UNAVAILABLE}\n{error}") + return + finally: + QApplication.restoreOverrideCursor() + + self._state.ml_result = judge_result + self._refresh_image() + self._update_controls() + failure = ml_failure_message(judge_result) + if failure is not None: + self._show_error(failure) + return + + result = judge_result.to_diagnosis() + # 研究用途での再確認のため,割合(整数 %)も INFO で残す(確率は core が記録する) + logger.info("AI 判定の表示: %s(上位 %s)", result.percentages, result.top_labels) + self._show_scores(result) + self._state.diagnosed = True + self._refresh_image() + self._update_controls() + # 結果画面には輪郭を重ねない画像を渡す(ルールベースと同じく患者側には元画像を見せる) + self._present_result(result, image) + def _on_continue(self) -> None: """[診断続行].同じ変換行列を使うか確認し,画像とクリックをリセットする.""" - answer = QMessageBox.question( - self, - "診断続行", - "同じ変換行列を使用して\n診断を続行しますか?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes, - ) - if answer == QMessageBox.StandardButton.No: - self._state.clear_transform() - self._matrix_edit.clear() + # AI モードは変換行列を使わないため,変換行列を引き継ぐかの確認はしない(そのまま残す) + if not self._state.is_ai_mode: + answer = QMessageBox.question( + self, + "診断続行", + "同じ変換行列を使用して\n診断を続行しますか?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if answer == QMessageBox.StandardButton.No: + self._state.clear_transform() + self._matrix_edit.clear() self._state.clear_image() self._image_edit.clear() @@ -811,6 +1145,16 @@ self._refresh_image() self._update_controls() + def _on_mode_toggled(self, ai_checked: bool) -> None: + """判定方法の切替.クリック点・AI の結果・重ね描きを初期化する.""" + mode = DiagnosisMode.AI if ai_checked else DiagnosisMode.RULE_BASED + if mode is self._state.mode: + return + self._state.set_mode(mode) + logger.info("判定方法を切り替えました: %s", mode.value) + self._refresh_image() + self._update_controls() + # --- 表示の更新 --- def _refresh_image(self) -> None: @@ -819,7 +1163,12 @@ if image is None: pixmap = self._placeholder_pixmap() else: - annotated = draw_roi_markers(image, self._state.points) + base = image + raw_image, ml_result = self._state.raw_image, self._state.ml_result + if raw_image is not None and ml_result is not None: + # 舌色判定の輪郭は撮影画像の座標で描き,表示用と同じクロップを掛ける + base = crop_center(draw_ml_overlay(raw_image, ml_result)) + annotated = draw_roi_markers(base, self._state.points) # 結果画面と同じ変換を使う(SPEC_01「画像表示」) pixmap = to_display_pixmap(annotated, DISPLAY_DIVISOR) self._image_view.setPixmap(pixmap) @@ -837,15 +1186,34 @@ self._matrix_button.setEnabled(not self._state.diagnosed) self._image_button.setEnabled(not self._state.diagnosed) + # 判定方法: 診断後は [診断続行] まで切り替えない.AI はモデルを使える場合だけ選べる + ai_mode = self._state.is_ai_mode + self._rule_radio.setEnabled(not self._state.diagnosed) + self._ai_radio.setEnabled(not self._state.diagnosed and self._ml_disabled_texts is None) + # AI モードでは①変換行列と③クリックを使わないため無効(灰色)にする + self._matrix_group.setEnabled(not ai_mode) + self._click_group.setEnabled(not ai_mode) + ml_result = self._state.ml_result + self._ml_overlay_label.setVisible( + ai_mode and ml_result is not None and ml_result.rect is not None + ) + # 言語切替は「次に開く画面の言語」を先に決められるよう常時有効とし, # 「閉じる」は対象がある時だけ有効にする self._result_language_button.setText(switch_button_label(self._result_language)) self._result_close_button.setEnabled(bool(self._result_windows)) - def _show_scores(self, result: DiagnosisResult) -> None: - """L*a*b* と 5 色スコアをパネルに表示する.""" - for key, value in zip(LAB_LABELS, lab_components(result.lab), strict=True): - self._lab_labels[key].setText(f"{value:.0f}") + def _show_scores(self, result: DisplayResult) -> None: + """L*a*b* と 5 色スコアをパネルに表示する. + + AI は変換行列を通さず L*a*b* を持たないため,L*a*b* は「-」のままにする. + """ + if isinstance(result, DiagnosisResult): + for key, value in zip(LAB_LABELS, lab_components(result.lab), strict=True): + self._lab_labels[key].setText(f"{value:.0f}") + else: + for label in self._lab_labels.values(): + label.setText("-") for key, label in self._score_labels.items(): label.setText(f"{result.percentages[key]:3d}%") @@ -854,7 +1222,19 @@ for label in (*self._lab_labels.values(), *self._score_labels.values()): label.setText("-") - def _present_result(self, result: DiagnosisResult, image: NDArray[np.uint8]) -> None: + def _show_ml_disabled_reason(self) -> None: + """モデルを使えない理由を「判定方法」内のラベルと AI の選択肢のツールチップに出す.""" + if self._ml_disabled_texts is None: + self._ml_disabled_label.setText("") + self._ml_disabled_label.setVisible(False) + self._ai_radio.setToolTip("") + return + label_text, tooltip_text = self._ml_disabled_texts + self._ml_disabled_label.setText(label_text) + self._ml_disabled_label.setVisible(True) + self._ai_radio.setToolTip(tooltip_text) + + def _present_result(self, result: DisplayResult, image: NDArray[np.uint8]) -> None: """結果画面へ結果を渡す(SPEC_01「結果画面(モードレス表示)」).""" self.diagnosis_finished.emit(result, image) if self.result_presenter is not None: @@ -863,7 +1243,7 @@ return self.open_result_window(result, image) - def open_result_window(self, result: DiagnosisResult, image: NDArray[np.uint8]) -> ResultWindow: + def open_result_window(self, result: DisplayResult, image: NDArray[np.uint8]) -> ResultWindow: """結果画面をモードレスで開く. 旧実装(`CResultDlg` の `Create` + `ShowWindow`)と同じく,メイン画面を @@ -871,7 +1251,7 @@ 閉じるまで保持する. Args: - result: 表示する診断結果. + result: 表示する診断結果(ルールベース診断,または AI 舌色判定). image: 診断に用いたクロップ後の BGR 画像. Returns: diff --git a/src/tiasjudge/gui/result_window.py b/src/tiasjudge/gui/result_window.py index d00f333..959b68a 100644 --- a/src/tiasjudge/gui/result_window.py +++ b/src/tiasjudge/gui/result_window.py @@ -9,6 +9,9 @@ (患者側モニタに操作 UI を出さないため) - 診断失敗時(`DiagnosisResult.failed`)は結果パネルに「Diagnosis failed / Please try again.」 を重ねて描画する +- AI 舌色判定(Issue #13.`core.ml.diagnosis.MlDiagnosis`)の結果も同じ画面で表示し, + ウィンドウタイトルに「AI 判定」である旨を付け足す(日英とも.ルールベース診断の表示は + 変えない) 2 画面運用(施術者側にメイン画面・患者側に結果画面)を想定し,メイン画面とは別のモニタが あればそこへ最大化して表示する(`show_on_secondary_screen`).モニタが 1 台の場合は @@ -54,6 +57,7 @@ QWidget, ) +from tiasjudge.core.ml.diagnosis import MlDiagnosis from tiasjudge.core.svm import DiagnosisResult logger = logging.getLogger(__name__) @@ -199,6 +203,10 @@ FAILURE_TITLE = "Diagnosis failed" FAILURE_DETAIL = "Please try again." +#: 結果画面に表示できる結果(ルールベース診断・AI 舌色判定).どちらも +#: `percentages`・`top_labels`・`failed` を持つ +DisplayResult = DiagnosisResult | MlDiagnosis + class Language(Enum): """結果画面の表示言語(SPEC_01「言語切替ボタン」).""" @@ -207,6 +215,18 @@ ENGLISH = "en" +#: AI 判定のときにウィンドウタイトルへ付け足す文言(言語別) +AI_REFERENCE_TITLE_SUFFIXES: dict[Language, str] = { + Language.JAPANESE: "(AI 判定)", + Language.ENGLISH: " (AI assessment)", +} + + +def is_ai_result(result: DisplayResult) -> bool: + """AI 舌色判定の結果かどうかを返す.""" + return isinstance(result, MlDiagnosis) + + @dataclass(frozen=True) class ColorRowText: """5 色の行に表示する文言(色名と説明). @@ -359,7 +379,7 @@ return f"{percent:3d}" -def build_result_rows(result: DiagnosisResult, language: Language) -> tuple[ResultRow, ...]: +def build_result_rows(result: DisplayResult, language: Language) -> tuple[ResultRow, ...]: """診断結果から結果パネルの行(表示順)を組み立てる. 上位 2 色(`DiagnosisResult.top_labels`)だけがスコアを持ち,強調表示される @@ -561,7 +581,11 @@ painter.restore() -def render_result_panel(rows: tuple[ResultRow, ...], texts: LanguageTexts, failed: bool) -> QPixmap: +def render_result_panel( + rows: tuple[ResultRow, ...], + texts: LanguageTexts, + failed: bool, +) -> QPixmap: """結果パネル(644x644)を描画して返す. 旧実装の背景ビットマップ(日本語)と `BuildEnglishBackground()`(英語)を @@ -767,7 +791,7 @@ def __init__( self, - result: DiagnosisResult, + result: DisplayResult, image: NDArray[np.uint8], language: Language = Language.JAPANESE, parent: QWidget | None = None, @@ -775,7 +799,7 @@ """結果画面を構築する. Args: - result: 表示する診断結果. + result: 表示する診断結果(ルールベース診断,または AI 舌色判定). image: 診断に用いたクロップ後の BGR 画像. language: 初期表示の言語. parent: 親ウィジェット(指定してもモードレスの独立ウィンドウとして開く). @@ -842,7 +866,10 @@ def _apply_language(self) -> None: """現在の言語でタイトルと結果パネルを描き直す.""" texts = texts_for(self._language) - self.setWindowTitle(texts.window_title) + title = texts.window_title + if is_ai_result(self._result): + title += AI_REFERENCE_TITLE_SUFFIXES[self._language] + self.setWindowTitle(title) rows = build_result_rows(self._result, self._language) self._panel_label.setPixmap(render_result_panel(rows, texts, self._result.failed)) diff --git a/uv.lock b/uv.lock index 7500ebc..a51fa10 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,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 = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } @@ -61,6 +69,24 @@ ] [[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" version = "4.14.0.94" source = { registry = "https://pypi.org/simple" } @@ -107,6 +133,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 = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } @@ -280,6 +321,7 @@ source = { editable = "." } dependencies = [ { name = "numpy" }, + { name = "onnxruntime" }, { name = "opencv-python" }, { name = "pyside6" }, ] @@ -294,6 +336,7 @@ [package.metadata] requires-dist = [ { name = "numpy", specifier = ">=2.0" }, + { name = "onnxruntime", specifier = ">=1.30.0" }, { name = "opencv-python", specifier = ">=4.10,<5" }, { name = "pyside6", specifier = ">=6.7" }, ]