diff --git a/scripts/run_uniformity.py b/scripts/run_uniformity.py index 5397aaf..2094d0a 100644 --- a/scripts/run_uniformity.py +++ b/scripts/run_uniformity.py @@ -10,7 +10,6 @@ """ import argparse -import json import sys from pathlib import Path @@ -21,6 +20,7 @@ sys.path.insert(0, str(PROJECT_ROOT)) from src.analysis.reproducibility import calc_pairwise_ssim_registered # noqa: E402 +from src.config import load_roi_config # noqa: E402 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 @@ -41,36 +41,6 @@ ) -def load_roi_config(config_path: str, roi_key: str) -> dict | None: - """ROI 設定ファイルを読み込んで指定キーの ROI を返す. - - Args: - config_path: JSON 設定ファイルのパス. - roi_key: ドット区切りのキー(例: "smtias.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, diff --git a/scripts/run_uniformity_dng.py b/scripts/run_uniformity_dng.py index d39ae70..cd04bb6 100644 --- a/scripts/run_uniformity_dng.py +++ b/scripts/run_uniformity_dng.py @@ -13,24 +13,17 @@ """ import argparse -import json import sys from pathlib import Path -import numpy as np - 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 ( # noqa: E402 - REC709_COEFF_B, - REC709_COEFF_G, - REC709_COEFF_R, - calc_uniformity, -) +from src.analysis.uniformity import calc_uniformity # noqa: E402 +from src.config import load_roi_config # noqa: E402 from src.export.exporter import ensure_output_dirs, export_results # noqa: E402 -from src.io.dng_loader import load_image_dng # noqa: E402 +from src.io.dng_pipeline import dng_luminance # noqa: E402 from src.io.loader import extract_roi # noqa: E402 from src.visualization.plotter import ( # noqa: E402 plot_histogram, @@ -40,120 +33,6 @@ ) -def load_roi_config(config_path: str, roi_key: str) -> dict | None: - """ROI 設定ファイルを読み込んで指定キーの ROI を返す. - - Args: - config_path: JSON 設定ファイルのパス. - roi_key: ドット区切りのキー(例: "smtias.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 _upsample(grid: np.ndarray, shape: tuple[int, int]) -> np.ndarray: - """(rows, cols) グリッドを画像サイズ shape へバイリニア補間する. - - Args: - grid: 補間元グリッドの NumPy 配列(rows x cols, float64). - shape: 補間先の画像サイズ (height, width). - - Returns: - 補間後の NumPy 配列(height x width, float64). - """ - rows, cols = grid.shape - h, w = shape - ys = np.linspace(0, rows - 1, h) - xs = np.linspace(0, cols - 1, w) - y0 = np.floor(ys).astype(int) - y1 = np.minimum(y0 + 1, rows - 1) - x0 = np.floor(xs).astype(int) - x1 = np.minimum(x0 + 1, cols - 1) - wy = (ys - y0)[:, None] - wx = (xs - x0)[None, :] - top = grid[y0][:, x0] * (1 - wx) + grid[y0][:, x1] * wx - bot = grid[y1][:, x0] * (1 - wx) + grid[y1][:, x1] * wx - return top * (1 - wy) + bot * wy - - -def lsc_gain_rgb(meta_path: Path, shape: tuple[int, int]) -> np.ndarray: - """meta.json の lscMap (4, M, N) を (H, W, 3) の RGB ゲインへ補間する. - - 4ch = [R, G_even, G_odd, B].G は G_even/G_odd の平均を用いる. - - Args: - meta_path: meta.json ファイルのパス. - shape: 補間先の画像サイズ (height, width). - - Returns: - RGB ゲインの NumPy 配列(H x W x 3, float64). - """ - meta = json.loads(meta_path.read_text(encoding="utf-8")) - rows, cols = meta["lscMapRowCount"], meta["lscMapColumnCount"] - m = np.array(meta["lscMap"], dtype=np.float64).reshape(rows, cols, 4) - gr = _upsample(m[:, :, 0], shape) - gg = _upsample((m[:, :, 1] + m[:, :, 2]) / 2.0, shape) - gb = _upsample(m[:, :, 3], shape) - return np.stack([gr, gg, gb], axis=2) - - -def dng_luminance(image_path: str, apply_lsc: bool) -> np.ndarray: - """DNG から portrait・linear・(任意で LSC 補正済)の輝度画像を返す. - - - output_format="rgb_float32"(output_color=raw, linear)で読み込み - - apply_lsc=True なら lscMap を順適用してレンズビネットを除去 - - Rec.709 で輝度化(uniformity.py と同じ係数) - - PNG と同じ portrait へ rot90(-1) で向き合わせ - - Args: - image_path: DNG ファイルのパス. - apply_lsc: True の場合,同名 .meta.json から LSC ゲインを読み込んで補正する. - - Returns: - portrait 向きの輝度画像(H x W, float64).値域は 0〜255 の linear スケール. - """ - rgb = load_image_dng(image_path, output_format="rgb_float32") # (H,W,3) linear landscape - - if apply_lsc: - meta_path = Path(image_path).with_suffix("").with_suffix(".meta.json") - if not meta_path.exists(): - # ".meta.json" は二重拡張子なので with_suffix では拾えない場合がある - meta_path = Path(str(Path(image_path).with_suffix("")) + ".meta.json") - if not meta_path.exists(): - raise FileNotFoundError(f"meta.json が見つかりません(LSC 補正に必要): {meta_path}") - rgb = rgb * lsc_gain_rgb(meta_path, rgb.shape[:2]) - - luma = ( - REC709_COEFF_R * rgb[:, :, 0] - + REC709_COEFF_G * rgb[:, :, 1] - + REC709_COEFF_B * rgb[:, :, 2] - ) - # linear のまま 0〜255 スケールで表現する(PNG 図と軸を揃えて比較するため). - # CoV・max/min・中心周辺比はスケール不変なので不変,mean/std のみ ×255 となる. - # 既存 plot_histogram が range=(0,255) 固定であることにも整合する. - luma = luma * 255.0 - return np.rot90(luma, -1) # portrait(PNG と同じ向き) - - def analyze_single_dng( image_path: str, roi: dict | None, diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..463bd09 --- /dev/null +++ b/src/config.py @@ -0,0 +1,34 @@ +"""設定ファイル(ROI 等)の読み込みユーティリティ.""" + +import json +from pathlib import Path + + +def load_roi_config(config_path: str, roi_key: str) -> dict | None: + """ROI 設定ファイルを読み込んで指定キーの ROI を返す. + + Args: + config_path: JSON 設定ファイルのパス. + roi_key: ドット区切りのキー(例: "smtias.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 diff --git a/src/io/dng_pipeline.py b/src/io/dng_pipeline.py new file mode 100644 index 0000000..ed042da --- /dev/null +++ b/src/io/dng_pipeline.py @@ -0,0 +1,105 @@ +"""DNG 定量モードの輝度化パイプライン. + +DNG(RAW_SENSOR)を読み込み,LSC(レンズシェーディング)補正を順適用して +PNG 経路と同じ portrait・linear・Rec.709 輝度画像へ変換する一連の処理をまとめる. + +- 読み込みは load_image_dng(output_format="rgb_float32", linear, output_color=raw) +- DNG は RAW_SENSOR で未補正のため,meta.json の lscMap でレンズビネットを順適用(任意) +- DNG はセンサ native が landscape のため,PNG と同じ portrait へ rot90(-1) で向き合わせ +""" + +import json +from pathlib import Path + +import numpy as np + +from src.analysis.uniformity import ( + REC709_COEFF_B, + REC709_COEFF_G, + REC709_COEFF_R, +) +from src.io.dng_loader import load_image_dng + + +def _upsample(grid: np.ndarray, shape: tuple[int, int]) -> np.ndarray: + """(rows, cols) グリッドを画像サイズ shape へバイリニア補間する. + + Args: + grid: 補間元グリッドの NumPy 配列(rows x cols, float64). + shape: 補間先の画像サイズ (height, width). + + Returns: + 補間後の NumPy 配列(height x width, float64). + """ + rows, cols = grid.shape + h, w = shape + ys = np.linspace(0, rows - 1, h) + xs = np.linspace(0, cols - 1, w) + y0 = np.floor(ys).astype(int) + y1 = np.minimum(y0 + 1, rows - 1) + x0 = np.floor(xs).astype(int) + x1 = np.minimum(x0 + 1, cols - 1) + wy = (ys - y0)[:, None] + wx = (xs - x0)[None, :] + top = grid[y0][:, x0] * (1 - wx) + grid[y0][:, x1] * wx + bot = grid[y1][:, x0] * (1 - wx) + grid[y1][:, x1] * wx + return top * (1 - wy) + bot * wy + + +def lsc_gain_rgb(meta_path: Path, shape: tuple[int, int]) -> np.ndarray: + """meta.json の lscMap (4, M, N) を (H, W, 3) の RGB ゲインへ補間する. + + 4ch = [R, G_even, G_odd, B].G は G_even/G_odd の平均を用いる. + + Args: + meta_path: meta.json ファイルのパス. + shape: 補間先の画像サイズ (height, width). + + Returns: + RGB ゲインの NumPy 配列(H x W x 3, float64). + """ + meta = json.loads(meta_path.read_text(encoding="utf-8")) + rows, cols = meta["lscMapRowCount"], meta["lscMapColumnCount"] + m = np.array(meta["lscMap"], dtype=np.float64).reshape(rows, cols, 4) + gr = _upsample(m[:, :, 0], shape) + gg = _upsample((m[:, :, 1] + m[:, :, 2]) / 2.0, shape) + gb = _upsample(m[:, :, 3], shape) + return np.stack([gr, gg, gb], axis=2) + + +def dng_luminance(image_path: str, apply_lsc: bool) -> np.ndarray: + """DNG から portrait・linear・(任意で LSC 補正済)の輝度画像を返す. + + - output_format="rgb_float32"(output_color=raw, linear)で読み込み + - apply_lsc=True なら lscMap を順適用してレンズビネットを除去 + - Rec.709 で輝度化(uniformity.py と同じ係数) + - PNG と同じ portrait へ rot90(-1) で向き合わせ + + Args: + image_path: DNG ファイルのパス. + apply_lsc: True の場合,同名 .meta.json から LSC ゲインを読み込んで補正する. + + Returns: + portrait 向きの輝度画像(H x W, float64).値域は 0〜255 の linear スケール. + """ + rgb = load_image_dng(image_path, output_format="rgb_float32") # (H,W,3) linear landscape + + if apply_lsc: + meta_path = Path(image_path).with_suffix("").with_suffix(".meta.json") + if not meta_path.exists(): + # ".meta.json" は二重拡張子なので with_suffix では拾えない場合がある + meta_path = Path(str(Path(image_path).with_suffix("")) + ".meta.json") + if not meta_path.exists(): + raise FileNotFoundError(f"meta.json が見つかりません(LSC 補正に必要): {meta_path}") + rgb = rgb * lsc_gain_rgb(meta_path, rgb.shape[:2]) + + luma = ( + REC709_COEFF_R * rgb[:, :, 0] + + REC709_COEFF_G * rgb[:, :, 1] + + REC709_COEFF_B * rgb[:, :, 2] + ) + # linear のまま 0〜255 スケールで表現する(PNG 図と軸を揃えて比較するため). + # CoV・max/min・中心周辺比はスケール不変なので不変,mean/std のみ ×255 となる. + # 既存 plot_histogram が range=(0,255) 固定であることにも整合する. + luma = luma * 255.0 + return np.rot90(luma, -1) # portrait(PNG と同じ向き) diff --git a/tests/scripts/test_run_uniformity.py b/tests/scripts/test_run_uniformity.py index 0983fc0..8996248 100644 --- a/tests/scripts/test_run_uniformity.py +++ b/tests/scripts/test_run_uniformity.py @@ -19,7 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) -from scripts.run_uniformity import load_roi_config +from src.config import load_roi_config # ---------------------------------------------------------------------------