"""輪帯ごとの方位プロファイルから cos 4φ 成分を抽出し「四隅の痕跡」を定量する(TEST_16).
立方体キャビティの壁の四角さが評価板に写り込むなら,対称性から必ず 90° 回転対称
(m=4)の方位成分として現れる.球は四回対称の壁を持たないため,球側の m=4 は
評価系(長方形 ROI・楕円ビン・MC ノイズ)が作る背景値を与える.
**立方体 − 球(同内面積)の m=4 が「立方体化が持ち込む四隅ムラ」の正味の量**になる.
設計上の要点:
- 板は左右・上下に鏡映対称なので,四隅の構造は板軸に揃った **cos 4φ の符号付き
係数 a4 だけ**に出る(a4 > 0 = 四隅が暗い,a4 < 0 = 辺の中央が暗い).直交成分
b4 は対称性からゼロのはずで,**b4 が生きたノイズ対照**になる(a4 と同じ統計で
計算されるが真の信号を含まない)
- 輪帯は評価パイプラインと同じ正規化楕円距離 20 ビン.ただし d が辺中点
(d≈0.707)を超える輪帯は ROI の四隅方向にしか画素が無く,方位カバレッジの
欠けが m=0(平均)を m=4 に漏れ込ませる偽信号を作るため,**全 72 セクタに
画素がある輪帯だけ**を使う
- セクタ平均を先に取ってからフーリエ係数を出す(セクタ間の画素数ムラで方位の
重みが偏るのを防ぐ).誤差はセクタ平均の標準誤差を伝播する
実行方法:
uv run python scripts/analyze_fourfold_component.py
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from build_cube_sphere_comparison import ( # noqa: E402
SPHERE_RUN_DIR,
nearest_by_area,
read_summary,
)
from sweep_cube_size import OUTPUT_BASE as CUBE_OUTPUT_BASE # noqa: E402
from sweep_cube_size import RUN_DIR as CUBE_RUN_DIR # noqa: E402
from sweep_cube_size import job_name as cube_job_name # noqa: E402
from sweep_sphere_radius import OUTPUT_BASE as SPHERE_OUTPUT_BASE # noqa: E402
from sweep_sphere_radius import job_name as sphere_job_name # noqa: E402
from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402
from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map # noqa: E402
plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"]
N_RINGS = 20 # 評価パイプラインの輪帯分割と同一
N_SECTORS = 72 # 方位セクタ数(m=4 の周期 90° あたり 18 セクタ)
MIN_PIXELS_PER_SECTOR = 5 # 全セクタがこれ以上の画素を持つ輪帯だけを「全方位カバー」とする
HARMONICS = (1, 2, 4)
# 集約に使う輪帯の距離範囲.内側は信号が原理的に小さく(どの方向も壁が遠い),
# 外側はカバレッジ限界(辺中点 d≈0.707)まで
AGGREGATE_RANGE = (0.35, 0.75)
def load_roi(luminance_npy: Path) -> np.ndarray:
"""luminance.npy を読み ROI(輝度 2 次元配列)を返す."""
arr = np.load(luminance_npy).astype(np.float64)
if arr.ndim == 3:
arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2]
return extract_roi_xywh(arr, SIM_ROI)
def ring_harmonics(roi: np.ndarray) -> list[dict[str, float]]:
"""全方位カバーの各輪帯について方位フーリエ係数(ring mean 比)を返す.
Returns:
輪帯ごとの dict のリスト.キーは
d_center / n_pixels と,各 m について am / bm / se_m
(am, bm はセクタ平均のフーリエ係数を輪帯平均で割った相対値,
se_m は両係数共通の 1σ 伝播誤差).
"""
height, width = roi.shape
distance = compute_normalized_distance_map(height, width)
ys = np.arange(height, dtype=np.float64).reshape(-1, 1) - height / 2.0
xs = np.arange(width, dtype=np.float64).reshape(1, -1) - width / 2.0
phi = np.arctan2(np.broadcast_to(ys, roi.shape), np.broadcast_to(xs, roi.shape))
ring_edges = np.linspace(0.0, 1.0, N_RINGS + 1)
ring_index = np.digitize(distance.ravel(), ring_edges, right=True)
sector_index = ((phi.ravel() + np.pi) / (2.0 * np.pi) * N_SECTORS).astype(int) % N_SECTORS
sector_phi = -np.pi + (np.arange(N_SECTORS) + 0.5) * (2.0 * np.pi / N_SECTORS)
flat = roi.ravel()
results: list[dict[str, float]] = []
for ring in range(1, N_RINGS + 1):
mask = ring_index == ring
if not mask.any():
continue
values = flat[mask]
sectors = sector_index[mask]
counts = np.bincount(sectors, minlength=N_SECTORS)
if counts.min() < MIN_PIXELS_PER_SECTOR:
continue # 方位カバレッジが欠けた輪帯は偽の m=4 を作るため除外
sums = np.bincount(sectors, weights=values, minlength=N_SECTORS)
sq_sums = np.bincount(sectors, weights=values**2, minlength=N_SECTORS)
means = sums / counts
# セクタ内標準偏差 → セクタ平均の標準誤差(MC ノイズは画素間独立とみなす)
variances = np.maximum(sq_sums / counts - means**2, 0.0)
sems = np.sqrt(variances / counts)
ring_mean = float(np.mean(means))
row: dict[str, float] = {
"d_center": float((ring_edges[ring - 1] + ring_edges[ring]) / 2.0),
"n_pixels": float(mask.sum()),
}
for m in HARMONICS:
cos_m, sin_m = np.cos(m * sector_phi), np.sin(m * sector_phi)
row[f"a{m}"] = float(2.0 / N_SECTORS * np.sum(means * cos_m) / ring_mean)
row[f"b{m}"] = float(2.0 / N_SECTORS * np.sum(means * sin_m) / ring_mean)
# Var(a) = (2/K)^2 Σ sem^2 cos^2 ≈ (2/K^2) Σ sem^2(cos^2 の平均 1/2)
row[f"se_{m}"] = float(
np.sqrt(4.0 / N_SECTORS**2 * np.sum(sems**2 * cos_m**2)) / ring_mean
)
results.append(row)
return results
def aggregate(rings: list[dict[str, float]], key: str) -> tuple[float, float]:
"""AGGREGATE_RANGE の輪帯を逆分散重みで集約し (推定値, 標準誤差) を返す."""
m = key.lstrip("ab")
selected = [r for r in rings if AGGREGATE_RANGE[0] <= r["d_center"] <= AGGREGATE_RANGE[1]]
weights = np.array([1.0 / r[f"se_{m}"] ** 2 for r in selected])
values = np.array([r[key] for r in selected])
return float(np.sum(weights * values) / np.sum(weights)), float(1.0 / np.sqrt(np.sum(weights)))
def analyze(label: str, luminance_npy: Path) -> dict[str, object]:
"""1 条件を解析して集約値と輪帯プロファイルを返す."""
rings = ring_harmonics(load_roi(luminance_npy))
result: dict[str, object] = {"label": label, "rings": rings}
for key in ("a4", "b4", "a2", "a1", "b1"):
result[key], result[f"{key}_se"] = aggregate(rings, key)
return result
def print_report(cube_results: list[dict], sphere_results: list[dict]) -> None:
"""集約表と立方体−球の差を標準出力に書く."""
print(f"\n=== 方位フーリエ成分(輪帯 d∈[{AGGREGATE_RANGE[0]}, {AGGREGATE_RANGE[1]}] "
"の逆分散重み集約・輪帯平均比 %)===")
print("a4 > 0 が「四隅が暗い」.b4 は対称性からゼロのはずのノイズ対照.")
print(f"{'条件':>14} {'a4 [%]':>14} {'b4 [%](対照)':>16} {'a2 [%]':>14} {'|m1| [%]':>10}")
for res in cube_results + sphere_results:
m1 = float(np.hypot(res["a1"], res["b1"]))
print(f"{res['label']:>14} {res['a4'] * 100:7.3f}±{res['a4_se'] * 100:5.3f} "
f"{res['b4'] * 100:8.3f}±{res['b4_se'] * 100:5.3f} "
f"{res['a2'] * 100:7.3f}±{res['a2_se'] * 100:5.3f} {m1 * 100:10.3f}")
print("\n=== 立方体 − 球(同内面積ペア)の a4 差 ===")
print(f"{'ペア':>22} {'Δa4 [%]':>14} {'有意性':>8}")
for cube, sphere in zip(cube_results, sphere_results, strict=True):
delta = cube["a4"] - sphere["a4"]
se = float(np.hypot(cube["a4_se"], sphere["a4_se"]))
sigma = abs(delta) / se
print(f"{cube['label'] + ' vs ' + sphere['label']:>22} "
f"{delta * 100:7.3f}±{se * 100:5.3f} {sigma:6.1f}σ")
def draw_figure(cube_results: list[dict], sphere_results: list[dict], output: Path) -> None:
"""a4 の輪帯プロファイルと集約値の比較図を描く."""
fig, axes = plt.subplots(1, 3, figsize=(19, 5.5))
colors = plt.cm.viridis(np.linspace(0.0, 0.9, len(cube_results)))
for results, ax, kind in ((cube_results, axes[0], "立方体"), (sphere_results, axes[1], "球")):
for res, color in zip(results, colors, strict=True):
d = [r["d_center"] for r in res["rings"]]
a4 = [r["a4"] * 100 for r in res["rings"]]
se = [r["se_4"] * 100 for r in res["rings"]]
ax.errorbar(d, a4, yerr=se, marker="o", ms=3, lw=1.4, color=color,
label=res["label"])
ax.axhline(0.0, color="gray", lw=0.8, ls="--")
ax.set_xlabel("正規化楕円距離(輪帯中心)")
ax.set_ylabel("a4(輪帯平均比 %・正=四隅が暗い)")
ax.set_title(f"{kind}: cos 4φ 係数の輪帯プロファイル(±1σ)", fontsize=11)
ax.legend(fontsize=8)
ax.grid(alpha=0.3)
y_all = [abs(r["a4"] * 100) + r["se_4"] * 100
for res in cube_results + sphere_results for r in res["rings"]]
for ax in axes[:2]:
ax.set_ylim(-max(y_all) * 1.05, max(y_all) * 1.05)
ax = axes[2]
x = np.arange(len(cube_results))
for results, offset, color, label in (
(cube_results, -0.12, "tab:red", "立方体 a4"),
(sphere_results, 0.12, "tab:blue", "球 a4"),
):
ax.errorbar(x + offset, [r["a4"] * 100 for r in results],
yerr=[r["a4_se"] * 100 for r in results],
fmt="s", capsize=4, color=color, label=label)
ax.errorbar(x, [r["b4"] * 100 for r in cube_results],
yerr=[r["b4_se"] * 100 for r in cube_results],
fmt="x", capsize=3, color="gray", label="立方体 b4(ノイズ対照)")
ax.axhline(0.0, color="gray", lw=0.8, ls="--")
ax.set_xticks(x)
ax.set_xticklabels([f"{c['label']}\nvs {s['label']}"
for c, s in zip(cube_results, sphere_results, strict=True)], fontsize=8)
ax.set_ylabel("集約 a4(輪帯平均比 %)")
ax.set_title(f"集約値の比較(d∈[{AGGREGATE_RANGE[0]}, {AGGREGATE_RANGE[1]}]・±1σ)",
fontsize=11)
ax.legend(fontsize=8)
ax.grid(alpha=0.3)
fig.suptitle(
"四隅の痕跡の定量: 輪帯ごとの方位プロファイルの cos 4φ 成分(板軸フレーム・符号付き)",
fontsize=14,
)
fig.tight_layout()
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output, dpi=110)
print(f"\n出力: {output}")
def main() -> int:
"""立方体全条件と同内面積の球について m=4 成分を抽出する."""
parser = argparse.ArgumentParser(description="cos 4φ 成分の抽出(四隅の痕跡の定量)")
parser.add_argument("--output", type=Path, default=None, help="出力 PNG パス")
args = parser.parse_args()
cube_run = CUBE_OUTPUT_BASE / CUBE_RUN_DIR
sphere_run = SPHERE_OUTPUT_BASE / SPHERE_RUN_DIR
cube_rows = read_summary(cube_run / "summary.csv", "size_mm")
sphere_rows = read_summary(sphere_run / "summary.csv", "radius_mm")
cube_results, sphere_results = [], []
for row in cube_rows:
lum = cube_run / cube_job_name(row["size_mm"]) / "luminance.npy"
if not lum.exists():
continue
cube_results.append(analyze(f"L={row['size_mm']:g}", lum))
sphere = nearest_by_area(row["cavity_area_mm2"], sphere_rows)
sphere_results.append(analyze(
f"r={sphere['radius_mm']:g}",
sphere_run / sphere_job_name(sphere["radius_mm"]) / "luminance.npy",
))
print_report(cube_results, sphere_results)
draw_figure(cube_results, sphere_results,
args.output or cube_run / "fourfold_component.png")
return 0
if __name__ == "__main__":
sys.exit(main())