"""立方体キャビティのサイズスイープ(球との形状比較・TEST_16).
TEST_15 の結論「効き目の序列は 板直下の喉 > 接合の相似・G2 化 > 球半径そのもの」を受け,
ロフトも接合も構造的に存在しない立方体キャビティで同じスイープを行う.立方体の上面は
板の縁から外へ広がる水平な天井そのもので,TEST_15 最良の similar_radial(リム水平出し)が
目指した形と一致する.「球のほうが理想積分球だから有利」が成り立つかを実測で確かめる.
幾何のパラメータ化:
キャビティ内寸 L の立方体.天井 z=0(リムと同一平面),底面 z=-L,内壁 x, y = ±L/2.
評価板・リム・材質・レンダ条件は球スイープ(sweep_sphere_radius.py)と完全に同一.
led_z = -L + LED_WALL_STANDOFF (底面中心に密着・評価板向き +Z)
バッフル = led_z + BAFFLE_GAP (球版と同じ閉形式 baffle_radius)
固定条件(球スイープと完全に同一に保つ):
材質 : blendbsdf rho=0.85 / omega=0.05 / alpha=0.1
光源 : 円板 radius=1.7662mm(面積 9.8mm^2 保存).board_baffle 構成のみ
レンダ: spp=1024 / max_depth=16 / 512^2 / llvm_ad_rgb / path / orthographic (50x52)
評価 : configs/eval_baseline_dng_target_shape.yaml(shape_metrics 込み)
球との対応(内面積を揃える解釈):
球 r=88/100/135/170/250 の内面積は L=126/142/188/234/341 の立方体に相当する.
スイープはキリの良い L で振り,比較は内面積を横軸にして行う.
実行方法:
uv run python scripts/sweep_cube_size.py --prepare-only # メッシュ + YAML のみ
uv run python scripts/sweep_cube_size.py --sizes 120 160 200 280 360
"""
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_CORNER,
BOARD_HALF,
BOARD_SURFACE_Z,
EVAL_CONFIG_YAML,
LED_RADIUS,
LED_WALL_STANDOFF,
RIM_INSET,
RIM_Z,
TEMPLATE_YAML,
THICKNESS,
raw_stats,
read_metrics_csv,
)
from sm_tias_lightsim.scene.paths import relative_mesh_path # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
TMP_DIR = ROOT / "configs" / "_tmp_cube_size"
MESH_DIR = TMP_DIR / "meshes"
OUTPUT_BASE = ROOT / "output" / "cube_size_sweep"
RUN_DIR = "board_baffle"
# 評価板 99×102mm が上面に収まる下限は L=102.天井の余白(Y 側片 9mm)を考え 120 から振る
# (L=220 は後から追加した 10 条件目.球スイープと同数にして図のまとまりを良くするため)
SIZES = [120.0, 140.0, 160.0, 180.0, 200.0, 220.0, 240.0, 280.0, 320.0, 360.0]
# 板の実面積とリム開口(板輪郭から RIM_INSET 内側)の面積(mm^2).内面積の集計に使う
BOARD_AREA = (2 * BOARD_HALF[0]) * (2 * BOARD_HALF[1])
RIM_OPENING_AREA = (2 * (BOARD_HALF[0] - RIM_INSET)) * (2 * (BOARD_HALF[1] - RIM_INSET))
SUMMARY_COLUMNS = [
"size_mm",
"led_z_mm",
"baffle_radius_mm",
"cavity_area_mm2",
"board_area_ratio",
"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 led_z(size: float) -> float:
"""LED 円板の Z 座標(底面 z=-L から壁面密着分だけ手前)を返す."""
return RIM_Z - size + LED_WALL_STANDOFF
def job_name(size: float) -> str:
"""一辺の長さからジョブ名(ディレクトリ名)を作る."""
return f"L{size:g}"
def baffle_radius(size: float) -> float:
"""評価板の全点から LED を隠すのに必要なバッフル円板の半径(mm)を返す.
導出は sweep_sphere_radius.baffle_radius と同一(LED と板の内分点による厳密上界).
LED_z と板の外接円だけで決まる式なので,キャビティが球でも立方体でも変わらない.
"""
f = BAFFLE_GAP / (BOARD_SURFACE_Z - led_z(size))
return BAFFLE_MARGIN * ((1.0 - f) * LED_RADIUS + f * BOARD_CORNER)
def build_baffle_mesh(size: float, dest: Path) -> trimesh.Trimesh:
"""LED 直前のバッフル円板(薄い円柱ソリッド)を生成して書き出す.
半セクタ回転は球版と同じ理由(蓋の三角形ファンのエッジを XZ / YZ 対称面から外し,
遮蔽検証レイの数値的なすり抜けを防ぐ).
"""
sections = 128
mesh = trimesh.creation.cylinder(
radius=baffle_radius(size), height=BAFFLE_THICKNESS, sections=sections
)
mesh.apply_transform(trimesh.transformations.rotation_matrix(np.pi / sections, [0, 0, 1]))
mesh.apply_translation([0.0, 0.0, led_z(size) + BAFFLE_GAP])
dest.parent.mkdir(parents=True, exist_ok=True)
mesh.export(dest)
return mesh
def build_mesh(size: float, dest: Path) -> trimesh.Trimesh:
"""指定サイズの立方体筐体 STL を生成して書き出す."""
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_geometry(size: float) -> dict[str, float]:
"""キャビティ内面積と,評価板がそれに占める割合(開口比)を返す.
内面積 = 側壁 4L^2 + 底面 L^2 + 天井 (L^2 - リム開口) + 評価板.
球版 cavity_geometry と同じ「板込みの総内面積」の定義で,比較の横軸に使う.
"""
total = 6.0 * size**2 - RIM_OPENING_AREA + BOARD_AREA
return {
"cavity_area_mm2": round(total, 1),
"board_area_ratio": round(BOARD_AREA / total, 5),
}
def build_scene_yaml(size: float, mesh_path: Path, dest: Path, baffle_path: Path) -> None:
"""テンプレートから当該サイズのシーン YAML を書き出す.
材質・センサ・レンダ設定・LED の半径/radiance は一切変更しない.変えるのは
筐体メッシュ(球 → 立方体)・LED の Z・LED の向き・バッフルだけ.
メッシュの相対パスは YAML 自身のディレクトリ基準で解決されるため,書き出し先の
YAML からの相対パスに付け替える(絶対パスだと Windows で生成した YAML を
研究室サーバで読めない).
"""
config = copy.deepcopy(yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8")))
config["name"] = f"cube_size_{RUN_DIR}_{job_name(size)}"
config["description"] = (
f"立方体キャビティのサイズスイープ [board_baffle]: 内寸 {size:g}mm"
f"(天井 z={RIM_Z:g}・底面 z={RIM_Z - size:g})."
f"LED は底面中心に密着し評価板向き(+Z).バッフルで直接光を遮断."
)
yaml_dir = dest.parent
for mesh in config["geometry"]["meshes"]:
if mesh["id"] == "sphere_housing":
mesh["id"] = "cube_housing"
mesh["path"] = relative_mesh_path(mesh_path, yaml_dir)
else:
mesh["path"] = relative_mesh_path(TEMPLATE_YAML.parent / mesh["path"], yaml_dir)
config["geometry"]["meshes"].append(
{
"id": "baffle",
"path": relative_mesh_path(baffle_path, yaml_dir),
"unit": "mm",
"material": "printer_white",
}
)
for light in config["lights"]:
light["center"] = [0.0, 0.0, led_z(size)]
light["direction"] = [0.0, 0.0, 1.0]
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 prepare(sizes: list[float], log) -> dict[float, Path]:
"""全サイズについて筐体・バッフルのメッシュとシーン YAML を生成し,watertight を検証する."""
scenes: dict[float, Path] = {}
log("=== 幾何生成(立方体 board_baffle)===")
for size in sizes:
mesh_path = MESH_DIR / f"立方体_{job_name(size)}.stl"
mesh = build_mesh(size, mesh_path)
if not mesh.is_watertight:
raise ValueError(f"L={size}: watertight でないメッシュが生成されました")
baffle_path = MESH_DIR / f"バッフル_{job_name(size)}.stl"
baffle = build_baffle_mesh(size, baffle_path)
if not baffle.is_watertight:
raise ValueError(f"L={size}: バッフルが watertight になりませんでした")
scene_yaml = TMP_DIR / f"scene_{RUN_DIR}_{job_name(size)}.yaml"
build_scene_yaml(size, mesh_path, scene_yaml, baffle_path)
scenes[size] = scene_yaml
geo = cavity_geometry(size)
log(
f" L={size:6.1f} LED_z={led_z(size):9.3f} "
f"内面積={geo['cavity_area_mm2']:9.0f}mm2 "
f"開口比={geo['board_area_ratio'] * 100:5.2f}% "
f"バッフル R={baffle_radius(size):5.2f}mm z={led_z(size) + BAFFLE_GAP:8.2f}"
)
return scenes
def run_job(size: float, scene_yaml: Path, log) -> dict[str, float] | None:
"""1 サイズをレンダ→評価まで実行して集約行を返す(失敗時 None).
既に metrics.csv と luminance.npy が揃っているジョブは再レンダせず再利用する.
"""
name = job_name(size)
out_dir = OUTPUT_BASE / RUN_DIR / name
row: dict[str, float] = {
"size_mm": size,
"led_z_mm": round(led_z(size), 3),
"baffle_radius_mm": round(baffle_radius(size), 3),
"elapsed_s": 0.0,
}
row.update(cavity_geometry(size))
if (out_dir / "metrics.csv").exists() and (out_dir / "luminance.npy").exists():
log(f"--- {name}: 既存結果を再利用")
else:
log(f"--- {name}: レンダ開始(LED_z={led_z(size):.2f})")
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 で復号され,
# sm-render が出す日本語ログで UnicodeDecodeError になる(球スイープで実測)
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"angular_ratio={row['angular_ratio']:.4f} C/P={row['center_periphery_ratio']:.4f} "
f"勾配={row['gradient_magnitude']:.2f}% 生mean={row['raw_mean_roi']:.4f}"
)
return row
def write_summary(rows: list[dict[str, float]]) -> None:
"""集約 CSV をサイズ昇順で書き直す(途中終了でも最新状態が残る)."""
summary_csv = OUTPUT_BASE / RUN_DIR / "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["size_mm"]):
writer.writerow({k: row.get(k, "") for k in SUMMARY_COLUMNS})
def main() -> int:
"""サイズスイープを実行するメインエントリポイント."""
parser = argparse.ArgumentParser(description="立方体キャビティのサイズスイープ")
parser.add_argument(
"--prepare-only",
action="store_true",
help="メッシュとシーン YAML の生成だけ行う(3D 配置確認用)",
)
parser.add_argument(
"--sizes",
type=float,
nargs="*",
default=None,
help="一辺の長さリスト(mm).省略時は既定グリッド",
)
args = parser.parse_args()
sizes = args.sizes if args.sizes else SIZES
log_path = ROOT / "output" / "_sweep_cube_size.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("=== 立方体キャビティ サイズスイープ(board_baffle)===")
scenes = prepare(sizes, log)
if args.prepare_only:
log(f"--prepare-only のため終了.シーン YAML: {TMP_DIR}")
log_file.close()
return 0
rows: list[dict[str, float]] = []
for size in sizes:
row = run_job(size, scenes[size], log)
if row is not None:
rows.append(row)
write_summary(rows)
log(f"\n=== 完了: {len(rows)} 条件 ===")
log(f" 集約 CSV: {OUTPUT_BASE / RUN_DIR / 'summary.csv'}")
log_file.close()
return 0
if __name__ == "__main__":
sys.exit(main())