松田研究室
FindingID: p1-a-1-brush-eval-audit
Status: stable / task P1.A.1
← Findings Index
Audit · brush eval pipeline · 12-axis diff checklist

P1.A.1 brush eval audit — 数式定式化 + diff 観点 12 項目

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 に引き渡す。

neutralrank: highaudit-complete第 1 軸第 2 軸p1a-1brushauditpsnrssimevalalphapremultipliedconvention
Δ PSNR
N/A (apparent gap mechanism の特定が主目的)
Δ Wallclock
N/A (audit task)
Impact
brush eval は (1) AlphaMode::Transparent で GT を α premultiply、(2) bg=Vec3::ZERO の黒背景に render、(3) composite_bg=None で premultiplied 同士を直接比較、(4) 8-bit roundtrip 後に MSE = mean((pred−gt)²) over H·W·3、(5) PSNR = 10·log10(1/MSE)。これは NeRF Synthetic (RGBA で α=0 の透明領域が支配的) において **透明領域は pred=gt=0 で完全一致** となり、MSE 分母に 0 寄与が大量に入る → conventional 「白背景に composite してから PSNR」より高く出る。splat-rs 側の eval 規約を A.2 で確認し、A.3 で「同 convention 下での真の gap」を測定する必要あり。
01

本文

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/背景/前処理) を完全に式化する。

Headline

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 を確認するまでは断定不可

1. PSNR 数式 (核心)

// 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)
  • MSE 範囲: mean((pred_rgb − gt_rgb)²) over H × W × 3 全 voxel (RGB 3 ch を均等に平均、α 含まず)
  • weighted by alpha なし: mask: false なので α-mask は適用されない
  • 8-bit roundtrip: pred を (x*255).round()/255 で量子化してから比較 — GT は元から u8 packed なので「両者 u8 精度で揃える」公平化
  • log base: natural log / ln(10) = log10 (`std::f32::consts::LN_10` 使用)

2. 背景処理 (apparent gap の主因候補)

brush eval は 3 段階で background を扱う:

  1. render 側: render_splats(..., Vec3::ZERO, ..., TextureMode::Float)黒背景 (0,0,0) に合成 (eval.rs:37)
  2. GT 側: view_to_sample_image(gt_img, alpha_mode)AlphaMode::Transparent かつ RGBA の場合 byte 空間で α premultiply (scene.rs:212-232)
  3. loss 側: 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 以上 嵩上げされる。

AlphaMode の決定ロジック

// 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)
  • NeRF Synthetic は mask file なしなので Transparent がデフォルト → premultiply 経路
  • もし brush の 37.4 dB が --alpha-mode=masked で取られていた場合、本 audit の主仮説は崩れる → A.2 で brush run command を確認すること

3. test split の選び方

// 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.jsontransforms_test.json → (なければ eval_split_every で train から間引き)
  • NeRF Synthetic: transforms_test.json が存在 (200 views) → 全 200 view が eval、subsample なし
  • --eval-split-every は val_views が存在する場合 無視される (`val_views.is_none()` 条件)
  • eval は run_eval() (train_stream.rs:455-513) で全 view を逐次回し、psnr/ssim を単純平均

4. image 前処理

  • normalization: pred は [0,1] f32 (renderer 出力); GT は u8 packed RGBA → kernel 内で byte * (1/255) で [0,1] f32 に変換 (brush-loss/src/lib.rs:82, 119-122)
  • premultiply: GT は view_to_sample_image で byte 空間で premultiply (前述); render 側はそのまま
  • tonemap / gamma / sRGB: 一切なし — 線形空間のまま比較
  • resize: max_resolution = 1920 デフォルト (config.rs:21) — NeRF Synthetic 800×800 は no-op
  • 8-bit roundtrip: pred のみ (*255).round()/255、GT は元から u8 packed なので追加処理なし (eval.rs:41)
  • channels: eval は render を slice(s![.., .., 0..3])RGB 3 channel のみ使用、α は loss 対象外 (training と異なる; training は α-match path あり)

5. SSIM 数式 (参考)

PSNR と同じ image_loss_evalcfg(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)
  • σ² ≥ 0 を保証するため F::max(zero, out1 - mu1²) で clip (lib.rs:321-322)

6. 訓練側 background (eval と区別すべき)

  • training bg default: (0,0,0) + uniform noise ±0.1 (config.rs:101-110) — つまり毎 iter ランダム black-ish 背景 (brush-train/src/train.rs:120-122)
  • training composite_bg: (has_alpha && background != Vec3::ZERO).then_some(background) — bg=ZERO の iter は composite 無効 (train.rs:155)
  • eval bg は固定 (Vec3::ZERO)、training と異なり noise なし、composite_bg=None で固定

7. その他 audit で確認した default

項目source
SH degree3config.rs:10 (`sh_degree` default)
total_train_iters30000brush-train/config.rs:9
lr_mean2e-5 → 2e-7 (linear)config.rs:16-21
lr_coeffs_dc2e-3config.rs:29
lr_coeffs_sh_scale10.0 (band 1+ は /10)config.rs:33
lr_opac0.012config.rs:37
lr_scale7e-3 → 5e-3config.rs:41-45
lr_rotation2e-3config.rs:49
ssim_weight (training)0.2 (L1 0.8 + SSIM 0.2)config.rs:80
match_alpha_weight (training)0.1config.rs:92
lpips_loss_weight (training)0.0 (default off)config.rs:95
growth_grad_threshold0.0025config.rs:62
refine_every200 iterconfig.rs:58
growth_stop_iter15000config.rs:71
max_splats cap10,000,000config.rs:53

8. splat-rs と diff すべき観点 (12 axes、次 phase A.2/A.3 で使用)

  1. (1) 評価時 background color: brush は Vec3::ZERO 固定。splat-rs は? 白背景に composite してから PSNR を取っているなら 機械的に -3〜-6 dB
  2. (2) GT premultiplication: brush は AlphaMode::Transparent で byte 空間 premultiply。splat-rs は? premultiply せず素の RGB を使う場合、α=0 の透明領域で GT≠0 のノイズ寄与が残る
  3. (3) loss 対象 channel: brush eval は RGB 3 channel のみ (α 対象外)。splat-rs が α も MSE に含むなら分母が H·W·4 となり PSNR が下がる
  4. (4) MSE 平均次元: brush は H·W·3 全 voxel 均等平均。splat-rs が per-channel mean してから平均する場合、結果は同一 (mean は結合的) だが、もし「per-channel PSNR → 平均」してると 違う値になる
  5. (5) PSNR MAX 値: brush は MAX²=1 暗黙 ([0,1] f32 前提)。splat-rs が MAX=255 ([0,255] 想定) で計算してると +48 dB シフト (検出は容易、無いはず)
  6. (6) log base: brush は log10。万が一 log2 や自然対数を使ってると数値が大きくズレる
  7. (7) 8-bit roundtrip: brush は pred を量子化してから MSE。splat-rs が量子化しないなら f32 精度誤差で わずかに高め、量子化込みなら同条件
  8. (8) test split source: brush は transforms_test.json全 200 view。splat-rs が eval_split_every で train を間引いてる場合、評価対象が異なり single-number 比較不可
  9. (9) resolution: brush は max_resolution=1920 → NeRF Synthetic 800×800 は no-op。splat-rs が downsample してると別件
  10. (10) tonemap / sRGB / gamma: brush は線形空間で素直に比較、tonemap なし。splat-rs に色空間変換が挟まると別問題
  11. (11) AlphaMode 経路: brush は CLI --alpha-mode=maskedMasked に切替可能 (premultiply しない、loss-map に gt.a を掛ける)。brush の公式 37.4 dB がどちらで取られたかは要確認 (主仮説の崩壊条件)
  12. (12) SSIM kernel: brush は 11-tap 可分離 Gaussian, C1=1e-4, C2=9e-4, clamp [-1,1]。SSIM 比較する場合は constant と blur 半径を一致させる

9. 主仮説と検証手順 (A.2 + A.3 で実行)

主仮説

「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。

  1. A.2: splat-rs 側の eval (PSNR 計算箇所、bg 処理、test split、α handling) を本 doc と同じ axes で audit
  2. A.3: 同 ckpt (brush 学習結果 .ply) を splat-rs の eval pipeline で評価 し直し、何 dB 出るかを測る (両 trainer の output を同 eval で揃える)
  3. A.3b: 同 GT を brush の eval convention で再計算: splat-rs render 結果を premultiplied-α + bg=black で PSNR 取り直し、何 dB shift するか測る
  4. A.3c: brush の actual run command (CLI args) を README/script から探し、--alpha-mode がどう設定されてたか確認 (axis 11 の主仮説崩壊条件 check)

10. 不明点 (深掘りせず次 phase に渡す)

  • brush の公式 37.4 dB report がどの commit / CLI args で取られたかは brush repo 内 README/CI スクリプトを A.2 で確認する必要あり (本 audit では特定せず)
  • brush の 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 確認)
  • brush の LOD 機能 (lod_levels) が 30k run でどう作用するか — default 0 (off) なので lego sh3 30k は plain train + 30k iter のみと推定するが要確認
  • training 側で毎 iter ランダム bg を使うが eval は固定 bg=ZERO — この「training/eval bg mismatch」が brush 側で意図的設計か、暗黙の bias なのかは brush DESIGN doc にあたる必要あり (本 audit scope 外)

11. file:line index (本 audit で読んだ source)

役割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 audit (並列 subagent が作成中): p1-a-2-splat-rs-eval-audit
  • P1.A.3 同 ckpt cross-eval: p1-a-3-cross-eval-bridge (本 audit + A.2 の checklist を使い実測)
  • 既存 brush vs splat 37 dB gap analysis: brush-vs-splat-37dB-gap-analysis.md
  • brush bench 実測: m4-brush-bench
  • NeRF Synthetic multi-scene results: a-4-nerf-synthetic-scene-results
03

関連 finding

© 2026 大竹律輝 — 松田研究室