diff --git a/config/roi_config.json b/config/roi_config.json new file mode 100644 index 0000000..c474086 --- /dev/null +++ b/config/roi_config.json @@ -0,0 +1,10 @@ +{ + "minitias": { + "whiteboard": { + "x": 539, + "y": 1015, + "width": 1400, + "height": 1409 + } + } +} \ No newline at end of file diff --git a/scripts/run_uniformity.py b/scripts/run_uniformity.py new file mode 100644 index 0000000..a8021b8 --- /dev/null +++ b/scripts/run_uniformity.py @@ -0,0 +1,193 @@ +"""MiniTIAS 白板画像の照明均一性評価スクリプト. + +使い方(単一画像): + python scripts/run_uniformity.py --image data/minitias/whiteboard/xxx.png + python scripts/run_uniformity.py --image data/minitias/whiteboard/xxx.png \\ + --config config/roi_config.json --roi minitias.whiteboard + +使い方(ディレクトリ一括): + python scripts/run_uniformity.py --image data/minitias/whiteboard/ +""" + +import argparse +import json +import sys +from pathlib import Path + +# プロジェクトルートを sys.path に追加(スクリプトを任意の場所から実行できるよう) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.analysis.uniformity import calc_uniformity, to_grayscale # noqa: E402 +from src.export.exporter import ( # noqa: E402 + ensure_output_dirs, + export_results, + export_summary, +) +from src.io.loader import extract_roi, load_image # noqa: E402 +from src.visualization.plotter import plot_histogram, plot_luminance_map # noqa: E402 + + +def load_roi_config(config_path: str, roi_key: str) -> dict | None: + """ROI 設定ファイルを読み込んで指定キーの ROI を返す. + + Args: + config_path: JSON 設定ファイルのパス. + roi_key: ドット区切りのキー(例: "minitias.whiteboard"). + + Returns: + ROI の辞書(x, y, width, height).キーが存在しない場合は None. + + Raises: + FileNotFoundError: 設定ファイルが存在しない場合. + """ + config_file = Path(config_path) + if not config_file.exists(): + raise FileNotFoundError(f"設定ファイルが見つかりません: {config_path}") + + with config_file.open(encoding="utf-8") as f: + config = json.load(f) + + # ドット区切りでネストされたキーを辿る + node = config + for key in roi_key.split("."): + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + + return node if isinstance(node, dict) else None + + +def analyze_single_image( + image_path: str, + roi: dict | None, + output_base: Path, +) -> dict | None: + """1 枚の画像を解析し,結果を出力する. + + Args: + image_path: 解析対象画像のパス. + roi: ROI の辞書(x, y, width, height).None の場合は画像全体を解析. + output_base: 出力ルートディレクトリのパス. + + Returns: + 均一性指標に image_name を加えた辞書.エラー時は None. + """ + print(f"解析開始: {image_path}") + + # 画像読み込み + image = load_image(image_path) + print(f"画像サイズ: {image.shape[1]} x {image.shape[0]} px") + + # ROI 切り出し + if roi is not None: + print( + f"ROI を適用します: x={roi['x']}, y={roi['y']}, " + f"width={roi['width']}, height={roi['height']}" + ) + region = extract_roi(image, roi["x"], roi["y"], roi["width"], roi["height"]) + else: + print("ROI 未指定: 画像全体を解析します") + region = image + + # 輝度変換(Rec.709) + luminance = to_grayscale(region) + + # 均一性指標算出 + results = calc_uniformity(luminance) + print("--- 均一性指標 ---") + print(f" 平均輝度 : {results['mean']:.2f}") + print(f" 標準偏差 : {results['std']:.2f}") + print(f" CoV : {results['cov']:.4f}") + print(f" 最大/最小比 : {results['max_min_ratio']:.4f}") + print(f" 最大輝度 : {results['max']:.2f}") + print(f" 最小輝度 : {results['min']:.2f}") + + # 出力ファイル名(入力ファイル名ベース) + stem = Path(image_path).stem + + # 可視化 + luminance_map_path = str(output_base / "figures" / f"{stem}_luminance_map.png") + plot_luminance_map(luminance, luminance_map_path) + plot_histogram(luminance, str(output_base / "figures" / f"{stem}_histogram.png")) + + # CSV 出力 + export_results(results, str(output_base / "results" / f"{stem}_uniformity.csv")) + + print(f"解析完了: {image_path}") + + return {"image_name": stem, **results} + + +def main() -> None: + """エントリーポイント.""" + parser = argparse.ArgumentParser( + description="MiniTIAS 白板画像の照明均一性を評価する." + ) + parser.add_argument( + "--image", + required=True, + help="解析対象の画像ファイルパスまたはディレクトリパス", + ) + parser.add_argument( + "--config", + default=str(PROJECT_ROOT / "config" / "roi_config.json"), + help="ROI 設定ファイルのパス(デフォルト: config/roi_config.json)", + ) + parser.add_argument( + "--roi", + default="minitias.whiteboard", + help="ROI のキー(ドット区切り,デフォルト: minitias.whiteboard)", + ) + parser.add_argument( + "--output", + default=str(PROJECT_ROOT / "output"), + help="出力ディレクトリのパス(デフォルト: output/)", + ) + args = parser.parse_args() + + output_base = Path(args.output) + ensure_output_dirs(args.output) + + # ROI 設定読み込み + roi = None + if args.config: + try: + roi = load_roi_config(args.config, args.roi) + except FileNotFoundError as e: + print(f"警告: {e} → 画像全体を解析します") + + image_target = Path(args.image) + + if image_target.is_dir(): + # バッチ解析: ディレクトリ内の全 PNG を処理 + png_files = sorted(image_target.glob("*.png")) + if not png_files: + print(f"PNG ファイルが見つかりません: {image_target}") + return + + print(f"バッチ解析: {len(png_files)} 枚の画像を処理します") + + all_results: list[dict] = [] + for png_path in png_files: + try: + result = analyze_single_image(str(png_path), roi, output_base) + if result is not None: + all_results.append(result) + except Exception as e: + print(f"エラー(スキップ): {png_path} — {e}") + + # まとめ CSV 出力 + if all_results: + summary_path = str(output_base / "results" / "summary_uniformity.csv") + export_summary(all_results, summary_path) + + print(f"バッチ解析完了: {len(all_results)} / {len(png_files)} 枚成功") + + else: + # 単一画像解析(後方互換) + analyze_single_image(args.image, roi, output_base) + + +if __name__ == "__main__": + main() diff --git a/scripts/select_roi.py b/scripts/select_roi.py new file mode 100644 index 0000000..94786d7 --- /dev/null +++ b/scripts/select_roi.py @@ -0,0 +1,196 @@ +"""インタラクティブ ROI 選択スクリプト. + +OpenCV の selectROI を使って画像上で矩形を選択し, +座標を config/roi_config.json に保存する. + +使い方: + python scripts/select_roi.py \\ + --image data/minitias/whiteboard/MiniTIAS_20260408_140317.png \\ + --key minitias.whiteboard + +操作: + マウスでドラッグして矩形を描き,Space または Enter で確定, + c キーでキャンセルする. +""" + +import argparse +import json +import sys +from pathlib import Path + +import cv2 +import numpy + +# プロジェクトルートを sys.path に追加(スクリプトを任意の場所から実行できるよう) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.io.loader import load_image # noqa: E402 + +# 表示用にリサイズする最大幅・高さ(画面に収まるよう) +DISPLAY_MAX_WIDTH = 1280 +DISPLAY_MAX_HEIGHT = 720 + + +def calc_display_scale(img_w: int, img_h: int) -> float: + """表示用スケール係数を計算する. + + 画像が DISPLAY_MAX_WIDTH x DISPLAY_MAX_HEIGHT に収まるよう + 縦横比を保ったスケール係数を返す. + + Args: + img_w: 元画像の幅(ピクセル). + img_h: 元画像の高さ(ピクセル). + + Returns: + スケール係数(1.0 以下). + """ + scale_w = DISPLAY_MAX_WIDTH / img_w if img_w > DISPLAY_MAX_WIDTH else 1.0 + scale_h = DISPLAY_MAX_HEIGHT / img_h if img_h > DISPLAY_MAX_HEIGHT else 1.0 + return min(scale_w, scale_h) + + +def load_or_create_config(config_path: Path) -> dict: + """既存の設定ファイルを読み込む,なければ空の辞書を返す. + + Args: + config_path: 設定ファイルのパス. + + Returns: + 設定辞書. + """ + if config_path.exists(): + with config_path.open(encoding="utf-8") as f: + return json.load(f) + return {} + + +def set_nested(config: dict, key: str, value: object) -> None: + """ドット区切りキーで辞書にネストして値をセットする(既存キーはマージ). + + Args: + config: 対象の辞書(インプレース更新). + key: ドット区切りキー(例: "minitias.whiteboard"). + value: セットする値. + """ + keys = key.split(".") + node = config + for k in keys[:-1]: + if k not in node or not isinstance(node[k], dict): + node[k] = {} + node = node[k] + node[keys[-1]] = value + + +def save_config(config: dict, config_path: Path) -> None: + """設定辞書を JSON ファイルに保存する. + + Args: + config: 保存する設定辞書. + config_path: 保存先ファイルパス. + """ + config_path.parent.mkdir(parents=True, exist_ok=True) + with config_path.open("w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + +def select_roi_interactive( + image_rgb: numpy.ndarray, +) -> tuple[int, int, int, int] | None: + """selectROI で ROI をインタラクティブに選択する. + + 画像が大きい場合は表示用にリサイズし,選択後に元解像度へスケーリングする. + + Args: + image_rgb: RGB 画像の NumPy 配列(H x W x 3, uint8). + + Returns: + (x, y, width, height) のタプル(元解像度基準). + キャンセルされた場合は None. + """ + img_h, img_w = image_rgb.shape[:2] + scale = calc_display_scale(img_w, img_h) + + # BGR に変換(OpenCV の表示用) + image_bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR) + + if scale < 1.0: + display_w = int(img_w * scale) + display_h = int(img_h * scale) + display_img = cv2.resize( + image_bgr, (display_w, display_h), interpolation=cv2.INTER_AREA + ) + print( + f"画像を表示用にリサイズしました: {img_w}x{img_h} → {display_w}x{display_h}" + f"(スケール: {scale:.3f})" + ) + else: + display_img = image_bgr + scale = 1.0 + + print("ROI を選択してください(Space または Enter で確定,c でキャンセル)") + roi = cv2.selectROI("Select ROI", display_img, fromCenter=False, showCrosshair=True) + cv2.destroyAllWindows() + + rx, ry, rw, rh = roi + + # キャンセル(幅または高さが 0) + if rw == 0 or rh == 0: + return None + + # 表示サイズから元解像度へスケーリング + orig_x = int(rx / scale) + orig_y = int(ry / scale) + orig_w = int(rw / scale) + orig_h = int(rh / scale) + + return orig_x, orig_y, orig_w, orig_h + + +def main() -> None: + """エントリーポイント.""" + parser = argparse.ArgumentParser( + description="画像上で ROI をインタラクティブに選択し,設定ファイルに保存する." + ) + parser.add_argument("--image", required=True, help="ROI を選択する画像のパス") + parser.add_argument( + "--key", + required=True, + help="ドット区切りの保存キー(例: minitias.whiteboard)", + ) + parser.add_argument( + "--config", + default=str(PROJECT_ROOT / "config" / "roi_config.json"), + help="設定ファイルのパス(デフォルト: config/roi_config.json)", + ) + args = parser.parse_args() + + config_path = Path(args.config) + + # 画像読み込み + print(f"画像を読み込みます: {args.image}") + image = load_image(args.image) + print(f"画像サイズ: {image.shape[1]} x {image.shape[0]} px") + + # ROI 選択 + result = select_roi_interactive(image) + + if result is None: + print("ROI の選択がキャンセルされました.設定ファイルは更新しません.") + return + + x, y, w, h = result + roi_dict = {"x": x, "y": y, "width": w, "height": h} + + print(f"選択した ROI: x={x}, y={y}, width={w}, height={h}") + + # 設定ファイルへ保存(既存設定とマージ) + config = load_or_create_config(config_path) + set_nested(config, args.key, roi_dict) + save_config(config, config_path) + + print(f"ROI を保存しました: {config_path} (キー: {args.key})") + + +if __name__ == "__main__": + main() diff --git a/src/analysis/uniformity.py b/src/analysis/uniformity.py new file mode 100644 index 0000000..6aa8e1c --- /dev/null +++ b/src/analysis/uniformity.py @@ -0,0 +1,75 @@ +"""輝度均一性評価モジュール.""" + +import numpy + +# Rec.709 グレースケール変換係数 +# 参照: ITU-R BT.709 (https://www.itu.int/rec/R-REC-BT.709/en) +REC709_COEFF_R = 0.2126 +REC709_COEFF_G = 0.7152 +REC709_COEFF_B = 0.0722 + + +def to_grayscale(image: numpy.ndarray) -> numpy.ndarray: + """RGB 画像を Rec.709 輝度係数でグレースケール(輝度画像)に変換する. + + OpenCV の cvtColor ではなく Rec.709 の係数を明示的に使用する. + 参照: ITU-R BT.709 + + Args: + image: 入力 RGB 画像の NumPy 配列(H x W x 3, uint8). + + Returns: + 輝度画像の NumPy 配列(H x W, float64).値域は 0.0〜255.0. + """ + # float64 に変換してから係数を適用する(整数演算による丸め誤差を防ぐ) + img_float = image.astype(numpy.float64) + luminance = ( + REC709_COEFF_R * img_float[:, :, 0] + + REC709_COEFF_G * img_float[:, :, 1] + + REC709_COEFF_B * img_float[:, :, 2] + ) + return luminance + + +def calc_uniformity(luminance: numpy.ndarray) -> dict: + """輝度画像から均一性指標を算出する. + + 3 つの指標を算出する: + - CoV(変動係数): σ / μ — 小さいほど均一(無次元) + - std(標準偏差): 輝度のばらつきの絶対値 + - max_min_ratio(最大/最小比): max / min — 極端なムラの検出 + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + + Returns: + 均一性指標を格納した辞書.キーは "cov", "std", "max_min_ratio", + "mean", "max", "min". + + Raises: + ValueError: 輝度画像の最小値が 0 の場合(最大/最小比が計算不能). + """ + mean = float(numpy.mean(luminance)) + std = float(numpy.std(luminance)) + lmax = float(numpy.max(luminance)) + lmin = float(numpy.min(luminance)) + + # CoV: 相対的なばらつきの指標 + cov = std / mean if mean != 0.0 else float("inf") + + # 最大/最小比: 極端なムラを検出する指標 + if lmin == 0.0: + raise ValueError( + "輝度の最小値が 0 のため最大/最小比を計算できません." + "ROI の設定を確認してください." + ) + max_min_ratio = lmax / lmin + + return { + "cov": cov, + "std": std, + "max_min_ratio": max_min_ratio, + "mean": mean, + "max": lmax, + "min": lmin, + } diff --git a/src/export/exporter.py b/src/export/exporter.py new file mode 100644 index 0000000..7678944 --- /dev/null +++ b/src/export/exporter.py @@ -0,0 +1,58 @@ +"""CSV・PNG 出力モジュール.""" + +import csv +from pathlib import Path + +SUMMARY_FIELDNAMES = ["image_name", "mean", "std", "cov", "max_min_ratio", "max", "min"] + + +def ensure_output_dirs(base_path: str) -> None: + """output/figures/ と output/results/ ディレクトリを作成する. + + Args: + base_path: 出力ルートディレクトリのパス. + """ + base = Path(base_path) + (base / "figures").mkdir(parents=True, exist_ok=True) + (base / "results").mkdir(parents=True, exist_ok=True) + print(f"出力ディレクトリを確認しました: {base}") + + +def export_results(results: dict, output_path: str) -> None: + """均一性指標を CSV ファイルに出力する. + + CSV の形式は 1 行目がヘッダー,2 行目が値. + + Args: + results: 均一性指標の辞書(calc_uniformity の戻り値). + output_path: 出力先ファイルパス(CSV). + """ + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + with Path(output_path).open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=list(results.keys())) + writer.writeheader() + writer.writerow(results) + + print(f"結果を保存しました: {output_path}") + + +def export_summary(all_results: list[dict], output_path: str) -> None: + """バッチ解析の集約結果を CSV ファイルに出力する. + + CSV の形式は 1 行目がヘッダー,2 行目以降が各画像の値. + + Args: + all_results: 均一性指標の辞書リスト.各要素は + {"image_name": "xxx", "mean": ..., "std": ..., "cov": ..., + "max_min_ratio": ..., "max": ..., "min": ...} の形式. + output_path: 出力先ファイルパス(CSV). + """ + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + with Path(output_path).open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=SUMMARY_FIELDNAMES, extrasaction="ignore") + writer.writeheader() + writer.writerows(all_results) + + print(f"まとめ CSV を保存しました: {output_path}") diff --git a/src/visualization/plotter.py b/src/visualization/plotter.py new file mode 100644 index 0000000..3056b9d --- /dev/null +++ b/src/visualization/plotter.py @@ -0,0 +1,51 @@ +"""輝度マップ・ヒストグラム生成モジュール.""" + +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy + + +def plot_luminance_map(luminance: numpy.ndarray, output_path: str) -> None: + """輝度画像をヒートマップとして保存する. + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + output_path: 出力先ファイルパス(PNG). + """ + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + fig, ax = plt.subplots(figsize=(8, 6)) + im = ax.imshow(luminance, cmap="hot", interpolation="nearest") + fig.colorbar(im, ax=ax, label="Luminance") + ax.set_title("Luminance Map") + ax.set_xlabel("X (px)") + ax.set_ylabel("Y (px)") + fig.tight_layout() + fig.savefig(output_path, dpi=150) + plt.close(fig) + + print(f"輝度マップを保存しました: {output_path}") + + +def plot_histogram(luminance: numpy.ndarray, output_path: str) -> None: + """輝度ヒストグラムを生成して保存する. + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + output_path: 出力先ファイルパス(PNG). + """ + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + fig, ax = plt.subplots(figsize=(8, 5)) + ax.hist( + luminance.ravel(), bins=256, range=(0, 255), color="steelblue", edgecolor="none" + ) + ax.set_title("Luminance Histogram") + ax.set_xlabel("Luminance") + ax.set_ylabel("Pixel Count") + fig.tight_layout() + fig.savefig(output_path, dpi=150) + plt.close(fig) + + print(f"輝度ヒストグラムを保存しました: {output_path}") diff --git a/tests/analysis/test_uniformity.py b/tests/analysis/test_uniformity.py new file mode 100644 index 0000000..6af3aca --- /dev/null +++ b/tests/analysis/test_uniformity.py @@ -0,0 +1,208 @@ +"""uniformity.py の単体テスト. + +仕様 (SPEC_01_アーキテクチャ設計.md): + - to_grayscale: RGB 画像を Rec.709 輝度係数でグレースケールに変換する + - calc_uniformity: CoV, std, max_min_ratio, mean, max, min を算出する + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - numpy で合成した画像を使用 + - 既知の値で期待値と比較 +""" + +import numpy +import pytest + +from src.analysis.uniformity import ( + REC709_COEFF_B, + REC709_COEFF_G, + REC709_COEFF_R, + calc_uniformity, + to_grayscale, +) + + +# --------------------------------------------------------------------------- +# to_grayscale テスト +# --------------------------------------------------------------------------- + + +class TestToGrayscale: + """to_grayscale 関数のテスト群.""" + + def test_uniform_white_image_returns_255(self) -> None: + """正常: 全ピクセル [255, 255, 255] の画像は全ピクセル 255.0 を返すこと.""" + image = numpy.full((8, 8, 3), 255, dtype=numpy.uint8) + result = to_grayscale(image) + # Rec.709 係数の和が 1.0 なので、全チャンネル 255 なら輝度も 255 + expected = (REC709_COEFF_R + REC709_COEFF_G + REC709_COEFF_B) * 255.0 + assert numpy.all(numpy.isclose(result, expected)) + + def test_uniform_black_image_returns_zero(self) -> None: + """正常: 全ピクセル [0, 0, 0] の画像は全ピクセル 0.0 を返すこと.""" + image = numpy.zeros((8, 8, 3), dtype=numpy.uint8) + result = to_grayscale(image) + assert numpy.all(result == 0.0) + + def test_red_only_image(self) -> None: + """正常: 赤のみ画像 [255, 0, 0] は 0.2126 * 255 = 54.213 を返すこと.""" + image = numpy.zeros((4, 4, 3), dtype=numpy.uint8) + image[:, :, 0] = 255 # R チャンネルのみ 255 + result = to_grayscale(image) + expected = REC709_COEFF_R * 255.0 + assert numpy.all(numpy.isclose(result, expected)) + + def test_green_only_image(self) -> None: + """正常: 緑のみ画像 [0, 255, 0] は 0.7152 * 255 = 182.376 を返すこと.""" + image = numpy.zeros((4, 4, 3), dtype=numpy.uint8) + image[:, :, 1] = 255 # G チャンネルのみ 255 + result = to_grayscale(image) + expected = REC709_COEFF_G * 255.0 + assert numpy.all(numpy.isclose(result, expected)) + + def test_blue_only_image(self) -> None: + """正常: 青のみ画像 [0, 0, 255] は 0.0722 * 255 = 18.411 を返すこと.""" + image = numpy.zeros((4, 4, 3), dtype=numpy.uint8) + image[:, :, 2] = 255 # B チャンネルのみ 255 + result = to_grayscale(image) + expected = REC709_COEFF_B * 255.0 + assert numpy.all(numpy.isclose(result, expected)) + + def test_output_shape_is_hw(self) -> None: + """正常: 戻り値の shape が (H, W) であること.""" + image = numpy.zeros((6, 10, 3), dtype=numpy.uint8) + result = to_grayscale(image) + assert result.shape == (6, 10) + + def test_output_dtype_is_float64(self) -> None: + """正常: 戻り値の dtype が float64 であること.""" + image = numpy.full((4, 4, 3), 128, dtype=numpy.uint8) + result = to_grayscale(image) + assert result.dtype == numpy.float64 + + def test_rec709_coefficients_applied_correctly(self) -> None: + """正常: Rec.709 係数が正しく適用されること(既知の RGB 値で検証).""" + # 1x1 画像で R=100, G=150, B=200 のピクセル + image = numpy.array([[[100, 150, 200]]], dtype=numpy.uint8) + result = to_grayscale(image) + expected = REC709_COEFF_R * 100 + REC709_COEFF_G * 150 + REC709_COEFF_B * 200 + assert numpy.isclose(result[0, 0], expected) + + def test_non_uniform_image_preserves_spatial_values(self) -> None: + """正常: 非均一画像で各ピクセルの輝度値が独立して計算されること.""" + image = numpy.zeros((2, 2, 3), dtype=numpy.uint8) + image[0, 0] = [255, 0, 0] # 左上: 赤 + image[0, 1] = [0, 255, 0] # 右上: 緑 + image[1, 0] = [0, 0, 255] # 左下: 青 + image[1, 1] = [255, 255, 255] # 右下: 白 + + result = to_grayscale(image) + + assert numpy.isclose(result[0, 0], REC709_COEFF_R * 255.0) + assert numpy.isclose(result[0, 1], REC709_COEFF_G * 255.0) + assert numpy.isclose(result[1, 0], REC709_COEFF_B * 255.0) + assert numpy.isclose( + result[1, 1], (REC709_COEFF_R + REC709_COEFF_G + REC709_COEFF_B) * 255.0 + ) + + +# --------------------------------------------------------------------------- +# calc_uniformity テスト +# --------------------------------------------------------------------------- + + +class TestCalcUniformity: + """calc_uniformity 関数のテスト群.""" + + def test_uniform_luminance_returns_zero_cov(self) -> None: + """正常: 均一輝度(全ピクセル 200.0)は cov=0.0, std=0.0, max_min_ratio=1.0 を返すこと.""" + luminance = numpy.full((8, 8), 200.0, dtype=numpy.float64) + result = calc_uniformity(luminance) + assert numpy.isclose(result["cov"], 0.0) + assert numpy.isclose(result["std"], 0.0) + assert numpy.isclose(result["max_min_ratio"], 1.0) + assert numpy.isclose(result["mean"], 200.0) + + def test_known_values_cov(self) -> None: + """正常: 既知の輝度配列で CoV が正しく計算されること.""" + # 値が 100 と 200 の 2 要素: mean=150, std=50, cov=50/150 + luminance = numpy.array([[100.0, 200.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + mean = 150.0 + std = float(numpy.std(numpy.array([100.0, 200.0]))) + expected_cov = std / mean + assert numpy.isclose(result["cov"], expected_cov) + + def test_known_values_max_min_ratio(self) -> None: + """正常: 既知の輝度配列で max_min_ratio が正しく計算されること.""" + luminance = numpy.array([[50.0, 100.0, 200.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + assert numpy.isclose(result["max_min_ratio"], 200.0 / 50.0) + + def test_raises_value_error_when_lmin_is_zero(self) -> None: + """異常: lmin==0 の場合に ValueError が送出されること.""" + luminance = numpy.array([[0.0, 100.0, 200.0]], dtype=numpy.float64) + with pytest.raises(ValueError): + calc_uniformity(luminance) + + def test_raises_value_error_for_all_zero_image(self) -> None: + """異常: 全ゼロ画像で ValueError が送出されること.""" + luminance = numpy.zeros((8, 8), dtype=numpy.float64) + with pytest.raises(ValueError): + calc_uniformity(luminance) + + def test_return_value_has_all_required_keys(self) -> None: + """正常: 戻り値のキーが全て存在すること(cov, std, max_min_ratio, mean, max, min).""" + luminance = numpy.full((4, 4), 128.0, dtype=numpy.float64) + result = calc_uniformity(luminance) + expected_keys = {"cov", "std", "max_min_ratio", "mean", "max", "min"} + assert set(result.keys()) == expected_keys + + def test_mean_value_is_correct(self) -> None: + """正常: mean が正しく計算されること.""" + luminance = numpy.array([[100.0, 200.0, 300.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + assert numpy.isclose(result["mean"], 200.0) + + def test_max_and_min_values_are_correct(self) -> None: + """正常: max と min が正しく返ること.""" + luminance = numpy.array([[50.0, 150.0, 250.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + assert numpy.isclose(result["max"], 250.0) + assert numpy.isclose(result["min"], 50.0) + + def test_std_value_is_correct(self) -> None: + """正常: std が numpy.std と一致すること.""" + values = [80.0, 120.0, 160.0, 200.0] + luminance = numpy.array([values], dtype=numpy.float64) + result = calc_uniformity(luminance) + expected_std = float(numpy.std(numpy.array(values))) + assert numpy.isclose(result["std"], expected_std) + + def test_cov_is_inf_when_mean_is_zero(self) -> None: + """エッジケース: mean=0 の場合に cov が inf となること. + + ただし lmin=0 のため ValueError が先に発生するはず。 + mean=0 かつ全値が 0 でない状況(負の値)は float64 では可能だが + 輝度の性質上このケースは仕様外のため、実装の副作用確認のみ。 + """ + # 負の輝度は現実には存在しないが、mean=0 のコードパスを検証 + # lmin が 0 以外になるケースのみテスト可能 + luminance = numpy.array([[1.0, -1.0, 2.0, -2.0]], dtype=numpy.float64) + # mean = 0.0 となるが lmin = -2.0 != 0.0 なので ValueError は発生しない + result = calc_uniformity(luminance) + assert result["cov"] == float("inf") + + def test_uniform_luminance_mean_equals_value(self) -> None: + """正常: 均一輝度の mean が設定値と等しいこと(複数値で確認).""" + for val in [50.0, 100.0, 200.0, 255.0]: + luminance = numpy.full((4, 4), val, dtype=numpy.float64) + result = calc_uniformity(luminance) + assert numpy.isclose(result["mean"], val) + + def test_return_values_are_float(self) -> None: + """正常: 戻り値の各フィールドが Python float 型であること.""" + luminance = numpy.array([[100.0, 200.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + for key in ["cov", "std", "max_min_ratio", "mean", "max", "min"]: + assert isinstance(result[key], float), f"{key} が float でない" diff --git a/tests/export/test_exporter.py b/tests/export/test_exporter.py new file mode 100644 index 0000000..f2ea0c8 --- /dev/null +++ b/tests/export/test_exporter.py @@ -0,0 +1,239 @@ +"""exporter.py の単体テスト. + +仕様 (SPEC_01_アーキテクチャ設計.md): + - export_results: 単一結果を CSV 出力 + - export_summary: バッチ結果を集約 CSV 出力 + - ensure_output_dirs: output/figures/ と output/results/ を作成 + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - tmp_path フィクスチャで一時ディレクトリを利用 +""" + +import csv +from pathlib import Path + +import pytest + +from src.export.exporter import ( + SUMMARY_FIELDNAMES, + ensure_output_dirs, + export_results, + export_summary, +) + + +# --------------------------------------------------------------------------- +# ensure_output_dirs テスト +# --------------------------------------------------------------------------- + + +class TestEnsureOutputDirs: + """ensure_output_dirs 関数のテスト群.""" + + def test_creates_figures_directory(self, tmp_path: Path) -> None: + """正常: figures/ サブディレクトリが作成されること.""" + ensure_output_dirs(str(tmp_path)) + assert (tmp_path / "figures").is_dir() + + def test_creates_results_directory(self, tmp_path: Path) -> None: + """正常: results/ サブディレクトリが作成されること.""" + ensure_output_dirs(str(tmp_path)) + assert (tmp_path / "results").is_dir() + + def test_idempotent_when_dirs_already_exist(self, tmp_path: Path) -> None: + """正常: 既にディレクトリが存在する場合でもエラーにならないこと.""" + ensure_output_dirs(str(tmp_path)) + # 2 回目の呼び出しでも例外が発生しないこと + ensure_output_dirs(str(tmp_path)) + assert (tmp_path / "figures").is_dir() + assert (tmp_path / "results").is_dir() + + def test_creates_nested_base_directory(self, tmp_path: Path) -> None: + """正常: base_path が存在しない場合でも自動作成されること.""" + nested_base = tmp_path / "new_output" + ensure_output_dirs(str(nested_base)) + assert (nested_base / "figures").is_dir() + assert (nested_base / "results").is_dir() + + +# --------------------------------------------------------------------------- +# export_results テスト +# --------------------------------------------------------------------------- + + +class TestExportResults: + """export_results 関数のテスト群.""" + + @pytest.fixture + def sample_results(self) -> dict: + """テスト用の均一性指標辞書を返す fixture.""" + return { + "cov": 0.05, + "std": 10.0, + "max_min_ratio": 1.2, + "mean": 200.0, + "max": 220.0, + "min": 180.0, + } + + def test_csv_file_is_created(self, sample_results: dict, tmp_path: Path) -> None: + """正常: CSV ファイルが生成されること.""" + output_path = str(tmp_path / "results.csv") + export_results(sample_results, output_path) + assert Path(output_path).exists() + + def test_csv_has_correct_header(self, sample_results: dict, tmp_path: Path) -> None: + """正常: CSV の 1 行目にヘッダーが含まれること.""" + output_path = str(tmp_path / "results.csv") + export_results(sample_results, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames + assert set(fieldnames) == set(sample_results.keys()) + + def test_csv_has_correct_values(self, sample_results: dict, tmp_path: Path) -> None: + """正常: CSV の値が正しく書き込まれていること.""" + output_path = str(tmp_path / "results.csv") + export_results(sample_results, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + assert len(rows) == 1 + row = rows[0] + assert float(row["cov"]) == pytest.approx(0.05) + assert float(row["std"]) == pytest.approx(10.0) + assert float(row["mean"]) == pytest.approx(200.0) + assert float(row["max"]) == pytest.approx(220.0) + assert float(row["min"]) == pytest.approx(180.0) + + def test_creates_parent_directory_automatically( + self, sample_results: dict, tmp_path: Path + ) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + nested_path = str(tmp_path / "results" / "uniformity.csv") + export_results(sample_results, nested_path) + assert Path(nested_path).exists() + + +# --------------------------------------------------------------------------- +# export_summary テスト +# --------------------------------------------------------------------------- + + +class TestExportSummary: + """export_summary 関数のテスト群.""" + + @pytest.fixture + def sample_all_results(self) -> list[dict]: + """テスト用の複数画像の均一性指標リストを返す fixture.""" + return [ + { + "image_name": "image_001", + "mean": 200.0, + "std": 10.0, + "cov": 0.05, + "max_min_ratio": 1.2, + "max": 220.0, + "min": 180.0, + }, + { + "image_name": "image_002", + "mean": 150.0, + "std": 20.0, + "cov": 0.133, + "max_min_ratio": 1.5, + "max": 200.0, + "min": 100.0, + }, + ] + + def test_csv_file_is_created( + self, sample_all_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV ファイルが生成されること.""" + output_path = str(tmp_path / "summary.csv") + export_summary(sample_all_results, output_path) + assert Path(output_path).exists() + + def test_csv_has_correct_summary_fieldnames( + self, sample_all_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV のヘッダーが SUMMARY_FIELDNAMES と一致すること.""" + output_path = str(tmp_path / "summary.csv") + export_summary(sample_all_results, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames + assert list(fieldnames) == SUMMARY_FIELDNAMES + + def test_csv_has_correct_row_count( + self, sample_all_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV の行数が入力リストのサイズと一致すること.""" + output_path = str(tmp_path / "summary.csv") + export_summary(sample_all_results, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == len(sample_all_results) + + def test_csv_first_row_values_are_correct( + self, sample_all_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV の 1 行目の値が正しいこと.""" + output_path = str(tmp_path / "summary.csv") + export_summary(sample_all_results, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + first = rows[0] + assert first["image_name"] == "image_001" + assert float(first["mean"]) == pytest.approx(200.0) + assert float(first["std"]) == pytest.approx(10.0) + + def test_csv_second_row_values_are_correct( + self, sample_all_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV の 2 行目の値が正しいこと.""" + output_path = str(tmp_path / "summary.csv") + export_summary(sample_all_results, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + second = rows[1] + assert second["image_name"] == "image_002" + assert float(second["mean"]) == pytest.approx(150.0) + + def test_summary_fieldnames_constant_has_correct_keys(self) -> None: + """正常: SUMMARY_FIELDNAMES 定数が期待されるキーを持つこと.""" + expected = ["image_name", "mean", "std", "cov", "max_min_ratio", "max", "min"] + assert SUMMARY_FIELDNAMES == expected + + def test_creates_parent_directory_automatically( + self, sample_all_results: list[dict], tmp_path: Path + ) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + nested_path = str(tmp_path / "results" / "summary.csv") + export_summary(sample_all_results, nested_path) + assert Path(nested_path).exists() + + def test_empty_list_creates_header_only_csv(self, tmp_path: Path) -> None: + """エッジケース: 空リストを渡した場合にヘッダーのみの CSV が生成されること.""" + output_path = str(tmp_path / "empty_summary.csv") + export_summary([], output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == 0 + assert list(reader.fieldnames) == SUMMARY_FIELDNAMES diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/scripts/__init__.py diff --git a/tests/scripts/test_run_uniformity.py b/tests/scripts/test_run_uniformity.py new file mode 100644 index 0000000..e346d5d --- /dev/null +++ b/tests/scripts/test_run_uniformity.py @@ -0,0 +1,109 @@ +"""run_uniformity.py の単体テスト. + +仕様(実装サマリー): + - load_roi_config: ROI 設定読み込み + - 正常なキーで ROI 辞書が返る + - 存在しないキーで None が返る + - ファイル不在で FileNotFoundError + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - tmp_path フィクスチャを利用 +""" + +import json +from pathlib import Path + +import pytest +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +from scripts.run_uniformity import load_roi_config + + +# --------------------------------------------------------------------------- +# load_roi_config テスト +# --------------------------------------------------------------------------- + + +class TestLoadRoiConfig: + """load_roi_config 関数のテスト群.""" + + @pytest.fixture + def roi_config_file(self, tmp_path: Path) -> Path: + """テスト用 ROI 設定 JSON ファイルを生成して返す fixture.""" + config = { + "minitias": { + "whiteboard": {"x": 100, "y": 200, "width": 400, "height": 300} + }, + "tias": { + "whiteboard": {"x": 50, "y": 80, "width": 600, "height": 400} + }, + } + config_path = tmp_path / "roi_config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + return config_path + + def test_returns_roi_dict_for_valid_key(self, roi_config_file: Path) -> None: + """正常: 存在するキーで ROI 辞書が返ること.""" + result = load_roi_config(str(roi_config_file), "minitias.whiteboard") + assert result == {"x": 100, "y": 200, "width": 400, "height": 300} + + def test_returns_roi_dict_for_another_valid_key(self, roi_config_file: Path) -> None: + """正常: 別の存在するキーで ROI 辞書が返ること.""" + result = load_roi_config(str(roi_config_file), "tias.whiteboard") + assert result == {"x": 50, "y": 80, "width": 600, "height": 400} + + def test_returns_none_for_nonexistent_key(self, roi_config_file: Path) -> None: + """正常: 存在しないキーで None が返ること.""" + result = load_roi_config(str(roi_config_file), "nonexistent.key") + assert result is None + + def test_returns_none_for_partial_key(self, roi_config_file: Path) -> None: + """正常: ネストの途中で存在しないキーでも None が返ること.""" + result = load_roi_config(str(roi_config_file), "minitias.nonexistent") + assert result is None + + def test_raises_file_not_found_for_missing_config(self, tmp_path: Path) -> None: + """異常: 設定ファイルが存在しない場合に FileNotFoundError が送出されること.""" + missing_path = str(tmp_path / "no_such_config.json") + with pytest.raises(FileNotFoundError): + load_roi_config(missing_path, "minitias.whiteboard") + + def test_file_not_found_error_contains_path(self, tmp_path: Path) -> None: + """異常: FileNotFoundError のメッセージにファイルパスが含まれること.""" + missing_path = str(tmp_path / "missing_roi.json") + with pytest.raises(FileNotFoundError, match="missing_roi.json"): + load_roi_config(missing_path, "minitias.whiteboard") + + def test_returns_dict_type_for_valid_key(self, roi_config_file: Path) -> None: + """正常: 戻り値が dict 型であること.""" + result = load_roi_config(str(roi_config_file), "minitias.whiteboard") + assert isinstance(result, dict) + + def test_returned_roi_has_required_keys(self, roi_config_file: Path) -> None: + """正常: 返された ROI が x, y, width, height キーを持つこと.""" + result = load_roi_config(str(roi_config_file), "minitias.whiteboard") + assert result is not None + for key in ("x", "y", "width", "height"): + assert key in result, f"ROI に '{key}' キーが存在しない" + + def test_returns_none_for_empty_dot_split_key(self, tmp_path: Path) -> None: + """エッジケース: 設定に存在しないトップレベルキーで None が返ること.""" + config = {"minitias": {"whiteboard": {"x": 0, "y": 0, "width": 10, "height": 10}}} + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + + result = load_roi_config(str(config_path), "unknown_top.sub") + assert result is None + + def test_returns_none_when_leaf_value_is_not_dict(self, tmp_path: Path) -> None: + """エッジケース: 指定キーの値が dict でない場合に None が返ること.""" + # キーの値が辞書ではなくスカラー値の場合 + config = {"minitias": {"whiteboard": "not_a_dict"}} + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + + result = load_roi_config(str(config_path), "minitias.whiteboard") + assert result is None diff --git a/tests/scripts/test_select_roi.py b/tests/scripts/test_select_roi.py new file mode 100644 index 0000000..d0cff6b --- /dev/null +++ b/tests/scripts/test_select_roi.py @@ -0,0 +1,213 @@ +"""select_roi.py の単体テスト(GUI 以外の関数). + +仕様(実装サマリー): + - calc_display_scale: 表示用スケール係数を計算 + - load_or_create_config: 既存 JSON 読み込みまたは空辞書 + - set_nested: ドット区切りキーでネスト辞書に値をセット + - save_config: JSON 保存 + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - GUI(selectROI)を伴うテストは除外 +""" + +import json +from pathlib import Path + +import pytest + +# スクリプトを直接インポートするため sys.path を調整 +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +from scripts.select_roi import ( + DISPLAY_MAX_HEIGHT, + DISPLAY_MAX_WIDTH, + calc_display_scale, + load_or_create_config, + save_config, + set_nested, +) + + +# --------------------------------------------------------------------------- +# calc_display_scale テスト +# --------------------------------------------------------------------------- + + +class TestCalcDisplayScale: + """calc_display_scale 関数のテスト群.""" + + def test_small_image_returns_1_0(self) -> None: + """正常: 表示最大サイズより小さい画像はスケール 1.0 を返すこと.""" + result = calc_display_scale(640, 360) + assert result == 1.0 + + def test_image_equal_to_max_returns_1_0(self) -> None: + """境界値: 表示最大サイズと同じ画像はスケール 1.0 を返すこと.""" + result = calc_display_scale(DISPLAY_MAX_WIDTH, DISPLAY_MAX_HEIGHT) + assert result == 1.0 + + def test_wide_image_returns_scale_less_than_1(self) -> None: + """正常: 幅が DISPLAY_MAX_WIDTH を超える画像は 1.0 未満のスケールを返すこと.""" + result = calc_display_scale(DISPLAY_MAX_WIDTH * 2, 360) + assert result < 1.0 + + def test_tall_image_returns_scale_less_than_1(self) -> None: + """正常: 高さが DISPLAY_MAX_HEIGHT を超える画像は 1.0 未満のスケールを返すこと.""" + result = calc_display_scale(640, DISPLAY_MAX_HEIGHT * 2) + assert result < 1.0 + + def test_large_image_scale_is_limited_by_smaller_dimension_ratio(self) -> None: + """正常: 縦横の両方が最大サイズを超える場合、制限のきつい方に合わせること.""" + # 幅 2560 (2x), 高さ 1440 (2x) → 両方 0.5 になるはず + result = calc_display_scale(DISPLAY_MAX_WIDTH * 2, DISPLAY_MAX_HEIGHT * 2) + assert result == pytest.approx(0.5) + + def test_asymmetric_large_image_limited_by_width(self) -> None: + """正常: 幅のみが大きい場合、幅の比率でスケーリングされること.""" + # 幅 2560 (2x max), 高さ 360 (0.5x max) + result = calc_display_scale(DISPLAY_MAX_WIDTH * 2, 360) + expected = DISPLAY_MAX_WIDTH / (DISPLAY_MAX_WIDTH * 2) + assert result == pytest.approx(expected) + + def test_asymmetric_large_image_limited_by_height(self) -> None: + """正常: 高さのみが大きく制限がきつい場合、高さの比率でスケーリングされること.""" + # 幅 1280 (1x max), 高さ 4320 (6x max) + result = calc_display_scale(DISPLAY_MAX_WIDTH, DISPLAY_MAX_HEIGHT * 6) + expected = DISPLAY_MAX_HEIGHT / (DISPLAY_MAX_HEIGHT * 6) + assert result == pytest.approx(expected) + + +# --------------------------------------------------------------------------- +# set_nested テスト +# --------------------------------------------------------------------------- + + +class TestSetNested: + """set_nested 関数のテスト群.""" + + def test_single_key(self) -> None: + """正常: 単一キーで値をセットできること.""" + config: dict = {} + set_nested(config, "key", "value") + assert config == {"key": "value"} + + def test_two_level_nested_key(self) -> None: + """正常: "a.b" のキーで 2 階層のネストが作成されること.""" + config: dict = {} + set_nested(config, "a.b", 42) + assert config == {"a": {"b": 42}} + + def test_three_level_nested_key(self) -> None: + """正常: "a.b.c" のキーで 3 階層のネストが作成されること.""" + config: dict = {} + set_nested(config, "a.b.c", "deep") + assert config == {"a": {"b": {"c": "deep"}}} + + def test_existing_keys_are_preserved(self) -> None: + """正常: 既存のキーが上書きされず保持されること.""" + config = {"existing": "value"} + set_nested(config, "new_key", 100) + assert config["existing"] == "value" + assert config["new_key"] == 100 + + def test_merge_with_existing_nested_dict(self) -> None: + """正常: 既存のネスト辞書にマージされること.""" + config = {"a": {"existing": "old"}} + set_nested(config, "a.new", "added") + assert config == {"a": {"existing": "old", "new": "added"}} + + def test_overwrite_existing_leaf_value(self) -> None: + """正常: 既存のリーフ値が上書きされること.""" + config = {"a": {"b": "old_value"}} + set_nested(config, "a.b", "new_value") + assert config["a"]["b"] == "new_value" + + def test_non_dict_intermediate_node_is_replaced(self) -> None: + """正常: 中間ノードが dict でない場合、dict で上書きされること.""" + config = {"a": "not_a_dict"} + set_nested(config, "a.b", "value") + assert config == {"a": {"b": "value"}} + + def test_set_dict_value(self) -> None: + """正常: 値として辞書をセットできること.""" + config: dict = {} + roi = {"x": 10, "y": 20, "width": 100, "height": 80} + set_nested(config, "minitias.whiteboard", roi) + assert config == {"minitias": {"whiteboard": roi}} + + +# --------------------------------------------------------------------------- +# load_or_create_config テスト +# --------------------------------------------------------------------------- + + +class TestLoadOrCreateConfig: + """load_or_create_config 関数のテスト群.""" + + def test_returns_empty_dict_for_nonexistent_file(self, tmp_path: Path) -> None: + """正常: 存在しないファイルで空の辞書が返ること.""" + nonexistent = tmp_path / "no_such_file.json" + result = load_or_create_config(nonexistent) + assert result == {} + + def test_loads_existing_json_file(self, tmp_path: Path) -> None: + """正常: 既存の JSON ファイルが正しく読み込まれること.""" + config_data = {"minitias": {"whiteboard": {"x": 10, "y": 20, "width": 100, "height": 80}}} + config_path = tmp_path / "roi_config.json" + config_path.write_text(json.dumps(config_data), encoding="utf-8") + + result = load_or_create_config(config_path) + assert result == config_data + + def test_loaded_config_is_dict(self, tmp_path: Path) -> None: + """正常: 戻り値が dict 型であること.""" + config_path = tmp_path / "config.json" + config_path.write_text("{}", encoding="utf-8") + + result = load_or_create_config(config_path) + assert isinstance(result, dict) + + def test_empty_dict_returned_for_missing_file_is_dict_type( + self, tmp_path: Path + ) -> None: + """正常: ファイル不在時の戻り値が dict 型であること.""" + result = load_or_create_config(tmp_path / "missing.json") + assert isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# save_config テスト +# --------------------------------------------------------------------------- + + +class TestSaveConfig: + """save_config 関数のテスト群.""" + + def test_saves_config_as_json(self, tmp_path: Path) -> None: + """正常: 設定辞書が JSON ファイルとして保存されること.""" + config = {"key": "value"} + config_path = tmp_path / "config.json" + save_config(config, config_path) + + assert config_path.exists() + loaded = json.loads(config_path.read_text(encoding="utf-8")) + assert loaded == config + + def test_creates_parent_directory_if_not_exists(self, tmp_path: Path) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + nested_path = tmp_path / "config" / "roi_config.json" + save_config({"a": 1}, nested_path) + assert nested_path.exists() + + def test_saved_json_is_valid(self, tmp_path: Path) -> None: + """正常: 保存された JSON が有効な形式であること.""" + config = {"minitias": {"whiteboard": {"x": 10, "y": 20}}} + config_path = tmp_path / "config.json" + save_config(config, config_path) + + # JSON として正常にパースできること + loaded = json.loads(config_path.read_text(encoding="utf-8")) + assert loaded["minitias"]["whiteboard"]["x"] == 10 diff --git a/tests/visualization/test_plotter.py b/tests/visualization/test_plotter.py new file mode 100644 index 0000000..b66821f --- /dev/null +++ b/tests/visualization/test_plotter.py @@ -0,0 +1,106 @@ +"""plotter.py の単体テスト. + +仕様 (SPEC_01_アーキテクチャ設計.md): + - plot_luminance_map: ヒートマップを PNG 保存 + - plot_histogram: ヒストグラムを PNG 保存 + +テスト方針 (GUIDE_08_テスト方針.md): + - 出力ファイルが生成されることを確認する程度でよい + - tmp_path フィクスチャを使用 +""" + +import matplotlib +matplotlib.use("Agg") # GUI なし環境でも動作するバックエンドを設定 + +from pathlib import Path + +import numpy +import pytest + +from src.visualization.plotter import plot_histogram, plot_luminance_map + + +@pytest.fixture +def sample_luminance() -> numpy.ndarray: + """テスト用の 8x8 輝度画像(float64)を返す fixture.""" + return numpy.random.uniform(50.0, 200.0, (8, 8)).astype(numpy.float64) + + +# --------------------------------------------------------------------------- +# plot_luminance_map テスト +# --------------------------------------------------------------------------- + + +class TestPlotLuminanceMap: + """plot_luminance_map 関数のテスト群.""" + + def test_output_file_is_created( + self, sample_luminance: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ファイルが生成されること.""" + output_path = str(tmp_path / "luminance_map.png") + plot_luminance_map(sample_luminance, output_path) + assert Path(output_path).exists() + + def test_output_file_is_not_empty( + self, sample_luminance: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ファイルが空でないこと(PNG バイナリが書き込まれていること).""" + output_path = str(tmp_path / "luminance_map.png") + plot_luminance_map(sample_luminance, output_path) + assert Path(output_path).stat().st_size > 0 + + def test_creates_parent_directory_automatically( + self, sample_luminance: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + nested_path = str(tmp_path / "figures" / "sub" / "luminance_map.png") + plot_luminance_map(sample_luminance, nested_path) + assert Path(nested_path).exists() + + def test_uniform_luminance_is_saved(self, tmp_path: Path) -> None: + """正常: 均一輝度画像でも正常に保存されること.""" + uniform_luminance = numpy.full((8, 8), 128.0, dtype=numpy.float64) + output_path = str(tmp_path / "uniform_map.png") + plot_luminance_map(uniform_luminance, output_path) + assert Path(output_path).exists() + + +# --------------------------------------------------------------------------- +# plot_histogram テスト +# --------------------------------------------------------------------------- + + +class TestPlotHistogram: + """plot_histogram 関数のテスト群.""" + + def test_output_file_is_created( + self, sample_luminance: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ファイルが生成されること.""" + output_path = str(tmp_path / "histogram.png") + plot_histogram(sample_luminance, output_path) + assert Path(output_path).exists() + + def test_output_file_is_not_empty( + self, sample_luminance: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ファイルが空でないこと.""" + output_path = str(tmp_path / "histogram.png") + plot_histogram(sample_luminance, output_path) + assert Path(output_path).stat().st_size > 0 + + def test_creates_parent_directory_automatically( + self, sample_luminance: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + nested_path = str(tmp_path / "figures" / "sub" / "histogram.png") + plot_histogram(sample_luminance, nested_path) + assert Path(nested_path).exists() + + def test_uniform_luminance_is_saved(self, tmp_path: Path) -> None: + """正常: 均一輝度画像でも正常に保存されること.""" + uniform_luminance = numpy.full((8, 8), 200.0, dtype=numpy.float64) + output_path = str(tmp_path / "uniform_histogram.png") + plot_histogram(uniform_luminance, output_path) + assert Path(output_path).exists()