diff --git a/config/templates/report.html b/config/templates/report.html new file mode 100644 index 0000000..901b40d --- /dev/null +++ b/config/templates/report.html @@ -0,0 +1,218 @@ + + + + + + MiniTIAS 照明均一性評価レポート + + + +
+ + +

MiniTIAS 照明均一性評価レポート

+

生成日時: {{ generated_at }}

+ + +

均一性サマリ

+ + + + + + + + + + + + + + {% for row in summary_rows %} + + + + + + + + + + {% endfor %} + +
画像名平均輝度標準偏差CoV最大/最小比最大輝度最小輝度
{{ row.image_name }}{{ "%.2f"|format(row.mean|float) }}{{ "%.2f"|format(row.std|float) }}{{ "%.4f"|format(row.cov|float) }}{{ "%.4f"|format(row.max_min_ratio|float) }}{{ "%.2f"|format(row.max|float) }}{{ "%.2f"|format(row.min|float) }}
+ + +

空間解析サマリ(中心-周辺勾配)

+ + + + + + + + + + + + + {% for row in spatial_rows %} + + + + + + + + + {% endfor %} + +
画像名中心平均輝度中間平均輝度周辺平均輝度中心/周辺比勾配量 (%)
{{ row.image_name }}{{ "%.2f"|format(row.center_mean|float) }}{{ "%.2f"|format(row.middle_mean|float) }}{{ "%.2f"|format(row.periphery_mean|float) }}{{ "%.4f"|format(row.center_periphery_ratio|float) }}{{ "%.2f"|format(row.gradient_magnitude|float) }}
+ + +

画像別詳細

+ {% for item in image_details %} +
+

{{ item.name }}

+
+ {% if item.luminance_map %} +
+ 輝度マップ +

輝度マップ

+
+ {% endif %} + {% if item.histogram %} +
+ 輝度ヒストグラム +

輝度ヒストグラム

+
+ {% endif %} + {% if item.radial_profile %} +
+ 放射状プロファイル +

放射状輝度プロファイル

+
+ {% endif %} + {% if item.zone_map %} +
+ ゾーンマップ +

ゾーンマップ(中心 / 中間 / 周辺)

+
+ {% endif %} +
+
+ {% endfor %} + +
+ + diff --git a/pyproject.toml b/pyproject.toml index 6762ec6..fc13c4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,9 @@ version = "0.1.0" description = "MiniTIAS 画質検証プロジェクト" requires-python = ">=3.11" +dependencies = [ + "jinja2", +] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt index af813d9..dd74700 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ matplotlib pandas streamlit +jinja2 diff --git a/scripts/generate_report.py b/scripts/generate_report.py new file mode 100644 index 0000000..1a72439 --- /dev/null +++ b/scripts/generate_report.py @@ -0,0 +1,62 @@ +"""MiniTIAS 照明均一性評価 HTML レポート生成スクリプト. + +使い方: + python scripts/generate_report.py + python scripts/generate_report.py --output output/ + python scripts/generate_report.py --output output/ \\ + --template config/templates/report.html +""" + +import argparse +import sys +from pathlib import Path + +# プロジェクトルートを sys.path に追加(スクリプトを任意の場所から実行できるよう) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.export.report import build_report_context, render_html_report # noqa: E402 + + +def main() -> None: + """エントリーポイント.""" + parser = argparse.ArgumentParser( + description="MiniTIAS 照明均一性評価の HTML レポートを生成する." + ) + parser.add_argument( + "--output", + default=str(PROJECT_ROOT / "output"), + help="出力ディレクトリのパス(デフォルト: output/)", + ) + parser.add_argument( + "--template", + default=str(PROJECT_ROOT / "config" / "templates" / "report.html"), + help="Jinja2 テンプレートのパス(デフォルト: config/templates/report.html)", + ) + args = parser.parse_args() + + output_base = Path(args.output) + summary_csv = str(output_base / "results" / "summary_uniformity.csv") + spatial_csv = str(output_base / "results" / "summary_spatial.csv") + figures_dir = str(output_base / "figures") + report_output = str(output_base / "reports" / "uniformity_report.html") + + print("HTML レポートを生成します...") + print(f" 均一性 CSV : {summary_csv}") + print(f" 空間解析 CSV: {spatial_csv}") + print(f" 図ディレクトリ: {figures_dir}") + print(f" テンプレート: {args.template}") + + try: + context = build_report_context(summary_csv, spatial_csv, figures_dir) + except FileNotFoundError as e: + print(f"エラー: {e}") + print("先に run_uniformity.py でバッチ解析を実行してください.") + sys.exit(1) + + render_html_report(context, args.template, report_output) + print(f"レポート生成完了: {report_output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_uniformity.py b/scripts/run_uniformity.py index a8021b8..ad95532 100644 --- a/scripts/run_uniformity.py +++ b/scripts/run_uniformity.py @@ -18,14 +18,21 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) +from src.analysis.spatial import calc_spatial_uniformity # noqa: E402 from src.analysis.uniformity import calc_uniformity, to_grayscale # noqa: E402 from src.export.exporter import ( # noqa: E402 ensure_output_dirs, export_results, + export_spatial_summary, 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 +from src.visualization.plotter import ( # noqa: E402 + plot_histogram, + plot_luminance_map, + plot_radial_profile, + plot_zone_map, +) def load_roi_config(config_path: str, roi_key: str) -> dict | None: @@ -106,17 +113,37 @@ # 出力ファイル名(入力ファイル名ベース) stem = Path(image_path).stem + # 空間解析(中心-周辺勾配) + spatial = calc_spatial_uniformity(luminance) + zone_stats = spatial["zone_stats"] + print("--- 空間解析指標 ---") + print(f" 中心平均輝度 : {zone_stats['center_mean']:.2f}") + print(f" 中間平均輝度 : {zone_stats['middle_mean']:.2f}") + print(f" 周辺平均輝度 : {zone_stats['periphery_mean']:.2f}") + print(f" 中心/周辺比 : {zone_stats['center_periphery_ratio']:.4f}") + print(f" 勾配量 (%) : {zone_stats['gradient_magnitude']:.2f}") + # 可視化 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")) + plot_radial_profile( + spatial["radial_profile"], + str(output_base / "figures" / f"{stem}_radial_profile.png"), + ) + plot_zone_map( + luminance, + spatial["zone_map"], + zone_stats, + str(output_base / "figures" / f"{stem}_zone_map.png"), + ) # CSV 出力 export_results(results, str(output_base / "results" / f"{stem}_uniformity.csv")) print(f"解析完了: {image_path}") - return {"image_name": stem, **results} + return {"image_name": stem, **results, **zone_stats} def main() -> None: @@ -181,6 +208,8 @@ if all_results: summary_path = str(output_base / "results" / "summary_uniformity.csv") export_summary(all_results, summary_path) + spatial_summary_path = str(output_base / "results" / "summary_spatial.csv") + export_spatial_summary(all_results, spatial_summary_path) print(f"バッチ解析完了: {len(all_results)} / {len(png_files)} 枚成功") diff --git a/src/analysis/spatial.py b/src/analysis/spatial.py new file mode 100644 index 0000000..184e276 --- /dev/null +++ b/src/analysis/spatial.py @@ -0,0 +1,177 @@ +"""照明均一性の空間分析モジュール(中心-周辺勾配).""" + +import numpy + + +def calc_distance_map(height: int, width: int) -> numpy.ndarray: + """各ピクセルの中心からの正規化楕円距離を算出する. + + 楕円距離を使用することで、縦横比が異なる画像でも中心を基準に + 等方的なゾーン分類が行えるようにする. + + Args: + height: 画像の高さ(ピクセル). + width: 画像の幅(ピクセル). + + Returns: + 正規化楕円距離の NumPy 配列(H x W, float64).値域は 0.0〜1.0. + """ + cy = height / 2 + cx = width / 2 + + # mgrid で全ピクセルの y, x 座標を生成 + y_coords, x_coords = numpy.mgrid[0:height, 0:width] + + # 中心を原点とした楕円距離(縦横それぞれ中心距離で正規化) + dist = numpy.sqrt(((x_coords - cx) / cx) ** 2 + ((y_coords - cy) / cy) ** 2) + + # 最大値で再正規化して 0.0〜1.0 に揃える + max_dist = dist.max() + if max_dist > 0: + dist = dist / max_dist + + return dist + + +def classify_zones( + distance_map: numpy.ndarray, + center_threshold: float = 0.33, + periphery_threshold: float = 0.66, +) -> numpy.ndarray: + """距離マップを 3 ゾーン(中心・中間・周辺)に分類する. + + ゾーン境界を正規化距離の三等分付近に置くことで、 + 均一性評価の標準的な領域分割に対応する. + + Args: + distance_map: 正規化楕円距離の NumPy 配列(H x W, float64). + center_threshold: 中心ゾーンの上限距離(デフォルト: 0.33). + periphery_threshold: 中間ゾーンの上限距離(デフォルト: 0.66). + + Returns: + ゾーンマップの NumPy 配列(H x W, uint8). + 0=center, 1=middle, 2=periphery. + """ + zone_map = numpy.zeros(distance_map.shape, dtype=numpy.uint8) + # 中間ゾーンと周辺ゾーンを順に上書きする + zone_map[distance_map >= center_threshold] = 1 + zone_map[distance_map >= periphery_threshold] = 2 + return zone_map + + +def calc_zone_stats(luminance: numpy.ndarray, zone_map: numpy.ndarray) -> dict: + """ゾーン別の輝度統計と中心-周辺勾配指標を算出する. + + center_periphery_ratio が 1.0 に近いほど均一、 + gradient_magnitude が 0 に近いほど空間的ムラが小さい. + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + zone_map: ゾーンマップの NumPy 配列(H x W, uint8). + 0=center, 1=middle, 2=periphery. + + Returns: + ゾーン統計を格納した辞書.キーは: + "center_mean", "center_std", "middle_mean", "middle_std", + "periphery_mean", "periphery_std", + "center_periphery_ratio", "gradient_magnitude". + """ + center_pixels = luminance[zone_map == 0] + middle_pixels = luminance[zone_map == 1] + periphery_pixels = luminance[zone_map == 2] + + center_mean = float(numpy.mean(center_pixels)) + center_std = float(numpy.std(center_pixels)) + middle_mean = float(numpy.mean(middle_pixels)) + middle_std = float(numpy.std(middle_pixels)) + periphery_mean = float(numpy.mean(periphery_pixels)) + periphery_std = float(numpy.std(periphery_pixels)) + + # 中心/周辺比: 照明が中心に集中しているかを示す + center_periphery_ratio = ( + center_mean / periphery_mean if periphery_mean != 0.0 else float("inf") + ) + + # 勾配量(%): 中心と周辺の輝度差を中心輝度に対する比率で表す + gradient_magnitude = ( + (center_mean - periphery_mean) / center_mean * 100 + if center_mean != 0.0 + else float("inf") + ) + + return { + "center_mean": center_mean, + "center_std": center_std, + "middle_mean": middle_mean, + "middle_std": middle_std, + "periphery_mean": periphery_mean, + "periphery_std": periphery_std, + "center_periphery_ratio": center_periphery_ratio, + "gradient_magnitude": gradient_magnitude, + } + + +def calc_radial_profile( + luminance: numpy.ndarray, + distance_map: numpy.ndarray, + num_bins: int = 20, +) -> numpy.ndarray: + """放射状輝度プロファイルを算出する. + + 距離ビンごとに平均輝度を集計することで、 + 中心から周辺にかけての輝度変化の傾向を把握する. + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + distance_map: 正規化楕円距離の NumPy 配列(H x W, float64). + num_bins: 距離方向の分割数(デフォルト: 20). + + Returns: + 放射状プロファイルの NumPy 配列(num_bins x 2, float64). + 列 0 = ビン中心距離, 列 1 = 平均輝度. + """ + bin_edges = numpy.linspace(0.0, 1.0, num_bins + 1) + profile = numpy.zeros((num_bins, 2), dtype=numpy.float64) + + for i in range(num_bins): + bin_center = (bin_edges[i] + bin_edges[i + 1]) / 2.0 + mask = (distance_map >= bin_edges[i]) & (distance_map < bin_edges[i + 1]) + + # 最外ビンは上端を含める + if i == num_bins - 1: + mask = distance_map >= bin_edges[i] + + pixels = luminance[mask] + mean_lum = float(numpy.mean(pixels)) if pixels.size > 0 else 0.0 + + profile[i, 0] = bin_center + profile[i, 1] = mean_lum + + return profile + + +def calc_spatial_uniformity(luminance: numpy.ndarray) -> dict: + """空間的均一性指標を一括算出するファサード関数. + + 中心-周辺勾配の定量化に必要な distance_map, zone_map, + zone_stats, radial_profile を一度の呼び出しで取得できる. + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + + Returns: + 空間分析結果を格納した辞書.キーは: + "zone_stats" (dict), "radial_profile" (numpy.ndarray), + "zone_map" (numpy.ndarray). + """ + height, width = luminance.shape + distance_map = calc_distance_map(height, width) + zone_map = classify_zones(distance_map) + zone_stats = calc_zone_stats(luminance, zone_map) + radial_profile = calc_radial_profile(luminance, distance_map) + + return { + "zone_stats": zone_stats, + "radial_profile": radial_profile, + "zone_map": zone_map, + } diff --git a/src/export/exporter.py b/src/export/exporter.py index 7678944..2d7b864 100644 --- a/src/export/exporter.py +++ b/src/export/exporter.py @@ -4,10 +4,18 @@ from pathlib import Path SUMMARY_FIELDNAMES = ["image_name", "mean", "std", "cov", "max_min_ratio", "max", "min"] +SPATIAL_FIELDNAMES = [ + "image_name", + "center_mean", + "middle_mean", + "periphery_mean", + "center_periphery_ratio", + "gradient_magnitude", +] def ensure_output_dirs(base_path: str) -> None: - """output/figures/ と output/results/ ディレクトリを作成する. + """output/figures/, output/results/, output/reports/ ディレクトリを作成する. Args: base_path: 出力ルートディレクトリのパス. @@ -15,6 +23,7 @@ base = Path(base_path) (base / "figures").mkdir(parents=True, exist_ok=True) (base / "results").mkdir(parents=True, exist_ok=True) + (base / "reports").mkdir(parents=True, exist_ok=True) print(f"出力ディレクトリを確認しました: {base}") @@ -56,3 +65,25 @@ writer.writerows(all_results) print(f"まとめ CSV を保存しました: {output_path}") + + +def export_spatial_summary(all_results: list[dict], output_path: str) -> None: + """空間解析の集約結果を CSV ファイルに出力する. + + CSV の形式は 1 行目がヘッダー,2 行目以降が各画像の値. + + Args: + all_results: 空間解析結果を含む辞書リスト.各要素は + {"image_name": "xxx", "center_mean": ..., "middle_mean": ..., + "periphery_mean": ..., "center_periphery_ratio": ..., + "gradient_magnitude": ...} を含む形式. + 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=SPATIAL_FIELDNAMES, extrasaction="ignore") + writer.writeheader() + writer.writerows(all_results) + + print(f"空間解析まとめ CSV を保存しました: {output_path}") diff --git a/src/export/report.py b/src/export/report.py new file mode 100644 index 0000000..b4b8217 --- /dev/null +++ b/src/export/report.py @@ -0,0 +1,120 @@ +"""HTML レポート生成モジュール.""" + +import base64 +from datetime import datetime +from pathlib import Path + +import pandas +from jinja2 import Environment, FileSystemLoader + + +def encode_image_base64(image_path: str) -> str: + """PNG 画像ファイルを Base64 エンコードした data URI に変換する. + + HTML に画像をインライン埋め込みすることで、レポートを自己完結させる. + + Args: + image_path: PNG 画像ファイルのパス. + + Returns: + "data:image/png;base64,..." 形式の文字列. + ファイルが存在しない場合は空文字列を返す. + """ + path = Path(image_path) + if not path.exists(): + return "" + + with path.open("rb") as f: + encoded = base64.b64encode(f.read()).decode("utf-8") + + return f"data:image/png;base64,{encoded}" + + +def build_report_context( + summary_csv_path: str, + spatial_csv_path: str, + figures_dir: str, +) -> dict: + """CSV と図ファイルからテンプレート描画用のコンテキスト辞書を構築する. + + 画像は Base64 エンコードしてインライン埋め込みにするため、 + レポート HTML が単独で完結する形になる. + + Args: + summary_csv_path: 均一性サマリ CSV のパス(summary_uniformity.csv). + spatial_csv_path: 空間解析サマリ CSV のパス(summary_spatial.csv). + figures_dir: 各種図ファイルが格納されたディレクトリのパス. + + Returns: + Jinja2 テンプレートに渡すコンテキスト辞書.キーは: + "generated_at", "summary_rows", "spatial_rows", "image_details". + + Raises: + FileNotFoundError: CSV ファイルが存在しない場合. + """ + summary_path = Path(summary_csv_path) + if not summary_path.exists(): + raise FileNotFoundError( + f"均一性サマリ CSV が見つかりません: {summary_csv_path}" + ) + + spatial_path = Path(spatial_csv_path) + if not spatial_path.exists(): + raise FileNotFoundError( + f"空間解析サマリ CSV が見つかりません: {spatial_csv_path}" + ) + + summary_df = pandas.read_csv(summary_path) + spatial_df = pandas.read_csv(spatial_path) + + figures_path = Path(figures_dir) + + # 画像別詳細: サマリに登場する image_name ごとに図を収集する + image_details = [] + for image_name in summary_df["image_name"].tolist(): + detail = { + "name": image_name, + "luminance_map": encode_image_base64( + str(figures_path / f"{image_name}_luminance_map.png") + ), + "histogram": encode_image_base64( + str(figures_path / f"{image_name}_histogram.png") + ), + "radial_profile": encode_image_base64( + str(figures_path / f"{image_name}_radial_profile.png") + ), + "zone_map": encode_image_base64( + str(figures_path / f"{image_name}_zone_map.png") + ), + } + image_details.append(detail) + + return { + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "summary_rows": summary_df.to_dict(orient="records"), + "spatial_rows": spatial_df.to_dict(orient="records"), + "image_details": image_details, + } + + +def render_html_report(context: dict, template_path: str, output_path: str) -> None: + """Jinja2 テンプレートにコンテキストを適用して HTML レポートを生成する. + + Args: + context: テンプレートに渡すコンテキスト辞書(build_report_context の戻り値). + template_path: Jinja2 テンプレートファイルのパス. + output_path: 出力先 HTML ファイルのパス. + """ + template_file = Path(template_path) + env = Environment( + loader=FileSystemLoader(str(template_file.parent)), + autoescape=True, + ) + template = env.get_template(template_file.name) + html = template.render(**context) + + out_path = Path(output_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(html, encoding="utf-8") + + print(f"HTML レポートを保存しました: {output_path}") diff --git a/src/visualization/plotter.py b/src/visualization/plotter.py index 3056b9d..08efdde 100644 --- a/src/visualization/plotter.py +++ b/src/visualization/plotter.py @@ -1,4 +1,4 @@ -"""輝度マップ・ヒストグラム生成モジュール.""" +"""輝度マップ・ヒストグラム・空間分析可視化モジュール.""" from pathlib import Path @@ -6,6 +6,15 @@ import numpy +def _ensure_parent(output_path: str) -> None: + """出力ファイルの親ディレクトリを作成する. + + Args: + output_path: 出力先ファイルパス. + """ + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + def plot_luminance_map(luminance: numpy.ndarray, output_path: str) -> None: """輝度画像をヒートマップとして保存する. @@ -13,7 +22,7 @@ luminance: 輝度画像の NumPy 配列(H x W, float64). output_path: 出力先ファイルパス(PNG). """ - Path(output_path).parent.mkdir(parents=True, exist_ok=True) + _ensure_parent(output_path) fig, ax = plt.subplots(figsize=(8, 6)) im = ax.imshow(luminance, cmap="hot", interpolation="nearest") @@ -35,7 +44,7 @@ luminance: 輝度画像の NumPy 配列(H x W, float64). output_path: 出力先ファイルパス(PNG). """ - Path(output_path).parent.mkdir(parents=True, exist_ok=True) + _ensure_parent(output_path) fig, ax = plt.subplots(figsize=(8, 5)) ax.hist( @@ -49,3 +58,86 @@ plt.close(fig) print(f"輝度ヒストグラムを保存しました: {output_path}") + + +def plot_radial_profile(radial_profile: numpy.ndarray, output_path: str) -> None: + """放射状輝度プロファイルを折れ線グラフとして保存する. + + Args: + radial_profile: 放射状プロファイルの NumPy 配列(N x 2, float64). + 列 0 = 正規化距離, 列 1 = 平均輝度. + output_path: 出力先ファイルパス(PNG). + """ + _ensure_parent(output_path) + + fig, ax = plt.subplots(figsize=(8, 5)) + ax.plot(radial_profile[:, 0], radial_profile[:, 1], marker="o", color="steelblue") + ax.set_title("Radial Luminance Profile") + ax.set_xlabel("Normalized Distance from Center") + ax.set_ylabel("Mean Luminance") + ax.grid(True, linestyle="--", alpha=0.5) + fig.tight_layout() + fig.savefig(output_path, dpi=150) + plt.close(fig) + + print(f"放射状プロファイルを保存しました: {output_path}") + + +def plot_zone_map( + luminance: numpy.ndarray, + zone_map: numpy.ndarray, + zone_stats: dict, + output_path: str, +) -> None: + """輝度マップにゾーン境界をオーバーレイして保存する. + + "hot" カラーマップの輝度ヒートマップ上に、3 ゾーンの境界を + contour で描画し、各ゾーンの平均輝度を注釈として追加する. + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + zone_map: ゾーンマップの NumPy 配列(H x W, uint8). + 0=center, 1=middle, 2=periphery. + zone_stats: ゾーン別統計の辞書(calc_zone_stats の戻り値). + output_path: 出力先ファイルパス(PNG). + """ + _ensure_parent(output_path) + + fig, ax = plt.subplots(figsize=(8, 6)) + + # 輝度ヒートマップ + im = ax.imshow(luminance, cmap="hot", interpolation="nearest") + fig.colorbar(im, ax=ax, label="Luminance") + + # ゾーン境界をコンター線で描画(境界値は 0.5, 1.5 でゾーン間を区切る) + ax.contour(zone_map, levels=[0.5, 1.5], colors=["cyan", "lime"], linewidths=1.5) + + # 各ゾーンの重心位置に平均輝度を注釈 + zone_labels = [ + (0, zone_stats["center_mean"], "Center"), + (1, zone_stats["middle_mean"], "Middle"), + (2, zone_stats["periphery_mean"], "Periphery"), + ] + for zone_id, mean_val, label in zone_labels: + ys, xs = numpy.where(zone_map == zone_id) + if ys.size > 0: + cy_pos = int(numpy.mean(ys)) + cx_pos = int(numpy.mean(xs)) + ax.annotate( + f"{label}\n{mean_val:.1f}", + xy=(cx_pos, cy_pos), + ha="center", + va="center", + color="white", + fontsize=8, + bbox={"boxstyle": "round,pad=0.2", "facecolor": "black", "alpha": 0.5}, + ) + + ax.set_title("Zone Map (center / middle / periphery)") + 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}") diff --git a/tests/analysis/test_spatial.py b/tests/analysis/test_spatial.py new file mode 100644 index 0000000..c9f9277 --- /dev/null +++ b/tests/analysis/test_spatial.py @@ -0,0 +1,420 @@ +"""spatial.py の単体テスト. + +仕様 (SPEC_02_照明均一性評価アルゴリズム.md): + - calc_distance_map: 各ピクセルの中心からの正規化楕円距離(0.0〜1.0) + - classify_zones: 3 ゾーン分類(0=center, 1=middle, 2=periphery) + - calc_zone_stats: ゾーン別 mean/std + center_periphery_ratio + gradient_magnitude(%) + - calc_radial_profile: 放射状プロファイル(num_bins x 2) + - calc_spatial_uniformity: ファサード関数 + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - numpy で合成した画像を使用 + - 既知の値で期待値と比較 +""" + +import numpy +import pytest + +from src.analysis.spatial import ( + calc_distance_map, + calc_radial_profile, + calc_spatial_uniformity, + calc_zone_stats, + classify_zones, +) + + +# --------------------------------------------------------------------------- +# calc_distance_map テスト +# --------------------------------------------------------------------------- + + +class TestCalcDistanceMap: + """calc_distance_map 関数のテスト群.""" + + def test_center_pixel_is_zero(self) -> None: + """正常: 中心ピクセルの距離が 0.0 であること. + + 実装は cy = height / 2, cx = width / 2 を使う. + 偶数サイズでは cy, cx が整数座標と一致するため + (cy, cx) のピクセルが距離 0 になる. + 例: 10x10 → cy=5.0, cx=5.0 → dist[5, 5] == 0.0 + """ + height, width = 10, 10 + dist = calc_distance_map(height, width) + # 10x10 では cy=5.0, cx=5.0 → ピクセル (5, 5) が中心 + assert numpy.isclose(dist[5, 5], 0.0) + + def test_output_shape_matches_input(self) -> None: + """正常: 出力配列の shape が (height, width) と一致すること.""" + height, width = 10, 20 + dist = calc_distance_map(height, width) + assert dist.shape == (height, width) + + def test_values_are_in_0_to_1_range(self) -> None: + """正常: 全要素が 0.0〜1.0 の範囲内であること.""" + dist = calc_distance_map(20, 20) + assert numpy.all(dist >= 0.0) + assert numpy.all(dist <= 1.0) + + def test_maximum_value_is_1(self) -> None: + """正常: 最大値が 1.0 であること(再正規化の確認).""" + dist = calc_distance_map(20, 30) + assert numpy.isclose(dist.max(), 1.0) + + def test_corner_pixels_are_far_from_center(self) -> None: + """正常: 角のピクセルは中心から離れており、最小値ピクセルよりも大きな値を持つこと.""" + height, width = 20, 20 + dist = calc_distance_map(height, width) + min_val = dist.min() + corner_val = dist[0, 0] + assert corner_val > min_val + + def test_1x1_image_boundary_condition(self) -> None: + """境界: 1x1 画像でゼロ除算・例外が発生しないこと. + + 1x1 の場合 cy=0.5, cx=0.5 のため唯一のピクセル (0,0) は中心外となる. + 最大値で再正規化されるため戻り値は 1.0 となる. + 重要なのは例外が発生しないこと(形状が正しいこと). + """ + dist = calc_distance_map(1, 1) + assert dist.shape == (1, 1) + assert numpy.isfinite(dist[0, 0]) + + def test_output_dtype_is_float64(self) -> None: + """正常: 戻り値の dtype が float64 であること.""" + dist = calc_distance_map(8, 8) + assert dist.dtype == numpy.float64 + + def test_minimum_value_is_at_single_pixel(self) -> None: + """正常: 距離マップの最小値が唯一の点(中心)で発生すること. + + 実装は cy = height / 2, cx = width / 2 を使う. + 偶数サイズでは (cy, cx) のピクセルが最小距離 0.0 となる. + """ + dist = calc_distance_map(10, 10) + min_idx = numpy.unravel_index(dist.argmin(), dist.shape) + # 10x10 では cy=5, cx=5 → ピクセル (5, 5) が最小 + assert min_idx == (5, 5) + + def test_distance_increases_from_center(self) -> None: + """正常: 中心から離れるほど距離値が大きくなること.""" + dist = calc_distance_map(20, 20) + # cy=10, cx=10 → 中心ピクセル (10, 10) + center_val = dist[10, 10] + # 中心から離れた数点を確認 + assert dist[10, 15] > center_val + assert dist[10, 5] > center_val + assert dist[15, 10] > center_val + assert dist[5, 10] > center_val + + +# --------------------------------------------------------------------------- +# classify_zones テスト +# --------------------------------------------------------------------------- + + +class TestClassifyZones: + """classify_zones 関数のテスト群.""" + + def test_center_zone_has_value_0(self) -> None: + """正常: 距離 < center_threshold のピクセルがゾーン 0(center)であること.""" + # 距離が 0.0 のピクセルは中心ゾーンに分類される + distance_map = numpy.array([[0.0, 0.5, 0.9]], dtype=numpy.float64) + zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66) + assert zone_map[0, 0] == 0 # 0.0 < 0.33 → center + + def test_middle_zone_has_value_1(self) -> None: + """正常: center_threshold <= 距離 < periphery_threshold のピクセルがゾーン 1(middle)であること.""" + distance_map = numpy.array([[0.0, 0.5, 0.9]], dtype=numpy.float64) + zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66) + assert zone_map[0, 1] == 1 # 0.5 → middle + + def test_periphery_zone_has_value_2(self) -> None: + """正常: 距離 >= periphery_threshold のピクセルがゾーン 2(periphery)であること.""" + distance_map = numpy.array([[0.0, 0.5, 0.9]], dtype=numpy.float64) + zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66) + assert zone_map[0, 2] == 2 # 0.9 >= 0.66 → periphery + + def test_output_shape_matches_input(self) -> None: + """正常: 出力配列の shape が入力と一致すること.""" + distance_map = numpy.zeros((10, 20), dtype=numpy.float64) + zone_map = classify_zones(distance_map) + assert zone_map.shape == (10, 20) + + def test_output_dtype_is_uint8(self) -> None: + """正常: 戻り値の dtype が uint8 であること.""" + distance_map = numpy.zeros((8, 8), dtype=numpy.float64) + zone_map = classify_zones(distance_map) + assert zone_map.dtype == numpy.uint8 + + def test_boundary_value_at_center_threshold(self) -> None: + """境界: center_threshold ちょうどの値はゾーン 1(middle)に分類されること.""" + distance_map = numpy.array([[0.33]], dtype=numpy.float64) + zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66) + assert zone_map[0, 0] == 1 # distance_map >= center_threshold → middle + + def test_boundary_value_at_periphery_threshold(self) -> None: + """境界: periphery_threshold ちょうどの値はゾーン 2(periphery)に分類されること.""" + distance_map = numpy.array([[0.66]], dtype=numpy.float64) + zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66) + assert zone_map[0, 0] == 2 # distance_map >= periphery_threshold → periphery + + def test_custom_thresholds(self) -> None: + """正常: カスタム閾値が正しく適用されること.""" + distance_map = numpy.array([[0.1, 0.4, 0.8]], dtype=numpy.float64) + zone_map = classify_zones(distance_map, center_threshold=0.2, periphery_threshold=0.6) + assert zone_map[0, 0] == 0 # 0.1 < 0.2 → center + assert zone_map[0, 1] == 1 # 0.2 <= 0.4 < 0.6 → middle + assert zone_map[0, 2] == 2 # 0.8 >= 0.6 → periphery + + def test_all_center_when_distance_all_zero(self) -> None: + """正常: 距離が全て 0.0 の場合、全ピクセルがゾーン 0(center)であること.""" + distance_map = numpy.zeros((5, 5), dtype=numpy.float64) + zone_map = classify_zones(distance_map) + assert numpy.all(zone_map == 0) + + +# --------------------------------------------------------------------------- +# calc_zone_stats テスト +# --------------------------------------------------------------------------- + + +class TestCalcZoneStats: + """calc_zone_stats 関数のテスト群.""" + + @pytest.fixture + def uniform_inputs(self) -> tuple[numpy.ndarray, numpy.ndarray]: + """均一輝度(200.0)と単純なゾーンマップを返す fixture.""" + luminance = numpy.full((9, 9), 200.0, dtype=numpy.float64) + # 中央 3x3 = center (0), 外周 = periphery (2), 残り = middle (1) + zone_map = numpy.full((9, 9), 2, dtype=numpy.uint8) + zone_map[3:6, 3:6] = 0 + zone_map[2:7, 2:7][zone_map[2:7, 2:7] != 0] = 1 + return luminance, zone_map + + def test_return_value_has_all_required_keys(self) -> None: + """正常: 戻り値のキーが全て存在すること.""" + luminance = numpy.full((8, 8), 128.0, dtype=numpy.float64) + zone_map = numpy.zeros((8, 8), dtype=numpy.uint8) + zone_map[4:, :] = 1 + zone_map[6:, :] = 2 + result = calc_zone_stats(luminance, zone_map) + + expected_keys = { + "center_mean", "center_std", + "middle_mean", "middle_std", + "periphery_mean", "periphery_std", + "center_periphery_ratio", "gradient_magnitude", + } + assert set(result.keys()) == expected_keys + + def test_uniform_image_ratio_is_1(self, uniform_inputs) -> None: + """正常: 均一輝度画像では center_periphery_ratio が 1.0 であること.""" + luminance, zone_map = uniform_inputs + result = calc_zone_stats(luminance, zone_map) + assert numpy.isclose(result["center_periphery_ratio"], 1.0) + + def test_uniform_image_gradient_is_0(self, uniform_inputs) -> None: + """正常: 均一輝度画像では gradient_magnitude が 0.0 であること.""" + luminance, zone_map = uniform_inputs + result = calc_zone_stats(luminance, zone_map) + assert numpy.isclose(result["gradient_magnitude"], 0.0) + + def test_uniform_image_std_is_0(self, uniform_inputs) -> None: + """正常: 均一輝度画像では各ゾーンの std が 0.0 であること.""" + luminance, zone_map = uniform_inputs + result = calc_zone_stats(luminance, zone_map) + assert numpy.isclose(result["center_std"], 0.0) + assert numpy.isclose(result["middle_std"], 0.0) + assert numpy.isclose(result["periphery_std"], 0.0) + + def test_brighter_center_yields_ratio_greater_than_1(self) -> None: + """正常: 中心が明るい場合に center_periphery_ratio > 1.0 であること.""" + luminance = numpy.full((9, 9), 100.0, dtype=numpy.float64) + zone_map = numpy.full((9, 9), 2, dtype=numpy.uint8) + # 中心ゾーン(zone=0)を明るくする + zone_map[3:6, 3:6] = 0 + luminance[3:6, 3:6] = 200.0 + + result = calc_zone_stats(luminance, zone_map) + assert result["center_mean"] > result["periphery_mean"] + assert result["center_periphery_ratio"] > 1.0 + + def test_center_periphery_ratio_inf_when_periphery_mean_is_zero(self) -> None: + """異常: periphery_mean が 0.0 の場合に center_periphery_ratio が inf であること.""" + luminance = numpy.full((5, 5), 0.0, dtype=numpy.float64) + luminance[2, 2] = 100.0 + zone_map = numpy.full((5, 5), 2, dtype=numpy.uint8) + zone_map[2, 2] = 0 + + result = calc_zone_stats(luminance, zone_map) + assert result["center_periphery_ratio"] == float("inf") + + def test_gradient_magnitude_inf_when_center_mean_is_zero(self) -> None: + """異常: center_mean が 0.0 の場合に gradient_magnitude が inf であること.""" + luminance = numpy.full((5, 5), 100.0, dtype=numpy.float64) + luminance[2, 2] = 0.0 + zone_map = numpy.full((5, 5), 2, dtype=numpy.uint8) + zone_map[2, 2] = 0 + + result = calc_zone_stats(luminance, zone_map) + assert result["gradient_magnitude"] == float("inf") + + def test_gradient_magnitude_formula(self) -> None: + """正常: gradient_magnitude が (center_mean - periphery_mean) / center_mean * 100 の公式に従うこと.""" + luminance = numpy.full((9, 9), 50.0, dtype=numpy.float64) + zone_map = numpy.full((9, 9), 2, dtype=numpy.uint8) + zone_map[3:6, 3:6] = 0 + luminance[3:6, 3:6] = 100.0 + + result = calc_zone_stats(luminance, zone_map) + center_mean = result["center_mean"] + periphery_mean = result["periphery_mean"] + expected_gradient = (center_mean - periphery_mean) / center_mean * 100 + assert numpy.isclose(result["gradient_magnitude"], expected_gradient) + + def test_return_values_are_float(self) -> None: + """正常: 戻り値の各フィールドが Python float 型であること.""" + luminance = numpy.full((8, 8), 128.0, dtype=numpy.float64) + zone_map = numpy.zeros((8, 8), dtype=numpy.uint8) + zone_map[4:, :] = 1 + zone_map[6:, :] = 2 + result = calc_zone_stats(luminance, zone_map) + for key in result: + assert isinstance(result[key], float), f"{key} が float でない" + + +# --------------------------------------------------------------------------- +# calc_radial_profile テスト +# --------------------------------------------------------------------------- + + +class TestCalcRadialProfile: + """calc_radial_profile 関数のテスト群.""" + + @pytest.fixture + def uniform_inputs(self) -> tuple[numpy.ndarray, numpy.ndarray]: + """均一輝度と距離マップを返す fixture.""" + from src.analysis.spatial import calc_distance_map + luminance = numpy.full((20, 20), 150.0, dtype=numpy.float64) + distance_map = calc_distance_map(20, 20) + return luminance, distance_map + + def test_output_shape_matches_num_bins(self, uniform_inputs) -> None: + """正常: 戻り値の shape が (num_bins, 2) であること.""" + luminance, distance_map = uniform_inputs + num_bins = 15 + profile = calc_radial_profile(luminance, distance_map, num_bins=num_bins) + assert profile.shape == (num_bins, 2) + + def test_default_num_bins_is_20(self, uniform_inputs) -> None: + """正常: デフォルトの num_bins=20 で shape が (20, 2) であること.""" + luminance, distance_map = uniform_inputs + profile = calc_radial_profile(luminance, distance_map) + assert profile.shape == (20, 2) + + def test_column_0_is_bin_center_distance(self, uniform_inputs) -> None: + """正常: 列 0 がビン中心距離(0.0〜1.0 の範囲内)であること.""" + luminance, distance_map = uniform_inputs + profile = calc_radial_profile(luminance, distance_map, num_bins=10) + assert numpy.all(profile[:, 0] >= 0.0) + assert numpy.all(profile[:, 0] <= 1.0) + + def test_column_0_is_monotonically_increasing(self, uniform_inputs) -> None: + """正常: 列 0(ビン中心距離)が単調増加であること.""" + luminance, distance_map = uniform_inputs + profile = calc_radial_profile(luminance, distance_map, num_bins=20) + diffs = numpy.diff(profile[:, 0]) + assert numpy.all(diffs > 0) + + def test_uniform_image_yields_constant_luminance(self, uniform_inputs) -> None: + """正常: 均一輝度画像では全ビンで輝度がほぼ等しいこと.""" + luminance, distance_map = uniform_inputs + profile = calc_radial_profile(luminance, distance_map, num_bins=20) + # 均一画像では全ビンの平均輝度が 150.0 に近いこと + assert numpy.allclose(profile[:, 1], 150.0, atol=1e-6) + + def test_output_dtype_is_float64(self, uniform_inputs) -> None: + """正常: 戻り値の dtype が float64 であること.""" + luminance, distance_map = uniform_inputs + profile = calc_radial_profile(luminance, distance_map) + assert profile.dtype == numpy.float64 + + def test_custom_num_bins(self, uniform_inputs) -> None: + """正常: カスタム num_bins が正しく反映されること.""" + luminance, distance_map = uniform_inputs + for num_bins in [5, 10, 30, 50]: + profile = calc_radial_profile(luminance, distance_map, num_bins=num_bins) + assert profile.shape == (num_bins, 2) + + +# --------------------------------------------------------------------------- +# calc_spatial_uniformity テスト +# --------------------------------------------------------------------------- + + +class TestCalcSpatialUniformity: + """calc_spatial_uniformity ファサード関数のテスト群.""" + + @pytest.fixture + def sample_luminance(self) -> numpy.ndarray: + """テスト用の 20x20 均一輝度画像を返す fixture.""" + return numpy.full((20, 20), 128.0, dtype=numpy.float64) + + def test_return_value_has_zone_stats_key(self, sample_luminance) -> None: + """正常: 戻り値に 'zone_stats' キーが存在すること.""" + result = calc_spatial_uniformity(sample_luminance) + assert "zone_stats" in result + + def test_return_value_has_radial_profile_key(self, sample_luminance) -> None: + """正常: 戻り値に 'radial_profile' キーが存在すること.""" + result = calc_spatial_uniformity(sample_luminance) + assert "radial_profile" in result + + def test_return_value_has_zone_map_key(self, sample_luminance) -> None: + """正常: 戻り値に 'zone_map' キーが存在すること.""" + result = calc_spatial_uniformity(sample_luminance) + assert "zone_map" in result + + def test_return_value_has_exactly_3_keys(self, sample_luminance) -> None: + """正常: 戻り値のキー数が 3 であること.""" + result = calc_spatial_uniformity(sample_luminance) + assert len(result) == 3 + + def test_zone_stats_is_dict(self, sample_luminance) -> None: + """正常: zone_stats が dict 型であること.""" + result = calc_spatial_uniformity(sample_luminance) + assert isinstance(result["zone_stats"], dict) + + def test_radial_profile_is_ndarray(self, sample_luminance) -> None: + """正常: radial_profile が numpy.ndarray 型であること.""" + result = calc_spatial_uniformity(sample_luminance) + assert isinstance(result["radial_profile"], numpy.ndarray) + + def test_zone_map_is_ndarray(self, sample_luminance) -> None: + """正常: zone_map が numpy.ndarray 型であること.""" + result = calc_spatial_uniformity(sample_luminance) + assert isinstance(result["zone_map"], numpy.ndarray) + + def test_zone_map_shape_matches_input(self, sample_luminance) -> None: + """正常: zone_map の shape が入力輝度と同じであること.""" + result = calc_spatial_uniformity(sample_luminance) + assert result["zone_map"].shape == sample_luminance.shape + + def test_radial_profile_default_shape(self, sample_luminance) -> None: + """正常: radial_profile のデフォルト形状が (20, 2) であること.""" + result = calc_spatial_uniformity(sample_luminance) + assert result["radial_profile"].shape == (20, 2) + + def test_zone_stats_has_all_required_keys(self, sample_luminance) -> None: + """正常: zone_stats が全ての必須キーを持つこと.""" + result = calc_spatial_uniformity(sample_luminance) + expected_keys = { + "center_mean", "center_std", + "middle_mean", "middle_std", + "periphery_mean", "periphery_std", + "center_periphery_ratio", "gradient_magnitude", + } + assert set(result["zone_stats"].keys()) == expected_keys diff --git a/tests/export/test_exporter.py b/tests/export/test_exporter.py index f2ea0c8..cfb61fc 100644 --- a/tests/export/test_exporter.py +++ b/tests/export/test_exporter.py @@ -16,9 +16,11 @@ import pytest from src.export.exporter import ( + SPATIAL_FIELDNAMES, SUMMARY_FIELDNAMES, ensure_output_dirs, export_results, + export_spatial_summary, export_summary, ) @@ -41,6 +43,11 @@ ensure_output_dirs(str(tmp_path)) assert (tmp_path / "results").is_dir() + def test_creates_reports_directory(self, tmp_path: Path) -> None: + """正常: reports/ サブディレクトリが作成されること.""" + ensure_output_dirs(str(tmp_path)) + assert (tmp_path / "reports").is_dir() + def test_idempotent_when_dirs_already_exist(self, tmp_path: Path) -> None: """正常: 既にディレクトリが存在する場合でもエラーにならないこと.""" ensure_output_dirs(str(tmp_path)) @@ -48,6 +55,7 @@ ensure_output_dirs(str(tmp_path)) assert (tmp_path / "figures").is_dir() assert (tmp_path / "results").is_dir() + assert (tmp_path / "reports").is_dir() def test_creates_nested_base_directory(self, tmp_path: Path) -> None: """正常: base_path が存在しない場合でも自動作成されること.""" @@ -55,6 +63,7 @@ ensure_output_dirs(str(nested_base)) assert (nested_base / "figures").is_dir() assert (nested_base / "results").is_dir() + assert (nested_base / "reports").is_dir() # --------------------------------------------------------------------------- @@ -237,3 +246,150 @@ rows = list(reader) assert len(rows) == 0 assert list(reader.fieldnames) == SUMMARY_FIELDNAMES + + +# --------------------------------------------------------------------------- +# export_spatial_summary テスト +# --------------------------------------------------------------------------- + + +class TestExportSpatialSummary: + """export_spatial_summary 関数のテスト群.""" + + @pytest.fixture + def sample_spatial_results(self) -> list[dict]: + """テスト用の空間解析結果リストを返す fixture.""" + return [ + { + "image_name": "image_001", + "center_mean": 180.0, + "middle_mean": 165.0, + "periphery_mean": 140.0, + "center_periphery_ratio": 1.29, + "gradient_magnitude": 22.2, + }, + { + "image_name": "image_002", + "center_mean": 200.0, + "middle_mean": 190.0, + "periphery_mean": 170.0, + "center_periphery_ratio": 1.18, + "gradient_magnitude": 15.0, + }, + ] + + def test_csv_file_is_created( + self, sample_spatial_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV ファイルが生成されること.""" + output_path = str(tmp_path / "spatial_summary.csv") + export_spatial_summary(sample_spatial_results, output_path) + assert Path(output_path).exists() + + def test_csv_has_correct_spatial_fieldnames( + self, sample_spatial_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV のヘッダーが SPATIAL_FIELDNAMES と一致すること.""" + output_path = str(tmp_path / "spatial_summary.csv") + export_spatial_summary(sample_spatial_results, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames + assert list(fieldnames) == SPATIAL_FIELDNAMES + + def test_csv_has_correct_row_count( + self, sample_spatial_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV の行数が入力リストのサイズと一致すること.""" + output_path = str(tmp_path / "spatial_summary.csv") + export_spatial_summary(sample_spatial_results, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == len(sample_spatial_results) + + def test_csv_first_row_values_are_correct( + self, sample_spatial_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV の 1 行目の値が正しいこと.""" + output_path = str(tmp_path / "spatial_summary.csv") + export_spatial_summary(sample_spatial_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["center_mean"]) == pytest.approx(180.0) + assert float(first["periphery_mean"]) == pytest.approx(140.0) + assert float(first["center_periphery_ratio"]) == pytest.approx(1.29) + + def test_csv_second_row_values_are_correct( + self, sample_spatial_results: list[dict], tmp_path: Path + ) -> None: + """正常: CSV の 2 行目の値が正しいこと.""" + output_path = str(tmp_path / "spatial_summary.csv") + export_spatial_summary(sample_spatial_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["gradient_magnitude"]) == pytest.approx(15.0) + + def test_spatial_fieldnames_constant_has_correct_keys(self) -> None: + """正常: SPATIAL_FIELDNAMES 定数が期待されるキーを持つこと.""" + expected = [ + "image_name", + "center_mean", + "middle_mean", + "periphery_mean", + "center_periphery_ratio", + "gradient_magnitude", + ] + assert SPATIAL_FIELDNAMES == expected + + def test_creates_parent_directory_automatically( + self, sample_spatial_results: list[dict], tmp_path: Path + ) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + nested_path = str(tmp_path / "results" / "spatial_summary.csv") + export_spatial_summary(sample_spatial_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_spatial.csv") + export_spatial_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) == SPATIAL_FIELDNAMES + + def test_extra_keys_in_dict_are_ignored(self, tmp_path: Path) -> None: + """正常: 余分なキーを含む辞書を渡しても CSV には SPATIAL_FIELDNAMES のみ出力されること.""" + results_with_extra = [ + { + "image_name": "image_001", + "center_mean": 180.0, + "middle_mean": 165.0, + "periphery_mean": 140.0, + "center_periphery_ratio": 1.29, + "gradient_magnitude": 22.2, + "extra_key": "should_be_ignored", + } + ] + output_path = str(tmp_path / "spatial_summary.csv") + export_spatial_summary(results_with_extra, output_path) + + with open(output_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames + assert list(fieldnames) == SPATIAL_FIELDNAMES diff --git a/tests/export/test_report.py b/tests/export/test_report.py new file mode 100644 index 0000000..929cd14 --- /dev/null +++ b/tests/export/test_report.py @@ -0,0 +1,411 @@ +"""report.py の単体テスト. + +仕様 (実装: src/export/report.py): + - encode_image_base64: PNG→data URI(不在時は空文字列) + - build_report_context: CSV からコンテキスト辞書を構築 + - render_html_report: Jinja2 テンプレートレンダリング + +テスト方針 (GUIDE_08_テスト方針.md): + - pytest を使用 + - tmp_path フィクスチャで一時ディレクトリを利用 +""" + +import base64 +import struct +import zlib +from pathlib import Path + +import pytest + +from src.export.report import ( + build_report_context, + encode_image_base64, + render_html_report, +) + + +# --------------------------------------------------------------------------- +# ヘルパー: 最小 PNG ファイルを生成する +# --------------------------------------------------------------------------- + + +def _create_minimal_png(path: Path) -> None: + """1x1 ピクセルの最小有効 PNG ファイルを tmp_path に作成する.""" + # 1x1 白ピクセル PNG の最小バイナリ + def png_chunk(chunk_type: bytes, data: bytes) -> bytes: + length = struct.pack(">I", len(data)) + crc = struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF) + return length + chunk_type + data + crc + + signature = b"\x89PNG\r\n\x1a\n" + ihdr_data = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) # 1x1 RGB + ihdr = png_chunk(b"IHDR", ihdr_data) + + # フィルタバイト (0) + RGB = 0xFF, 0xFF, 0xFF + raw_row = b"\x00\xff\xff\xff" + compressed = zlib.compress(raw_row) + idat = png_chunk(b"IDAT", compressed) + iend = png_chunk(b"IEND", b"") + + path.write_bytes(signature + ihdr + idat + iend) + + +# --------------------------------------------------------------------------- +# encode_image_base64 テスト +# --------------------------------------------------------------------------- + + +class TestEncodeImageBase64: + """encode_image_base64 関数のテスト群.""" + + def test_returns_data_uri_for_existing_png(self, tmp_path: Path) -> None: + """正常: 存在する PNG ファイルで data URI 文字列が返ること.""" + png_path = tmp_path / "test.png" + _create_minimal_png(png_path) + + result = encode_image_base64(str(png_path)) + assert result.startswith("data:image/png;base64,") + + def test_returns_empty_string_for_missing_file(self, tmp_path: Path) -> None: + """正常: 存在しないファイルパスを渡した場合に空文字列を返すこと.""" + missing_path = str(tmp_path / "nonexistent.png") + result = encode_image_base64(missing_path) + assert result == "" + + def test_base64_content_is_decodable(self, tmp_path: Path) -> None: + """正常: data URI の base64 部分がデコード可能であること.""" + png_path = tmp_path / "test.png" + _create_minimal_png(png_path) + + result = encode_image_base64(str(png_path)) + # "data:image/png;base64," プレフィックスを除去してデコードを試みる + prefix = "data:image/png;base64," + encoded_part = result[len(prefix):] + decoded = base64.b64decode(encoded_part) + # PNG シグネチャで始まることを確認 + assert decoded[:8] == b"\x89PNG\r\n\x1a\n" + + def test_base64_content_matches_file_content(self, tmp_path: Path) -> None: + """正常: エンコードされたデータがファイルの内容と一致すること.""" + png_path = tmp_path / "test.png" + _create_minimal_png(png_path) + + result = encode_image_base64(str(png_path)) + prefix = "data:image/png;base64," + encoded_part = result[len(prefix):] + decoded = base64.b64decode(encoded_part) + + original = png_path.read_bytes() + assert decoded == original + + def test_returns_string_type(self, tmp_path: Path) -> None: + """正常: 戻り値が str 型であること(存在するファイル).""" + png_path = tmp_path / "test.png" + _create_minimal_png(png_path) + result = encode_image_base64(str(png_path)) + assert isinstance(result, str) + + def test_returns_string_type_for_missing_file(self, tmp_path: Path) -> None: + """正常: 戻り値が str 型であること(存在しないファイル).""" + result = encode_image_base64(str(tmp_path / "missing.png")) + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# build_report_context テスト +# --------------------------------------------------------------------------- + + +class TestBuildReportContext: + """build_report_context 関数のテスト群.""" + + @pytest.fixture + def setup_csv_files(self, tmp_path: Path) -> tuple[Path, Path, Path]: + """モック CSV ファイルと figures ディレクトリを作成して返す fixture.""" + summary_csv = tmp_path / "summary_uniformity.csv" + spatial_csv = tmp_path / "summary_spatial.csv" + figures_dir = tmp_path / "figures" + figures_dir.mkdir() + + summary_csv.write_text( + "image_name,mean,std,cov,max_min_ratio,max,min\n" + "image_001,180.0,5.0,0.028,1.15,190.0,165.0\n" + "image_002,170.0,8.0,0.047,1.20,185.0,155.0\n", + encoding="utf-8", + ) + spatial_csv.write_text( + "image_name,center_mean,middle_mean,periphery_mean,center_periphery_ratio,gradient_magnitude\n" + "image_001,185.0,178.0,165.0,1.12,10.8\n" + "image_002,175.0,168.0,152.0,1.15,13.1\n", + encoding="utf-8", + ) + return summary_csv, spatial_csv, figures_dir + + def test_return_value_has_generated_at_key( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: 戻り値に 'generated_at' キーが存在すること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert "generated_at" in context + + def test_return_value_has_summary_rows_key( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: 戻り値に 'summary_rows' キーが存在すること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert "summary_rows" in context + + def test_return_value_has_spatial_rows_key( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: 戻り値に 'spatial_rows' キーが存在すること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert "spatial_rows" in context + + def test_return_value_has_image_details_key( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: 戻り値に 'image_details' キーが存在すること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert "image_details" in context + + def test_return_value_has_exactly_4_keys( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: 戻り値のキー数が 4 であること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert len(context) == 4 + + def test_summary_rows_count_matches_csv_rows( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: summary_rows の要素数が CSV のデータ行数と一致すること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert len(context["summary_rows"]) == 2 + + def test_spatial_rows_count_matches_csv_rows( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: spatial_rows の要素数が CSV のデータ行数と一致すること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert len(context["spatial_rows"]) == 2 + + def test_image_details_count_matches_summary_rows( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: image_details の要素数が summary_rows と一致すること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert len(context["image_details"]) == len(context["summary_rows"]) + + def test_image_details_have_name_key( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: image_details の各要素が 'name' キーを持つこと.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + for detail in context["image_details"]: + assert "name" in detail + + def test_image_details_have_figure_keys( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: image_details の各要素が図ファイルのキーを持つこと.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + required_fig_keys = {"luminance_map", "histogram", "radial_profile", "zone_map"} + for detail in context["image_details"]: + assert required_fig_keys.issubset(set(detail.keys())) + + def test_missing_figure_files_return_empty_string( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: 対応する図ファイルが存在しない場合に空文字列が設定されること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + # 図ファイルを作成していないため全て空文字列 + for detail in context["image_details"]: + assert detail["luminance_map"] == "" + assert detail["histogram"] == "" + assert detail["radial_profile"] == "" + assert detail["zone_map"] == "" + + def test_raises_file_not_found_for_missing_summary_csv( + self, tmp_path: Path + ) -> None: + """異常: 均一性サマリ CSV が存在しない場合に FileNotFoundError が送出されること.""" + missing_summary = str(tmp_path / "missing_summary.csv") + spatial_csv = tmp_path / "spatial.csv" + spatial_csv.write_text( + "image_name,center_mean,middle_mean,periphery_mean,center_periphery_ratio,gradient_magnitude\n", + encoding="utf-8", + ) + + with pytest.raises(FileNotFoundError): + build_report_context(missing_summary, str(spatial_csv), str(tmp_path)) + + def test_raises_file_not_found_for_missing_spatial_csv( + self, tmp_path: Path + ) -> None: + """異常: 空間解析サマリ CSV が存在しない場合に FileNotFoundError が送出されること.""" + summary_csv = tmp_path / "summary.csv" + summary_csv.write_text( + "image_name,mean,std,cov,max_min_ratio,max,min\n", + encoding="utf-8", + ) + missing_spatial = str(tmp_path / "missing_spatial.csv") + + with pytest.raises(FileNotFoundError): + build_report_context(str(summary_csv), missing_spatial, str(tmp_path)) + + def test_generated_at_is_string( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: generated_at が文字列であること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + assert isinstance(context["generated_at"], str) + + def test_existing_figure_files_are_base64_encoded( + self, setup_csv_files: tuple[Path, Path, Path] + ) -> None: + """正常: 図ファイルが存在する場合に data URI として設定されること.""" + summary_csv, spatial_csv, figures_dir = setup_csv_files + # image_001 の luminance_map PNG を作成 + png_path = figures_dir / "image_001_luminance_map.png" + _create_minimal_png(png_path) + + context = build_report_context( + str(summary_csv), str(spatial_csv), str(figures_dir) + ) + # image_001 の detail を探す + detail_001 = next( + d for d in context["image_details"] if d["name"] == "image_001" + ) + assert detail_001["luminance_map"].startswith("data:image/png;base64,") + + +# --------------------------------------------------------------------------- +# render_html_report テスト +# --------------------------------------------------------------------------- + + +class TestRenderHtmlReport: + """render_html_report 関数のテスト群.""" + + @pytest.fixture + def simple_template(self, tmp_path: Path) -> Path: + """シンプルな Jinja2 テンプレートを作成して返す fixture.""" + template_path = tmp_path / "templates" / "report.html.j2" + template_path.parent.mkdir(parents=True, exist_ok=True) + template_path.write_text( + "" + "

Generated: {{ generated_at }}

" + "{% for row in summary_rows %}" + "

{{ row.image_name }}

" + "{% endfor %}" + "", + encoding="utf-8", + ) + return template_path + + @pytest.fixture + def simple_context(self) -> dict: + """シンプルなレンダリングコンテキストを返す fixture.""" + return { + "generated_at": "2026-04-08 12:00:00", + "summary_rows": [{"image_name": "image_001"}], + "spatial_rows": [], + "image_details": [], + } + + def test_output_html_file_is_created( + self, + simple_template: Path, + simple_context: dict, + tmp_path: Path, + ) -> None: + """正常: HTML ファイルが生成されること.""" + output_path = str(tmp_path / "report.html") + render_html_report(simple_context, str(simple_template), output_path) + assert Path(output_path).exists() + + def test_output_html_file_is_not_empty( + self, + simple_template: Path, + simple_context: dict, + tmp_path: Path, + ) -> None: + """正常: 出力ファイルが空でないこと.""" + output_path = str(tmp_path / "report.html") + render_html_report(simple_context, str(simple_template), output_path) + assert Path(output_path).stat().st_size > 0 + + def test_output_html_contains_rendered_content( + self, + simple_template: Path, + simple_context: dict, + tmp_path: Path, + ) -> None: + """正常: テンプレートがコンテキストで正しくレンダリングされること.""" + output_path = str(tmp_path / "report.html") + render_html_report(simple_context, str(simple_template), output_path) + + html_content = Path(output_path).read_text(encoding="utf-8") + assert "2026-04-08 12:00:00" in html_content + assert "image_001" in html_content + + def test_creates_parent_directory_automatically( + self, + simple_template: Path, + simple_context: dict, + tmp_path: Path, + ) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + nested_output = str(tmp_path / "output" / "reports" / "report.html") + render_html_report(simple_context, str(simple_template), nested_output) + assert Path(nested_output).exists() + + def test_output_is_utf8_encoded( + self, + simple_template: Path, + simple_context: dict, + tmp_path: Path, + ) -> None: + """正常: 出力ファイルが UTF-8 エンコードで書き込まれること.""" + output_path = str(tmp_path / "report.html") + render_html_report(simple_context, str(simple_template), output_path) + # UTF-8 として読み込めること(例外が発生しないこと) + content = Path(output_path).read_text(encoding="utf-8") + assert isinstance(content, str) diff --git a/tests/visualization/test_plotter.py b/tests/visualization/test_plotter.py index b66821f..98e4975 100644 --- a/tests/visualization/test_plotter.py +++ b/tests/visualization/test_plotter.py @@ -3,6 +3,8 @@ 仕様 (SPEC_01_アーキテクチャ設計.md): - plot_luminance_map: ヒートマップを PNG 保存 - plot_histogram: ヒストグラムを PNG 保存 + - plot_radial_profile: 放射状プロファイルを折れ線グラフで PNG 保存 + - plot_zone_map: ゾーンオーバーレイ輝度マップを PNG 保存 テスト方針 (GUIDE_08_テスト方針.md): - 出力ファイルが生成されることを確認する程度でよい @@ -17,7 +19,12 @@ import numpy import pytest -from src.visualization.plotter import plot_histogram, plot_luminance_map +from src.visualization.plotter import ( + plot_histogram, + plot_luminance_map, + plot_radial_profile, + plot_zone_map, +) @pytest.fixture @@ -104,3 +111,119 @@ output_path = str(tmp_path / "uniform_histogram.png") plot_histogram(uniform_luminance, output_path) assert Path(output_path).exists() + + +# --------------------------------------------------------------------------- +# plot_radial_profile テスト +# --------------------------------------------------------------------------- + + +class TestPlotRadialProfile: + """plot_radial_profile 関数のテスト群.""" + + @pytest.fixture + def sample_radial_profile(self) -> numpy.ndarray: + """テスト用の放射状プロファイル(20 x 2)を返す fixture.""" + bins = numpy.linspace(0.025, 0.975, 20) + luminance = numpy.linspace(200.0, 150.0, 20) + return numpy.column_stack([bins, luminance]) + + def test_output_file_is_created( + self, sample_radial_profile: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ファイルが生成されること.""" + output_path = str(tmp_path / "radial_profile.png") + plot_radial_profile(sample_radial_profile, output_path) + assert Path(output_path).exists() + + def test_output_file_is_not_empty( + self, sample_radial_profile: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ファイルが空でないこと(PNG バイナリが書き込まれていること).""" + output_path = str(tmp_path / "radial_profile.png") + plot_radial_profile(sample_radial_profile, output_path) + assert Path(output_path).stat().st_size > 0 + + def test_creates_parent_directory_automatically( + self, sample_radial_profile: numpy.ndarray, tmp_path: Path + ) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + nested_path = str(tmp_path / "figures" / "sub" / "radial_profile.png") + plot_radial_profile(sample_radial_profile, nested_path) + assert Path(nested_path).exists() + + def test_uniform_profile_is_saved(self, tmp_path: Path) -> None: + """正常: 均一輝度プロファイルでも正常に保存されること.""" + bins = numpy.linspace(0.025, 0.975, 20) + luminance = numpy.full(20, 200.0) + profile = numpy.column_stack([bins, luminance]) + output_path = str(tmp_path / "uniform_radial.png") + plot_radial_profile(profile, output_path) + assert Path(output_path).exists() + + +# --------------------------------------------------------------------------- +# plot_zone_map テスト +# --------------------------------------------------------------------------- + + +class TestPlotZoneMap: + """plot_zone_map 関数のテスト群.""" + + @pytest.fixture + def sample_inputs(self) -> tuple[numpy.ndarray, numpy.ndarray, dict]: + """テスト用の輝度画像・ゾーンマップ・ゾーン統計を返す fixture.""" + luminance = numpy.random.uniform(100.0, 200.0, (20, 20)).astype(numpy.float64) + zone_map = numpy.zeros((20, 20), dtype=numpy.uint8) + zone_map[7:13, 7:13] = 0 # center + zone_map[4:16, 4:16][zone_map[4:16, 4:16] != 0] = 1 # middle + zone_map[zone_map == 0] = 2 # outer regions → periphery + # Rebuild: center=0, then middle=1, rest=2 + zone_map[:] = 2 + zone_map[4:16, 4:16] = 1 + zone_map[7:13, 7:13] = 0 + + zone_stats = { + "center_mean": 180.0, + "center_std": 5.0, + "middle_mean": 165.0, + "middle_std": 8.0, + "periphery_mean": 140.0, + "periphery_std": 10.0, + "center_periphery_ratio": 1.29, + "gradient_magnitude": 22.2, + } + return luminance, zone_map, zone_stats + + def test_output_file_is_created( + self, + sample_inputs: tuple[numpy.ndarray, numpy.ndarray, dict], + tmp_path: Path, + ) -> None: + """正常: 出力ファイルが生成されること.""" + luminance, zone_map, zone_stats = sample_inputs + output_path = str(tmp_path / "zone_map.png") + plot_zone_map(luminance, zone_map, zone_stats, output_path) + assert Path(output_path).exists() + + def test_output_file_is_not_empty( + self, + sample_inputs: tuple[numpy.ndarray, numpy.ndarray, dict], + tmp_path: Path, + ) -> None: + """正常: 出力ファイルが空でないこと(PNG バイナリが書き込まれていること).""" + luminance, zone_map, zone_stats = sample_inputs + output_path = str(tmp_path / "zone_map.png") + plot_zone_map(luminance, zone_map, zone_stats, output_path) + assert Path(output_path).stat().st_size > 0 + + def test_creates_parent_directory_automatically( + self, + sample_inputs: tuple[numpy.ndarray, numpy.ndarray, dict], + tmp_path: Path, + ) -> None: + """正常: 出力ディレクトリが存在しない場合に自動作成されること.""" + luminance, zone_map, zone_stats = sample_inputs + nested_path = str(tmp_path / "figures" / "sub" / "zone_map.png") + plot_zone_map(luminance, zone_map, zone_stats, nested_path) + assert Path(nested_path).exists()