Newer
Older
TIASjudge / src / tiasjudge / core / svm.py
"""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<CString, float>`(キー昇順 "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]))