"""照明配置スイープ(TEST_18)の発表スライド用の図を英語で生成する.
既存の解析図(build_led_placement_comparison.py / build_led_placement_maps.py)は日本語で
情報量が多いので,スライドに貼れる体裁(題・枠なし,ラベルは英語,右にカラーバー)で
次の 5 枚を作る.
slide_led_geometry.png LED 位置の定義(球 φ340 と立方体 L=234 の断面に θ=0〜120° の LED)
slide_led_position_maps.png θ=0/30/60/90/120° の輝度マップ(上: 球 φ340,下: 立方体 L=234)
slide_led_position_curves.png 構造 CoV と min の θ 依存(4 ペア)
slide_led_count_maps.png 灯数 N=1〜4(θ=90°)の輝度マップ + 軸上 1 灯の参照
slide_led_count_curves.png 構造 CoV と min の N 依存
輝度マップは build_led_placement_maps.py と同じ処理(メディアン 5 → ガウス σ=4 → 99 パーセンタイル
= 1)で,各マップの下に θ または N と min(ノイズ補正付き最小/最大比)を書く.
実行方法:
uv run python scripts/build_led_placement_slides.py
uv run python scripts/build_led_placement_slides.py --material calibrated
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import matplotlib
import numpy as np
import pandas as pd
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"))
import build_led_placement_maps as maps # noqa: E402
from sweep_sphere_radius import center_z, cut_radius # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
THETAS = [0.0, 30.0, 60.0, 90.0, 120.0]
COUNTS = [1, 2, 3, 4]
REP_PAIR = 2 # 球 φ340 / 立方体 L=234
PAIR_LABELS = {
"sphere": {100.0: "Sphere φ200", 135.0: "Sphere φ270", 170.0: "Sphere φ340",
250.0: "Sphere φ500"},
"cube": {141.0: "Cube L=141", 187.0: "Cube L=187", 234.0: "Cube L=234", 340.0: "Cube L=340"},
}
PAIR_COLORS = ["#4b1d6b", "#2c6e9e", "#1fa187", "#8fd744"]
SHAPE_STYLE = {"sphere": ("-", "o"), "cube": ("--", "s")}
VMIN, VMAX = 0.80, 1.00
PLATE_HALF = 50.0 # 評価板の表示半幅 [mm](orthographic センサの ortho_scale と同程度)
BAFFLE_DIST = 16.0 # 模式図でのバッフルの LED からの距離 [mm](実際は視線上 10mm)
BAFFLE_HALF = 9.0 # 模式図でのバッフルの半幅 [mm]
COLOR_WALL = "#3f6f95"
COLOR_PLATE = "#d8232a"
COLOR_LED = "#f2a900"
plt.rcParams["font.family"] = ["DejaVu Sans"] # 英語図.θ・φ は DejaVu に含まれる
# --------------------------------------------------------------------------- 幾何の模式図
def sphere_outline(radius: float) -> tuple[np.ndarray, np.ndarray, float]:
"""球ロフト(similar_radial)キャビティの断面輪郭 (x, z) と球中心 z を返す."""
cz = center_z(radius, maps.sweep.SPHERE_CAVITY)
rc = cut_radius(radius, maps.sweep.SPHERE_CAVITY)
# 接続平面より下の球面(右回りに一周)
phi0 = np.arcsin(rc / radius) # 接続円の極角(上向きから)
phi = np.linspace(phi0, 2 * np.pi - phi0, 400)
xs = radius * np.sin(phi)
zs = cz + radius * np.cos(phi)
# ロフト: 接続円 → 評価板の縁(直線で近似)
x = np.concatenate([[PLATE_HALF * 1.15], xs, [-PLATE_HALF * 1.15]])
z = np.concatenate([[0.0], zs, [0.0]])
return x, z, cz
def cube_outline(size: float) -> tuple[np.ndarray, np.ndarray, float]:
"""立方体キャビティの断面輪郭 (x, z) と中心 z を返す(天井 z=0)."""
h = size / 2
x = np.array([-h, h, h, -h, -h])
z = np.array([0.0, 0.0, -size, -size, 0.0])
return x, z, -h
def led_point(shape: str, size: float, theta_deg: float) -> tuple[float, float]:
"""キャビティ中心から (sinθ, −cosθ) に伸ばした半直線と壁の交点(断面 x, z)."""
t = np.radians(theta_deg)
d = np.array([np.sin(t), -np.cos(t)])
if shape == "sphere":
cz = center_z(size, maps.sweep.SPHERE_CAVITY)
return float(size * d[0]), float(cz + size * d[1])
h = size / 2
cz = -h
# 正方形の壁との交点(底面 or 側壁)
ts = []
if d[1] < 0:
ts.append(h / -d[1]) # 底面 z=-size
if d[0] > 0:
ts.append(h / d[0]) # 側壁 x=h
s = min(ts)
return float(s * d[0]), float(cz + s * d[1])
def draw_geometry(out: Path) -> None:
"""LED 位置 θ の定義を球と立方体の断面で示す."""
r_sphere, l_cube = maps.SIZE_PAIRS[REP_PAIR]
fig, axes = plt.subplots(1, 2, figsize=(9.5, 4.6))
specs = [("sphere", r_sphere, "Sphere φ340", sphere_outline),
("cube", l_cube, "Cube L=234", cube_outline)]
for ax, (shape, size, title, outline) in zip(axes, specs, strict=True):
x, z, cz = outline(size)
ax.plot(x, z, color=COLOR_WALL, lw=2)
ax.plot([-PLATE_HALF, PLATE_HALF], [0, 0], color=COLOR_PLATE, lw=6, solid_capstyle="butt")
ax.text(0, -10, "Evaluation plate", ha="center", va="top", fontsize=10)
ax.plot(0, cz, marker="+", color="k", ms=10, mew=1.5)
ax.text(-6, cz + 4, "center", fontsize=9, va="bottom", ha="right")
for th in THETAS:
px, pz = led_point(shape, size, th)
ax.plot([0, px], [cz, pz], color="0.6", lw=0.8, ls=":")
# LED の向き(板中心へ)
vx, vz = -px, -4.5 - pz
n = np.hypot(vx, vz)
ax.annotate("", xy=(px + vx / n * 30, pz + vz / n * 30), xytext=(px, pz),
arrowprops={"arrowstyle": "-|>", "color": COLOR_LED, "lw": 1.5})
ax.plot(px, pz, marker="^", color=COLOR_LED, ms=9, mec="k", mew=0.5)
# バッフル: LED の視線上(板側)に置いた小円板を,視線に直交する短い線で表す
ux, uz = vx / n, vz / n
bx, bz = px + ux * BAFFLE_DIST, pz + uz * BAFFLE_DIST
ax.plot([bx - uz * BAFFLE_HALF, bx + uz * BAFFLE_HALF],
[bz + ux * BAFFLE_HALF, bz - ux * BAFFLE_HALF], color="k", lw=2.5,
solid_capstyle="butt", zorder=5)
if th == 90.0:
ax.text(bx - 6, bz + 14, "Baffle", fontsize=9, ha="right", va="bottom")
on_floor = shape == "cube" and th < 45
if on_floor:
ax.text(px, pz - 14, f"θ={th:g}°", fontsize=10, ha="center", va="top")
else:
off = (12, -4) if th < 100 else (10, 8)
ax.text(px + off[0], pz + off[1], f"θ={th:g}°", fontsize=10, ha="left",
va="center")
# θ の弧(中心のまわり,0° から 120°)
arc = np.linspace(0, np.radians(120), 60)
ax.plot(35 * np.sin(arc), cz - 35 * np.cos(arc), color="0.3", lw=1)
ax.text(38 * np.sin(np.radians(60)) + 3, cz - 38 * np.cos(np.radians(60)), "θ",
fontsize=12, ha="left", va="center")
ax.set_title(title, fontsize=13, pad=14)
ax.set_aspect("equal")
ax.axis("off")
pad = 0.15 * size
ax.set_xlim(-size - pad if shape == "sphere" else -size / 2 - pad,
size + pad + 40 if shape == "sphere" else size / 2 + pad + 40)
ax.set_ylim((cz - size if shape == "sphere" else -size) - 28, 25)
fig.tight_layout()
fig.savefig(out, dpi=200, facecolor="white")
plt.close(fig)
print(f"保存: {out}")
# --------------------------------------------------------------------------- マップパネル
def map_grid(cells: list[list[tuple[str, str]]], row_labels: list[str], out: Path,
material: str, label_fs: float = 12) -> None:
"""cells[row][col] = (ラベル, 条件名) のマップ格子を描く(スライド体裁)."""
n_rows, n_cols = len(cells), len(cells[0])
fig, axes = plt.subplots(n_rows, n_cols, figsize=(2.55 * n_cols + 1.4, 2.75 * n_rows))
axes = np.atleast_2d(axes)
im = None
for r, row in enumerate(cells):
for c, (label, name) in enumerate(row):
ax = axes[r, c]
data = maps.load_map(name, material)
if data is None:
ax.axis("off")
ax.set_title(f"{label}\n(missing)", fontsize=9)
continue
im = ax.imshow(data, cmap=maps.CMAP, vmin=VMIN, vmax=VMAX)
ratio = maps.min_ratio(name, material)
ax.set_xlabel(f"{label} (min={ratio:.3f})", fontsize=label_fs, labelpad=5)
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
axes[r, 0].set_ylabel(row_labels[r], fontsize=13, labelpad=8)
fig.tight_layout(rect=(0, 0, 0.9, 1.0), h_pad=1.2, w_pad=0.8)
cbar_ax = fig.add_axes((0.905, 0.25, 0.015, 0.5))
cbar = fig.colorbar(im, cax=cbar_ax, format="%.2f")
cbar.set_label("Luminance\n(normalized by max of each)", fontsize=11)
cbar.ax.tick_params(labelsize=10)
fig.savefig(out, dpi=200, facecolor="white")
plt.close(fig)
print(f"保存: {out}")
def draw_position_maps(out: Path, material: str) -> None:
"""θ=0〜120° の輝度マップ(球 φ340 / 立方体 L=234)."""
r_sphere, l_cube = maps.SIZE_PAIRS[REP_PAIR]
cells = [[(f"θ={th:g}°", maps.cond_name(shape, size, th, "board", 1)) for th in THETAS]
for shape, size in (("sphere", r_sphere), ("cube", l_cube))]
map_grid(cells, ["Sphere φ340", "Cube L=234"], out, material)
def draw_count_maps(out: Path, material: str) -> None:
"""灯数 N=1〜4(θ=90°)の輝度マップ + 軸上 1 灯(球 φ340 / 立方体 L=234)."""
r_sphere, l_cube = maps.SIZE_PAIRS[REP_PAIR]
cells = []
for shape, size in (("sphere", r_sphere), ("cube", l_cube)):
row = [("On-axis, N=1", maps.cond_name(shape, size, 0.0, "board", 1))]
row += [(f"θ=90°, N={n}", maps.cond_name(shape, size, 90.0, "board", n)) for n in COUNTS]
cells.append(row)
map_grid(cells, ["Sphere φ340", "Cube L=234"], out, material, label_fs=11)
# --------------------------------------------------------------------------- 曲線
def load_analysis(material: str) -> pd.DataFrame:
"""analysis.csv を読む."""
return pd.read_csv(maps.output_root(material) / "analysis.csv")
def pair_label(shape: str, size: float) -> str:
"""凡例のラベル."""
return PAIR_LABELS[shape][float(size)]
def draw_position_curves(out: Path, material: str) -> None:
"""構造 CoV と min の θ 依存(4 ペア・球は実線・立方体は破線)."""
df = load_analysis(material)
sel = df[(df["group"] == "position") & (df["aim"] == "board") & (df["n_led"] == 1)
& (df["rep"] == 1) & (df["azimuth_deg"] == 0)]
fig, axes = plt.subplots(1, 2, figsize=(11, 4.3))
for pair, (r_sphere, l_cube) in enumerate(maps.SIZE_PAIRS):
for shape, size in (("sphere", r_sphere), ("cube", l_cube)):
d = sel[(sel["shape"] == shape) & (sel["size_mm"] == size)].sort_values("theta_deg")
if d.empty:
continue
ls, mk = SHAPE_STYLE[shape]
kw = {"color": PAIR_COLORS[pair], "ls": ls, "marker": mk, "ms": 5, "lw": 1.6,
"label": pair_label(shape, size)}
axes[0].plot(d["theta_deg"], d["structure_cov"], **kw)
axes[1].plot(d["theta_deg"], d["fullimage_min_max"], **kw)
axes[0].set_ylabel("Structural CoV (lower = more uniform)", fontsize=11)
axes[1].set_ylabel("min (max = 1, higher = more uniform)", fontsize=11)
axes[0].set_ylim(bottom=0)
for ax in axes:
ax.set_xlabel("LED polar angle θ [deg]", fontsize=11)
ax.set_xticks(range(0, 121, 30))
ax.grid(alpha=0.3)
axes[0].legend(fontsize=8, ncol=2, title="solid: sphere, dashed: cube", title_fontsize=8)
fig.suptitle("θ = 0: bottom center (on-axis), 90: side wall, 120: just below the plate",
fontsize=10, y=0.995)
fig.tight_layout()
fig.savefig(out, dpi=200, facecolor="white")
plt.close(fig)
print(f"保存: {out}")
def draw_count_curves(out: Path, material: str) -> None:
"""構造 CoV と min の N 依存(θ=90°・板向き・総光束一定)."""
df = load_analysis(material)
sel = df[(df["theta_deg"] == 90.0) & (df["aim"] == "board") & (df["rep"] == 1)
& (df["azimuth_deg"] == 0) & (df["n_led"].isin(COUNTS))]
fig, axes = plt.subplots(1, 2, figsize=(11, 4.3))
for pair, (r_sphere, l_cube) in enumerate(maps.SIZE_PAIRS):
for shape, size in (("sphere", r_sphere), ("cube", l_cube)):
d = sel[(sel["shape"] == shape) & (sel["size_mm"] == size)].sort_values("n_led")
if len(d) < 2:
continue
ls, mk = SHAPE_STYLE[shape]
kw = {"color": PAIR_COLORS[pair], "ls": ls, "marker": mk, "ms": 5, "lw": 1.6,
"label": pair_label(shape, size)}
axes[0].plot(d["n_led"], d["structure_cov"], **kw)
axes[1].plot(d["n_led"], d["fullimage_min_max"], **kw)
axes[0].set_ylabel("Structural CoV (lower = more uniform)", fontsize=11)
axes[1].set_ylabel("min (max = 1, higher = more uniform)", fontsize=11)
axes[0].set_ylim(bottom=0)
for ax in axes:
ax.set_xlabel("Number of LEDs N (θ=90°, equally spaced, total flux constant)",
fontsize=10)
ax.set_xticks(COUNTS)
ax.grid(alpha=0.3)
axes[0].legend(fontsize=8, ncol=2, title="solid: sphere, dashed: cube", title_fontsize=8)
fig.tight_layout()
fig.savefig(out, dpi=200, facecolor="white")
plt.close(fig)
print(f"保存: {out}")
def main() -> int:
"""5 枚の図を出力する."""
parser = argparse.ArgumentParser(description="TEST_18 のスライド用図(英語)")
parser.add_argument("--material", choices=("legacy", "calibrated"), default="legacy")
parser.add_argument("--output", type=Path, default=None)
args = parser.parse_args()
out_dir = args.output or maps.output_root(args.material) / "slides"
out_dir.mkdir(parents=True, exist_ok=True)
draw_geometry(out_dir / "slide_led_geometry.png")
draw_position_maps(out_dir / "slide_led_position_maps.png", args.material)
draw_position_curves(out_dir / "slide_led_position_curves.png", args.material)
draw_count_maps(out_dir / "slide_led_count_maps.png", args.material)
draw_count_curves(out_dir / "slide_led_count_curves.png", args.material)
return 0
if __name__ == "__main__":
sys.exit(main())