"""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]))