松田研究室
FindingID: p1-a-2-splat-eval-audit
Status: stable / task P1.A.2
← Findings Index
P1 audit · splat-rs side · eval-only

P1.A.2 splat-rs eval audit — val split 100view・α 除外・rendered 黒背景の RGB-only PSNR

splat-rs trainer の eval (PSNR 計算) を定式化。PSNR は train.rs::compute_psnr の 1 箇所のみで、(a) transforms_val.json (100 view、test 200 view ではない)、(b) RGB only / α 除外、(c) rendered は raw f32 (clamp なし)、(d) target は white-bg pre-composite、(e) rendered には bg 合成を行わず Σ α_i T_i c_i のみで終わる。training 側 loss は同じ 4ch (α 含む) を見るため、α=1-T が暗黙に white-bg collapse を引っ張る。brush 標準 (test split / 加重 / clamp / α-mask) と複数の convention diff があり、P1.A.3 reproducer で 7 項目を順次切り分けて潜在的 −3〜−6 dB の apparent 部分を分離する。

neutralrank: highneutral第 1 軸第 3 軸phase-1brush-parityevalpsnr-formulaconvention-diffauditself-trainer
Δ PSNR
N/A (本タスクでは PSNR を変えない、audit 結果のみ提示)
Δ Wallclock
N/A (audit のみ)
Impact
splat-rs trainer の eval は (1) val split 100 view・brush は test split 200 view、(2) PSNR は RGB のみ・α 除外、(3) rendered は raw f32 (clamp / quantize なし)・brush 標準は u8 quant 後、(4) rendered は bg 合成なしで Σαi·Ti·ci のみ・target は white-bg pre-composite — の 4 つの diff 軸を持つ。training loss は 4ch (α 含む) で動くため、α 通り collapse が暗黙の white-bg 効果を作るが、convergence は不完全。P1.A.3 で 7 項目の切り分け reproducer を作り apparent gap (推定 −3〜−6 dB) を分離する。
01

本文

splat-rs (自作 trainer) と brush (37.40 dB) の lego sh3 30k での 12.56 dB gap の真因 audit のため、splat-rs 側 eval (PSNR 計算) の数式・dataset 範囲・前処理を file:line 単位で定式化する。並行で P1.A.1 が brush 側を audit 中、本 doc はそれと diff を取って P1.A.3 reproducer script で convention-diff 部分の apparent gap を分離するための入力データを提供する。

実施日
2026-05-24 P1 brush parity loop
audit 対象
splat-cli/src/cmd/train.rs::compute_psnr + 周辺 (eval pipeline 全体)
binary
main HEAD (lego sh3 30k で 24.879 dB を出す stamp)
確認 grep
PSNR/eval 計算は train.rs L182 の 1 箇所のみ (render.rs / check.rs / splat-summary には別 PSNR 経路なし)

1. PSNR の数式定式化

splat-rs の PSNR は splat-cli/src/cmd/train.rs:182-200 fn compute_psnr で定義される唯一の経路。数式は以下のとおり:

MSE = (1 / (3·H·W)) · Σ_{p ∈ pixels} Σ_{c ∈ {R,G,B}} (rendered[p,c] − target[p,c])²
PSNR = -10 · log10(MSE)        [α channel は和から除外、MAX = 1.0]

  if MSE <= 1e-12: return 100.0    # 飽和カットオフ

入力:
  rendered : Vec<f32> 長さ H·W·4  (chunks(4) で per-pixel RGBA、α 列 = 1 - T)
  target   : Vec<f32> 長さ H·W·4  (chunks(4) で per-pixel RGBA、α 列 = 1.0 固定)

実装: train.rs:187-194
  for chunk in rendered.chunks(4).zip(target.chunks(4)):
      for c in 0..3:           # ← α channel index 3 は除外
          diff = (r[c] - t[c]) as f64
          sum_sq += diff * diff
          count  += 1
  mse = sum_sq / count          # count = pixels · 3
  • MAX = 1.0 固定 ([0,1] 線形 RGB 前提)、255 系ではない
  • α 除外 (chunk[3] は MSE 和に入らない、target α = 1.0 固定)
  • per-pixel 平均 (per-channel 平均ではない、Σ÷count で count = 3·H·W)
  • clamp なし (raw f32 をそのまま使う、shader comment が「< 0 や > 1 を許可」と明記)
  • quantize なし (u8 化 → 戻し をしない、brush 標準は通常 u8 quant 後)
  • per-view PSNR の mean (mean-PSNR、PSNR-of-mean-MSE ではない、train.rs:147-149)
  • MSE ≤ 1e-12 で PSNR = 100 dB に飽和 (lossless 時の log10(0) 回避)

2. eval が見る画像 — rendered の作られ方

train.rs:130 trainer.forward_rgba(¶m, cam, img_size) 経由で shaders/forward/rasterize.metal::render_splats_f32 が動く。compositing 数式は brush/rasterize.wgsl と同一の volumetric front-to-back:

per-pixel state: pix_out = float3(0.0)、T = 1.0
per-splat:
  sigma = 0.5·(conic_x·dx² + conic_z·dy²) + conic_y·dx·dy
  alpha = min(0.999, color.a · exp(-sigma))
  if alpha < 1/255: skip
  next_T = T · (1 - alpha)
  if next_T <= 1e-4: break               # early termination (T 飽和)
  pix_out += rgb · (alpha · T)
  T = next_T

最終出力 (rasterize.metal:215):
  out[idx] = float4(pix_out,  1.0 - T)   # α channel = foreground occupancy
重要

rendered には background color が合成されていない。RGB は単に Σ α_i T_i c_i で終わり、T が残る部分 (背景 pixel) の RGB は (0, 0, 0) のまま。一方 target は load_rgba_white_bg白背景 pre-composite される (PNG 4ch RGBA から rgb·α + (1-α)·1.0)。よって training は「splat 自身が白を学ぶ」前提に依存しており、α channel (1-T = target=1.0) を loss に含めることで暗黙の white-bg collapse を強制する。完全 convergence できない場合、background 領域での MSE が大きく残り、apparent PSNR を下げる。

3. eval が見る画像 — target の作られ方

splat-io/src/dataset.rs:140-166 fn load_rgba_white_bg:

for px in PNG.pixels():                    # 8-bit RGBA を [0,1] 正規化
    r, g, b, a = px / 255.0
    # 白背景 α blend: 真の white-bg composite
    rgb_out = rgb · α + (1 - α) · 1
    α_out   = 1.0                          # 出力 α は常に 1 (mask 情報は捨てる)

target は H·W·4 RGBA、値域 [0, 1]、α channel = 1.0 固定。
  • 前景の α-mask 情報は 捨てられる (α_out = 1.0 hardcode、L163)
  • つまり brush の image *= alpha_mask 風 eval (前景のみ評価) は構造的に不可能
  • linear RGB / sRGB 区別なし (PNG を単純に 255 で割る、gamma curve 補正なし)
  • tonemap なし、HDR clip なし

4. dataset split — どの json を eval に使うか

train.rs:115 val_path = cfg.data.dataset_path.join("transforms_val.json")val split (100 view) を eval に使用。NeRF Synthetic は train/val/test = 100/100/200 の 3 split。brush と original 3DGS は test split (200 view) で報告するのが標準。これは apples-to-oranges の最大候補で、camera 分布が異なるため絶対 PSNR が違う。

項目splat-rs (本実装)brush / orig 3DGS 標準影響
split filetransforms_val.jsontransforms_test.jsoncamera 分布差
view 数100200mean のばらつき
eval 順序全 view 順 iterate (skip なし)通常全 view、論文 figure は subsetneutral
per-view weight等加重 mean等加重 mean同等

5. 画像前処理 (eval 時) — diff の温床

項目splat-rsbrush 標準 (推測、P1.A.1 で確定)推定 diff 寄与
値域f32 raw (clamp なし)u8 quantize 後または clamp 済み+1 dB 程度?
α channelMSE 和に含めない (RGB only)通常 RGB only、α-mask 適用ありの場合ありconvention
sRGB↔linearなし (両方 raw linear)なし (両方 raw linear) と思われるneutral
tonemapなしなしneutral
α-mask 適用なしimage *= alpha_mask が brush eval にある (gap doc §6)−1〜−3 dB?
bg compositerendered には未合成 (黒)、target は white 合成済み通常 rendered にも同 bg を合成して MSE 計算training の暗黙吸収次第

6. training loss との関係 (内部 consistency)

Training loss は shaders/loss/loss.metal::loss_l1_only および loss_l1_combine_ssim。重要点は n_total = W·H·4α channel を含めて L1 loss を取ること:

loss_metal::n_total = W · H · 4         # ← 4 channel すべて
dldr[gid] = sign(r - t) / n_total       # all 4 channels
loss_sum = Σ |r[gid] - t[gid]|          # all 4 channels
  • training は α channel を loss に含める。target α=1.0 vs rendered α=1-T で diff |T| が常に loss に効く
  • 結果として、splat 群は背景 pixel で T → 0 (= 完全 occupancy) になるように pressure を受ける
  • rendered.rgb が黒のまま T → 0 になると pix_out = Σ α_i T_i c_i → 1 (白) になる学習が暗黙に進行 — つまり background 領域は「白の splat を生やす」方向に学ぶ
  • ただし完全 convergence には至らないため、background 領域での MSE は完全には消えない (eval 時に apparent gap として現れる)
  • eval は RGB のみ (α 除外)、training は α 含む — 内部 inconsistency

7. brush との diff 候補 (P1.A.3 reproducer 設計用)

P1.A.3 で「同じ trained ply / camera から、convention を 1 つずつ揃えていって brush に近づける」reproducer を組むときに切り分けるべき軸を、潜在 impact 順 (確信度高い順) に列挙する:

#convention 軸splat-rs 現状brush 想定推定 impact
D1split filetransforms_val.json (100view)transforms_test.json (200view)absolute 差 ±1〜2 dB
D2rendered の bg未合成 (T 残り部分 RGB=0)white-bg 合成 (rendered.rgb += T·1)−2〜−4 dB? (training 不完全分)
D3α-mask 適用なしimage *= alpha_mask (gap doc 推定)−1〜−3 dB
D4rendered clampraw f32 (負値・>1 もそのまま)u8 quant or [0,1] clamp+0.5〜1 dB
D5PSNR formulaRGB only、MAX=1、per-pixel mean、view mean同等と推測 (要 P1.A.1 確認)neutral か ±0.3 dB
D6α channel in MSE除外通常除外、ただし α-mask path で実質含まれるD3 と相関
D7lossless saturationMSE≤1e-12 で 100 dB clamp実装依存neutral

8. 現状 splat-rs eval が brush 準拠でない可能性が高い箇所 (疑い順)

  • (最強疑い) D1: val vs test split — train.rs:115 が transforms_val.json を hardcode、brush は transforms_test.json。100 view ≠ 200 view で camera 分布も違うため、apples-to-oranges。これだけで absolute PSNR の起点が違う可能性。
  • (強い疑い) D2: rendered に bg を合成していない — rasterize.metal:215 が float4(pix_out, 1-T) で終わる。brush では rendered 側も同じ white-bg composite を行う実装が多い (training/eval の対称性を保つため)。training の不完全 convergence 分が background 残差として eval MSE を膨らませる可能性。
  • (中程度) D3: α-mask 適用なし — splat-rs eval は前景マスクなし、brush は image *= alpha_mask で前景のみ eval する path を持つ (gap doc 推定)。前景のみで PSNR を取ると背景の MSE 不一致がキャンセルされ +3 dB 級の上振れがあり得る。
  • (弱い疑い) D4: clamp / quantize 欠如 — render_splats_f32 の comment に「< 0 や > 1 を許可 (debug 用)」とある。eval で raw f32 を MSE に入れると overshoot が二乗で増幅される。brush 標準は通常 u8 quant 後の MSE。
  • (neutral) D5: PSNR formula 自体 — RGB only / MAX=1 / per-pixel mean / view mean — orig 3DGS 標準と同じはず、ただし P1.A.1 で brush 側数式を確認するまでは 100% 同等とは断定不可。
  • (構造的) D6 training-eval inconsistency — training loss は α 含む 4ch、eval は RGB のみ。training は「α=1.0 にせよ」と loss を流すが eval は α を見ない。α channel に費やされる gradient 分が RGB 改善のための gradient と competing し、convergence を遅らせる可能性 (gap への寄与は二次的、しかし潜在的)。

9. P1.A.3 reproducer に持ち込むべき file:line

役割fileline備考
PSNR 関数本体splat/crates/splat-cli/src/cmd/train.rs182-200compute_psnr (-10·log10(MSE))
eval driversplat/crates/splat-cli/src/cmd/train.rs112-151val_ds.views を iterate
split hardcodesplat/crates/splat-cli/src/cmd/train.rs115transforms_val.json
forward (rasterize)splat/crates/splat-train-v1/src/trainer.rs97-104forward_rgba
rasterize shadersplat/shaders/forward/rasterize.metal140-218render_splats_f32 (bg 合成なし)
target loadersplat/crates/splat-io/src/dataset.rs140-166load_rgba_white_bg (target に bg 合成)
dataset split filesplat/crates/splat-io/src/dataset.rs32-71load_nerf_synthetic
PNG dump (debug)splat/crates/splat-cli/src/cmd/train.rs293-318save_rgba_png (eval には影響しない、参考)
training loss (α 含む)splat/shaders/loss/loss.metal31-88loss_l1_only / loss_l1_combine_ssim、n_total = W·H·4

10. 不明点 (P1.A.1 brush 側 audit 結果との突合で確定)

  • brush の test split file 名・view 数の確認 (transforms_test.json で 200 view?)
  • brush の rendered output が background 合成済みか否か (training loss / eval 共通で同じ canvas を使うか)
  • brush eval が clamp / u8 quant を入れるか (raw f32 MSE か u8 MSE か)
  • brush eval が α-mask gating を本当にやっているか (gap doc は「推定」止まり、ソース確認必要)
  • brush の PSNR formula が MAX=1 か MAX=255 か (前者なら同じ、後者なら +48 dB の系統差で gap が逆向きに反転する罠)
  • brush は per-view mean か per-view MSE → 全体 MSE → 1 回 PSNR か (前者と後者で値が違う、splat-rs は前者)
  • brush eval が sRGB → linear に変換するか (target / rendered 両方 linear なら不問)

11. P1.A.3 reproducer に必要な最小資材リスト

  • 同一 trained ply (splat-rs lego sh3 30k final.ply、必要に応じて brush 30k final も)
  • NeRF Synthetic lego dataset の transforms_val.json と transforms_test.json (両方欠かさず)
  • 切り分け 7 軸を ON/OFF できる小さい python or rust script (PIL/numpy で十分): (i) bg composite render side、(ii) clamp [0,1]、(iii) u8 quant、(iv) α-mask 適用、(v) split 切替、(vi) MAX=1 vs 255、(vii) per-view mean vs aggregate MSE
  • brush 側 eval を C 経由で叩く方法 または brush の rendered output を PNG dump して同じ script に流す方法 (どちらかでも diff 切り分け可能)
次アクション (P1.A.3 へ申し送り)

本 audit で「splat-rs 側の eval 全体像」は file:line で完全に確定。残る不確実性は brush 側 eval の挙動 (D1〜D7 の対応関係) であり、P1.A.1 finding doc とこの doc を並べて読めば reproducer の最小実装は (i) 同一 ply で同 camera を render し、(ii) 上記 7 軸を変えながら PSNR を計測する 1 つの python script で済む。expected outcome: 12.56 dB gap のうち −3〜−6 dB が convention diff (apparent)、残り −6〜−9 dB が真の trainer 品質差 という仕分けが付くこと。

関連

  • P1.A.1 brush eval audit (並走中、本 doc と diff する): 起草中
  • P1.A.3 reproducer script (本 doc + P1.A.1 を入力): 未着手
  • brush vs splat 37 dB gap 既存分析: brush-vs-splat-37dB-gap-analysis
  • c32 orig 3DGS eval convention 注意点: c32-orig3dgs-bench
  • lego sh3 30k 自作 trainer 24.879 dB の root run: a-4-nerf-synthetic-scene-results
02

関連する run

この finding が観測された / 言及している実験 run。

03

関連 finding

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