"""オフ軸 LED スイープ: 光源位置ずれの影響とキャビティサイズによる減衰(TEST_17).
TEST_15/16 の board_baffle 構成は LED・光軸・センサがすべて z 軸上にあり,
輝度分布は同心円対称になる.本スイープでは **LED を軸から外して側壁に置き**,
その非対称が評価板にどれだけ写るか,キャビティが大きいほど多重反射の混合で
薄まるか(=配置誤差へのロバスト性)を球・立方体の両方で測る.
幾何(ユーザー指定):
- LED は深さ方向の軸(板中心〜キャビティ底)の**中点の高さ**で側壁と交わる点の
**+X 側 1 箇所**に置く
立方体: 側壁 x=+L/2・z=-L/2
球 : z_mid = pole_z/2 = -0.96r の高さで球面と交わる点(ほぼ赤道)
- 発光方向は**評価板の中心** (0, 0, -4.5) へ向ける.壁法線ではないので円板は
壁に対して斜めになり,傾いた縁が壁を貫かない分だけ浮かせる
- バッフルは LED→板中心の視線上 10mm 先に置き,板の全点 × LED の全点の直線を
遮る最小半径(軸対称でないため数値で求める)× 安全率とする.遮蔽はこれまで
どおりレイキャストで直接検証する(verify_baffle_occlusion --scene)
固定条件は TEST_15/16 と同一(材質・レンダ・評価・評価板).LED の面積・radiance も
据え置き(全光束保存).球は similar_radial キャビティ(TEST_15 最良)を使う.
実行方法:
uv run python scripts/sweep_offaxis_led.py --prepare-only # メッシュ + シーン YAML のみ
uv run python scripts/sweep_offaxis_led.py # 上記 + レンダ + 評価
"""
from __future__ import annotations
import argparse
import copy
import csv
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
import trimesh
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from generate_cube_housing import build_cube_housing # noqa: E402
from sweep_sphere_radius import ( # noqa: E402
BAFFLE_GAP,
BAFFLE_MARGIN,
BAFFLE_THICKNESS,
BOARD_HALF,
BOARD_SURFACE_Z,
EVAL_CONFIG_YAML,
LED_RADIUS,
RIM_INSET,
RIM_Z,
TEMPLATE_YAML,
THICKNESS,
raw_stats,
read_metrics_csv,
)
from sweep_sphere_radius import (
build_mesh as build_sphere_mesh,
)
from sweep_sphere_radius import (
center_z as sphere_center_z,
)
ROOT = Path(__file__).resolve().parents[1]
TMP_DIR = ROOT / "configs" / "_tmp_offaxis"
MESH_DIR = TMP_DIR / "meshes"
OUTPUT_BASE = ROOT / "output" / "offaxis_led_sweep"
SPHERE_CAVITY = "similar_radial" # TEST_15 最良の球設計を比較対象にする
SPHERE_RADII = [88.0, 100.0, 135.0, 170.0, 250.0]
CUBE_SIZES = [120.0, 160.0, 200.0, 280.0, 360.0]
# 評価板の中心(キャビティ側の面)= LED の照準点
BOARD_CENTER = np.array([0.0, 0.0, BOARD_SURFACE_Z])
# 傾いた円板の縁が壁に触れないための追加マージン(mm).球面のサジッタも吸収する
STANDOFF_MARGIN = 0.5
SUMMARY_COLUMNS = [
"shape", "size_mm", "led_x_mm", "led_z_mm", "led_standoff_mm",
"tilt_deg", "baffle_radius_mm", "cavity_area_mm2", "elapsed_s",
"raw_mean_roi", "raw_mean_full", "raw_rel_std_roi",
"cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio",
"angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm",
"max_min_ratio",
]
def job_name(shape: str, size: float) -> str:
"""条件名(ディレクトリ名)を作る."""
return f"{shape}_{'r' if shape == 'sphere' else 'L'}{size:g}"
def wall_anchor(shape: str, size: float) -> tuple[np.ndarray, np.ndarray]:
"""LED を貼り付ける側壁上の点と,そこでの内向き法線を返す(mm).
深さ方向の軸(板中心 z=0 付近〜キャビティ最深部)の中点の高さで,+X 側の壁面.
"""
if shape == "cube":
z_mid = RIM_Z - size / 2.0
return np.array([size / 2.0, 0.0, z_mid]), np.array([-1.0, 0.0, 0.0])
c_z = sphere_center_z(size, SPHERE_CAVITY)
pole_z = c_z - size
z_mid = (RIM_Z + pole_z) / 2.0
dz = z_mid - c_z
rho = float(np.sqrt(size**2 - dz**2))
point = np.array([rho, 0.0, z_mid])
normal = (np.array([0.0, 0.0, c_z]) - point) / size
return point, normal
def led_geometry(shape: str, size: float) -> dict[str, np.ndarray | float]:
"""LED 円板の中心・発光方向・浮かせ量・傾き角を返す.
浮かせ量は「傾いた円板の縁が壁面へ食い込む量(LED_RADIUS×sinθ)+ マージン」.
方向は浮かせた後の中心から板中心へ向け直す(1 回の反復で十分収束する).
"""
wall, normal = wall_anchor(shape, size)
center = wall.copy()
for _ in range(2):
direction = BOARD_CENTER - center
direction = direction / np.linalg.norm(direction)
sin_tilt = float(np.linalg.norm(np.cross(direction, normal)))
standoff = LED_RADIUS * sin_tilt + STANDOFF_MARGIN
center = wall + standoff * normal
tilt_deg = float(np.degrees(np.arcsin(np.clip(sin_tilt, -1.0, 1.0))))
return {
"center": center, "direction": direction,
"standoff": standoff, "tilt_deg": tilt_deg,
}
def _disk_points(center: np.ndarray, direction: np.ndarray, radius: float, n: int) -> np.ndarray:
"""direction を法線とする円板の外周点 n 個 + 中心を返す."""
axis = np.array([0.0, 1.0, 0.0]) if abs(direction[1]) < 0.9 else np.array([0.0, 0.0, 1.0])
u = np.cross(direction, axis)
u /= np.linalg.norm(u)
v = np.cross(direction, u)
# 位相をずらして対称面(y=0)上にサンプル点を置かない(球版と同じ配慮)
angles = 0.1234 + np.linspace(0.0, 2.0 * np.pi, n, endpoint=False)
ring = center + radius * (np.outer(np.cos(angles), u) + np.outer(np.sin(angles), v))
return np.vstack([center[None, :], ring])
def _board_points(grid: int = 41) -> np.ndarray:
"""評価板のキャビティ側の面の格子点(四隅を含む)を返す."""
gx = np.linspace(-BOARD_HALF[0], BOARD_HALF[0], grid)
gy = np.linspace(-BOARD_HALF[1], BOARD_HALF[1], grid)
mesh_x, mesh_y = np.meshgrid(gx, gy)
z = np.full(mesh_x.size, BOARD_SURFACE_Z)
return np.stack([mesh_x.ravel(), mesh_y.ravel(), z], axis=1)
def baffle_radius(led: dict) -> float:
"""板の全点から LED 全点を隠すのに必要なバッフル半径(mm)を数値的に求める.
軸対称でないため閉形式は使えない.LED 円板上の点 Q × 板上の点 P の全直線が
バッフル平面(LED から BAFFLE_GAP 先・法線 = 発光方向)を横切る位置を計算し,
バッフル中心からの面内距離の最大値に安全率を掛ける.サンプルは遮蔽検証
(verify_baffle_occlusion)と同じ密度(板 41×41・LED 外周 64+1)を使う.
"""
center, direction = led["center"], led["direction"]
b_center = center + BAFFLE_GAP * direction
q = _disk_points(center, direction, LED_RADIUS, 64)
p = _board_points()
# 全組合せ: Q (M,3) × P (N,3)
delta = p[None, :, :] - q[:, None, :] # (M, N, 3)
denom = delta @ direction
t = ((b_center - q) @ direction)[:, None] / denom
x = q[:, None, :] + t[..., None] * delta
offset = x - b_center
radial = offset - (offset @ direction)[..., None] * direction[None, None, :]
return float(BAFFLE_MARGIN * np.linalg.norm(radial, axis=-1).max())
def _wall_distance(shape: str, size: float, points: np.ndarray) -> float:
"""点群からキャビティ内壁までの最小距離(mm)を返す(負なら壁の外)."""
if shape == "cube":
margins = np.concatenate([
(size / 2.0 - np.abs(points[:, 0:2])).ravel(),
(points[:, 2] - (RIM_Z - size)),
(RIM_Z - points[:, 2]),
])
return float(margins.min())
c = np.array([0.0, 0.0, sphere_center_z(size, SPHERE_CAVITY)])
return float((size - np.linalg.norm(points - c, axis=1)).min())
def build_baffle_mesh(
shape: str, size: float, led: dict, dest: Path
) -> tuple[trimesh.Trimesh, float]:
"""LED の視線上に置く傾いたバッフル円板を生成して書き出す.
半セクタ回転(対称面エッジ回避)→ Z 軸を発光方向へ向ける回転 → 平行移動の順.
円板が内壁に食い込むのは許容する(実物の積分球バッフルと同じ「壁に取り付けた板」の
解釈.LED が壁際にあるため,板の手前側の縁への視線=壁とほぼ平行な直線を遮るには
円板が壁まで届く必要があり,円形ゆえ反対側の張り出しが壁に埋まる).ただし外殻
(壁厚 THICKNESS)を貫通してはならない.
Returns:
(メッシュ, 内壁への食い込み深さ mm.正なら食い込みあり・0 なら非接触).
Raises:
ValueError: バッフルが外殻の外にはみ出す場合.
"""
sections = 128
radius = baffle_radius(led)
mesh = trimesh.creation.cylinder(radius=radius, height=BAFFLE_THICKNESS, sections=sections)
mesh.apply_transform(trimesh.transformations.rotation_matrix(np.pi / sections, [0, 0, 1]))
direction = led["direction"]
rot = trimesh.geometry.align_vectors([0.0, 0.0, 1.0], direction)
mesh.apply_transform(rot)
mesh.apply_translation(led["center"] + BAFFLE_GAP * direction)
embed = max(0.0, -_wall_distance(shape, size, mesh.vertices))
if embed > THICKNESS:
raise ValueError(
f"{job_name(shape, size)}: バッフルが外殻を貫通します(食い込み {embed:.2f}mm > "
f"壁厚 {THICKNESS:g}mm)"
)
dest.parent.mkdir(parents=True, exist_ok=True)
mesh.export(dest)
return mesh, embed
def build_housing_mesh(shape: str, size: float, dest: Path) -> trimesh.Trimesh:
"""筐体メッシュを生成して書き出す(軸上スイープと同一のビルダを使う)."""
if shape == "sphere":
return build_sphere_mesh(size, dest, SPHERE_CAVITY)
mesh = build_cube_housing(
size=size, thickness=THICKNESS,
board_half_x=BOARD_HALF[0], board_half_y=BOARD_HALF[1],
rim_inset=RIM_INSET, rim_z=RIM_Z,
)
dest.parent.mkdir(parents=True, exist_ok=True)
mesh.export(dest)
return mesh
def cavity_area(shape: str, size: float) -> float:
"""比較の横軸に使う内面積(mm²)を返す(各スイープと同じ定義)."""
if shape == "cube":
from sweep_cube_size import cavity_geometry as cube_geo
return cube_geo(size)["cavity_area_mm2"]
from sweep_sphere_radius import cavity_geometry as sphere_geo
return sphere_geo(size, SPHERE_CAVITY)["cavity_area_mm2"]
def build_scene_yaml(
shape: str, size: float, mesh_path: Path, baffle_path: Path, led: dict, dest: Path
) -> None:
"""テンプレートから当該条件のシーン YAML を書き出す.
材質・センサ・レンダ設定・LED の半径/radiance は一切変更しない.変えるのは
筐体メッシュ・LED の位置と向き・バッフルだけ.
"""
config = copy.deepcopy(yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8")))
name = job_name(shape, size)
config["name"] = f"offaxis_{name}"
config["description"] = (
f"オフ軸 LED スイープ [{shape} / {size:g}mm]: LED を側壁(軸中点の高さ・+X 側)に置き,"
f"評価板中心 (0,0,{BOARD_SURFACE_Z:g}) へ向ける(傾き {led['tilt_deg']:.1f}°)."
f"バッフルで直接光を遮断."
)
for mesh in config["geometry"]["meshes"]:
if mesh["id"] == "sphere_housing":
if shape == "cube":
mesh["id"] = "cube_housing"
mesh["path"] = str(mesh_path.resolve())
else:
mesh["path"] = str((TEMPLATE_YAML.parent / mesh["path"]).resolve())
config["geometry"]["meshes"].append({
"id": "baffle",
"path": str(baffle_path.resolve()),
"unit": "mm",
"material": "printer_white",
})
for light in config["lights"]:
light["center"] = [round(float(c), 4) for c in led["center"]]
light["direction"] = [round(float(d), 6) for d in led["direction"]]
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8")
def jobs() -> list[tuple[str, float]]:
"""実行する(形状, サイズ)の一覧を返す."""
return [("sphere", r) for r in SPHERE_RADII] + [("cube", size) for size in CUBE_SIZES]
def prepare(log) -> dict[tuple[str, float], Path]:
"""全条件のメッシュとシーン YAML を生成し,watertight とクリアランスを検証する."""
scenes: dict[tuple[str, float], Path] = {}
log("=== 幾何生成(オフ軸 LED / 球 similar_radial + 立方体)===")
for shape, size in jobs():
name = job_name(shape, size)
led = led_geometry(shape, size)
mesh_path = MESH_DIR / f"筐体_{name}.stl"
housing = build_housing_mesh(shape, size, mesh_path)
if not housing.is_watertight:
raise ValueError(f"{name}: watertight でないメッシュが生成されました")
# LED 円板の縁が壁の内側にあることを数値で確認する
led_rim = _disk_points(led["center"], led["direction"], LED_RADIUS, 64)
rim_clearance = _wall_distance(shape, size, led_rim)
if rim_clearance < 0.0:
raise ValueError(f"{name}: LED 円板が壁を貫いています(余裕 {rim_clearance:.3f}mm)")
baffle_path = MESH_DIR / f"バッフル_{name}.stl"
_, embed = build_baffle_mesh(shape, size, led, baffle_path)
scene_yaml = TMP_DIR / f"scene_offaxis_{name}.yaml"
build_scene_yaml(shape, size, mesh_path, baffle_path, led, scene_yaml)
scenes[(shape, size)] = scene_yaml
log(f" {name:>12} LED=({led['center'][0]:7.2f}, 0, {led['center'][2]:8.2f}) "
f"傾き={led['tilt_deg']:5.1f}° 浮かせ={led['standoff']:4.2f}mm "
f"バッフル R={baffle_radius(led):5.2f}mm 円板余裕={rim_clearance:5.2f}mm "
f"壁食い込み={embed:4.2f}mm")
return scenes
def run_job(shape: str, size: float, scene_yaml: Path, log) -> dict | None:
"""1 条件をレンダ→評価まで実行して集約行を返す(失敗時 None)."""
name = job_name(shape, size)
out_dir = OUTPUT_BASE / name
led = led_geometry(shape, size)
row: dict = {
"shape": shape,
"size_mm": size,
"led_x_mm": round(float(led["center"][0]), 3),
"led_z_mm": round(float(led["center"][2]), 3),
"led_standoff_mm": round(float(led["standoff"]), 3),
"tilt_deg": round(led["tilt_deg"], 2),
"baffle_radius_mm": round(baffle_radius(led), 3),
"cavity_area_mm2": cavity_area(shape, size),
"elapsed_s": 0.0,
}
if (out_dir / "metrics.csv").exists() and (out_dir / "luminance.npy").exists():
log(f"--- {name}: 既存結果を再利用")
else:
log(f"--- {name}: レンダ開始")
out_dir.mkdir(parents=True, exist_ok=True)
start = time.time()
for cmd in (
["uv", "run", "sm-render", "--scene", str(scene_yaml), "--output", str(out_dir)],
["uv", "run", "sm-evaluate", "--image", str(out_dir / "luminance.npy"),
"--config", str(EVAL_CONFIG_YAML), "--output", str(out_dir)],
):
# encoding 明示は必須(Windows 既定 cp932 で日本語ログが読めなくなる.球版で実測)
result = subprocess.run(
cmd, cwd=ROOT, capture_output=True, text=True,
encoding="utf-8", errors="replace",
)
if result.returncode != 0:
log(f" !! 失敗 ({cmd[2]}): {result.stderr[-800:]}")
return None
row["elapsed_s"] = round(time.time() - start, 1)
row.update(read_metrics_csv(out_dir / "metrics.csv"))
row.update(raw_stats(out_dir / "luminance.npy"))
log(f" -> {row['elapsed_s']:.0f}s CoV={row['cov']:.4f} "
f"centroid_offset={row['intensity_centroid_offset_norm']:.5f} "
f"angular_ratio={row['angular_ratio']:.4f} 生mean={row['raw_mean_roi']:.4f}")
return row
def write_summary(rows: list[dict]) -> None:
"""集約 CSV を(形状, サイズ)順で書き直す(途中終了でも最新状態が残る)."""
summary_csv = OUTPUT_BASE / "summary.csv"
summary_csv.parent.mkdir(parents=True, exist_ok=True)
with summary_csv.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=SUMMARY_COLUMNS)
writer.writeheader()
for row in sorted(rows, key=lambda r: (r["shape"], r["size_mm"])):
writer.writerow({k: row.get(k, "") for k in SUMMARY_COLUMNS})
def main() -> int:
"""オフ軸 LED スイープを実行するメインエントリポイント."""
parser = argparse.ArgumentParser(description="オフ軸 LED スイープ(球 + 立方体)")
parser.add_argument("--prepare-only", action="store_true",
help="メッシュとシーン YAML の生成だけ行う(3D 配置確認用)")
parser.add_argument("--shapes", choices=("sphere", "cube"), nargs="*", default=None,
help="実行する形状(省略時は両方)")
args = parser.parse_args()
log_path = ROOT / "output" / "_sweep_offaxis_led.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_file = log_path.open("a", encoding="utf-8")
def log(message: str) -> None:
print(message, flush=True)
log_file.write(message + "\n")
log_file.flush()
log("=== オフ軸 LED スイープ ===")
scenes = prepare(log)
if args.prepare_only:
log(f"--prepare-only のため終了.シーン YAML: {TMP_DIR}")
log_file.close()
return 0
rows: list[dict] = []
for shape, size in jobs():
if args.shapes and shape not in args.shapes:
continue
row = run_job(shape, size, scenes[(shape, size)], log)
if row is not None:
rows.append(row)
write_summary(rows)
log(f"\n=== 完了: {len(rows)} 条件 ===")
log(f" 集約 CSV: {OUTPUT_BASE / 'summary.csv'}")
log_file.close()
return 0
if __name__ == "__main__":
sys.exit(main())