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 部分を分離する。
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 を分離するための入力データを提供する。
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
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 を下げる。
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 固定。
image *= alpha_mask 風 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 file | transforms_val.json | transforms_test.json | camera 分布差 |
| view 数 | 100 | 200 | mean のばらつき |
| eval 順序 | 全 view 順 iterate (skip なし) | 通常全 view、論文 figure は subset | neutral |
| per-view weight | 等加重 mean | 等加重 mean | 同等 |
| 項目 | splat-rs | brush 標準 (推測、P1.A.1 で確定) | 推定 diff 寄与 |
|---|---|---|---|
| 値域 | f32 raw (clamp なし) | u8 quantize 後または clamp 済み | +1 dB 程度? |
| α channel | MSE 和に含めない (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 composite | rendered には未合成 (黒)、target は white 合成済み | 通常 rendered にも同 bg を合成して MSE 計算 | training の暗黙吸収次第 |
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
pix_out = Σ α_i T_i c_i → 1 (白) になる学習が暗黙に進行 — つまり background 領域は「白の splat を生やす」方向に学ぶP1.A.3 で「同じ trained ply / camera から、convention を 1 つずつ揃えていって brush に近づける」reproducer を組むときに切り分けるべき軸を、潜在 impact 順 (確信度高い順) に列挙する:
| # | convention 軸 | splat-rs 現状 | brush 想定 | 推定 impact |
|---|---|---|---|---|
| D1 | split file | transforms_val.json (100view) | transforms_test.json (200view) | absolute 差 ±1〜2 dB |
| D2 | rendered の bg | 未合成 (T 残り部分 RGB=0) | white-bg 合成 (rendered.rgb += T·1) | −2〜−4 dB? (training 不完全分) |
| D3 | α-mask 適用 | なし | image *= alpha_mask (gap doc 推定) | −1〜−3 dB |
| D4 | rendered clamp | raw f32 (負値・>1 もそのまま) | u8 quant or [0,1] clamp | +0.5〜1 dB |
| D5 | PSNR formula | RGB only、MAX=1、per-pixel mean、view mean | 同等と推測 (要 P1.A.1 確認) | neutral か ±0.3 dB |
| D6 | α channel in MSE | 除外 | 通常除外、ただし α-mask path で実質含まれる | D3 と相関 |
| D7 | lossless saturation | MSE≤1e-12 で 100 dB clamp | 実装依存 | neutral |
transforms_val.json を hardcode、brush は transforms_test.json。100 view ≠ 200 view で camera 分布も違うため、apples-to-oranges。これだけで absolute PSNR の起点が違う可能性。float4(pix_out, 1-T) で終わる。brush では rendered 側も同じ white-bg composite を行う実装が多い (training/eval の対称性を保つため)。training の不完全 convergence 分が background 残差として eval MSE を膨らませる可能性。image *= alpha_mask で前景のみ eval する path を持つ (gap doc 推定)。前景のみで PSNR を取ると背景の MSE 不一致がキャンセルされ +3 dB 級の上振れがあり得る。| 役割 | file | line | 備考 |
|---|---|---|---|
| PSNR 関数本体 | splat/crates/splat-cli/src/cmd/train.rs | 182-200 | compute_psnr (-10·log10(MSE)) |
| eval driver | splat/crates/splat-cli/src/cmd/train.rs | 112-151 | val_ds.views を iterate |
| split hardcode | splat/crates/splat-cli/src/cmd/train.rs | 115 | transforms_val.json |
| forward (rasterize) | splat/crates/splat-train-v1/src/trainer.rs | 97-104 | forward_rgba |
| rasterize shader | splat/shaders/forward/rasterize.metal | 140-218 | render_splats_f32 (bg 合成なし) |
| target loader | splat/crates/splat-io/src/dataset.rs | 140-166 | load_rgba_white_bg (target に bg 合成) |
| dataset split file | splat/crates/splat-io/src/dataset.rs | 32-71 | load_nerf_synthetic |
| PNG dump (debug) | splat/crates/splat-cli/src/cmd/train.rs | 293-318 | save_rgba_png (eval には影響しない、参考) |
| training loss (α 含む) | splat/shaders/loss/loss.metal | 31-88 | loss_l1_only / loss_l1_combine_ssim、n_total = W·H·4 |
本 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 品質差 という仕分けが付くこと。
brush-vs-splat-37dB-gap-analysisc32-orig3dgs-bencha-4-nerf-synthetic-scene-resultsこの finding が観測された / 言及している実験 run。