diff --git a/CLAUDE.md b/CLAUDE.md index a181f70..116225b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 ステップ 3(core: 色変換)完了.次はステップ 4(svm/roi) +最新: PLAN_02 ステップ 4(core: SVM 分類・ROI)完了.次はステップ 5(回帰テスト.旧 exe 採取はユーザー作業) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git "a/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" index 5664538..64a4bd5 100644 --- "a/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" @@ -51,7 +51,12 @@ 5 色スコア(% 整数)が特定の組(淡紅 11・紅 5・暗赤 0・紫 42・淡白 3)と完全一致した場合,現行実装は「Diagnosis failed」と表示する. -※ これは「学習不能時に SVM が返す既知の出力パターン」をマジックナンバーで検出する現行のワークアラウンド.新実装では**学習不能・予測失敗を直接検出**して失敗表示する(表示仕様は現行と同じ). +このパターンの正体は「入力が学習データ域から大きく外れ,RBF カーネル値がほぼ 0 になり各列の判定がバイアス項のみで決まるときの SVM の既定応答」である(ステップ 4 で実測確認.L\* が正でも発生する). + +新実装は以下の**両方**を失敗条件とする(表示仕様は現行と同じ.失敗時もスコアは算出値を表示する): + +1. 原因の直接検出: 入力の NaN/inf(`INVALID_INPUT`),L\* ≤ 0(`DEGENERATE_INPUT`),全列学習不能(`NO_TRAINABLE_COLUMN`),学習・予測の内部失敗(`TRAINING_FAILED` / `PREDICTION_FAILED`) +2. 現行パターン判定の踏襲: スコア組が上記の値と完全一致した場合(`OUT_OF_DOMAIN`).現行実装の失敗表示と完全一致させるために残す(直接検出のみでは L\* が小さい正値の域外入力を取りこぼすことが実測で判明したため) ## データファイル形式 (Data File Formats) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d47f1e3..ba81626 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -32,3 +32,8 @@ - 設計判断: 旧実装の精度を忠実に再現(係数 float32 保持 → double 演算 → Lab を float32 丸め).SPEC_02 に精度踏襲の注記を追加.CIE 低輝度分岐は仕様どおり実装しない(テストは「分岐が入っていないこと」まで検証済み) - テストの検出力をミューテーション 6 種(項順入替・クリップ削除・CIE 分岐追加等)で確認し全検出 - 留意(ステップ 4 へ): XYZ=0 のとき L\*=−16 と負値になり学習データの範囲(0〜100)外の入力が SVM に入りうる(旧実装と同一挙動.問題ではないが把握しておく) +- [x] **PLAN_02 ステップ 4 完了(core: SVM 分類・ROI)**: `core/svm.py`(190 列 SVM 学習・予測・5 色スコア・上位 2 色・失敗検出)と `core/roi.py`(51×51 ROI 平均色)を実装(無人運転) + - 確定事項: 旧実装の SVM パラメータは C_SVC / RBF / γ=0.011 / C=12.0 / termCrit=EPS のみ(OpenCV 2.4 と 4.x で termCrit の解釈が同一であることをソース比較で確認.COUNT を足すと挙動が変わるため足さない).旧失敗判定マジックナンバー(42/0/5/11/3)は「域外入力に対する SVM の既定応答」であることを実測で特定し,Lab=(−16,0,0) で新実装から完全再現(旧実装との挙動一致の強い傍証) + - **⚠ 要レビュー(無人運転中の仕様変更判断)**: 失敗検出を「直接検出のみ」とする /setup 時の設計判断を一部戻した.直接検出だけでは L\* が小さい正値の域外入力(旧実装は失敗表示)を取りこぼし「紫 42%」等の誤結果を表示してしまうことが tester の実測で判明したため,最上位ルール「診断結果は旧実装と一致」を優先し,**旧パターン判定(42/0/5/11/3 → OUT_OF_DOMAIN)を直接検出と併用する形で復活**させた.SPEC_02「診断失敗の判定」節を改訂済み.異議があれば `LEGACY_FAILURE_PERCENTAGES`(svm.py)と当該節を戻すこと + - 設計判断: 集計の単精度丸め(float32 逐次加算・float32 100 倍 → 偶数丸め)と上位 2 色タイブレーク(旧 std::map キー順 crimson→dark_red→pink→purple→pale)まで旧実装を再現.ROI 範囲外は旧実装のクラッシュを ValueError に置換(GUI 側でガード前提) + - 未実装・後段: TRAINING_FAILED / PREDICTION_FAILED は健全な入力から再現不能のためテスト未発火.GUI 実装時に 5 点目クリックのガードが必要(MAX_ROI_POINTS は core 側の定数のみ) diff --git a/src/tiasjudge/core/roi.py b/src/tiasjudge/core/roi.py new file mode 100644 index 0000000..0f338c6 --- /dev/null +++ b/src/tiasjudge/core/roi.py @@ -0,0 +1,146 @@ +"""ROI(関心領域)の平均色算出. + +SPEC_02 (0) を実装する.旧実装(`org/src` の `CtonguediagnosisMFC2Dlg::getRGB()` と +`OnBnClickedButtonCalc()`)と同じく,クリック点を中心とする 51×51 px の矩形 ROI の +平均 BGR を求め,複数点クリック時は各 ROI 平均をさらに平均する. + +※ 画像は numpy 配列(OpenCV の BGR 画像)として受け取り,GUI には依存しない +(`core` の依存方向は project-structure.md を参照). +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence + +import numpy as np +from numpy.typing import NDArray + +logger = logging.getLogger(__name__) + +# --- 定数(SPEC_02 (0) / SPEC_01「ROI 指定」)--- + +#: ROI の一辺の長さ(px.旧実装 `cv::Rect(x - 25, y - 25, 51, 51)`) +ROI_SIZE = 51 +#: ROI 中心から左上までのオフセット(px.旧実装の -25) +ROI_OFFSET = 25 +#: クリック可能な最大点数(SPEC_01.5 点目はエラー) +MAX_ROI_POINTS = 4 +#: BGR 画像のチャンネル数 +CHANNEL_COUNT = 3 +#: BGR 画像の軸数(高さ・幅・チャンネルの 3 軸.`CHANNEL_COUNT` とは意味が異なる) +_IMAGE_NDIM = 3 + + +def roi_mean_bgr(image: NDArray[np.generic], point: tuple[int, int]) -> tuple[float, float, float]: + """クリック点を中心とする 51×51 px の ROI の平均 BGR を返す. + + Args: + image: BGR 画像(形状 (高さ, 幅, 3).OpenCV の `imread` と同じ並び). + point: クリック点 (x, y).画像座標(クロップ後・縮小前)で指定する. + + Returns: + (B, G, R) の平均値(float.丸めは行わない). + + Raises: + ValueError: 画像が BGR 3 チャンネルでない場合, + または ROI が画像の範囲外にはみ出す場合. + """ + array = _as_bgr_image(image) + x, y = _as_point(point) + + left = x - ROI_OFFSET + top = y - ROI_OFFSET + height, width = array.shape[:2] + # 旧実装は範囲チェックを持たず,はみ出すと OpenCV が例外を投げる. + # 新実装では原因の分かるエラーにして GUI 側でガードできるようにする + if left < 0 or top < 0 or left + ROI_SIZE > width or top + ROI_SIZE > height: + raise ValueError( + f"ROI が画像の範囲外です: 中心 ({x}, {y})・ROI {ROI_SIZE}x{ROI_SIZE}・" + f"画像 {width}x{height}" + ) + + region = array[top : top + ROI_SIZE, left : left + ROI_SIZE] + # 旧実装の `cv::mean(roiBGR)` に対応する(各チャンネルの単純平均) + totals = region.reshape(-1, CHANNEL_COUNT).sum(axis=0, dtype=np.float64) + means = totals / float(ROI_SIZE * ROI_SIZE) + return float(means[0]), float(means[1]), float(means[2]) + + +def mean_bgr( + image: NDArray[np.generic], points: Sequence[tuple[int, int]] +) -> tuple[float, float, float]: + """複数のクリック点の ROI 平均 BGR を,さらに平均して返す(SPEC_02 (0)). + + 旧実装の `m_meanColors += meanBGR` → `m_meanColors /= m_numClick` に対応する + (各 ROI 平均の合算をクリック数で割る). + + Args: + image: BGR 画像(形状 (高さ, 幅, 3)). + points: クリック点 (x, y) の並び(1 点以上). + + Returns: + (B, G, R) の平均値. + + Raises: + ValueError: 点が 1 つも無い場合,画像が不正な場合, + または ROI が画像の範囲外にはみ出す場合. + """ + if len(points) == 0: + raise ValueError("ROI の平均色には 1 点以上のクリック点が必要です") + + totals = np.zeros(CHANNEL_COUNT, dtype=np.float64) + for point in points: + totals += np.asarray(roi_mean_bgr(image, point), dtype=np.float64) + means = totals / float(len(points)) + + logger.info( + "ROI 平均色: %d 点 -> BGR=(%.6f, %.6f, %.6f)", + len(points), + means[0], + means[1], + means[2], + ) + return float(means[0]), float(means[1]), float(means[2]) + + +def mean_rgb( + image: NDArray[np.generic], points: Sequence[tuple[int, int]] +) -> tuple[float, float, float]: + """複数のクリック点の ROI 平均色を (R, G, B) の順で返す. + + `core.color.rgb_to_lab` の入力に合わせた並べ替えのみを行う(値は `mean_bgr` と同じ). + + Args: + image: BGR 画像(形状 (高さ, 幅, 3)). + points: クリック点 (x, y) の並び(1 点以上). + + Returns: + (R, G, B) の平均値. + + Raises: + ValueError: 点が 1 つも無い場合,画像が不正な場合, + または ROI が画像の範囲外にはみ出す場合(`mean_bgr` と同じ). + """ + blue, green, red = mean_bgr(image, points) + return red, green, blue + + +def _as_bgr_image(image: NDArray[np.generic]) -> NDArray[np.generic]: + """入力が BGR 3 チャンネル画像であることを検証して返す.""" + array = np.asarray(image) + if array.ndim != _IMAGE_NDIM or array.shape[2] != CHANNEL_COUNT: + raise ValueError( + f"画像は形状 (高さ, 幅, {CHANNEL_COUNT}) の BGR 画像が必要です: {array.shape}" + ) + if array.shape[0] == 0 or array.shape[1] == 0: + raise ValueError("画像が空です") + return array + + +def _as_point(point: tuple[int, int]) -> tuple[int, int]: + """クリック点を整数座標 (x, y) に整える.""" + values = tuple(point) + if len(values) != 2: + raise ValueError(f"クリック点は (x, y) の 2 要素が必要です: {point!r}") + return int(values[0]), int(values[1]) diff --git a/src/tiasjudge/core/svm.py b/src/tiasjudge/core/svm.py new file mode 100644 index 0000000..62a055f --- /dev/null +++ b/src/tiasjudge/core/svm.py @@ -0,0 +1,350 @@ +"""SVM による舌色 5 分類(SPEC_02 (3)). + +旧実装(`org/src` の `CtonguediagnosisMFC2Dlg::CalcSVM()` / `Text()`)と同じ手順を踏む. + +1. 医師 38 名 × 5 色 = 190 列それぞれについて 2 クラス SVM を**診断のたびに学習**する + (正例が 2 未満の列は学習せずスコア 0) +2. 入力 L*a*b* を各列の SVM で予測する(予測値は 0.0 / 1.0 のクラスラベル) +3. 色ごとに 38 医師分の予測を合算し,医師数で割って一致率(0〜1)を得る +4. 一致率の降順で上位 2 色を主要ラベルとする + +※ 診断失敗(SPEC_02「診断失敗の判定」)は,学習不能・予測失敗・色変換が成立しない入力を +原因として直接検出する.加えて,旧実装がスコア組 42/0/5/11/3 との一致で失敗表示していた +条件も踏襲する(このスコア組は学習データ域外の入力に対する SVM の既定応答であり, +表示を旧実装と一致させるために必要.`LEGACY_FAILURE_PERCENTAGES` を参照). +失敗は例外ではなく `DiagnosisResult.failed` で返す(error-handling.md の分類に従う). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum + +import cv2 +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from tiasjudge.core.color import Lab +from tiasjudge.core.csv_io import COLOR_COUNT, LAB_DIMENSION, TrainingData + +logger = logging.getLogger(__name__) + +# --- SVM パラメータ(SPEC_02 (3).旧実装 `CvSVMParams` の設定値と同一)--- + +#: RBF カーネルの γ(旧実装 `paramGamma = 0.011`) +SVM_GAMMA = 0.011 +#: C_SVC の C(旧実装 `paramC = 12.0`) +SVM_C = 12.0 +#: 終了条件の ε(旧実装 `FLT_EPSILON`) +SVM_TERM_EPSILON = float(np.finfo(np.float32).eps) +#: 終了条件の反復回数(旧実装 `cvTermCriteria(CV_TERMCRIT_EPS, 1000, FLT_EPSILON)` の第 2 引数). +#: 種別が EPS のみなので OpenCV 側では使われない(2.4 の `cvCheckTermCriteria`, +#: 4.x の `SVMImpl::checkParams` がともに COUNT 未指定時に maxCount を INT_MAX に置き換える) +SVM_TERM_MAX_COUNT = 1000 + +#: 学習に必要な正例の最小数(これ未満の列は学習せずスコア 0.SPEC_02 (3)) +MIN_POSITIVE_SAMPLES = 2 + +#: 有効な入力とみなす L* の下限(これ以下は診断失敗). +#: L* <= 0 は Y がほぼ 0(RGB→XYZ 変換の結果が 0 にクリップされた状態)を意味し, +#: 学習データ(L* は約 42〜71)から完全に外れるため予測が信頼できない. +#: 旧実装で「Diagnosis failed」となるスコア組(紫 42・暗赤 0・紅 5・淡紅 11・淡白 3)は, +#: まさに Lab=(-16, 0, 0)(XYZ が全て 0)を入力したときの出力であることを実測で確認した +MINIMUM_LIGHTNESS = 0.0 + +#: 旧実装が「Diagnosis failed」を表示するスコア組(%). +#: 旧実装(`org/src` の `ResultDlg.cpp`)は L* に関係なくこの組と一致したら失敗表示するため, +#: 表示仕様を一致させるために踏襲する(CLAUDE.md「診断結果は旧実装と一致させる」). +#: 値の正体はマジックナンバーではなく,**学習データ域外の入力に対する SVM の既定応答**である. +#: 入力が全サポートベクタから遠いと RBF カーネルの値がほぼ 0 になり,各列の判定が +#: バイアス項のみで決まるため,域外入力では入力値によらずこの固定パターンが出る +#: (Lab=(-16,0,0)・(0.001,0,0)・(5,0,0)・(50,200,200) 等で実測確認済み) +LEGACY_FAILURE_PERCENTAGES: dict[str, int] = { + "purple": 42, + "dark_red": 0, + "crimson": 5, + "pink": 11, + "pale": 3, +} + +#: 5 色のキー(列インデックス `i % 5` の順=紫・暗赤・紅・淡紅・淡白) +COLOR_KEYS: tuple[str, ...] = ("purple", "dark_red", "crimson", "pink", "pale") + +#: 同点時の順位付けの基準となる並び. +#: 旧実装は `std::map`(キー昇順 "C" < "D" < "Pi" < "Pu" < "W")を +#: `std::sort` で降順に並べ替える.要素数 5 では MSVC の `std::sort` が挿入ソートとなり +#: 安定なので,同点時はこのキー順が保たれる.その順序を再現する +_TIE_BREAK_ORDER: tuple[str, ...] = ("crimson", "dark_red", "pink", "purple", "pale") + + +class FailureReason(Enum): + """診断失敗の原因(SPEC_02「診断失敗の判定」の直接検出).""" + + #: 入力 L*a*b* に NaN / inf が含まれ,予測できない + INVALID_INPUT = "入力の L*a*b* が数値として不正です" + #: 入力 L* が 0 以下(XYZ が 0 にクリップされた)で,予測が信頼できない + DEGENERATE_INPUT = "入力の L* が 0 以下で,色変換が成立していません" + #: 入力が学習データの範囲から外れ,SVM が域外入力の既定応答を返している + OUT_OF_DOMAIN = "入力が学習データの範囲外で,予測が信頼できません" + #: 学習可能な列(正例 2 以上)が 1 つも無い + NO_TRAINABLE_COLUMN = "学習可能な評価列がありません" + #: SVM の学習に失敗した列がある + TRAINING_FAILED = "SVM の学習に失敗した評価列があります" + #: SVM の予測結果が不正(0/1 以外,または非有限値) + PREDICTION_FAILED = "SVM の予測結果が不正です" + + def describe(self) -> str: + """失敗理由の説明文(日本語)を返す.""" + return self.value + + +@dataclass(frozen=True) +class DiagnosisResult: + """5 色分類の結果(SPEC_02 (3)). + + Attributes: + lab: 診断に用いた L*a*b*. + scores: 5 色の一致率(0.0〜1.0).キーは `COLOR_KEYS`. + percentages: 表示用の百分率(整数.旧実装の `"%3.0lf"` と同じ丸め). + top_labels: スコア降順の上位 2 色のキー. + predictions: 190 列の予測値(形状 (190,)・dtype float32.回帰確認用). + failed: 診断失敗なら True(結果画面に「Diagnosis failed」を表示する). + failure_reason: 失敗の原因.成功時は None. + skipped_columns: 正例が 2 未満で学習しなかった列のインデックス. + failed_columns: 学習・予測に失敗した列のインデックス. + """ + + lab: Lab + scores: dict[str, float] + percentages: dict[str, int] + top_labels: tuple[str, str] + predictions: NDArray[np.float32] + failed: bool = False + failure_reason: FailureReason | None = None + skipped_columns: tuple[int, ...] = () + failed_columns: tuple[int, ...] = () + + +def diagnose(lab: Lab | ArrayLike, training: TrainingData) -> DiagnosisResult: + """入力 L*a*b* を 190 列の SVM で分類し,5 色スコアと上位 2 色を返す. + + Args: + lab: 診断対象の `Lab`,または (L*, a*, b*) の 3 要素. + training: `csv_io.load_training_data` で読み込んだ学習データ. + + Returns: + 5 色スコア・上位 2 色・失敗情報を持つ `DiagnosisResult`. + + Raises: + ValueError: `lab` の要素数が 3 でない場合. + """ + lab_value = lab if isinstance(lab, Lab) else _as_lab(lab) + sample = lab_value.as_array().reshape(1, -1) + + if not bool(np.all(np.isfinite(sample))): + # 予測不能な入力(NaN / inf)はここで失敗として返す + return _failure_result(lab_value, FailureReason.INVALID_INPUT, training.output_dimension) + + predictions, skipped_columns, failed_columns, prediction_invalid = _predict_columns( + sample, training + ) + scores = _aggregate_scores(predictions, training.doctor_count) + percentages = {key: _to_percent(score) for key, score in scores.items()} + top_labels = _top_two_labels(scores) + + reason = _detect_failure( + lab=lab_value, + percentages=percentages, + trainable_count=training.output_dimension - len(skipped_columns), + failed_columns=failed_columns, + prediction_invalid=prediction_invalid, + ) + + logger.info( + "SVM 診断: Lab=(%.6f, %.6f, %.6f) -> scores=%s(上位: %s, %s)%s", + lab_value.l_star, + lab_value.a_star, + lab_value.b_star, + {key: round(value, 6) for key, value in scores.items()}, + top_labels[0], + top_labels[1], + "" if reason is None else f" / 診断失敗: {reason.describe()}", + ) + + return DiagnosisResult( + lab=lab_value, + scores=scores, + percentages=percentages, + top_labels=top_labels, + predictions=predictions, + failed=reason is not None, + failure_reason=reason, + skipped_columns=tuple(skipped_columns), + failed_columns=tuple(failed_columns), + ) + + +def create_model() -> cv2.ml.SVM: + """旧実装(OpenCV 2.4 の `CvSVM`)と同一パラメータの SVM を生成する. + + 旧実装は `CvSVMParams(CvSVM::C_SVC, CvSVM::RBF, degree, gamma, coef0, C, nu, p, + NULL, cvTermCriteria(CV_TERMCRIT_EPS, 1000, FLT_EPSILON))` を用いる. + degree・coef0・nu・p は C_SVC + RBF では未使用のため設定しない + (OpenCV は RBF カーネルで γ のみを参照する). + + Returns: + 学習前の `cv2.ml.SVM`. + """ + model = cv2.ml.SVM.create() + model.setType(cv2.ml.SVM_C_SVC) + model.setKernel(cv2.ml.SVM_RBF) + model.setGamma(SVM_GAMMA) + model.setC(SVM_C) + # 終了条件は EPS のみ(COUNT は付けない).OpenCV 2.4 / 4.x のどちらも + # COUNT 未指定なら反復上限を INT_MAX とするため,挙動は一致する + model.setTermCriteria((cv2.TERM_CRITERIA_EPS, SVM_TERM_MAX_COUNT, SVM_TERM_EPSILON)) + return model + + +def _predict_columns( + sample: NDArray[np.float32], training: TrainingData +) -> tuple[NDArray[np.float32], list[int], list[int], bool]: + """190 列それぞれを学習・予測し,予測値と学習状況を返す. + + Returns: + (予測値 (190,), 学習を飛ばした列, 学習・予測に失敗した列, 予測値が不正か). + """ + samples = np.ascontiguousarray(training.lab, dtype=np.float32) + column_count = training.output_dimension + predictions = np.zeros(column_count, dtype=np.float32) + skipped_columns: list[int] = [] + failed_columns: list[int] = [] + prediction_invalid = False + + for column in range(column_count): + responses = np.ascontiguousarray(training.evaluations[:, column], dtype=np.int32) + # 旧実装: 正例(> 0)が 2 未満なら学習せずスコア 0 + if int(np.count_nonzero(responses > 0)) < MIN_POSITIVE_SAMPLES: + skipped_columns.append(column) + continue + + value = _train_and_predict(samples, responses.reshape(-1, 1), sample, column) + if value is None: + failed_columns.append(column) + continue + if not np.isfinite(value) or value not in (0.0, 1.0): + # 2 クラス(0/1)の C_SVC なので予測値は 0.0 か 1.0 になるはず + prediction_invalid = True + failed_columns.append(column) + continue + predictions[column] = np.float32(value) + + return predictions, skipped_columns, failed_columns, prediction_invalid + + +def _train_and_predict( + samples: NDArray[np.float32], + responses: NDArray[np.int32], + sample: NDArray[np.float32], + column: int, +) -> float | None: + """1 列分の SVM を学習して予測する.学習・予測できなければ None を返す.""" + model = create_model() + try: + trained = bool(model.train(samples, cv2.ml.ROW_SAMPLE, responses)) + if not trained: + logger.warning("SVM の学習に失敗しました(列 %d)", column) + return None + _, results = model.predict(sample) + except cv2.error: + # 単一クラスしか含まない等,OpenCV が学習を拒否した場合(診断失敗として扱う) + logger.warning( + "SVM の学習・予測で OpenCV エラーが発生しました(列 %d)", column, exc_info=True + ) + return None + return float(np.asarray(results).reshape(-1)[0]) + + +def _aggregate_scores(predictions: NDArray[np.float32], doctor_count: int) -> dict[str, float]: + """190 列の予測を色ごとに合算し,医師数で割って一致率にする(SPEC_02 (3)). + + 旧実装は単精度 float で合算・除算するため,同じ丸めを再現する. + """ + scores: dict[str, float] = {} + for color_index, key in enumerate(COLOR_KEYS): + total = np.float32(0.0) + for value in predictions[color_index::COLOR_COUNT]: + total = np.float32(total + value) + scores[key] = float(np.float32(total / np.float32(doctor_count))) + return scores + + +def _to_percent(score: float) -> int: + """一致率(0〜1)を表示用の百分率(整数)に変換する. + + 旧実装の `float ftemp = 100 * m_score[i];` → `Format("%3.0lf", ftemp)` に対応し, + 単精度での 100 倍と偶数丸め(printf の丸め)を再現する. + """ + percent = np.float32(np.float32(100.0) * np.float32(score)) + return int(round(float(percent))) + + +def _top_two_labels(scores: dict[str, float]) -> tuple[str, str]: + """スコア降順で上位 2 色のキーを返す(同点時は `_TIE_BREAK_ORDER` の順).""" + ordered = sorted(_TIE_BREAK_ORDER, key=lambda key: scores[key], reverse=True) + return ordered[0], ordered[1] + + +def _detect_failure( + *, + lab: Lab, + percentages: dict[str, int], + trainable_count: int, + failed_columns: list[int], + prediction_invalid: bool, +) -> FailureReason | None: + """診断失敗の原因を検出する(SPEC_02「診断失敗の判定」). + + 新実装では学習不能・予測失敗・色変換が成立しない入力を**原因として直接**判定する. + 加えて,旧実装(`ResultDlg.cpp`)がスコア組 `LEGACY_FAILURE_PERCENTAGES` との一致で + 失敗表示していた条件も踏襲する(表示を旧実装と一致させるため).このスコア組は + 学習データ域外の入力に対する SVM の既定応答パターンであり,L* の値によらず起こりうる. + + スコア自体は失敗時も算出済みの値をそのまま返す(旧実装も失敗表示に重ねて + スコアを描画するため). + """ + if trainable_count == 0: + return FailureReason.NO_TRAINABLE_COLUMN + if prediction_invalid: + return FailureReason.PREDICTION_FAILED + if failed_columns: + return FailureReason.TRAINING_FAILED + if lab.l_star <= MINIMUM_LIGHTNESS: + return FailureReason.DEGENERATE_INPUT + if percentages == LEGACY_FAILURE_PERCENTAGES: + return FailureReason.OUT_OF_DOMAIN + return None + + +def _failure_result(lab: Lab, reason: FailureReason, column_count: int) -> DiagnosisResult: + """スコアを算出できない場合の失敗結果を組み立てる.""" + scores = dict.fromkeys(COLOR_KEYS, 0.0) + return DiagnosisResult( + lab=lab, + scores=scores, + percentages=dict.fromkeys(COLOR_KEYS, 0), + # 全色が同点なので `_TIE_BREAK_ORDER` の先頭 2 色になる(順序の知識を二重に持たない) + top_labels=_top_two_labels(scores), + predictions=np.zeros(column_count, dtype=np.float32), + failed=True, + failure_reason=reason, + ) + + +def _as_lab(values: ArrayLike) -> Lab: + """(L*, a*, b*) の 3 要素を `Lab` に整える.""" + array = np.asarray(values, dtype=np.float64).reshape(-1) + if array.size != LAB_DIMENSION: + raise ValueError(f"Lab は {LAB_DIMENSION} 要素である必要があります: {array.size} 要素") + return Lab(l_star=float(array[0]), a_star=float(array[1]), b_star=float(array[2])) diff --git a/tests/core/test_roi.py b/tests/core/test_roi.py new file mode 100644 index 0000000..b1ca99e --- /dev/null +++ b/tests/core/test_roi.py @@ -0,0 +1,293 @@ +"""`core/roi.py` の検証(SPEC_02 (0)「ROI 平均色」・PLAN_02 ステップ 4). + +仕様上の要件は以下. + +- クリック点ごとに 51×51 px の矩形 ROI の平均 BGR を算出する +- 複数点クリック時は各 ROI 平均の平均(全クリックの合算 ÷ クリック数)を診断に使用する +- クリック可能な最大点数は 4 点(SPEC_01「ROI 指定」) +- ROI が画像外にはみ出す等の不正入力は `core` が例外で通知する(error-handling.md) + +テストは実装ではなく仕様を基準とするため,期待値は解析的に手計算できる画像 +(単色・座標に比例するグラデーション・単一点のみ明るい画像)で与える. +""" + +import numpy as np +import pytest + +from tiasjudge.core.roi import ( + MAX_ROI_POINTS, + ROI_OFFSET, + ROI_SIZE, + mean_bgr, + mean_rgb, + roi_mean_bgr, +) + +ROI_PIXEL_COUNT = ROI_SIZE * ROI_SIZE + + +# --- テスト用画像の生成ヘルパー --- + + +def uniform_image(bgr: tuple[int, int, int], width: int = 200, height: int = 200) -> np.ndarray: + """全画素が同じ BGR の画像を作る.""" + image = np.empty((height, width, 3), dtype=np.uint8) + image[:, :] = bgr + return image + + +def ramp_image(width: int = 200, height: int = 200, red: int = 7) -> np.ndarray: + """B = x座標,G = y座標,R = 定数のグラデーション画像を作る. + + ROI(一辺 51 px の奇数サイズ)の平均は中心座標に一致するため, + 「クリック点を中心とする 51×51」であることを解析的に検証できる. + """ + image = np.zeros((height, width, 3), dtype=np.uint8) + image[:, :, 0] = np.arange(width, dtype=np.uint8)[np.newaxis, :] + image[:, :, 1] = np.arange(height, dtype=np.uint8)[:, np.newaxis] + image[:, :, 2] = red + return image + + +# --- 定数(SPEC_02 (0)・SPEC_01) --- + + +def test_roi_size_is_51_px() -> None: + """ROI の一辺は 51 px(SPEC_02 (0)).""" + assert ROI_SIZE == 51 + + +def test_roi_offset_centers_the_roi_on_the_click_point() -> None: + """オフセットは ROI をクリック点中心に置く値((51 - 1) / 2 = 25)である.""" + assert ROI_OFFSET == (ROI_SIZE - 1) // 2 == 25 + + +def test_max_roi_points_is_four() -> None: + """クリック可能な最大点数は 4 点(SPEC_01「ROI 指定」).""" + assert MAX_ROI_POINTS == 4 + + +# --- 単一 ROI の平均(SPEC_02 (0)) --- + + +def test_uniform_image_returns_the_same_bgr() -> None: + """単色画像の ROI 平均は,その色そのものになる.""" + image = uniform_image((10, 20, 30)) + + assert roi_mean_bgr(image, (100, 100)) == (10.0, 20.0, 30.0) + + +@pytest.mark.parametrize("point", [(25, 25), (100, 60), (174, 174)]) +def test_ramp_image_mean_equals_the_click_point(point: tuple[int, int]) -> None: + """B = x・G = y のグラデーションでは,51×51 の平均が中心(クリック点)と一致する. + + ROI が「クリック点中心・一辺 51 px」でなければ平均は中心座標からずれる. + """ + image = ramp_image() + + blue, green, red = roi_mean_bgr(image, point) + + assert blue == pytest.approx(float(point[0])) + assert green == pytest.approx(float(point[1])) + assert red == pytest.approx(7.0) + + +def test_mean_is_not_rounded_to_integer() -> None: + """平均は整数に丸めず実数で返す(後段の色変換の精度を落とさない).""" + image = np.zeros((200, 200, 3), dtype=np.uint8) + # ROI 内の 1 画素だけ 1 にすると,平均は 1/2601 の非整数になる + image[100, 100, 0] = 1 + + blue, _, _ = roi_mean_bgr(image, (100, 100)) + + assert blue == pytest.approx(1.0 / ROI_PIXEL_COUNT) + + +@pytest.mark.parametrize( + "offset", + [(-ROI_OFFSET, -ROI_OFFSET), (ROI_OFFSET, ROI_OFFSET), (-ROI_OFFSET, ROI_OFFSET), (0, 0)], +) +def test_pixels_inside_the_51x51_window_are_counted(offset: tuple[int, int]) -> None: + """中心から ±25 px(= 51×51 の四隅・中央)の画素は ROI に含まれる.""" + center = (100, 100) + image = np.zeros((200, 200, 3), dtype=np.uint8) + image[center[1] + offset[1], center[0] + offset[0], 0] = 255 + + blue, _, _ = roi_mean_bgr(image, center) + + assert blue == pytest.approx(255.0 / ROI_PIXEL_COUNT) + + +@pytest.mark.parametrize( + "offset", + [(-ROI_OFFSET - 1, 0), (ROI_OFFSET + 1, 0), (0, -ROI_OFFSET - 1), (0, ROI_OFFSET + 1)], +) +def test_pixels_outside_the_51x51_window_are_ignored(offset: tuple[int, int]) -> None: + """中心から 26 px 離れた画素は ROI に含まれない(ROI は 51×51 に収まる).""" + center = (100, 100) + image = np.zeros((200, 200, 3), dtype=np.uint8) + image[center[1] + offset[1], center[0] + offset[0], 0] = 255 + + assert roi_mean_bgr(image, center) == (0.0, 0.0, 0.0) + + +def test_mean_matches_an_independent_computation() -> None: + """任意の画像でも ROI 内 2601 画素の単純平均になる(`cv::mean` 相当). + + 期待値は実装とは別経路(float64 での全画素平均)で計算する. + """ + rng = np.random.default_rng(seed=20240401) + image = rng.integers(0, 256, size=(200, 200, 3), dtype=np.uint8) + center = (100, 80) + window = image[ + center[1] - ROI_OFFSET : center[1] + ROI_OFFSET + 1, + center[0] - ROI_OFFSET : center[0] + ROI_OFFSET + 1, + ].astype(np.float64) + expected = tuple(window.reshape(-1, 3).mean(axis=0)) + + assert roi_mean_bgr(image, center) == pytest.approx(expected) + + +def test_accepts_a_cropped_view_of_an_image() -> None: + """クロップ後の部分ビュー(非連続配列)でも同じ結果になる. + + SPEC_01 の操作フローではクロップ後の画像上でクリックするため, + ビューを渡しても座標どおりに ROI を切り出せる必要がある. + """ + full = ramp_image(width=400, height=400) + cropped = full[100:300, 100:300] # 元画像の (100, 100) が原点になる + + blue, green, red = roi_mean_bgr(cropped, (60, 40)) + + assert blue == pytest.approx(160.0) + assert green == pytest.approx(140.0) + assert red == pytest.approx(7.0) + + +def test_high_bit_depth_image_is_not_truncated() -> None: + """8 bit を超える値の画像でも平均が桁あふれ・切り捨てされない.""" + image = np.full((200, 200, 3), 1000, dtype=np.uint16) + + assert roi_mean_bgr(image, (100, 100)) == (1000.0, 1000.0, 1000.0) + + +# --- 複数点の平均(SPEC_02 (0)「合算 ÷ クリック数」) --- + + +def test_single_point_mean_matches_roi_mean() -> None: + """1 点だけのときは,その ROI 平均がそのまま診断に使われる.""" + image = ramp_image() + + assert mean_bgr(image, [(60, 80)]) == roi_mean_bgr(image, (60, 80)) + + +def test_two_points_are_averaged() -> None: + """2 点クリック時は 2 つの ROI 平均の平均になる(端数も保持する).""" + image = ramp_image() + + blue, green, red = mean_bgr(image, [(40, 30), (101, 90)]) + + assert blue == pytest.approx((40.0 + 101.0) / 2.0) + assert green == pytest.approx((30.0 + 90.0) / 2.0) + assert red == pytest.approx(7.0) + + +def test_four_points_are_averaged() -> None: + """最大クリック数(4 点)でも合算 ÷ クリック数になる.""" + image = ramp_image() + points = [(30, 30), (60, 40), (90, 50), (120, 61)] + + blue, green, _ = mean_bgr(image, points) + + assert blue == pytest.approx(sum(x for x, _ in points) / len(points)) + assert green == pytest.approx(sum(y for _, y in points) / len(points)) + + +def test_mean_of_max_points_equals_the_average_of_each_roi_mean() -> None: + """最大クリック数(4 点)の平均は,各 ROI 平均の合算 ÷ クリック数と一致する.""" + image = ramp_image() + points = [(30, 31), (60, 42), (91, 50), (120, 64)][:MAX_ROI_POINTS] + per_point = [roi_mean_bgr(image, point) for point in points] + expected = tuple(sum(values) / MAX_ROI_POINTS for values in zip(*per_point, strict=True)) + + assert len(points) == MAX_ROI_POINTS + assert mean_bgr(image, points) == pytest.approx(expected) + + +def test_repeated_point_is_counted_each_time() -> None: + """同じ点を複数回クリックした場合もクリック数で割る(重複を除去しない).""" + image = ramp_image() + + assert mean_bgr(image, [(60, 80), (60, 80)]) == pytest.approx(roi_mean_bgr(image, (60, 80))) + + +def test_average_is_over_roi_means_not_over_all_pixels() -> None: + """複数点の平均は「各 ROI 平均の平均」である(重なりも含めた画素平均ではない).""" + image = uniform_image((0, 0, 0)) + # 左側の ROI だけ 100,右側は 0 のままにする + image[75:126, 75:126] = (100, 100, 100) + + assert mean_bgr(image, [(100, 100), (160, 100)]) == pytest.approx((50.0, 50.0, 50.0)) + + +def test_mean_rgb_reorders_channels() -> None: + """`mean_rgb` は (R, G, B) の順で返す(`color.rgb_to_lab` の入力並び).""" + image = uniform_image((10, 20, 30)) + points = [(100, 100)] + + assert mean_rgb(image, points) == (30.0, 20.0, 10.0) + assert mean_rgb(image, points) == tuple(reversed(mean_bgr(image, points))) + + +# --- 異常系(error-handling.md) --- + + +@pytest.mark.parametrize( + "point", + [ + (ROI_OFFSET - 1, 100), # 左にはみ出す + (100, ROI_OFFSET - 1), # 上にはみ出す + (200 - ROI_OFFSET, 100), # 右にはみ出す + (100, 200 - ROI_OFFSET), # 下にはみ出す + (-5, -5), + ], +) +def test_out_of_range_point_raises(point: tuple[int, int]) -> None: + """ROI が画像外にはみ出す場合はエラーにする(旧実装のクラッシュを防ぐ).""" + image = ramp_image(width=200, height=200) + + with pytest.raises(ValueError): + roi_mean_bgr(image, point) + + +def test_roi_touching_the_image_edge_is_valid() -> None: + """ROI が画像端にちょうど収まる位置は有効である(境界値).""" + image = uniform_image((1, 2, 3), width=51, height=51) + + assert roi_mean_bgr(image, (ROI_OFFSET, ROI_OFFSET)) == (1.0, 2.0, 3.0) + + +def test_grayscale_image_raises() -> None: + """BGR 3 チャンネル以外の画像は受け付けない.""" + with pytest.raises(ValueError): + roi_mean_bgr(np.zeros((200, 200), dtype=np.uint8), (100, 100)) + + +def test_empty_image_raises() -> None: + """空の画像は受け付けない.""" + with pytest.raises(ValueError): + roi_mean_bgr(np.zeros((0, 0, 3), dtype=np.uint8), (100, 100)) + + +def test_no_points_raises() -> None: + """クリック点が 0 個のときは診断に使う平均色を作れない.""" + with pytest.raises(ValueError): + mean_bgr(ramp_image(), []) + + +def test_out_of_range_point_in_multi_point_raises() -> None: + """複数点のうち 1 点でも範囲外ならエラーにする.""" + image = ramp_image() + + with pytest.raises(ValueError): + mean_bgr(image, [(100, 100), (5, 5)]) diff --git a/tests/core/test_svm.py b/tests/core/test_svm.py new file mode 100644 index 0000000..7eee9b1 --- /dev/null +++ b/tests/core/test_svm.py @@ -0,0 +1,549 @@ +"""`core/svm.py` の検証(SPEC_02 (3)「SVM 分類」・「診断失敗の判定」・PLAN_02 ステップ 4). + +仕様上の要件は以下. + +- 出力 190 列(医師 38 名 × 5 色)それぞれについて 2 クラス SVM を診断のたびに学習し, + 入力 L\\*a\\*b\\* を予測する(正例が 2 未満の列は学習せずスコア 0) +- SVM パラメータは C_SVC / RBF・γ=0.011・C=12.0・終了条件 EPS 1000 回・FLT_EPSILON +- 色ごとに 38 医師分の予測値を平均 → 5 色の一致率(0〜1)を百分率で表示する +- スコア降順で上位 2 色を主要ラベルとする +- 学習不能・予測失敗は直接検出して「診断失敗」として返す(旧実装のマジックナンバー + 比較 42/0/5/11/3 は移植しない.ただし表示仕様=失敗判定の結果は同じであること) + +旧実装との結果同一性が成功基準(PLAN_01)のため,実 `data/data.csv` を用いた +入出力ペアを回帰テストとして固定する(testing.md「回帰テストを最重視する」). +""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 +import numpy as np +import pytest + +from tiasjudge.core.color import Lab, rgb_to_lab +from tiasjudge.core.csv_io import ( + COLOR_COUNT, + DOCTOR_COUNT, + EVALUATION_COLUMN_COUNT, + SAMPLE_COUNT, + TrainingData, + load_training_data, +) +from tiasjudge.core.roi import mean_rgb +from tiasjudge.core.svm import ( + COLOR_KEYS, + MIN_POSITIVE_SAMPLES, + SVM_C, + SVM_GAMMA, + DiagnosisResult, + FailureReason, + create_model, + diagnose, +) + +#: 旧実装が「Diagnosis failed」と表示するスコア組(SPEC_02「診断失敗の判定」). +#: 実測でこれは Lab=(-16, 0, 0)(XYZ が全て 0)の出力であることを確認している +FAILED_PERCENTAGES = {"purple": 42, "dark_red": 0, "crimson": 5, "pink": 11, "pale": 3} +DEGENERATE_LAB = Lab(l_star=-16.0, a_star=0.0, b_star=0.0) + +#: 実 data.csv での回帰値(入力 Lab -> (5 色の %, 上位 2 色)) +REGRESSION_CASES: dict[str, tuple[tuple[float, float, float], dict[str, int], tuple[str, str]]] = { + "purple_dark_red": ( + (60.0, 15.0, 10.0), + {"purple": 58, "dark_red": 37, "crimson": 0, "pink": 0, "pale": 0}, + ("purple", "dark_red"), + ), + "dark_red_crimson": ( + (50.0, 20.0, 5.0), + {"purple": 5, "dark_red": 55, "crimson": 21, "pink": 8, "pale": 3}, + ("dark_red", "crimson"), + ), + "crimson_pink": ( + (45.0, 25.0, -5.0), + {"purple": 21, "dark_red": 8, "crimson": 29, "pink": 24, "pale": 5}, + ("crimson", "pink"), + ), + "high_lightness": ( + (65.0, 20.0, 8.0), + {"purple": 53, "dark_red": 45, "crimson": 5, "pink": 0, "pale": 3}, + ("purple", "dark_red"), + ), +} + + +# --- フィクスチャ(SVM 学習は 1 診断あたり 190 列なので結果を使い回す) --- + + +@pytest.fixture(scope="module") +def training(data_csv_path: Path) -> TrainingData: + """同梱の実学習データ(84 サンプル × 190 列).""" + return load_training_data(data_csv_path) + + +@pytest.fixture(scope="module") +def results(training: TrainingData) -> dict[str, DiagnosisResult]: + """回帰ケースの診断結果(モジュール内で共有する).""" + return {name: diagnose(lab, training) for name, (lab, _, _) in REGRESSION_CASES.items()} + + +@pytest.fixture(scope="module") +def degenerate_result(training: TrainingData) -> DiagnosisResult: + """旧実装が「Diagnosis failed」を表示する入力(Lab=(-16, 0, 0))の診断結果.""" + return diagnose(DEGENERATE_LAB, training) + + +# --- 合成学習データのヘルパー(正例数の条件を検証するために使う) --- + + +def synthetic_training(positive_columns: dict[int, int] | None = None) -> TrainingData: + """仕様どおりの形(84 サンプル × 190 列)の合成学習データを作る. + + 既定では全列の正例が 1 個(= `MIN_POSITIVE_SAMPLES` 未満)になる. + `positive_columns` に {列: 正例数} を渡すと,その列だけ先頭サンプル群を正例にする + (先頭 42 サンプルは Lab クラスタ A,後半 42 サンプルはクラスタ B). + """ + half = SAMPLE_COUNT // 2 + lab = np.empty((SAMPLE_COUNT, 3), dtype=np.float32) + lab[:half] = np.array([60.0, 20.0, 10.0], dtype=np.float32) + lab[half:] = np.array([45.0, 5.0, 0.0], dtype=np.float32) + # サンプルが完全重複しないように微小なばらつきを与える + lab += (np.arange(SAMPLE_COUNT, dtype=np.float32) * 0.01)[:, np.newaxis] + + evaluations = np.zeros((SAMPLE_COUNT, EVALUATION_COLUMN_COUNT), dtype=np.int32) + evaluations[0, :] = 1 # 全列に正例 1 個(学習対象外の状態) + for column, count in (positive_columns or {}).items(): + evaluations[:count, column] = 1 + return TrainingData(lab=lab, evaluations=evaluations) + + +CLUSTER_A_LAB = (60.0, 20.0, 10.0) + + +# --- SVM パラメータ(SPEC_02 (3)) --- + + +def test_svm_parameters_match_the_spec() -> None: + """γ=0.011・C=12.0 は仕様どおりの値である.""" + assert SVM_GAMMA == 0.011 + assert SVM_C == 12.0 + + +def test_model_uses_c_svc_with_rbf_kernel() -> None: + """生成されるモデルは C_SVC・RBF カーネルで,γ・C が設定されている.""" + model = create_model() + + assert model.getType() == cv2.ml.SVM_C_SVC + assert model.getKernelType() == cv2.ml.SVM_RBF + assert model.getGamma() == pytest.approx(0.011) + assert model.getC() == pytest.approx(12.0) + + +def test_termination_criteria_uses_eps() -> None: + """終了条件は EPS(FLT_EPSILON)・反復 1000 である(SPEC_02 (3)).""" + criteria_type, max_count, epsilon = create_model().getTermCriteria() + + assert criteria_type & cv2.TERM_CRITERIA_EPS + assert max_count == 1000 + assert epsilon == pytest.approx(float(np.finfo(np.float32).eps)) + + +def test_minimum_positive_samples_is_two() -> None: + """学習に必要な正例数は 2(これ未満は学習せずスコア 0).""" + assert MIN_POSITIVE_SAMPLES == 2 + + +def test_color_keys_follow_the_csv_column_order() -> None: + """5 色のキーは data.csv の列順(紫・暗赤・紅・淡紅・淡白)に対応する.""" + assert COLOR_KEYS == ("purple", "dark_red", "crimson", "pink", "pale") + assert len(COLOR_KEYS) == COLOR_COUNT + + +# --- 実データでの回帰(PLAN_01 成功基準) --- + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_percentages_match_the_reference_values( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """既知入力に対する 5 色の百分率が固定値と一致する(旧実装との同一性の回帰).""" + _, expected, _ = REGRESSION_CASES[name] + + assert results[name].percentages == expected + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_top_labels_match_the_reference_values( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """上位 2 色が固定値と一致する.""" + _, _, expected = REGRESSION_CASES[name] + + assert results[name].top_labels == expected + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_valid_input_is_not_reported_as_failure( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """学習・予測が成立する入力は診断失敗にならない.""" + result = results[name] + + assert result.failed is False + assert result.failure_reason is None + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_top_labels_are_the_two_highest_scores( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """上位 2 色はスコア降順の先頭 2 件である(SPEC_02 (3)).""" + scores = results[name].scores + first, second = results[name].top_labels + others = [key for key in COLOR_KEYS if key not in (first, second)] + + assert scores[first] >= scores[second] + assert all(scores[second] >= scores[key] for key in others) + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_scores_are_ratios_of_doctor_agreement( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """スコアは 38 医師の予測の平均(0〜1・分母 38 の分数)である.""" + for key, score in results[name].scores.items(): + assert 0.0 <= score <= 1.0, key + assert score * DOCTOR_COUNT == pytest.approx(round(score * DOCTOR_COUNT), abs=1e-4), key + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_percentages_are_rounded_scores(name: str, results: dict[str, DiagnosisResult]) -> None: + """百分率は一致率を 100 倍して整数に丸めた値である.""" + result = results[name] + + for key, score in result.scores.items(): + assert isinstance(result.percentages[key], int) + assert abs(result.percentages[key] - score * 100.0) <= 0.5 + 1e-6, key + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_predictions_are_binary_class_labels( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """予測値は 190 列分の 0/1(2 クラス SVM の出力)である.""" + predictions = results[name].predictions + + assert predictions.shape == (EVALUATION_COLUMN_COUNT,) + assert set(np.unique(predictions).tolist()) <= {0.0, 1.0} + + +@pytest.mark.parametrize("name", list(REGRESSION_CASES)) +def test_scores_are_the_mean_of_each_color_columns( + name: str, results: dict[str, DiagnosisResult] +) -> None: + """各色のスコアは,その色に対応する 38 列の予測の平均である.""" + result = results[name] + + for color_index, key in enumerate(COLOR_KEYS): + expected = float(result.predictions[color_index::COLOR_COUNT].sum()) / DOCTOR_COUNT + assert result.scores[key] == pytest.approx(expected, abs=1e-6), key + + +def test_diagnosis_is_deterministic(training: TrainingData) -> None: + """同じ入力・同じ学習データなら結果は毎回同じ(testing.md: 乱数に依存させない).""" + first = diagnose((50.0, 20.0, 5.0), training) + second = diagnose((50.0, 20.0, 5.0), training) + + assert first.scores == second.scores + assert first.top_labels == second.top_labels + assert np.array_equal(first.predictions, second.predictions) + + +def test_columns_with_few_positives_are_skipped( + training: TrainingData, results: dict[str, DiagnosisResult] +) -> None: + """正例が 2 未満の列は学習せず,予測値 0 のままになる(SPEC_02 (3)).""" + expected_skipped = tuple( + column + for column in range(EVALUATION_COLUMN_COUNT) + if int((training.evaluations[:, column] > 0).sum()) < MIN_POSITIVE_SAMPLES + ) + result = results["purple_dark_red"] + + assert result.skipped_columns == expected_skipped + assert all(result.predictions[column] == 0.0 for column in expected_skipped) + + +def test_no_column_fails_to_train_with_the_bundled_data( + results: dict[str, DiagnosisResult], +) -> None: + """同梱データでは学習可能な全列が学習・予測に成功する.""" + assert results["purple_dark_red"].failed_columns == () + + +def test_skipped_column_count_is_fixed_for_the_bundled_data( + results: dict[str, DiagnosisResult], +) -> None: + """同梱データで学習されない列はちょうど 20 列である(回帰値). + + 190 列のうち 20 列(正例 2 未満)が学習対象外なので,学習される列は 170 列になる. + data.csv を差し替えた際に気付けるよう固定値で押さえる. + """ + result = results["purple_dark_red"] + + assert len(result.skipped_columns) == 20 + assert EVALUATION_COLUMN_COUNT - len(result.skipped_columns) == 170 + + +def test_predictions_are_float32(results: dict[str, DiagnosisResult]) -> None: + """予測値は旧実装の `float result[190]` と同じ単精度で保持する.""" + assert results["purple_dark_red"].predictions.dtype == np.float32 + + +def test_training_data_is_not_modified_by_diagnosis(training: TrainingData) -> None: + """診断は学習データを書き換えない(同じ `TrainingData` を繰り返し使えること).""" + lab_before = training.lab.copy() + evaluations_before = training.evaluations.copy() + + diagnose((50.0, 20.0, 5.0), training) + + assert np.array_equal(training.lab, lab_before) + assert np.array_equal(training.evaluations, evaluations_before) + + +def test_model_is_retrained_for_every_diagnosis(training: TrainingData) -> None: + """SVM は診断のたびに学習する(SPEC_02 (3)). + + 学習データを差し替えると結果も変わることで,学習結果を使い回していないことを示す. + ここでは医師 1 名目の紫(列 0)だけを残し,他の列は評価なし(学習対象外)にする. + """ + modified_evaluations = np.zeros_like(training.evaluations) + modified_evaluations[:, 0] = training.evaluations[:, 0] + modified = TrainingData(lab=training.lab.copy(), evaluations=modified_evaluations) + + baseline = diagnose((50.0, 20.0, 5.0), training) + retrained = diagnose((50.0, 20.0, 5.0), modified) + + assert retrained.scores != baseline.scores + # 列ごとに独立に学習するので,残した列 0 の予測は元の学習データのときと変わらない + assert retrained.predictions[0] == baseline.predictions[0] + for key in ("dark_red", "crimson", "pink", "pale"): + assert retrained.scores[key] == 0.0, key + + +# --- 診断失敗の判定(SPEC_02「診断失敗の判定」) --- + + +def test_degenerate_input_reproduces_the_legacy_failure_percentages( + degenerate_result: DiagnosisResult, +) -> None: + """旧実装が失敗と判定するスコア組(紫 42・暗赤 0・紅 5・淡紅 11・淡白 3)を再現する.""" + assert degenerate_result.percentages == FAILED_PERCENTAGES + + +def test_degenerate_input_is_reported_as_failure(degenerate_result: DiagnosisResult) -> None: + """L* が 0 以下の入力は診断失敗として直接検出する(マジックナンバー比較を使わない).""" + assert degenerate_result.failed is True + assert degenerate_result.failure_reason is FailureReason.DEGENERATE_INPUT + + +def test_failure_result_still_carries_scores(degenerate_result: DiagnosisResult) -> None: + """失敗時もスコアは返す(旧実装は失敗表示に重ねてスコアを描画する).""" + assert sum(degenerate_result.scores.values()) > 0.0 + assert degenerate_result.lab == DEGENERATE_LAB + + +def test_failure_reason_has_japanese_description() -> None: + """失敗理由は GUI 表示のための説明文を持つ(core は表示自体は行わない).""" + for reason in FailureReason: + assert isinstance(reason.describe(), str) + assert reason.describe() != "" + + +@pytest.mark.parametrize( + "lab", + [(float("nan"), 0.0, 0.0), (float("inf"), 10.0, 5.0), (50.0, float("nan"), 5.0)], +) +def test_non_finite_input_is_reported_as_failure( + lab: tuple[float, float, float], training: TrainingData +) -> None: + """NaN / inf を含む入力は予測できないため診断失敗にする.""" + result = diagnose(lab, training) + + assert result.failed is True + assert result.failure_reason is FailureReason.INVALID_INPUT + assert result.percentages == dict.fromkeys(COLOR_KEYS, 0) + + +def test_no_trainable_column_is_reported_as_failure() -> None: + """全列の正例が 2 未満なら学習できず,診断失敗になる(スコアは全て 0).""" + result = diagnose(CLUSTER_A_LAB, synthetic_training()) + + assert result.failed is True + assert result.failure_reason is FailureReason.NO_TRAINABLE_COLUMN + assert result.scores == dict.fromkeys(COLOR_KEYS, 0.0) + assert result.percentages == dict.fromkeys(COLOR_KEYS, 0) + assert len(result.skipped_columns) == EVALUATION_COLUMN_COUNT + + +def test_all_zero_scores_use_the_fixed_tie_break_order() -> None: + """全色が同点のときの上位 2 色は旧実装のキー順(紅・暗赤)になる.""" + result = diagnose(CLUSTER_A_LAB, synthetic_training()) + + assert result.top_labels == ("crimson", "dark_red") + + +def test_tied_non_zero_scores_use_the_fixed_tie_break_order() -> None: + """同点の色が複数あるときも旧実装のキー順(C < D < Pi < Pu < W)で順位が決まる. + + 旧実装は `std::map`(キー昇順)を降順ソートするため, + 同点の紫(Pu)と紅(C)では紅が先に並ぶ.紫・紅の 2 色だけを 1/38 で同点にする. + """ + training = synthetic_training({0: SAMPLE_COUNT // 2, 2: SAMPLE_COUNT // 2}) + + result = diagnose(CLUSTER_A_LAB, training) + + assert result.scores["purple"] == result.scores["crimson"] == pytest.approx(1.0 / DOCTOR_COUNT) + assert result.top_labels == ("crimson", "purple") + + +def test_zero_lightness_is_reported_as_failure(training: TrainingData) -> None: + """L* = 0(Y が 0 にクリップされた状態)も色変換が成立しておらず失敗である(境界値).""" + result = diagnose((0.0, 0.0, 0.0), training) + + assert result.failed is True + assert result.failure_reason is FailureReason.DEGENERATE_INPUT + + +def test_legacy_failure_pattern_is_always_reported_as_failure(training: TrainingData) -> None: + """旧実装が失敗表示するスコア組になる入力は,L* > 0 でも診断失敗とする(SPEC_02). + + SPEC_02「診断失敗の判定」は失敗の検出方法だけを変え,表示仕様は現行と同じとしている. + 旧実装(`ResultDlg.cpp`)はスコア組のみで判定するため,学習データから外れた入力 + (例: L* = 5)でも「Diagnosis failed」になる. + """ + result = diagnose((5.0, 0.0, 0.0), training) + + assert result.percentages == FAILED_PERCENTAGES + assert result.failed is True + + +# --- 正例数の条件と色の割り当て(SPEC_02 (3)) --- + + +@pytest.mark.parametrize( + ("column", "expected_key"), + [(0, "purple"), (1, "dark_red"), (2, "crimson"), (3, "pink"), (4, "pale"), (7, "crimson")], +) +def test_trained_column_contributes_to_its_color(column: int, expected_key: str) -> None: + """学習された列の予測は,列インデックス % 5 の色に加算される(医師 5 列の one-hot). + + 他の 189 列は正例 1 個で学習されないため,スコアは対象色のみ 1/38 になる. + """ + training = synthetic_training({column: SAMPLE_COUNT // 2}) + + result = diagnose(CLUSTER_A_LAB, training) + + assert result.failed is False + assert result.predictions[column] == 1.0 + assert result.scores[expected_key] == pytest.approx(1.0 / DOCTOR_COUNT) + for key in COLOR_KEYS: + if key != expected_key: + assert result.scores[key] == 0.0, key + assert result.top_labels[0] == expected_key + + +def test_column_with_exactly_two_positives_is_trained() -> None: + """正例がちょうど 2 個の列は学習対象になる(境界値).""" + training = synthetic_training({0: MIN_POSITIVE_SAMPLES}) + + result = diagnose(CLUSTER_A_LAB, training) + + assert 0 not in result.skipped_columns + assert len(result.skipped_columns) == EVALUATION_COLUMN_COUNT - 1 + assert result.failure_reason is not FailureReason.NO_TRAINABLE_COLUMN + + +def test_column_with_one_positive_is_skipped() -> None: + """正例が 1 個の列は学習されずスコア 0 のままである(境界値).""" + training = synthetic_training({0: SAMPLE_COUNT // 2}) + + result = diagnose(CLUSTER_A_LAB, training) + + assert tuple(range(1, EVALUATION_COLUMN_COUNT)) == result.skipped_columns + assert np.array_equal(result.predictions[1:], np.zeros(EVALUATION_COLUMN_COUNT - 1)) + + +# --- 入力の受け取り方 --- + + +def test_accepts_lab_dataclass_and_tuple(training: TrainingData) -> None: + """`Lab` でも (L*, a*, b*) の 3 要素でも同じ結果になる.""" + lab = Lab(l_star=50.0, a_star=20.0, b_star=5.0) + + from_dataclass = diagnose(lab, training) + from_tuple = diagnose((50.0, 20.0, 5.0), training) + + assert from_dataclass.lab == from_tuple.lab == lab + assert from_dataclass.scores == from_tuple.scores + + +@pytest.mark.parametrize("values", [(50.0, 20.0), (50.0, 20.0, 5.0, 1.0), ()]) +def test_wrong_number_of_components_raises( + values: tuple[float, ...], training: TrainingData +) -> None: + """L*a*b* は 3 要素でなければならない.""" + with pytest.raises(ValueError): + diagnose(values, training) + + +# --- color.py との結合(SPEC_02 のパイプライン) --- + + +def linear_coefficients() -> np.ndarray: + """線形項のみの変換係数(sRGB → XYZ 相当を 0〜255 スケールに合わせたもの).""" + coefficients = np.zeros((17, 3), dtype=np.float64) + coefficients[0] = (0.4124, 0.2126, 0.0193) + coefficients[1] = (0.3576, 0.7152, 0.1192) + coefficients[2] = (0.1805, 0.0722, 0.9505) + return coefficients * (100.0 / 255.0) + + +def test_roi_to_lab_to_diagnosis_pipeline(training: TrainingData) -> None: + """ROI 平均 RGB → XYZ → Lab → SVM 分類のパイプラインが連結する(SPEC_02 冒頭). + + ROI(51×51)が単色なのでその色がそのまま診断入力になり,同じ色の画素値を + 直接 `rgb_to_lab` に渡した場合と結果が一致する. + """ + bgr = (110, 110, 180) # BGR 並び(= RGB (180, 110, 110)) + image = np.empty((200, 200, 3), dtype=np.uint8) + image[:, :] = bgr + + rgb = mean_rgb(image, [(60, 70), (140, 130)]) + lab = rgb_to_lab(rgb, linear_coefficients()) + result = diagnose(lab, training) + + assert rgb == (180.0, 110.0, 110.0) + assert result.lab == rgb_to_lab((180.0, 110.0, 110.0), linear_coefficients()) + assert set(result.scores) == set(COLOR_KEYS) + assert result.top_labels[0] in COLOR_KEYS + + +def test_lab_from_rgb_conversion_can_be_diagnosed(training: TrainingData) -> None: + """`rgb_to_lab` の出力をそのまま `diagnose` に渡せる(ROI 平均 RGB → 分類の連結).""" + # 線形項のみの変換係数(sRGB → XYZ 相当を 0〜255 スケールに合わせたもの) + coefficients = np.zeros((17, 3), dtype=np.float64) + coefficients[0] = (0.4124, 0.2126, 0.0193) + coefficients[1] = (0.3576, 0.7152, 0.1192) + coefficients[2] = (0.1805, 0.0722, 0.9505) + coefficients *= 100.0 / 255.0 + + lab = rgb_to_lab((180.0, 110.0, 110.0), coefficients) + result = diagnose(lab, training) + + assert result.lab == lab + assert lab.l_star > 0.0 + assert result.failed is False + assert set(result.scores) == set(COLOR_KEYS) + assert all(0.0 <= score <= 1.0 for score in result.scores.values())