"""loader.py の単体テスト.
仕様 (SPEC_01_アーキテクチャ設計.md):
- load_image: PNG を OpenCV で読み込み BGR→RGB 変換して返す
- extract_roi: NumPy スライスで矩形 ROI を切り出す
テスト方針 (GUIDE_08_テスト方針.md):
- pytest を使用
- テスト用画像は tests/fixtures/ に配置するか numpy.zeros 等で合成する
- extract_roi は純粋な NumPy 操作のためダミー配列でテスト可能
- load_image はテスト用の小さな PNG を一時生成して使用する
"""
import re
import tempfile
from pathlib import Path
import cv2
import numpy
import pytest
from src.io.loader import extract_roi, load_image
# ---------------------------------------------------------------------------
# ヘルパー: テスト用 PNG を一時ファイルとして生成する
# ---------------------------------------------------------------------------
def _write_test_png(path: str, image: numpy.ndarray) -> None:
"""BGR の NumPy 配列をPNG として保存するヘルパー."""
cv2.imwrite(path, image)
# ---------------------------------------------------------------------------
# load_image テスト
# ---------------------------------------------------------------------------
class TestLoadImage:
"""load_image 関数のテスト群."""
def test_returns_rgb_ndarray_with_correct_shape(self, tmp_path: Path) -> None:
"""正常: PNG を読み込み H x W x 3 の uint8 配列が返ること."""
# 8x6 の BGR 画像を生成して保存
bgr_image = numpy.zeros((6, 8, 3), dtype=numpy.uint8)
png_path = str(tmp_path / "test.png")
_write_test_png(png_path, bgr_image)
result = load_image(png_path)
assert isinstance(result, numpy.ndarray)
assert result.ndim == 3
assert result.shape == (6, 8, 3)
assert result.dtype == numpy.uint8
def test_bgr_to_rgb_conversion_is_correct(self, tmp_path: Path) -> None:
"""正常: BGR→RGB 変換が正しく行われていること.
OpenCV は BGR 順で保存・読み込みするため、変換後は R と B が入れ替わる.
既知の BGR 値を持つ 1x1 画像で検証する.
"""
# BGR = (100, 150, 200) の 1x1 画像を保存
bgr_value = (100, 150, 200) # B=100, G=150, R=200
bgr_image = numpy.array([[[bgr_value[0], bgr_value[1], bgr_value[2]]]], dtype=numpy.uint8)
png_path = str(tmp_path / "color_test.png")
_write_test_png(png_path, bgr_image)
result = load_image(png_path)
# load_image 後は RGB 順になるはず: R=200, G=150, B=100
assert result[0, 0, 0] == 200, f"R チャンネルが期待値と異なる: {result[0, 0, 0]}"
assert result[0, 0, 1] == 150, f"G チャンネルが期待値と異なる: {result[0, 0, 1]}"
assert result[0, 0, 2] == 100, f"B チャンネルが期待値と異なる: {result[0, 0, 2]}"
def test_raises_file_not_found_for_nonexistent_path(self) -> None:
"""異常: 存在しないパスで FileNotFoundError が送出されること."""
with pytest.raises(FileNotFoundError):
load_image("/nonexistent/path/image.png")
def test_raises_value_error_for_invalid_format(self, tmp_path: Path) -> None:
"""異常: 不正な形式のファイル(PNG ではないテキスト)で ValueError が送出されること."""
invalid_path = tmp_path / "invalid.png"
invalid_path.write_bytes(b"this is not a valid image file content")
with pytest.raises(ValueError):
load_image(str(invalid_path))
def test_file_not_found_error_message_contains_path(self, tmp_path: Path) -> None:
"""異常: FileNotFoundError のメッセージにパスが含まれること."""
missing = str(tmp_path / "missing.png")
with pytest.raises(FileNotFoundError, match=re.escape(missing)):
load_image(missing)
def test_value_error_message_contains_path(self, tmp_path: Path) -> None:
"""異常: ValueError のメッセージにパスが含まれること."""
invalid_path = tmp_path / "invalid.png"
invalid_path.write_bytes(b"not an image")
with pytest.raises(ValueError, match=re.escape(str(invalid_path))):
load_image(str(invalid_path))
def test_uniform_white_image_values(self, tmp_path: Path) -> None:
"""正常: 均一な白画像の全ピクセル値が 255 で返ること."""
# BGR で全ピクセル (255, 255, 255) → RGB でも (255, 255, 255)
white_bgr = numpy.full((8, 8, 3), 255, dtype=numpy.uint8)
png_path = str(tmp_path / "white.png")
_write_test_png(png_path, white_bgr)
result = load_image(png_path)
assert numpy.all(result == 255)
# ---------------------------------------------------------------------------
# extract_roi テスト
# ---------------------------------------------------------------------------
class TestExtractRoi:
"""extract_roi 関数のテスト群."""
@pytest.fixture
def sample_image(self) -> numpy.ndarray:
"""テスト用の 10x8 (H=10, W=8) RGB 画像を返す fixture.
ピクセル値: row * 10 + col で一意にする(チャンネルは同値).
"""
image = numpy.zeros((10, 8, 3), dtype=numpy.uint8)
for row in range(10):
for col in range(8):
image[row, col, :] = (row * 10 + col) % 256
return image
def test_correct_roi_is_extracted(self, sample_image: numpy.ndarray) -> None:
"""正常: 指定した座標・サイズの ROI が正しく切り出されること."""
roi = extract_roi(sample_image, x=2, y=3, width=4, height=5)
assert roi.shape == (5, 4, 3)
# 切り出し後の左上ピクセルが元画像の (y=3, x=2) と一致すること
numpy.testing.assert_array_equal(roi[0, 0], sample_image[3, 2])
def test_roi_contents_match_original(self, sample_image: numpy.ndarray) -> None:
"""正常: 切り出した ROI の全ピクセル値が元画像と一致すること."""
x, y, width, height = 1, 2, 3, 4
roi = extract_roi(sample_image, x=x, y=y, width=width, height=height)
expected = sample_image[y : y + height, x : x + width]
numpy.testing.assert_array_equal(roi, expected)
def test_full_image_roi(self, sample_image: numpy.ndarray) -> None:
"""正常: 画像全体を ROI として指定した場合、元画像と同一内容が返ること."""
img_h, img_w = sample_image.shape[:2]
roi = extract_roi(sample_image, x=0, y=0, width=img_w, height=img_h)
assert roi.shape == sample_image.shape
numpy.testing.assert_array_equal(roi, sample_image)
def test_single_pixel_roi(self, sample_image: numpy.ndarray) -> None:
"""正常: 1x1 ピクセルの ROI を切り出せること."""
roi = extract_roi(sample_image, x=3, y=4, width=1, height=1)
assert roi.shape == (1, 1, 3)
numpy.testing.assert_array_equal(roi[0, 0], sample_image[4, 3])
def test_roi_at_bottom_right_corner(self, sample_image: numpy.ndarray) -> None:
"""正常: 右下隅ギリギリの ROI を切り出せること."""
img_h, img_w = sample_image.shape[:2]
# 右下 1x1 ピクセル
roi = extract_roi(sample_image, x=img_w - 1, y=img_h - 1, width=1, height=1)
assert roi.shape == (1, 1, 3)
numpy.testing.assert_array_equal(roi[0, 0], sample_image[img_h - 1, img_w - 1])
def test_raises_value_error_for_negative_x(self, sample_image: numpy.ndarray) -> None:
"""異常: x < 0 の場合に ValueError が送出されること."""
with pytest.raises(ValueError):
extract_roi(sample_image, x=-1, y=0, width=3, height=3)
def test_raises_value_error_for_negative_y(self, sample_image: numpy.ndarray) -> None:
"""異常: y < 0 の場合に ValueError が送出されること."""
with pytest.raises(ValueError):
extract_roi(sample_image, x=0, y=-1, width=3, height=3)
def test_raises_value_error_for_zero_width(self, sample_image: numpy.ndarray) -> None:
"""異常: width = 0 の場合に ValueError が送出されること."""
with pytest.raises(ValueError):
extract_roi(sample_image, x=0, y=0, width=0, height=3)
def test_raises_value_error_for_zero_height(self, sample_image: numpy.ndarray) -> None:
"""異常: height = 0 の場合に ValueError が送出されること."""
with pytest.raises(ValueError):
extract_roi(sample_image, x=0, y=0, width=3, height=0)
def test_raises_value_error_for_negative_width(self, sample_image: numpy.ndarray) -> None:
"""異常: width < 0 の場合に ValueError が送出されること."""
with pytest.raises(ValueError):
extract_roi(sample_image, x=0, y=0, width=-5, height=3)
def test_raises_value_error_for_negative_height(self, sample_image: numpy.ndarray) -> None:
"""異常: height < 0 の場合に ValueError が送出されること."""
with pytest.raises(ValueError):
extract_roi(sample_image, x=0, y=0, width=3, height=-5)
def test_raises_value_error_when_roi_exceeds_width(self, sample_image: numpy.ndarray) -> None:
"""異常: x + width が画像幅を超える場合に ValueError が送出されること."""
img_h, img_w = sample_image.shape[:2]
with pytest.raises(ValueError):
extract_roi(sample_image, x=img_w - 1, y=0, width=2, height=3)
def test_raises_value_error_when_roi_exceeds_height(self, sample_image: numpy.ndarray) -> None:
"""異常: y + height が画像高さを超える場合に ValueError が送出されること."""
img_h, img_w = sample_image.shape[:2]
with pytest.raises(ValueError):
extract_roi(sample_image, x=0, y=img_h - 1, width=3, height=2)
def test_raises_value_error_when_roi_starts_outside_image(
self, sample_image: numpy.ndarray
) -> None:
"""異常: x が画像幅以上の場合に ValueError が送出されること."""
img_h, img_w = sample_image.shape[:2]
with pytest.raises(ValueError):
extract_roi(sample_image, x=img_w, y=0, width=1, height=1)
def test_return_type_is_ndarray(self, sample_image: numpy.ndarray) -> None:
"""正常: 戻り値が numpy.ndarray であること."""
roi = extract_roi(sample_image, x=0, y=0, width=4, height=4)
assert isinstance(roi, numpy.ndarray)
def test_return_dtype_is_uint8(self, sample_image: numpy.ndarray) -> None:
"""正常: 戻り値の dtype が uint8 であること."""
roi = extract_roi(sample_image, x=0, y=0, width=4, height=4)
assert roi.dtype == numpy.uint8
def test_return_shape_is_height_x_width_x_3(self, sample_image: numpy.ndarray) -> None:
"""正常: 戻り値の shape が (height, width, 3) であること."""
roi = extract_roi(sample_image, x=1, y=2, width=5, height=3)
assert roi.shape == (3, 5, 3)