Newer
Older
SmTIAS-LightSim / tests / evaluation / test_roi.py
"""evaluation/roi.py の仕様ベーステスト.

仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluation/roi 層の責務)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation.roi スキーマ)
- .claude/rules/testing.md(テスト方針)

evaluation/roi の責務:
  画像 + ROI 定義 → ROI 内の輝度配列
  image_pixel / scene_face 両対応(現在は image_pixel のみ実装)

テスト方針:
  - RoiConfig を直接構築して extract_roi の振る舞いを検証する
  - ファイル I/O はモックせず実 ndarray を使う (testing.md)
  - numpy.testing.assert_array_equal で配列値の一致を確認する
"""

from __future__ import annotations

import numpy
import numpy.testing
import pytest

from sm_tias_lightsim.evaluation.roi import extract_roi
from sm_tias_lightsim.scene.schema import RoiConfig  # noqa: I001

# ---------------------------------------------------------------------------
# テスト用ヘルパー
# ---------------------------------------------------------------------------


def _make_roi(x: int, y: int, width: int, height: int) -> RoiConfig:
    """テスト用 RoiConfig を構築するヘルパー(image_pixel 固定)."""
    return RoiConfig(type="image_pixel", x=x, y=y, width=width, height=height)


# ---------------------------------------------------------------------------
# 1. 2D 入力(輝度配列)
# ---------------------------------------------------------------------------


def test_extract_roi_2d_shape():
    """2D 輝度配列から切り出した ROI の shape が (height, width) になること.

    仕様: 画像 + ROI 定義 → ROI 内の輝度配列(SPEC_01 evaluation/roi 責務)
    入力 100x100,中央 ROI (x=20, y=20, w=40, h=40) → shape (40, 40)
    """
    image = numpy.zeros((100, 100), dtype=numpy.float64)
    roi = _make_roi(x=20, y=20, width=40, height=40)
    result = extract_roi(image, roi)
    assert result.shape == (40, 40)


def test_extract_roi_2d_values_match():
    """2D 配列の ROI 切り出し値が元の配列の対応領域と一致すること.

    仕様: ROI クロップは元配列の対応領域と値が同一でなければならない
    """
    rng = numpy.random.RandomState(42)
    image = rng.uniform(1.0, 200.0, (100, 100)).astype(numpy.float64)
    roi = _make_roi(x=20, y=20, width=40, height=40)
    result = extract_roi(image, roi)
    expected = image[20:60, 20:60]
    numpy.testing.assert_array_equal(result, expected)


# ---------------------------------------------------------------------------
# 2. 3D 入力(RGB 配列)
# ---------------------------------------------------------------------------


def test_extract_roi_3d_shape():
    """3D RGB 配列から切り出した ROI の shape が (height, width, 3) になること.

    仕様: 先頭 2 軸が H, W であること(evaluation/roi.py extract_roi docstring)
    """
    image = numpy.zeros((100, 100, 3), dtype=numpy.uint8)
    roi = _make_roi(x=20, y=20, width=40, height=40)
    result = extract_roi(image, roi)
    assert result.shape == (40, 40, 3)


def test_extract_roi_3d_values_match():
    """3D RGB 配列の ROI 切り出し値が元の配列の対応領域と一致すること."""
    rng = numpy.random.RandomState(0)
    image = rng.randint(0, 256, (100, 100, 3), dtype=numpy.uint8)
    roi = _make_roi(x=20, y=20, width=40, height=40)
    result = extract_roi(image, roi)
    expected = image[20:60, 20:60, :]
    numpy.testing.assert_array_equal(result, expected)


# ---------------------------------------------------------------------------
# 3. 境界ケース(ぴったり端)
# ---------------------------------------------------------------------------


def test_extract_roi_2d_exact_boundary():
    """x+width == img_width かつ y+height == img_height でエラーにならないこと.

    仕様: 画像範囲内であれば ROI 抽出は成功しなければならない
    """
    image = numpy.ones((100, 100), dtype=numpy.float64)
    roi = _make_roi(x=60, y=60, width=40, height=40)  # 60+40 == 100 == img_width
    result = extract_roi(image, roi)
    assert result.shape == (40, 40)


def test_extract_roi_3d_exact_boundary():
    """3D 配列でも x+width == img_width かつ y+height == img_height でエラーにならないこと."""
    image = numpy.ones((100, 100, 3), dtype=numpy.float64)
    roi = _make_roi(x=60, y=60, width=40, height=40)
    result = extract_roi(image, roi)
    assert result.shape == (40, 40, 3)


def test_extract_roi_starts_at_origin():
    """x=0, y=0 から始まる ROI(左上角からのクロップ)でエラーにならないこと."""
    image = numpy.arange(10000, dtype=numpy.float64).reshape(100, 100)
    roi = _make_roi(x=0, y=0, width=10, height=10)
    result = extract_roi(image, roi)
    assert result.shape == (10, 10)
    numpy.testing.assert_array_equal(result, image[0:10, 0:10])


# ---------------------------------------------------------------------------
# 4. 範囲外(はみ出し)
# ---------------------------------------------------------------------------


def test_extract_roi_raises_when_x_overflow():
    """x + width > img_width で ValueError が発生すること.

    仕様: ROI が画像の範囲外にはみ出す場合は ValueError (evaluation/roi.py docstring)
    """
    image = numpy.zeros((100, 100), dtype=numpy.float64)
    roi = _make_roi(x=70, y=0, width=40, height=10)  # 70+40=110 > 100
    with pytest.raises(ValueError):
        extract_roi(image, roi)


def test_extract_roi_raises_when_y_overflow():
    """y + height > img_height で ValueError が発生すること."""
    image = numpy.zeros((100, 100), dtype=numpy.float64)
    roi = _make_roi(x=0, y=70, width=10, height=40)  # 70+40=110 > 100
    with pytest.raises(ValueError):
        extract_roi(image, roi)


def test_extract_roi_raises_when_both_overflow():
    """x + width > img_width かつ y + height > img_height で ValueError が発生すること."""
    image = numpy.zeros((100, 100), dtype=numpy.float64)
    roi = _make_roi(x=70, y=70, width=40, height=40)  # 110 > 100 in both axes
    with pytest.raises(ValueError):
        extract_roi(image, roi)


# ---------------------------------------------------------------------------
# 5. ゼロサイズではないが範囲外(x が画像幅以上)
# ---------------------------------------------------------------------------


def test_extract_roi_raises_when_x_equals_img_width():
    """x が img_width と等しい(x = img_width)場合に ValueError が発生すること.

    仕様: x + width > img_width の場合にエラー (roi.py docstring)
    x=100, width=1 → 101 > 100 → ValueError
    """
    image = numpy.zeros((100, 100), dtype=numpy.float64)
    roi = _make_roi(x=100, y=0, width=1, height=10)  # 100+1=101 > 100
    with pytest.raises(ValueError):
        extract_roi(image, roi)


def test_extract_roi_raises_when_y_equals_img_height():
    """y が img_height と等しい(y = img_height)場合に ValueError が発生すること."""
    image = numpy.zeros((100, 100), dtype=numpy.float64)
    roi = _make_roi(x=0, y=100, width=10, height=1)  # 100+1=101 > 100
    with pytest.raises(ValueError):
        extract_roi(image, roi)


# ---------------------------------------------------------------------------
# 6. 1D 配列の取り扱い
# ---------------------------------------------------------------------------


def test_extract_roi_1d_array_raises():
    """1D 配列を渡したとき ValueError または IndexError が発生すること.

    仕様: 先頭 2 軸が H, W であること(docstring).
    1D 配列では image.shape[:2] のアンパックが失敗するため例外が発生する.
    実装は shape[:2] を img_height, img_width にアンパックするので
    ValueError か IndexError(または同等のエラー)が発生することを確認する.
    """
    image = numpy.ones(100, dtype=numpy.float64)  # 1D
    roi = _make_roi(x=0, y=0, width=10, height=10)
    with pytest.raises((ValueError, IndexError)):
        extract_roi(image, roi)


# ---------------------------------------------------------------------------
# 7. image_pixel 以外の ROI type
# ---------------------------------------------------------------------------


def test_extract_roi_raises_not_implemented_for_scene_face():
    """roi.type が 'image_pixel' 以外のとき NotImplementedError が発生すること.

    仕様: scene_face 型は将来実装予定(SPEC_02 evaluation.roi節),
          現在は image_pixel のみサポート(evaluation/roi.py docstring)
    """
    # Pydantic の Literal["image_pixel"] バリデーションを回避するため
    # model_construct でダミーオブジェクトを構築する
    roi = RoiConfig.model_construct(type="scene_face", x=0, y=0, width=10, height=10)
    image = numpy.zeros((100, 100), dtype=numpy.float64)
    with pytest.raises(NotImplementedError):
        extract_roi(image, roi)