diff --git a/CLAUDE.md b/CLAUDE.md index e0cc92d..a181f70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 ステップ 2(core: CSV 読込)完了.次はステップ 3(color) +最新: PLAN_02 ステップ 3(core: 色変換)完了.次はステップ 4(svm/roi) ※ 本欄は**最新ステップ 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 543a2e7..5664538 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" @@ -37,6 +37,8 @@ ※ CIE 標準の低輝度側条件分岐(t ≤ (6/29)³ のときの線形近似)は現行実装に無いため**意図的に実装しない**(結果同一性優先). +※ 精度の踏襲: 旧実装は変換係数を単精度(`float a[17]` 等)で保持し,X, Y, Z の計算は倍精度,出力 L\*a\*b\* は単精度(`float`)に丸めて保持する.新実装(`core/color.py`)もこれを再現する(係数を float32 に丸めてから float64 で演算し,Lab 出力を float32 に丸める). + ### (3) SVM 分類 - 学習データ: `data.csv` の 84 サンプル(入力: L\*a\*b\* 3 次元,出力: 190 列 = 医師 38 名 × 5 色の 0/1 評価) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 0d5d82b..d47f1e3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -28,3 +28,7 @@ - 設計判断: dtype は旧実装(`CV_32F`・int キャスト)に合わせ lab=float32 / evaluations=int32.変換行列は計算精度優先で float64(旧実装も double で計算) - レビューポイント(仕様に規定なし・挙動は現状維持のまま起床後の判断待ち): ① 変換行列の nan/inf セルを受理する(有限値チェック追加の要否)/ ② 評価列 `1.0` 表記を 1 と解釈する / ③ UTF-8 失敗時の cp932 フォールバックの要否(ENV_01 の UTF-8 統一方針との整合)/ ④ 変換行列は 17 行ちょうどを要求(旧実装は 18 行以上でも先頭 17 行で通す) - 旧実装の潜在バグ発見: `Readfile()` の係数初期化ループが `i < 16` までで `a[16]` 等が未初期化(正常な 17 行ファイルなら影響なし.新実装には持ち込まない) +- [x] **PLAN_02 ステップ 3 完了(core: 色変換)**: `core/color.py`(RGB→XYZ 17 項多項式・XYZ→Lab 現行式)を実装(無人運転).C++ 逐語移植の参照実装と 4 ケースでビット一致を確認 + - 設計判断: 旧実装の精度を忠実に再現(係数 float32 保持 → double 演算 → Lab を float32 丸め).SPEC_02 に精度踏襲の注記を追加.CIE 低輝度分岐は仕様どおり実装しない(テストは「分岐が入っていないこと」まで検証済み) + - テストの検出力をミューテーション 6 種(項順入替・クリップ削除・CIE 分岐追加等)で確認し全検出 + - 留意(ステップ 4 へ): XYZ=0 のとき L\*=−16 と負値になり学習データの範囲(0〜100)外の入力が SVM に入りうる(旧実装と同一挙動.問題ではないが把握しておく) diff --git a/src/tiasjudge/core/color.py b/src/tiasjudge/core/color.py new file mode 100644 index 0000000..be55b0a --- /dev/null +++ b/src/tiasjudge/core/color.py @@ -0,0 +1,225 @@ +"""色変換(RGB → XYZ → L*a*b*). + +SPEC_02 (1)(2) の式を実装する.旧実装(`org/src` の `CtonguediagnosisMFC2Dlg::CalcLab()`)と +数値が一致することを最優先とし,式・項の順序・データ型(係数 float32/演算 double/ +Lab 出力 float32)を踏襲する. + +※ Lab 変換には CIE 標準の低輝度側の条件分岐(t <= (6/29)^3 の線形近似)が無い. +これは旧実装に無いための**意図的な踏襲**であり,診断結果の同一性を優先した判断である +(SPEC_02 (2) の注記を参照).CIE 的に「正しい」式へ直してはならない. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +logger = logging.getLogger(__name__) + +# --- 定数(SPEC_02 (1)(2))--- + +#: 多項式変換の項数(変換行列 CSV の行数と一致する) +POLYNOMIAL_TERM_COUNT = 17 +#: 色の成分数(RGB / XYZ / Lab の 3 成分.変換行列の列数 a[i], b[i], c[i] とも一致する) +COMPONENT_COUNT = 3 + +#: Lab 変換に用いる白色点(旧実装で固定値.SPEC_02 (2)) +WHITE_POINT_X = 102.07 +WHITE_POINT_Y = 100.0 +WHITE_POINT_Z = 79.41 + +#: 立方根の指数(旧実装の `pow(t, 1.0/3.0)` と同じ) +_CUBE_ROOT_EXPONENT = 1.0 / 3.0 + + +@dataclass(frozen=True) +class Xyz: + """三刺激値 XYZ(負値は 0 にクリップ済み).""" + + x: float + y: float + z: float + + def as_array(self) -> NDArray[np.float64]: + """形状 (3,)・dtype float64 の配列 [X, Y, Z] を返す.""" + return np.array([self.x, self.y, self.z], dtype=np.float64) + + +@dataclass(frozen=True) +class Lab: + """L*a*b* 値(旧実装と同じく float32 相当に丸めた値).""" + + l_star: float + a_star: float + b_star: float + + def as_array(self) -> NDArray[np.float32]: + """形状 (3,)・dtype float32 の配列 [L*, a*, b*] を返す(SVM 入力用).""" + return np.array([self.l_star, self.a_star, self.b_star], dtype=np.float32) + + +def rgb_to_xyz(rgb: ArrayLike, coefficients: ArrayLike) -> Xyz: + """RGB を多項式回帰で XYZ に変換する(SPEC_02 (1)). + + 旧実装と同じく,係数は float32 で保持された値(CSV を `fscanf("%f")` で読んだ相当)を + double に昇格して計算し,結果の負値は 0 にクリップする. + + Args: + rgb: (R, G, B) の 3 要素(0〜255 の平均色.`roi.py` の ROI 平均を想定). + coefficients: 形状 (17, 3) の係数(`csv_io.load_transform_matrix` の戻り値). + 列 0 が a[i](X 用),列 1 が b[i](Y 用),列 2 が c[i](Z 用). + + Returns: + 変換後の `Xyz`(各成分は 0 以上). + + Raises: + ValueError: `rgb` が 3 要素でない,または係数の形状が (17, 3) でない場合. + """ + red, green, blue = _as_rgb(rgb) + matrix = _as_coefficients(coefficients) + + terms = _polynomial_terms(red, green, blue) + # 項の並びは旧実装の加算順(1 次 → 2 次クロス → 2 乗 → 3 次 → R*G*B → 定数)に対応する + xyz = terms @ matrix + + # 旧実装の `if(X < 0) X = 0;`(Y, Z も同様)に対応する + clipped = np.maximum(xyz, 0.0) + return Xyz(x=float(clipped[0]), y=float(clipped[1]), z=float(clipped[2])) + + +def xyz_to_lab(xyz: Xyz | ArrayLike) -> Lab: + """XYZ を L*a*b* に変換する(SPEC_02 (2)). + + 白色点は Xn=102.07, Yn=100.0, Zn=79.41 の固定値で,比の立方根をそのまま用いる + (CIE 標準の低輝度側の分岐は旧実装に無いため実装しない). + + Args: + xyz: `Xyz`,または (X, Y, Z) の 3 要素(いずれも 0 以上). + + Returns: + 変換後の `Lab`(旧実装が float 変数へ代入するのと同じく float32 相当に丸める). + + Raises: + ValueError: 要素数が 3 でない場合,または負の成分を含む場合. + """ + x, y, z = _as_xyz(xyz) + + # 旧実装: pow((Y / Yn), 1.0/3.0) 等.double で計算する + fx = (x / WHITE_POINT_X) ** _CUBE_ROOT_EXPONENT + fy = (y / WHITE_POINT_Y) ** _CUBE_ROOT_EXPONENT + fz = (z / WHITE_POINT_Z) ** _CUBE_ROOT_EXPONENT + + lightness = 116.0 * fy - 16.0 + a_star = 500.0 * (fx - fy) + b_star = 200.0 * (fy - fz) + + # 旧実装は結果を float(単精度)変数に格納するため,同じ丸めを再現する + return Lab( + l_star=_to_single(lightness), + a_star=_to_single(a_star), + b_star=_to_single(b_star), + ) + + +def rgb_to_lab(rgb: ArrayLike, coefficients: ArrayLike) -> Lab: + """RGB を L*a*b* に変換する(SPEC_02 (1) → (2) の連結). + + Args: + rgb: (R, G, B) の 3 要素(0〜255 の平均色). + coefficients: 形状 (17, 3) の係数(`csv_io.load_transform_matrix` の戻り値). + + Returns: + 変換後の `Lab`. + """ + red, green, blue = _as_rgb(rgb) + xyz = rgb_to_xyz((red, green, blue), coefficients) + lab = xyz_to_lab(xyz) + logger.info( + "色変換: RGB=(%.6f, %.6f, %.6f) -> XYZ=(%.6f, %.6f, %.6f) -> Lab=(%.6f, %.6f, %.6f)", + red, + green, + blue, + xyz.x, + xyz.y, + xyz.z, + lab.l_star, + lab.a_star, + lab.b_star, + ) + return lab + + +def _polynomial_terms(red: float, green: float, blue: float) -> NDArray[np.float64]: + """多項式 17 項を旧実装と同じ並びで返す(形状 (17,)・dtype float64). + + 並び: R, G, B, RG, RB, GB, R^2, G^2, B^2, R^2*B, R^2*G, G^2*R, G^2*B, + B^2*R, B^2*G, R*G*B, 1(定数項). + """ + return np.array( + [ + red, + green, + blue, + red * green, + red * blue, + green * blue, + red * red, + green * green, + blue * blue, + red * red * blue, + red * red * green, + green * green * red, + green * green * blue, + blue * blue * red, + blue * blue * green, + red * green * blue, + 1.0, + ], + dtype=np.float64, + ) + + +def _as_components(values: ArrayLike, label: str) -> NDArray[np.float64]: + """入力を double の 3 成分配列に整える(要素数が違えば `ValueError`).""" + array = np.asarray(values, dtype=np.float64).reshape(-1) + if array.size != COMPONENT_COUNT: + raise ValueError( + f"{label} は {COMPONENT_COUNT} 要素である必要があります: {array.size} 要素" + ) + return array + + +def _as_rgb(rgb: ArrayLike) -> tuple[float, float, float]: + """入力を (R, G, B) の double 3 要素に整える(旧実装の m_R/m_G/m_B は double).""" + values = _as_components(rgb, "RGB") + return float(values[0]), float(values[1]), float(values[2]) + + +def _as_xyz(xyz: Xyz | ArrayLike) -> tuple[float, float, float]: + """入力を (X, Y, Z) の double 3 要素に整える(負値があれば拒否する).""" + values = _as_components(xyz.as_array() if isinstance(xyz, Xyz) else xyz, "XYZ") + if bool(np.any(values < 0.0)): + # 旧実装は XYZ を 0 でクリップしてから Lab 変換する(負値なら pow が NaN になる) + raise ValueError(f"XYZ に負の成分があります: {tuple(float(v) for v in values)}") + return float(values[0]), float(values[1]), float(values[2]) + + +def _as_coefficients(coefficients: ArrayLike) -> NDArray[np.float64]: + """係数を検証し,旧実装と同じ有効桁(float32 保持)で double 配列にして返す.""" + matrix = np.asarray(coefficients, dtype=np.float64) + if matrix.shape != (POLYNOMIAL_TERM_COUNT, COMPONENT_COUNT): + raise ValueError( + f"変換行列は形状 ({POLYNOMIAL_TERM_COUNT}, {COMPONENT_COUNT}) が必要です: " + f"{matrix.shape}" + ) + # 旧実装は係数を float 配列(`float a[17]` を `fscanf("%f")` で読む)に保持するため, + # 同じ丸め(単精度)を経てから double 演算に用いる + return matrix.astype(np.float32).astype(np.float64) + + +def _to_single(value: float) -> float: + """double の計算結果を単精度に丸める(旧実装の float 変数への代入に対応).""" + return float(np.float32(value)) diff --git a/tests/core/test_color.py b/tests/core/test_color.py new file mode 100644 index 0000000..7dc09b3 --- /dev/null +++ b/tests/core/test_color.py @@ -0,0 +1,583 @@ +"""`core/color.py` の検証(SPEC_02 (1) RGB→XYZ・(2) XYZ→Lab・PLAN_02 ステップ 3). + +仕様上の要件は以下(`docs/04_SPEC/SPEC_02_診断ロジック仕様.md`). + +- (1) 変換行列 CSV の係数 `a[0..16], b[0..16], c[0..16]` を用いた 17 項の多項式で X, Y, Z を求める. + 項の内訳は 1 次 3 項 + 2 次クロス 3 項 + 2 乗 3 項 + 3 次 7 項 + 定数項.負値は 0 にクリップする +- (2) 白色点 Xn=102.07, Yn=100.0, Zn=79.41 の固定値で,比の立方根から L\\*a\\*b\\* を求める. + **CIE 標準の低輝度側の条件分岐は現行実装に無いため意図的に実装しない**(結果同一性優先)ので, + 本テストでも CIE 標準値との比較は行わず,SPEC_02 に書かれた式そのものを正解とする + +期待値は実装コードを参照せず,本ファイル内でテスト用に独立実装した `spec_*` 関数 +(SPEC_02 の式をそのまま書き下したもの)から求める. +比較の許容誤差は float32 精度(rel 1e-6 程度)とする.旧実装は係数と Lab を単精度で保持するため, +倍精度で計算した仕様値とは float32 の丸め分だけずれうるが,診断結果に影響しない範囲である. +""" + +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np +import pytest + +from tiasjudge.core.color import ( + POLYNOMIAL_TERM_COUNT, + WHITE_POINT_X, + WHITE_POINT_Y, + WHITE_POINT_Z, + Lab, + Xyz, + rgb_to_lab, + rgb_to_xyz, + xyz_to_lab, +) +from tiasjudge.core.csv_io import load_transform_matrix + +# --- 比較の許容誤差 --- + +#: Lab の比較許容誤差(float32 精度.係数の単精度保持による差も吸収する) +LAB_REL = 1e-5 +LAB_ABS = 1e-4 +#: XYZ の比較許容誤差 +XYZ_REL = 1e-6 +XYZ_ABS = 1e-9 + +#: CIE 標準の低輝度分岐の閾値 t <= (6/29)^3(本実装では**使わない**ことの確認に用いる) +CIE_LINEAR_THRESHOLD = (6.0 / 29.0) ** 3 + + +# --- SPEC_02 の式の独立実装(テスト用.実装コードは参照しない) --- + + +def spec_polynomial_terms(red: float, green: float, blue: float) -> list[float]: + """SPEC_02 (1) の 17 項を式の並びどおりに返す. + + X = a0*R + a1*G + a2*B + + a3*R*G + a4*R*B + a5*G*B + + a6*R^2 + a7*G^2 + a8*B^2 + + a9*R^2*B + a10*R^2*G + a11*G^2*R + a12*G^2*B + a13*B^2*R + a14*B^2*G + + a15*R*G*B + + a16 + """ + r, g, b = float(red), float(green), float(blue) + return [ + r, + g, + b, + r * g, + r * b, + g * b, + r * r, + g * g, + b * b, + r * r * b, + r * r * g, + g * g * r, + g * g * b, + b * b * r, + b * b * g, + r * g * b, + 1.0, + ] + + +#: 17 項の意味(テストの可読性のため名前を付ける.並びは SPEC_02 の式の順) +TERM_NAMES = [ + "R", + "G", + "B", + "R*G", + "R*B", + "G*B", + "R^2", + "G^2", + "B^2", + "R^2*B", + "R^2*G", + "G^2*R", + "G^2*B", + "B^2*R", + "B^2*G", + "R*G*B", + "1", +] + + +def spec_rgb_to_xyz(rgb, coefficients) -> tuple[float, float, float]: + """SPEC_02 (1) の多項式変換(負値は 0 にクリップ).""" + terms = spec_polynomial_terms(*[float(value) for value in rgb]) + matrix = [[float(cell) for cell in row] for row in coefficients] + result = [] + for column in range(3): + total = math.fsum(terms[index] * matrix[index][column] for index in range(17)) + result.append(max(total, 0.0)) + return result[0], result[1], result[2] + + +def spec_xyz_to_lab(x: float, y: float, z: float) -> tuple[float, float, float]: + """SPEC_02 (2) の Lab 変換(低輝度側の条件分岐は持たない).""" + fx = (x / 102.07) ** (1.0 / 3.0) + fy = (y / 100.0) ** (1.0 / 3.0) + fz = (z / 79.41) ** (1.0 / 3.0) + return (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)) + + +def spec_rgb_to_lab(rgb, coefficients) -> tuple[float, float, float]: + """SPEC_02 (1) → (2) の連結.""" + return spec_xyz_to_lab(*spec_rgb_to_xyz(rgb, coefficients)) + + +def cie_lightness(y: float) -> float: + """CIE 標準(低輝度側は線形近似)の L\\*.本実装との**差異の確認にのみ**使う.""" + t = y / 100.0 + if t <= CIE_LINEAR_THRESHOLD: + return 903.3 * t + return 116.0 * t ** (1.0 / 3.0) - 16.0 + + +# --- テスト用の係数 --- + + +def zero_coefficients() -> np.ndarray: + """全項 0 の係数行列(形状 (17, 3)).""" + return np.zeros((POLYNOMIAL_TERM_COUNT, 3), dtype=np.float64) + + +def white_point_constant_coefficients() -> np.ndarray: + """定数項だけに白色点を置いた係数(RGB によらず XYZ = 白色点になる).""" + matrix = zero_coefficients() + matrix[16] = [WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z] + return matrix + + +def linear_coefficients() -> np.ndarray: + """1 次項のみの現実的な係数(sRGB 相当の比率を白色点に合わせて正規化したもの). + + RGB = (255, 255, 255) が白色点 (Xn, Yn, Zn) になるよう各列を正規化しているため, + 無彩色(R=G=B)では a\\*, b\\* が 0 になる. + 実際のキャリブレーション結果に近い規模で XYZ が得られればよく,値そのものに仕様上の意味はない. + """ + ratios = np.array( + [ + [0.4124, 0.2126, 0.0193], + [0.3576, 0.7152, 0.1192], + [0.1805, 0.0722, 0.9505], + ], + dtype=np.float64, + ) + white_point = np.array([WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z], dtype=np.float64) + matrix = zero_coefficients() + matrix[0:3] = ratios * (white_point / (255.0 * ratios.sum(axis=0))) + return matrix + + +#: 高次項の係数スケール(項の大きさが 2 次で ~6.5e4,3 次で ~1.7e7 になるため次数ごとに調整する) +HIGHER_ORDER_SCALE = np.array([1e-6] * 6 + [1e-9] * 7, dtype=np.float64).reshape(13, 1) + + +def higher_order_coefficients(rng: np.random.Generator) -> np.ndarray: + """2 次・3 次項(行 3〜15)の係数を,XYZ が現実的な範囲に収まる大きさで生成する.""" + return rng.uniform(-1.0, 1.0, size=(13, 3)) * HIGHER_ORDER_SCALE + + +def full_coefficients() -> np.ndarray: + """全 17 項が効く現実的な係数(1 次項 + 高次項 + 定数項).""" + matrix = linear_coefficients() + matrix[3:16] = higher_order_coefficients(np.random.default_rng(20260803)) + matrix[16] = [0.5, -0.25, 0.75] + return matrix + + +def as_float32_exact(matrix: np.ndarray) -> np.ndarray: + """float32 で厳密に表現できる値に丸めた係数を返す(丸め差を排して式だけを比較するため).""" + return matrix.astype(np.float32).astype(np.float64) + + +SAMPLE_RGB = [ + (0.0, 0.0, 0.0), + (255.0, 255.0, 255.0), + (170.5, 95.25, 88.0), + (120.0, 70.0, 65.0), + (12.5, 8.25, 6.0), + (200.0, 10.0, 250.0), + (1.0, 2.0, 3.0), +] + + +@pytest.fixture +def transform_csv(tmp_path: Path) -> Path: + """`csv_io.load_transform_matrix` が読む形式(17 行 × 3 列・ヘッダなし)の CSV.""" + matrix = full_coefficients() + lines = [",".join(f"{value:.10g}" for value in row) for row in matrix] + path = tmp_path / "matrix.csv" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def assert_lab_close(actual: Lab, expected: tuple[float, float, float]) -> None: + values = (actual.l_star, actual.a_star, actual.b_star) + assert values == pytest.approx(expected, rel=LAB_REL, abs=LAB_ABS) + + +class TestRgbToXyzPolynomial: + """SPEC_02 (1) RGB → XYZ の多項式変換.""" + + @pytest.mark.parametrize("rgb", SAMPLE_RGB) + def test_matches_spec_formula_with_full_coefficients(self, rgb: tuple[float, ...]) -> None: + coefficients = as_float32_exact(full_coefficients()) + xyz = rgb_to_xyz(rgb, coefficients) + expected = spec_rgb_to_xyz(rgb, coefficients) + assert (xyz.x, xyz.y, xyz.z) == pytest.approx(expected, rel=XYZ_REL, abs=XYZ_ABS) + + @pytest.mark.parametrize("term_index", range(POLYNOMIAL_TERM_COUNT)) + @pytest.mark.parametrize("column", [0, 1, 2]) + def test_each_of_17_terms_is_applied_to_the_right_output( + self, term_index: int, column: int + ) -> None: + """係数を 1 項ずつ立てると,その項の単項式が X / Y / Z に現れる. + + 列 0 = a[i](X 用),列 1 = b[i](Y 用),列 2 = c[i](Z 用)(SPEC_02 データ形式). + """ + coefficients = zero_coefficients() + coefficients[term_index, column] = 1.0 + red, green, blue = 170.5, 95.25, 88.0 + + xyz = rgb_to_xyz((red, green, blue), coefficients) + + expected_term = spec_polynomial_terms(red, green, blue)[term_index] + actual = (xyz.x, xyz.y, xyz.z) + assert actual[column] == pytest.approx(expected_term, rel=1e-12), ( + f"{TERM_NAMES[term_index]} 項が列 {column} に反映されていない" + ) + for other in range(3): + if other != column: + assert actual[other] == 0.0 + + def test_term_count_is_17(self) -> None: + assert POLYNOMIAL_TERM_COUNT == 17 + + def test_terms_are_independent_of_each_other(self) -> None: + """各項の寄与は線形に加算される(17 項の和 = 個別項の和).""" + coefficients = as_float32_exact(full_coefficients()) + rgb = (170.5, 95.25, 88.0) + total = rgb_to_xyz(rgb, coefficients).as_array() + + # 個別項の寄与(負になりうるのでクリップは最後に 1 度だけ適用する) + terms = spec_polynomial_terms(*rgb) + parts = np.zeros(3, dtype=np.float64) + for term_index in range(POLYNOMIAL_TERM_COUNT): + parts += terms[term_index] * coefficients[term_index] + + assert total == pytest.approx(np.maximum(parts, 0.0), rel=XYZ_REL, abs=1e-9) + + def test_constant_term_only_is_independent_of_rgb(self) -> None: + coefficients = white_point_constant_coefficients() + first = rgb_to_xyz((0.0, 0.0, 0.0), coefficients) + second = rgb_to_xyz((255.0, 128.0, 3.5), coefficients) + assert (first.x, first.y, first.z) == pytest.approx((second.x, second.y, second.z)) + assert (first.x, first.y, first.z) == pytest.approx( + (WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z), rel=1e-6 + ) + + def test_negative_result_is_clipped_to_zero(self) -> None: + """負値は 0 にクリップする(SPEC_02 (1)).""" + coefficients = zero_coefficients() + coefficients[16] = [-10.0, 50.0, -0.001] + xyz = rgb_to_xyz((100.0, 100.0, 100.0), coefficients) + assert xyz.x == 0.0 + assert xyz.y == pytest.approx(50.0) + assert xyz.z == 0.0 + + def test_all_zero_coefficients_give_zero_xyz(self) -> None: + xyz = rgb_to_xyz((123.0, 45.0, 67.0), zero_coefficients()) + assert (xyz.x, xyz.y, xyz.z) == (0.0, 0.0, 0.0) + + @pytest.mark.parametrize("rgb", SAMPLE_RGB) + def test_accepts_list_tuple_and_ndarray(self, rgb: tuple[float, ...]) -> None: + coefficients = as_float32_exact(linear_coefficients()) + expected = rgb_to_xyz(rgb, coefficients) + for form in (list(rgb), np.array(rgb, dtype=np.float64), np.array(rgb, dtype=np.float32)): + xyz = rgb_to_xyz(form, coefficients) + assert (xyz.x, xyz.y, xyz.z) == pytest.approx( + (expected.x, expected.y, expected.z), rel=1e-6, abs=1e-6 + ) + + def test_result_dtype_is_float64_array(self) -> None: + xyz = rgb_to_xyz((10.0, 20.0, 30.0), as_float32_exact(linear_coefficients())) + array = xyz.as_array() + assert array.shape == (3,) + assert array.dtype == np.float64 + + def test_random_inputs_match_spec_formula(self) -> None: + """乱数で生成した RGB・係数でも仕様の式と一致する(シード固定で決定的).""" + rng = np.random.default_rng(12345) + base = linear_coefficients() + for _ in range(200): + coefficients = base.copy() + coefficients[3:16] = higher_order_coefficients(rng) + coefficients[16] = rng.uniform(-2.0, 2.0, size=3) + coefficients = as_float32_exact(coefficients) + rgb = rng.uniform(0.0, 255.0, size=3) + + xyz = rgb_to_xyz(rgb, coefficients) + expected = spec_rgb_to_xyz(rgb, coefficients) + assert (xyz.x, xyz.y, xyz.z) == pytest.approx(expected, rel=1e-9, abs=1e-9) + + def test_high_precision_coefficients_are_used_within_float32_accuracy(self) -> None: + """float32 で表現しきれない係数でも,仕様値と float32 精度の範囲で一致する.""" + coefficients = zero_coefficients() + coefficients[0] = [0.412345678901234, 0.212345678901234, 0.019345678901234] + coefficients[1] = [0.357612345678901, 0.715212345678901, 0.119212345678901] + coefficients[2] = [0.180512345678901, 0.072212345678901, 0.950512345678901] + rgb = (170.5, 95.25, 88.0) + + xyz = rgb_to_xyz(rgb, coefficients) + expected = spec_rgb_to_xyz(rgb, coefficients) + assert (xyz.x, xyz.y, xyz.z) == pytest.approx(expected, rel=1e-6) + + +class TestRgbToXyzErrors: + """RGB → XYZ の異常系(入力ガード).""" + + @pytest.mark.parametrize("rgb", [(), (1.0,), (1.0, 2.0), (1.0, 2.0, 3.0, 4.0)]) + def test_rgb_must_have_three_components(self, rgb: tuple[float, ...]) -> None: + with pytest.raises(ValueError): + rgb_to_xyz(rgb, zero_coefficients()) + + @pytest.mark.parametrize("shape", [(16, 3), (18, 3), (17, 2), (17, 4), (3, 17), (17,), (51,)]) + def test_coefficient_shape_must_be_17_by_3(self, shape: tuple[int, ...]) -> None: + with pytest.raises(ValueError): + rgb_to_xyz((1.0, 2.0, 3.0), np.zeros(shape, dtype=np.float64)) + + +class TestXyzToLab: + """SPEC_02 (2) XYZ → L\\*a\\*b\\*.""" + + def test_white_point_maps_to_l100_a0_b0(self) -> None: + """白色点そのものを入れると L\\*=100, a\\*=0, b\\*=0 になる.""" + lab = xyz_to_lab((WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z)) + assert lab.l_star == pytest.approx(100.0, abs=1e-5) + assert lab.a_star == pytest.approx(0.0, abs=1e-5) + assert lab.b_star == pytest.approx(0.0, abs=1e-5) + + def test_white_point_constants_follow_spec(self) -> None: + assert (WHITE_POINT_X, WHITE_POINT_Y, WHITE_POINT_Z) == (102.07, 100.0, 79.41) + + @pytest.mark.parametrize( + "xyz", + [ + (102.07, 100.0, 79.41), + (50.0, 50.0, 50.0), + (20.0, 18.0, 15.0), + (95.0, 100.0, 108.0), + (1.0, 1.0, 1.0), + (0.5, 0.25, 0.125), + (0.0, 0.0, 0.0), + (200.0, 5.0, 0.0), + (0.0, 100.0, 0.0), + ], + ) + def test_matches_spec_formula(self, xyz: tuple[float, float, float]) -> None: + lab = xyz_to_lab(xyz) + assert_lab_close(lab, spec_xyz_to_lab(*xyz)) + + def test_accepts_xyz_dataclass(self) -> None: + lab_from_object = xyz_to_lab(Xyz(x=30.0, y=25.0, z=20.0)) + lab_from_tuple = xyz_to_lab((30.0, 25.0, 20.0)) + assert lab_from_object == lab_from_tuple + + def test_zero_xyz_gives_l_minus_16(self) -> None: + """XYZ = 0 でも分岐せず立方根の式が使われる(L\\* = -16 になる).""" + lab = xyz_to_lab((0.0, 0.0, 0.0)) + assert_lab_close(lab, (-16.0, 0.0, 0.0)) + + @pytest.mark.parametrize("y", [0.0001, 0.001, 0.01, 0.1, 0.5, 0.8, 0.885]) + def test_low_luminance_uses_cube_root_formula(self, y: float) -> None: + """低輝度域(t <= (6/29)^3)でも立方根の式をそのまま使う(SPEC_02 (2) 注記).""" + assert y / 100.0 <= CIE_LINEAR_THRESHOLD, "この検証は低輝度域でのみ意味を持つ" + lab = xyz_to_lab((y * 1.0207, y, y * 0.7941)) + expected = 116.0 * (y / 100.0) ** (1.0 / 3.0) - 16.0 + assert lab.l_star == pytest.approx(expected, rel=LAB_REL, abs=LAB_ABS) + + @pytest.mark.parametrize("y", [0.0001, 0.001, 0.01, 0.1]) + def test_low_luminance_differs_from_cie_linear_branch(self, y: float) -> None: + """CIE 標準の線形近似に切り替える実装とは値が明確に異なる(分岐が無いことの確認). + + ※ 分岐点近傍は両者が連続的に一致するため,差が現れる領域でのみ検証する. + """ + lab = xyz_to_lab((y * 1.0207, y, y * 0.7941)) + assert abs(lab.l_star - cie_lightness(y)) > 0.5 + + def test_a_star_depends_only_on_x_and_y(self) -> None: + base = xyz_to_lab((60.0, 50.0, 40.0)) + changed_z = xyz_to_lab((60.0, 50.0, 70.0)) + assert changed_z.a_star == pytest.approx(base.a_star, rel=LAB_REL, abs=LAB_ABS) + assert changed_z.l_star == pytest.approx(base.l_star, rel=LAB_REL, abs=LAB_ABS) + assert changed_z.b_star != pytest.approx(base.b_star, rel=LAB_REL, abs=LAB_ABS) + + def test_scaling_x_up_increases_a_star(self) -> None: + """X が大きい(赤方向)ほど a\\* は大きくなる.""" + lower = xyz_to_lab((40.0, 50.0, 40.0)) + higher = xyz_to_lab((70.0, 50.0, 40.0)) + assert higher.a_star > lower.a_star + + def test_scaling_z_up_decreases_b_star(self) -> None: + """Z が大きい(青方向)ほど b\\* は小さくなる.""" + lower = xyz_to_lab((50.0, 50.0, 30.0)) + higher = xyz_to_lab((50.0, 50.0, 70.0)) + assert higher.b_star < lower.b_star + + def test_l_star_is_monotonic_in_y(self) -> None: + values = [xyz_to_lab((50.0, y, 50.0)).l_star for y in (0.0, 1.0, 10.0, 50.0, 100.0, 150.0)] + assert values == sorted(values) + assert len(set(values)) == len(values) + + def test_random_inputs_match_spec_formula(self) -> None: + rng = np.random.default_rng(999) + for _ in range(300): + xyz = rng.uniform(0.0, 150.0, size=3) + assert_lab_close(xyz_to_lab(xyz), spec_xyz_to_lab(*xyz)) + + def test_result_dtype_is_float32_array(self) -> None: + """SVM 入力(`data.csv` の Lab は float32)と同じ dtype で渡せる.""" + array = xyz_to_lab((60.0, 50.0, 40.0)).as_array() + assert array.shape == (3,) + assert array.dtype == np.float32 + + def test_is_deterministic(self) -> None: + first = xyz_to_lab((60.0, 50.0, 40.0)) + second = xyz_to_lab((60.0, 50.0, 40.0)) + assert (first.l_star, first.a_star, first.b_star) == ( + second.l_star, + second.a_star, + second.b_star, + ) + + def test_result_is_immutable(self) -> None: + lab = xyz_to_lab((60.0, 50.0, 40.0)) + assert isinstance(lab, Lab) + with pytest.raises(Exception): # noqa: B017 - frozen dataclass の再代入禁止 + lab.l_star = 0.0 # type: ignore[misc] + + +class TestXyzToLabErrors: + """XYZ → Lab の異常系.""" + + @pytest.mark.parametrize("xyz", [(), (1.0,), (1.0, 2.0), (1.0, 2.0, 3.0, 4.0)]) + def test_xyz_must_have_three_components(self, xyz: tuple[float, ...]) -> None: + with pytest.raises(ValueError): + xyz_to_lab(xyz) + + @pytest.mark.parametrize("xyz", [(-1.0, 50.0, 40.0), (50.0, -1.0, 40.0), (50.0, 50.0, -1e-9)]) + def test_negative_component_is_rejected(self, xyz: tuple[float, float, float]) -> None: + """負の XYZ は (1) で 0 クリップ済みのはずなので受け付けない(NaN を返さない).""" + with pytest.raises(ValueError): + xyz_to_lab(xyz) + + +class TestRgbToLab: + """SPEC_02 (1) → (2) のパイプライン.""" + + @pytest.mark.parametrize("rgb", SAMPLE_RGB) + def test_matches_spec_formula(self, rgb: tuple[float, ...]) -> None: + coefficients = as_float32_exact(full_coefficients()) + assert_lab_close(rgb_to_lab(rgb, coefficients), spec_rgb_to_lab(rgb, coefficients)) + + @pytest.mark.parametrize("rgb", SAMPLE_RGB) + def test_equals_composition_of_the_two_steps(self, rgb: tuple[float, ...]) -> None: + coefficients = as_float32_exact(full_coefficients()) + assert rgb_to_lab(rgb, coefficients) == xyz_to_lab(rgb_to_xyz(rgb, coefficients)) + + def test_constant_white_point_coefficients_give_white(self) -> None: + """定数項に白色点を置くと,どの RGB でも Lab = (100, 0, 0) になる.""" + coefficients = white_point_constant_coefficients() + lab = rgb_to_lab((170.5, 95.25, 88.0), coefficients) + assert lab.l_star == pytest.approx(100.0, abs=1e-4) + assert lab.a_star == pytest.approx(0.0, abs=1e-4) + assert lab.b_star == pytest.approx(0.0, abs=1e-4) + + def test_negative_z_is_clipped_before_lab(self) -> None: + """Z が負になる係数でも (1) の 0 クリップにより b\\* = 200*(Y/Yn)^(1/3) となる.""" + coefficients = zero_coefficients() + coefficients[16] = [102.07, 100.0, -50.0] + lab = rgb_to_lab((10.0, 20.0, 30.0), coefficients) + assert lab.b_star == pytest.approx(200.0 * 1.0, rel=LAB_REL, abs=LAB_ABS) + assert lab.l_star == pytest.approx(100.0, abs=1e-4) + + def test_all_negative_xyz_is_clipped(self) -> None: + """全成分が負になっても例外にならず Lab = (-16, 0, 0) になる.""" + coefficients = zero_coefficients() + coefficients[16] = [-1.0, -1.0, -1.0] + assert_lab_close(rgb_to_lab((10.0, 20.0, 30.0), coefficients), (-16.0, 0.0, 0.0)) + + @pytest.mark.parametrize("gray", [1.0, 32.0, 128.0, 200.0, 255.0]) + def test_neutral_rgb_gives_zero_chroma(self, gray: float) -> None: + """白色点に正規化した 1 次係数では,無彩色(R=G=B)の a\\*, b\\* が 0 になる.""" + coefficients = as_float32_exact(linear_coefficients()) + lab = rgb_to_lab((gray, gray, gray), coefficients) + assert lab.a_star == pytest.approx(0.0, abs=1e-4) + assert lab.b_star == pytest.approx(0.0, abs=1e-4) + + def test_white_rgb_maps_to_l100(self) -> None: + """RGB = (255, 255, 255) が白色点になる係数では L\\* = 100 になる.""" + coefficients = as_float32_exact(linear_coefficients()) + lab = rgb_to_lab((255.0, 255.0, 255.0), coefficients) + assert lab.l_star == pytest.approx(100.0, abs=1e-4) + + def test_reddish_rgb_has_positive_a_star(self) -> None: + """舌色の想定(赤み)で a\\* が正になる.""" + coefficients = as_float32_exact(linear_coefficients()) + lab = rgb_to_lab((170.5, 95.25, 88.0), coefficients) + assert lab.a_star > 0.0 + + def test_random_inputs_match_spec_formula(self) -> None: + rng = np.random.default_rng(4242) + base = linear_coefficients() + for _ in range(200): + coefficients = base.copy() + coefficients[3:16] = higher_order_coefficients(rng) + coefficients[16] = rng.uniform(0.0, 2.0, size=3) + coefficients = as_float32_exact(coefficients) + rgb = rng.uniform(0.0, 255.0, size=3) + assert_lab_close(rgb_to_lab(rgb, coefficients), spec_rgb_to_lab(rgb, coefficients)) + + def test_is_deterministic(self) -> None: + coefficients = as_float32_exact(full_coefficients()) + rgb = (170.5, 95.25, 88.0) + assert rgb_to_lab(rgb, coefficients) == rgb_to_lab(rgb, coefficients) + + +class TestIntegrationWithCsvIo: + """`csv_io.load_transform_matrix` が返す行列でそのまま変換できること(PLAN_02 ステップ 3).""" + + def test_matrix_loaded_from_csv_can_be_used_directly(self, transform_csv: Path) -> None: + coefficients = load_transform_matrix(transform_csv) + assert coefficients.shape == (POLYNOMIAL_TERM_COUNT, 3) + lab = rgb_to_lab((170.5, 95.25, 88.0), coefficients) + assert_lab_close(lab, spec_rgb_to_lab((170.5, 95.25, 88.0), coefficients)) + + def test_lab_from_csv_matrix_is_in_plausible_range(self, transform_csv: Path) -> None: + """実運用相当の係数で,Lab が学習データと同程度の範囲に収まる.""" + coefficients = load_transform_matrix(transform_csv) + lab = rgb_to_lab((170.5, 95.25, 88.0), coefficients) + assert 0.0 <= lab.l_star <= 100.0 + assert abs(lab.a_star) <= 128.0 + assert abs(lab.b_star) <= 128.0 + + def test_csv_row_order_maps_to_term_order(self, tmp_path: Path) -> None: + """CSV の i 行目が i 番目の項の係数として使われる(SPEC_02 データ形式).""" + for term_index in range(POLYNOMIAL_TERM_COUNT): + rows = [["0", "0", "0"] for _ in range(POLYNOMIAL_TERM_COUNT)] + rows[term_index] = ["1", "0", "0"] + path = tmp_path / f"matrix_{term_index}.csv" + path.write_text("\n".join(",".join(row) for row in rows) + "\n", encoding="utf-8") + + coefficients = load_transform_matrix(path) + red, green, blue = 170.5, 95.25, 88.0 + xyz = rgb_to_xyz((red, green, blue), coefficients) + expected = spec_polynomial_terms(red, green, blue)[term_index] + assert xyz.x == pytest.approx(expected, rel=1e-12), ( + f"CSV {term_index + 1} 行目が {TERM_NAMES[term_index]} 項に対応していない" + )