brush trainer (/Users/otkrickey/dev/brush, Apache 2.0 OSS) の eval pipeline を 完全に read-only で audit。PSNR = 10·log10(1/MSE), MAX=1, MSE は H·W·3 mean。GT は AlphaMode::Transparent デフォルトで RGB を α で premultiply、render は bg=Vec3::ZERO で固定、loss は composite_bg=None で premultiplied 同士の差分。NeRF Synthetic では α=0 の透明領域 (画面の大部分) で pred=0, gt=0 となり完全一致 → 大量の free PSNR。これが apparent gap (-3〜-6 dB) の主仮説。test split は transforms_test.json の全 view、8-bit roundtrip + RGB-only (α-match 無効) + SH degree=3。splat-rs A.2 と比較すべき diff 観点を 12 項目に list 化、次 phase A.2/A.3 に引き渡す。
brush trainer は /Users/otkrickey/dev/brush/ (Apache 2.0 OSS、別 repo、本 audit 中は read-only) で公開されており、lego sh3 30k で PSNR 37.40 dB。splat-rs (24.879 dB) との 12.56 dB gap の真因切り分け前提として、brush 側 eval (PSNR/SSIM/split/背景/前処理) を完全に式化する。
brush eval は premultiplied-α 空間で bg=black に対して PSNR を計算する。 NeRF Synthetic の透明領域 (画面の大部分) では pred=gt=0 となり完全一致 → MSE 分母に 0 寄与が大量に入り、conventional 「composite-on-white してから PSNR」より構造的に高く出る。これが apparent gap (-3〜-6 dB) の主仮説。A.2 で splat-rs 側の convention を確認するまでは断定不可。
// crates/brush-train/src/eval.rs:36-53
let (img, render_aux) =
render_splats(splats, gt_cam, res, Vec3::ZERO, None, TextureMode::Float).await;
let render_rgb = img.slice(s![.., .., 0..3]);
// Simulate an 8-bit roundtrip for fair comparison.
let render_rgb = (render_rgb * 255.0).round() / 255.0;
let cfg = |l1, ssim| ImageLossConfig {
l1_weight: l1,
ssim_weight: ssim,
composite_bg: None,
mask: false,
};
// MSE = mean(L1^2) since |a - b|^2 == (a - b)^2.
let mse = image_loss_eval(render_rgb.clone(), gt_packed.clone(), cfg(1.0, 0.0))
.powi_scalar(2)
.mean();
let psnr = mse.recip().log() * 10.0 / std::f32::consts::LN_10;
PSNR = 10·log10(1 / MSE) — MAX² = 1 暗黙固定 (画像は [0,1] f32)mean((pred_rgb − gt_rgb)²) over H × W × 3 全 voxel (RGB 3 ch を均等に平均、α 含まず)mask: false なので α-mask は適用されない(x*255).round()/255 で量子化してから比較 — GT は元から u8 packed なので「両者 u8 精度で揃える」公平化natural log / ln(10) = log10 (`std::f32::consts::LN_10` 使用)brush eval は 3 段階で background を扱う:
render_splats(..., Vec3::ZERO, ..., TextureMode::Float) で 黒背景 (0,0,0) に合成 (eval.rs:37)view_to_sample_image(gt_img, alpha_mode) が AlphaMode::Transparent かつ RGBA の場合 byte 空間で α premultiply (scene.rs:212-232)composite_bg: None なので kernel 内 gt_eff = gt_c (premultiplied bytes をそのまま比較、再 composite なし) (brush-loss/src/lib.rs:228-233)// crates/brush-dataset/src/scene.rs:212-232
pub fn view_to_sample_image(image: DynamicImage, alpha_mode: AlphaMode) -> DynamicImage {
if image.color().has_alpha() && alpha_mode == AlphaMode::Transparent {
let mut rgba_bytes = image.to_rgba8();
// Assume image has un-multiplied alpha and convert it to pre-multiplied.
for pixel in rgba_bytes.chunks_exact_mut(4) {
let (r, g, b, a) = (pixel[0], pixel[1], pixel[2], pixel[3]);
pixel[0] = ((r as u16 * a as u16 + 127) / 255) as u8;
pixel[1] = ((g as u16 * a as u16 + 127) / 255) as u8;
pixel[2] = ((b as u16 * a as u16 + 127) / 255) as u8;
pixel[3] = a;
}
DynamicImage::ImageRgba8(rgba_bytes)
} else { image }
}
NeRF Synthetic GT は (r,g,b,a); a=0 の透明領域では premultiply 後 (0,0,0,0)。brush render は bg=Vec3::ZERO で α-blend 出力なので、透明領域では render output も (0,0,0)。→ (pred − gt)² = 0 が 透明領域全ピクセルで成立。NeRF Synthetic では透明領域が画面の半分以上を占めることが多い (例: lego の car 周辺、ficus の枝間) ため、MSE が機械的に半分以下になり、PSNR は構造的に +3 dB 以上 嵩上げされる。
// crates/brush-dataset/src/scene.rs:46-52
let alpha_mode = override_alpha_mode.unwrap_or_else(|| {
if mask_path.is_some() {
AlphaMode::Masked // mask file あり → Masked (premultiply しない)
} else {
AlphaMode::Transparent // mask file なし → Transparent (premultiply する)
}
});
--alpha-mode CLI で override 可能 (config.rs:33)Transparent がデフォルト → premultiply 経路--alpha-mode=masked で取られていた場合、本 audit の主仮説は崩れる → A.2 で brush run command を確認すること// crates/brush-dataset/src/formats/nerfstudio.rs:259-308
let eval_trans_path = json_files
.iter()
.find(|x| x.ends_with("transforms_val.json"))
.or_else(|| json_files.iter().find(|x| x.ends_with("transforms_test.json")));
// ...
for (i, view) in train_handles.into_iter().enumerate() {
if let Some(eval_period) = load_args.eval_split_every {
if i % eval_period == 0 && val_views.is_none() {
eval_views.push(view);
} else { train_views.push(view); }
} else { train_views.push(view); }
}
if let Some(val_views) = val_views { eval_views.extend(val_views); }
transforms_val.json → transforms_test.json → (なければ eval_split_every で train から間引き)transforms_test.json が存在 (200 views) → 全 200 view が eval、subsample なし--eval-split-every は val_views が存在する場合 無視される (`val_views.is_none()` 条件)run_eval() (train_stream.rs:455-513) で全 view を逐次回し、psnr/ssim を単純平均byte * (1/255) で [0,1] f32 に変換 (brush-loss/src/lib.rs:82, 119-122)view_to_sample_image で byte 空間で premultiply (前述); render 側はそのままmax_resolution = 1920 デフォルト (config.rs:21) — NeRF Synthetic 800×800 は no-op(*255).round()/255、GT は元から u8 packed なので追加処理なし (eval.rs:41)slice(s![.., .., 0..3]) で RGB 3 channel のみ使用、α は loss 対象外 (training と異なる; training は α-match path あり)PSNR と同じ image_loss_eval を cfg(0.0, 1.0) で呼び出して per-pixel SSIM map を取得、.mean()。kernel は 11-tap 可分離 Gaussian (brush-loss/src/lib.rs:165-341) で、定数:
C1 = 0.01² = 1e-4 (lib.rs:80)C2 = 0.03² = 9e-4 (lib.rs:81)val = clamp((2·μ1·μ2 + C1)(2·σ12 + C2) / ((μ1²+μ2²+C1)(σ1²+σ2²+C2)), -1, 1)F::max(zero, out1 - mu1²) で clip (lib.rs:321-322)(0,0,0) + uniform noise ±0.1 (config.rs:101-110) — つまり毎 iter ランダム black-ish 背景 (brush-train/src/train.rs:120-122)(has_alpha && background != Vec3::ZERO).then_some(background) — bg=ZERO の iter は composite 無効 (train.rs:155)| 項目 | 値 | source |
|---|---|---|
| SH degree | 3 | config.rs:10 (`sh_degree` default) |
| total_train_iters | 30000 | brush-train/config.rs:9 |
| lr_mean | 2e-5 → 2e-7 (linear) | config.rs:16-21 |
| lr_coeffs_dc | 2e-3 | config.rs:29 |
| lr_coeffs_sh_scale | 10.0 (band 1+ は /10) | config.rs:33 |
| lr_opac | 0.012 | config.rs:37 |
| lr_scale | 7e-3 → 5e-3 | config.rs:41-45 |
| lr_rotation | 2e-3 | config.rs:49 |
| ssim_weight (training) | 0.2 (L1 0.8 + SSIM 0.2) | config.rs:80 |
| match_alpha_weight (training) | 0.1 | config.rs:92 |
| lpips_loss_weight (training) | 0.0 (default off) | config.rs:95 |
| growth_grad_threshold | 0.0025 | config.rs:62 |
| refine_every | 200 iter | config.rs:58 |
| growth_stop_iter | 15000 | config.rs:71 |
| max_splats cap | 10,000,000 | config.rs:53 |
Vec3::ZERO 固定。splat-rs は? 白背景に composite してから PSNR を取っているなら 機械的に -3〜-6 dBH·W·3 全 voxel 均等平均。splat-rs が per-channel mean してから平均する場合、結果は同一 (mean は結合的) だが、もし「per-channel PSNR → 平均」してると 違う値になるMAX²=1 暗黙 ([0,1] f32 前提)。splat-rs が MAX=255 ([0,255] 想定) で計算してると +48 dB シフト (検出は容易、無いはず)log10。万が一 log2 や自然対数を使ってると数値が大きくズレるtransforms_test.json の 全 200 view。splat-rs が eval_split_every で train を間引いてる場合、評価対象が異なり single-number 比較不可max_resolution=1920 → NeRF Synthetic 800×800 は no-op。splat-rs が downsample してると別件--alpha-mode=masked で Masked に切替可能 (premultiply しない、loss-map に gt.a を掛ける)。brush の公式 37.4 dB がどちらで取られたかは要確認 (主仮説の崩壊条件)「brush 37.4 dB と splat-rs 24.879 dB の gap のうち 3〜6 dB は eval convention 差 (premultiplied + bg=black vs composite-on-white) で説明可能」。残り 6〜9 dB が真の algorithmic gap。
--alpha-mode がどう設定されてたか確認 (axis 11 の主仮説崩壊条件 check)render_splats 内 α-blend の precise 数式 (front-to-back vs back-to-front, premultiplied output か否か) は brush-render-bwd 内の kernel を見ないと完全確認できないが、bg=Vec3::ZERO + Transparent GT で eval する設計上 render output は premultiplied (α合成済み RGB) と推定 (要 A.2 で kernel 確認)lod_levels) が 30k run でどう作用するか — default 0 (off) なので lego sh3 30k は plain train + 30k iter のみと推定するが要確認| 役割 | path:line |
|---|---|
| eval entry (PSNR/SSIM) | /Users/otkrickey/dev/brush/crates/brush-train/src/eval.rs:22-63 |
| eval loop (split iteration + 平均) | /Users/otkrickey/dev/brush/crates/brush-process/src/train_stream.rs:455-513 |
| GT premultiply | /Users/otkrickey/dev/brush/crates/brush-dataset/src/scene.rs:212-232 |
| GT pack to u32 RGBA | /Users/otkrickey/dev/brush/crates/brush-dataset/src/scene.rs:239-262 |
| AlphaMode 決定 | /Users/otkrickey/dev/brush/crates/brush-dataset/src/scene.rs:38-62 |
| AlphaMode enum (default Masked) | /Users/otkrickey/dev/brush/crates/brush-render/src/lib.rs:107-115 |
| image_loss_eval (forward-only) | /Users/otkrickey/dev/brush/crates/brush-loss/src/lib.rs:1090-1100 |
| L1 + SSIM kernel forward | /Users/otkrickey/dev/brush/crates/brush-loss/src/lib.rs:163-341 |
| read_gt (byte → [0,1] f32) | /Users/otkrickey/dev/brush/crates/brush-loss/src/lib.rs:107-126 |
| ImageLossConfig | /Users/otkrickey/dev/brush/crates/brush-loss/src/lib.rs:686-692 |
| NeRF Synthetic split loader | /Users/otkrickey/dev/brush/crates/brush-dataset/src/formats/nerfstudio.rs:214-308 |
| LoadDataseConfig (max_resolution etc.) | /Users/otkrickey/dev/brush/crates/brush-dataset/src/config.rs:13-34 |
| TrainConfig defaults | /Users/otkrickey/dev/brush/crates/brush-train/src/config.rs:7-146 |
| render_splats (eval render) | /Users/otkrickey/dev/brush/crates/brush-render/src/gaussian_splats.rs:252-314 |
| training step (training bg / composite) | /Users/otkrickey/dev/brush/crates/brush-train/src/train.rs:101-214 |
p1-a-2-splat-rs-eval-auditp1-a-3-cross-eval-bridge (本 audit + A.2 の checklist を使い実測)brush-vs-splat-37dB-gap-analysis.mdm4-brush-bencha-4-nerf-synthetic-scene-results