Newer
Older
SmTIAS-Evaluation / tests / io / test_dng_loader.py
"""dng_loader.py の単体テスト.

仕様 (TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md):
  - load_image_dng: DNG ファイルを読み込み numpy.ndarray として返す
    - output_format="rgb_float32": (H, W, 3) float32 linear [0, 1]
    - output_format="luma_float32": (H, W) float32 linear [0, 1], Rec.709 係数
    - output_format="rgb_uint8": (H, W, 3) uint8 sRGB OETF 適用後 [0, 255]
  - demosaic 既定は "bilinear"
  - エラー: FileNotFoundError(存在しないパス), ValueError(不正 format/demosaic)
  - Rec.709 係数 (0.2126, 0.7152, 0.0722) は uniformity.py の REC709_COEFF_* と一致すること

テスト方針 (GUIDE_08_テスト方針.md):
  - pytest を使用
  - サンプル DNG は .gitignore 対象のため不在時は pytest.mark.skipif でスキップ
  - 合成 DNG(rawpy モック)または実 DNG で検証する
"""

from pathlib import Path

import numpy
import numpy as np
import pytest

# サンプル DNG パス(.gitignore 対象のため不在環境では実 DNG テストをスキップ)
_SAMPLE_DNG_PATH = (
    Path(__file__).parent.parent.parent
    / "data"
    / "minitias"
    / "quantitative"
    / "MiniTIAS_QM_20260601_102753.dng"
)
_DNG_AVAILABLE = _SAMPLE_DNG_PATH.exists()

# Rec.709 係数(仕様: uniformity.py の REC709_COEFF_* と一致)
_REC709_R = 0.2126
_REC709_G = 0.7152
_REC709_B = 0.0722


# ---------------------------------------------------------------------------
# エラーパス テスト(DNG 不要 — パス検証のみ)
# ---------------------------------------------------------------------------


class TestLoadImageDngErrors:
    """load_image_dng のエラー送出テスト群."""

    def test_raises_file_not_found_for_nonexistent_path(self) -> None:
        """仕様 V2 / エラー仕様: 存在しないパスで FileNotFoundError が送出されること."""
        from src.io.dng_loader import load_image_dng

        with pytest.raises(FileNotFoundError):
            load_image_dng("/nonexistent/path/image.dng")

    def test_file_not_found_message_contains_path(self, tmp_path: Path) -> None:
        """エラー仕様: FileNotFoundError のメッセージにパスが含まれること."""
        from src.io.dng_loader import load_image_dng

        missing = str(tmp_path / "missing.dng")
        with pytest.raises(FileNotFoundError, match="missing.dng"):
            load_image_dng(missing)

    def test_raises_value_error_for_invalid_output_format(self, tmp_path: Path) -> None:
        """エラー仕様: 不正な output_format で ValueError が送出されること.

        仕様では rgb_float32 / luma_float32 / rgb_uint8 の 3 種のみ有効.
        """
        from src.io.dng_loader import load_image_dng

        # 実在するダミーファイルを作成(パス存在チェック通過のため)
        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"FAKE_DNG_CONTENT")

        with pytest.raises(ValueError):
            load_image_dng(str(dummy), output_format="invalid_format")

    def test_raises_value_error_for_invalid_demosaic(self, tmp_path: Path) -> None:
        """エラー仕様: 不正な demosaic 値で ValueError が送出されること.

        仕様では nearest / bilinear / ahd の 3 種のみ有効.
        """
        from src.io.dng_loader import load_image_dng

        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"FAKE_DNG_CONTENT")

        with pytest.raises(ValueError):
            load_image_dng(str(dummy), demosaic="invalid_demosaic")

    def test_value_error_for_invalid_format_message_is_descriptive(
        self, tmp_path: Path
    ) -> None:
        """エラー仕様: ValueError メッセージに不正な値が含まれること."""
        from src.io.dng_loader import load_image_dng

        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"FAKE_DNG_CONTENT")

        with pytest.raises(ValueError, match="bad_format"):
            load_image_dng(str(dummy), output_format="bad_format")

    def test_invalid_output_format_rejected_before_file_read(
        self, tmp_path: Path
    ) -> None:
        """仕様: 不正な output_format は rawpy 読み込み前にバリデーションされること.

        ファイルが存在するが内容が DNG でない場合でも ValueError が先に出ること.
        (存在するが不正な内容のファイルは rawpy で ValueError になる可能性があるが,
         output_format バリデーションはその前に実行されること)
        """
        from src.io.dng_loader import load_image_dng

        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"NOT_A_DNG")

        with pytest.raises(ValueError):
            load_image_dng(str(dummy), output_format="unknown_format")


# ---------------------------------------------------------------------------
# output_format 値ガード テスト(DNG 不要)
# ---------------------------------------------------------------------------


class TestOutputFormatValidation:
    """output_format バリデーション専用テスト群.

    仕様: 有効な output_format は rgb_float32 / luma_float32 / rgb_uint8 の 3 種のみ.
    """

    @pytest.mark.parametrize(
        "invalid_format",
        ["RGB", "float32", "uint8", "luma", "rgb", "luma_uint8", ""],
    )
    def test_invalid_format_raises_value_error(
        self, invalid_format: str, tmp_path: Path
    ) -> None:
        """エラー仕様: 有効でない output_format は ValueError を送出すること."""
        from src.io.dng_loader import load_image_dng

        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"FAKE")

        with pytest.raises(ValueError):
            load_image_dng(str(dummy), output_format=invalid_format)

    @pytest.mark.parametrize(
        "invalid_demosaic",
        ["linear", "LINEAR", "adaptive", "vng", ""],
    )
    def test_invalid_demosaic_raises_value_error(
        self, invalid_demosaic: str, tmp_path: Path
    ) -> None:
        """エラー仕様: 有効でない demosaic は ValueError を送出すること."""
        from src.io.dng_loader import load_image_dng

        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"FAKE")

        with pytest.raises(ValueError):
            load_image_dng(str(dummy), demosaic=invalid_demosaic)


# ---------------------------------------------------------------------------
# 実 DNG テスト(DNG ファイルが存在する環境のみ)
# ---------------------------------------------------------------------------

_skip_if_no_dng = pytest.mark.skipif(
    not _DNG_AVAILABLE,
    reason=f"サンプル DNG が存在しない: {_SAMPLE_DNG_PATH}",
)


@_skip_if_no_dng
class TestLoadImageDngRgbFloat32:
    """output_format="rgb_float32" の仕様検証テスト群."""

    @pytest.fixture
    def rgb_float32_result(self) -> numpy.ndarray:
        """rgb_float32 形式で DNG を読み込んだ結果を返す fixture."""
        from src.io.dng_loader import load_image_dng

        return load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32")

    def test_shape_is_3d_with_3_channels(
        self, rgb_float32_result: numpy.ndarray
    ) -> None:
        """仕様: rgb_float32 の shape が (H, W, 3) であること."""
        assert rgb_float32_result.ndim == 3
        assert rgb_float32_result.shape[2] == 3

    def test_dtype_is_float32(self, rgb_float32_result: numpy.ndarray) -> None:
        """仕様: rgb_float32 の dtype が float32 であること."""
        assert rgb_float32_result.dtype == numpy.float32

    def test_values_are_non_negative(self, rgb_float32_result: numpy.ndarray) -> None:
        """仕様: rgb_float32 の値が 0 以上であること(linear raw, black_level 補正済み)."""
        assert numpy.all(rgb_float32_result >= 0.0)

    def test_values_are_mostly_within_0_to_1(
        self, rgb_float32_result: numpy.ndarray
    ) -> None:
        """仕様: rgb_float32 の値がおおむね [0, 1] 範囲内であること.

        仕様では「飽和は 1.0 を超えることがある(クリップしない)」とあるが,
        通常の撮影では大部分のピクセルが 1.0 以下のはず.
        99 パーセンタイルが 1.0 以下であることを確認する.
        """
        p99 = numpy.percentile(rgb_float32_result, 99)
        assert p99 <= 1.0, f"99 パーセンタイルが 1.0 超: {p99}"

    def test_mean_value_is_positive_and_reasonable(
        self, rgb_float32_result: numpy.ndarray
    ) -> None:
        """仕様 V1 系: CoV > 0 等の妥当性確認 — 平均値が正の有限値であること."""
        mean_val = float(numpy.mean(rgb_float32_result))
        assert mean_val > 0.0
        assert numpy.isfinite(mean_val)

    def test_std_is_positive(self, rgb_float32_result: numpy.ndarray) -> None:
        """仕様 V1 系: 画像内に輝度のバラツキが存在すること(CoV > 0 に相当)."""
        std_val = float(numpy.std(rgb_float32_result))
        assert std_val > 0.0


@_skip_if_no_dng
class TestLoadImageDngLumaFloat32:
    """output_format="luma_float32" の仕様検証テスト群."""

    @pytest.fixture
    def luma_and_rgb(self) -> tuple[numpy.ndarray, numpy.ndarray]:
        """luma_float32 と rgb_float32 の両方を読み込んで返す fixture."""
        from src.io.dng_loader import load_image_dng

        luma = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="luma_float32")
        rgb = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32")
        return luma, rgb

    def test_shape_is_2d(
        self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray]
    ) -> None:
        """仕様: luma_float32 の shape が (H, W) の 2 次元配列であること."""
        luma, _ = luma_and_rgb
        assert luma.ndim == 2

    def test_dtype_is_float32(
        self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray]
    ) -> None:
        """仕様: luma_float32 の dtype が float32 であること."""
        luma, _ = luma_and_rgb
        assert luma.dtype == numpy.float32

    def test_shape_matches_rgb_hw(
        self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray]
    ) -> None:
        """仕様: luma_float32 の shape が rgb_float32 の (H, W) と一致すること."""
        luma, rgb = luma_and_rgb
        assert luma.shape == rgb.shape[:2]

    def test_values_are_non_negative(
        self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray]
    ) -> None:
        """仕様: luma_float32 の値が 0 以上であること."""
        luma, _ = luma_and_rgb
        assert numpy.all(luma >= 0.0)

    def test_luma_is_rec709_weighted_sum_of_rgb(
        self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray]
    ) -> None:
        """仕様: luma_float32 が Rec.709 係数 (0.2126, 0.7152, 0.0722) で rgb_float32 から
        導出した輝度と一致すること.

        luma = 0.2126 * R + 0.7152 * G + 0.0722 * B
        この係数は uniformity.py の REC709_COEFF_* と一致する.
        """
        luma, rgb = luma_and_rgb
        expected_luma = (
            _REC709_R * rgb[:, :, 0]
            + _REC709_G * rgb[:, :, 1]
            + _REC709_B * rgb[:, :, 2]
        ).astype(numpy.float32)
        numpy.testing.assert_allclose(
            luma, expected_luma, rtol=1e-5, atol=1e-6,
            err_msg="luma_float32 が Rec.709 係数による rgb_float32 の加重和と一致しない",
        )

    def test_mean_value_is_positive_and_finite(
        self, luma_and_rgb: tuple[numpy.ndarray, numpy.ndarray]
    ) -> None:
        """仕様 V1 系: luma_float32 の平均値が正の有限値であること."""
        luma, _ = luma_and_rgb
        mean_val = float(numpy.mean(luma))
        assert mean_val > 0.0
        assert numpy.isfinite(mean_val)


@_skip_if_no_dng
class TestLoadImageDngRgbUint8:
    """output_format="rgb_uint8" の仕様検証テスト群."""

    @pytest.fixture
    def rgb_uint8_result(self) -> numpy.ndarray:
        """rgb_uint8 形式で DNG を読み込んだ結果を返す fixture."""
        from src.io.dng_loader import load_image_dng

        return load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_uint8")

    def test_shape_is_3d_with_3_channels(
        self, rgb_uint8_result: numpy.ndarray
    ) -> None:
        """仕様: rgb_uint8 の shape が (H, W, 3) であること."""
        assert rgb_uint8_result.ndim == 3
        assert rgb_uint8_result.shape[2] == 3

    def test_dtype_is_uint8(self, rgb_uint8_result: numpy.ndarray) -> None:
        """仕様: rgb_uint8 の dtype が uint8 であること."""
        assert rgb_uint8_result.dtype == numpy.uint8

    def test_values_are_in_0_to_255_range(
        self, rgb_uint8_result: numpy.ndarray
    ) -> None:
        """仕様: rgb_uint8 の値が [0, 255] の範囲内であること(uint8 なので常に満たす)."""
        assert numpy.all(rgb_uint8_result >= 0)
        assert numpy.all(rgb_uint8_result <= 255)

    def test_mean_value_is_positive_and_finite(
        self, rgb_uint8_result: numpy.ndarray
    ) -> None:
        """仕様 V1 系: rgb_uint8 の平均値が正の有限値であること(CoV > 0 等の妥当性)."""
        mean_val = float(numpy.mean(rgb_uint8_result))
        assert mean_val > 0.0
        assert numpy.isfinite(mean_val)


@_skip_if_no_dng
class TestLoadImageDngDemosaic:
    """demosaic 引数の仕様検証テスト群."""

    def test_default_demosaic_is_bilinear_callable(self) -> None:
        """仕様: 既定 demosaic="bilinear" で正常に読み込めること."""
        from src.io.dng_loader import load_image_dng

        # 引数なし(既定値 bilinear)で例外が発生しないこと
        result = load_image_dng(str(_SAMPLE_DNG_PATH))
        assert isinstance(result, numpy.ndarray)

    def test_demosaic_ahd_does_not_raise(self) -> None:
        """仕様: demosaic="ahd" で例外が発生しないこと."""
        from src.io.dng_loader import load_image_dng

        result = load_image_dng(str(_SAMPLE_DNG_PATH), demosaic="ahd")
        assert isinstance(result, numpy.ndarray)
        assert result.ndim == 3  # rgb_float32 既定

    def test_demosaic_nearest_does_not_raise(self) -> None:
        """仕様: demosaic="nearest" で例外が発生しないこと."""
        from src.io.dng_loader import load_image_dng

        result = load_image_dng(str(_SAMPLE_DNG_PATH), demosaic="nearest")
        assert isinstance(result, numpy.ndarray)

    def test_demosaic_bilinear_explicit_does_not_raise(self) -> None:
        """仕様: demosaic="bilinear" を明示的に指定しても例外が発生しないこと."""
        from src.io.dng_loader import load_image_dng

        result = load_image_dng(str(_SAMPLE_DNG_PATH), demosaic="bilinear")
        assert isinstance(result, numpy.ndarray)

    def test_bilinear_and_ahd_return_same_shape(self) -> None:
        """仕様: bilinear と ahd で同一 shape の結果が返ること(センサ解像度が同一のため)."""
        from src.io.dng_loader import load_image_dng

        bilinear = load_image_dng(
            str(_SAMPLE_DNG_PATH), demosaic="bilinear", output_format="rgb_float32"
        )
        ahd = load_image_dng(
            str(_SAMPLE_DNG_PATH), demosaic="ahd", output_format="rgb_float32"
        )
        assert bilinear.shape == ahd.shape


@_skip_if_no_dng
class TestLoadImageDngLinearity:
    """線形性(linear ドメイン)の仕様検証テスト群.

    仕様: rgb_float32 の各チャンネルが入射光量に比例すること(カラーマトリクス非適用).
    直接的な「減光プロファイル vs 生 Bayer 比較」は実機依存のため,
    ここでは proxy 検証(no_auto_bright + gamma=(1,1) の確認)を行う.
    """

    def test_rgb_float32_and_luma_float32_have_consistent_brightness(self) -> None:
        """仕様 線形性: rgb_float32 から計算した Rec.709 luma と luma_float32 が一致すること.

        これは linear ドメインでの輝度計算の一貫性を確認する検証である.
        """
        from src.io.dng_loader import load_image_dng

        rgb = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32")
        luma = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="luma_float32")

        computed_luma = (
            _REC709_R * rgb[:, :, 0]
            + _REC709_G * rgb[:, :, 1]
            + _REC709_B * rgb[:, :, 2]
        ).astype(numpy.float32)

        numpy.testing.assert_allclose(
            luma, computed_luma, rtol=1e-5, atol=1e-6,
        )

    def test_rgb_float32_channels_are_all_positive(self) -> None:
        """仕様 線形性: 黒レベル除去後の全チャンネル値が非負であること(線形担保の確認)."""
        from src.io.dng_loader import load_image_dng

        rgb = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32")
        # 黒レベル補正済み linear raw なので全値が 0 以上
        assert numpy.all(rgb >= 0.0), "黒レベル補正後に負値が存在する(線形性が崩れている)"

    def test_rgb_float32_green_channel_has_highest_mean(self) -> None:
        """仕様 線形性: BGGR Bayer では G チャンネルのサンプリング密度が高く,
        平均輝度が他チャンネルより大きい(または近い)ことを確認する.

        カラーマトリクス非適用(output_color=raw)のため,
        G チャンネル(Rec.709 重み 0.7152 が最大)の値が全体で高い傾向がある.
        """
        from src.io.dng_loader import load_image_dng

        rgb = load_image_dng(str(_SAMPLE_DNG_PATH), output_format="rgb_float32")
        mean_r = float(numpy.mean(rgb[:, :, 0]))
        mean_g = float(numpy.mean(rgb[:, :, 1]))
        mean_b = float(numpy.mean(rgb[:, :, 2]))

        # G > B であることは BGGR の物理的特性から期待できる
        # (R との大小関係は照明スペクトルに依存するため厳密な比較はしない)
        assert mean_g > mean_b, (
            f"G チャンネル平均 ({mean_g:.4f}) が B チャンネル平均 ({mean_b:.4f}) 以下"
        )