🗒️ 3d-research indice esperimento E001 EN · IT

Esperimento E001 · diario di bordo

3DGS fotorealistico dai nostri dati: quanto, dove, e perché no

Dal work order alla scheda di valutazione: training reale di 3D Gaussian Splatting sulla scena test-gta-demo-1, con la scoperta dell'inviluppo di navigabilità e la serie depth-regularized. 1–2 luglio 2026, box GPU A10G.

Domanda
Quanto fotorealismo esce da un 3DGS addestrato sui nostri dati (video di gioco + pose della ricostruzione LingBot-MAP)? Abbastanza da farne lo hero renderer di 3d-viewer?
Risposta breve
Sì lungo il percorso di cattura (PSNR 28.7, superfici solide, ~90 fps): un salto netto sui punti. No in free-fly: la qualità collassa ~20 cm fuori dal corridoio di cattura (0.9 m), ed è un limite della cattura, non dell'ottimizzazione — nessun regolarizzatore lo sposta.
Dove
codice e doc: 3d-data-reconstruction/3dgs/ (commit 7a6fbe9) · artefatti: /home/ubuntu/3d-data-reconstruction/testruns/3dgs/test-gta-demo-1/ sulla box (ply 453 MB, spz 34 MB, 3 checkpoint, dataset COLMAP)
Stack
gsplat 1.5.3 (Apache-2.0) · torch 2.11+cu128 · A10G 24 GB · ~30 min e 4.4 GB VRAM per run

Il contesto

La pipeline 3d-data-reconstruction (LingBot-MAP, feed-forward) produce da un video: pose per frame, depth per pixel, e una point cloud densa. 3d-viewer oggi renderizza punti — il soffitto visivo è «pallini colorati». Il 3DGS promette superfici vere allo stesso costo di hosting statico. Il work order (Marco, 30/06) chiedeva: addestrare per davvero e misurare, prima di investire nell'integrazione.

Fase 0 — Ritrovare gli ingredienti · 1 lug

Il 3DGS si addestra contro immagini calibrate: servivano i 300 frame sorgente e le intrinsics. Nessuno dei due era dove ci si aspettava.

I frame: la directory demo non li referenzia; il comando originale, ripescato dalla bash history, puntava a ProPainter/gta_cave/19-47-44clip_300-600.mp4 (frame 0–299 a 60 fps) — non ai video di gta_videos/session-2/ che sembravano i candidati ovvi. La scena è un bunker-officina sotterraneo di GTA, non il «negozio» del work order.

Le intrinsics: LingBot-MAP le predice (FOV nel pose encoding) ma demo-two.py non le salva. Recuperarle è diventato un mini-risultato a sé:

Metodopointcloud.npz è esattamente depth.npy riproiettata attraverso K, filtrata per confidenza, concatenata in ordine frame/raster. Lo slice del frame 0 parte per costruzione dall'offset 0: accoppiando i suoi pixel mascherati con il prefisso della cloud si ottengono corrispondenze punto↔pixel esatte fino alla prima divergenza (rilevata via consistenza di profondità). Least-squares su 63k coppie pulite → fx=1100.39, fy=1089.54 @1080p (FOV 82.2°×52.7°), residui ~1e-5 px, con cx,cy che cadono su W/2,H/2 esattamente come prevede il codice della pipeline. Script: 3dgs/recover_intrinsics.py.
Strada abbandonata — ricostruire la corrispondenza sull'intera cloud: fallisce perché la rigenerazione delle sky mask non è bit-stabile (~1% dei pixel ai bordi morbidi cambia) e il conteggio non torna mai (43.88M vs 43.40M). Il prefisso pulito del frame 0 basta ed è dimostrabilmente esatto. Abbandonata anche l'idea di rilanciare il modello per farsi ridare le intrinsics: costo GPU inutile a fronte di un fit chiuso.

Sanity check di riproiezione (cloud → 4 frame, overlay a schermo diviso): allineamento al pixel su tutta la sequenza. Solo a quel punto si è acceso il training.

E1 — Baseline vanilla · 1 lug

gsplat simple_trainer default, 30k iterazioni, frame a 1600×900, init da 2M punti della cloud, holdout 1 frame su 8. Scelta deliberata: --no-normalize-world-space, così le gaussiane restano nel frame world della ricostruzione e tutto ciò che il viewer già fa (fly-through, framing) si trasferisce invariato.

Strade abbandonate — l'implementazione di riferimento Inria (CC-BY-NC: incompatibile con prodotti commerciali; gsplat è Apache-2.0 e riproduce il paper); COLMAP da zero (atterra in un frame di coordinate arbitrario e rompe l'allineamento con cloud e camera path — le pose LingBot-MAP fanno da «SfM» gratis); gli esempi di gsplat master (pin a torch 2.9.1 + dipendenze NVIDIA: checkout v1.5.3 allineato al wheel pip).
stepPSNRSSIMLPIPSgaussiane
3k (smoke)27.140.8340.3531.76M
30k28.700.8520.2531.92M
frame reale vs render 3DGS
Frame held-out 128 — sinistra il frame reale (mai visto in training), destra il render. Emissivi, falloff delle luci e cartelli leggibili; si perde un po' di texture ad alta frequenza sulla roccia.
La scoperta vera — le 300 pose coprono un corridoio di ~0.9 m. Dentro e lungo il corridoio: quasi fotorealismo. A 5 cm laterali: artefatti ai bordi. A 10 cm: smearing periferico. A 20+ cm: «spaghetti» di splat. La rotazione pura da una posa di training invece regge. Il modello non ha mai visto la scena da fuori il corridoio: è l'anamorfosi da street art — perfetta dal punto designato, deforme altrove. La decisione sul renderer è anche una decisione sul protocollo di cattura.
sweep offset laterale
Sweep laterale dalla posa 128 — in lettura: 0 cm, +5, +10, +20 cm. La degradazione è graduale ma inesorabile.

E2 — Depth-regularized, e la cloud esce di scena · 2 lug

Due mosse in un colpo. (1) L'init non legge più pointcloud.npz: si riproietta direttamente depth.npy — che è la stessa cosa per costruzione (verificato al float32), quindi la catena 3DGS perde un artefatto da 567 MB. Input del producer: frame + pose + depth + K, punto. (2) La depth entra nella loss: il --depth_loss di gsplat supervisiona la disparità sui punti SfM ancorati ai frame; campionando 6.700 pixel di depth per frame e registrandoli come track COLMAP, i campioni diventano ancore di profondità senza toccare il trainer.

runPSNRerr. depth p50p90splataniso p99
E1 baseline28.7012.45%32.2%1.92M1.6k
E2 depth loss28.634.78%20.5%1.88M2.2k
Lezione — la depth loss sistema la geometria, non i render off-path. L'errore di profondità renderizzata crolla di 2.6× (a fotometria identica): ottimo per usi a valle che si fidano della depth del 3DGS (labeling 3D, occlusion test). Ma l'inviluppo di navigazione non si muove: le ancore vivono sugli stessi raggi dei frame di training, e le gaussiane-ago che imbrogliano lungo quei raggi soddisfano fotometria e depth. La coda anisotropa (p99) resta intatta.

E3 — Regolarizzatori di forma: metà lezione, metà autogol · 2 lug

Attacco diretto alla coda: depth_lambda 5×, scale_reg 0.01 (penalizza gaussiane grandi), opacity_reg 0.001 (scoraggia i floaters).

runPSNRerr. depth p50p90splataniso p99
E3 +reg28.573.25%13.7%1.43M (−25%)289k (!)
Autogol istruttivoscale_reg penalizza la scala assoluta: per pagare meno, gli aghi si sono fatti più sottili invece che più corti (anisotropia p99 da 1.6k a 289k, strisciate luminose visibili off-path). Da sostituire, se mai, con una penalità sul rapporto max/min delle scale. opacity_reg invece tiene: −25% di splat a pari PSNR, render più veloce, file più piccolo.
confronto off-path baseline / E2 / E3
Off-path a confronto (colonne: E1 | E2 | E3; righe: +10, +20, +40 cm) — a occhio si equivalgono: la conferma che fuori dal corridoio il collo di bottiglia sono le osservazioni mancanti, non la ricetta di training.

La matrice delle ablazioni (leggere PRIMA di lanciare un run)

Ogni run costa ~30 min di A10G. Prima di lanciarne uno: controlla qui che la combinazione non sia già stata provata, e cambia una variabile alla volta. Metriche: PSNR sui 38 frame held-out; errore depth = depth renderizzata vs depth LingBot; aniso = rapporto max/min delle scale per gaussiana (la coda p99 sono gli «spaghetti»).

rundepth_λscale_regopacity_regPSNRdepth p50p90splataniso p99verdetto
E128.7012.45%32.2%1.92M1.6kbaseline vanilla
E21e-228.634.78%20.5%1.88M2.2k✓ geometria 2.6×, fotometria invariata — pulito (1 sola variabile)
E35e-20.010.00128.573.25%13.7%1.43M289k⚠️ 3 variabili insieme → attribuzione impossibile; scale_reg patologico (aghi più sottili, non più corti)
E4 — aperto5e-20.001?il test di attribuzione: se depth p50 → ~3.3% con aniso sana, il merito era di depth_λ (e va nel producer); se resta ~4.8%, il guadagno di E3 era un artefatto di scale_reg
Metodo — E3 è la lezione: tre variabili in un run solo hanno prodotto il miglior numero di geometria e l'impossibilità di sapere perché. D'ora in poi: una variabile per run, oppure combinazioni pianificate qui sopra prima di lanciare. Candidati sensati dopo E4: penalità sul rapporto delle scale (al posto di scale_reg assoluto, contro gli aghi), dense depth loss pesata su depth_conf.npy. La ricetta del producer oggi è la riga «E2 + opacity_reg» — si aggiorna solo con una riga verificata di questa tabella.

Ingegnerizzazione — la catena è chiusa · 2 lug

Dal risultato di ricerca al prodotto, due comandi:

# GPU box (3d-data-reconstruction): prep → training (E2+opacity_reg) → scene.sog
python 3dgs/train_gsplat.py --demo-dir <recon> --frames-dir <frames> --intrinsics <K.npz>
# viewer (3d-viewer): bounds + camera path + manifest, nessuna GPU
python build_scene.py <recon> --name <scena> --producer gsplat --gsplat-asset <scene.sog>

La divisione rispetta la separazione dei repo: il training (GPU, conda) vive con la pipeline; 3d-viewer riceve solo il .sog pronto e resta display+navigation. Il rendering nel viewer è Spark 2.1 su three.js moderno, isolato dal three antico di Potree; camera path e fly-through invariati. Smoke test end-to-end a 300 step: ha già ripagato trovando un bug di path relativi prima del primo run vero.

Dove siamo, cosa resta aperto

Config del producer: ricetta E2 (init da depth + depth loss) + opacity_reg, niente scale_reg — si cambia solo con una riga verificata della matrice delle ablazioni.


Convenzione del diario: una pagina per campagna di esperimenti, in ordine cronologico; le decisioni nei callout terracotta, le strade abbandonate (con motivazione) nei callout grigi, le sorprese in verde oliva. I numeri sempre in tabella, gli artefatti pesanti restano sulla box con il path annotato nella scheda.