diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/__init__.py diff --git a/src/analysis/__init__.py b/src/analysis/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/analysis/__init__.py diff --git a/src/export/__init__.py b/src/export/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/export/__init__.py diff --git a/src/io/__init__.py b/src/io/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/io/__init__.py diff --git a/src/io/loader.py b/src/io/loader.py new file mode 100644 index 0000000..24aac44 --- /dev/null +++ b/src/io/loader.py @@ -0,0 +1,73 @@ +"""PNG 画像の読み込みと ROI 抽出モジュール.""" + +from pathlib import Path + +import cv2 +import numpy + + +def load_image(path: str) -> numpy.ndarray: + """PNG 画像を読み込み RGB 配列として返す. + + OpenCV は BGR で読み込むため RGB に変換する. + + Args: + path: 画像ファイルのパス. + + Returns: + RGB 画像の NumPy 配列(H x W x 3, uint8). + + Raises: + FileNotFoundError: 指定されたパスに画像ファイルが存在しない場合. + ValueError: OpenCV が画像を読み込めなかった場合(形式が不正等). + """ + if not Path(path).exists(): + raise FileNotFoundError(f"画像ファイルが見つかりません: {path}") + + bgr = cv2.imread(str(path)) + if bgr is None: + raise ValueError( + f"画像の読み込みに失敗しました(形式が不正の可能性があります): {path}" + ) + + # BGR → RGB 変換 + rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + return rgb + + +def extract_roi( + image: numpy.ndarray, + x: int, + y: int, + width: int, + height: int, +) -> numpy.ndarray: + """画像から矩形 ROI(関心領域)を切り出す. + + Args: + image: 入力画像の NumPy 配列(H x W x 3). + x: ROI 左上隅の X 座標(列方向,ピクセル). + y: ROI 左上隅の Y 座標(行方向,ピクセル). + width: ROI の幅(ピクセル). + height: ROI の高さ(ピクセル). + + Returns: + 切り出した ROI の NumPy 配列(height x width x 3, uint8). + + Raises: + ValueError: ROI が画像の範囲外に出る場合. + """ + img_h, img_w = image.shape[:2] + + if x < 0 or y < 0 or width <= 0 or height <= 0: + raise ValueError( + f"ROI のパラメータが不正です: x={x}, y={y}, width={width}, height={height}" + ) + + if x + width > img_w or y + height > img_h: + raise ValueError( + f"ROI が画像範囲外に出ています: " + f"ROI=({x}, {y}, {width}, {height}), 画像サイズ=({img_w}, {img_h})" + ) + + return image[y : y + height, x : x + width] diff --git a/src/visualization/__init__.py b/src/visualization/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/visualization/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/analysis/__init__.py b/tests/analysis/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/analysis/__init__.py diff --git a/tests/export/__init__.py b/tests/export/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/export/__init__.py diff --git a/tests/io/__init__.py b/tests/io/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/io/__init__.py diff --git a/tests/io/test_loader.py b/tests/io/test_loader.py new file mode 100644 index 0000000..d90cc53 --- /dev/null +++ b/tests/io/test_loader.py @@ -0,0 +1,242 @@ +"""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) diff --git a/tests/visualization/__init__.py b/tests/visualization/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/visualization/__init__.py