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/(commit7a6fbe9) · 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é:
pointcloud.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.
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.
v1.5.3 allineato al wheel pip).
| step | PSNR | SSIM | LPIPS | gaussiane |
|---|---|---|---|---|
| 3k (smoke) | 27.14 | 0.834 | 0.353 | 1.76M |
| 30k | 28.70 | 0.852 | 0.253 | 1.92M |
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.
| run | PSNR | err. depth p50 | p90 | splat | aniso p99 |
|---|---|---|---|---|---|
| E1 baseline | 28.70 | 12.45% | 32.2% | 1.92M | 1.6k |
| E2 depth loss | 28.63 | 4.78% | 20.5% | 1.88M | 2.2k |
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).
| run | PSNR | err. depth p50 | p90 | splat | aniso p99 |
|---|---|---|---|---|---|
| E3 +reg | 28.57 | 3.25% | 13.7% | 1.43M (−25%) | 289k (!) |
scale_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.
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»).
| run | depth_λ | scale_reg | opacity_reg | PSNR | depth p50 | p90 | splat | aniso p99 | verdetto |
|---|---|---|---|---|---|---|---|---|---|
| E1 | – | – | – | 28.70 | 12.45% | 32.2% | 1.92M | 1.6k | baseline vanilla |
| E2 | 1e-2 | – | – | 28.63 | 4.78% | 20.5% | 1.88M | 2.2k | ✓ geometria 2.6×, fotometria invariata — pulito (1 sola variabile) |
| E3 | 5e-2 | 0.01 | 0.001 | 28.57 | 3.25% | 13.7% | 1.43M | 289k | ⚠️ 3 variabili insieme → attribuzione impossibile; scale_reg patologico (aghi più sottili, non più corti) |
| E4 — aperto | 5e-2 | – | 0.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 | ||||
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.
- Dense depth loss pesata sulla confidenza — oggi usiamo 6.7k ancore/frame
su 1.44M pixel e
depth_conf.npysolo come filtro binario; una L1 densa sul canale depth del render è una piccola patch al trainer. - Protocollo di cattura — la risposta strutturale al free-fly: passate a baseline larga sulla stessa scena. Da provare appena la pipeline di cattura lo consente.
- Verifica in browser — ✅ fatta (2 lug):
loadGsplat()implementato in3d-viewercon Spark 2.1 + three.js moderno (engine separato dal three antico di Potree, stesso spazio Z-up: camera path e fly-through invariati). Sorpresa di formato: lo.spzdi splat-transform 2.7 è versione 4, che Spark rifiuta — risolto esportando.sog(27 MB, ancora più piccolo). La scena da 1.92M splat renderizza dalla prima posa con traiettoria in overlay (verificato headless con SwiftShader). Il producergsplatè chiuso anche lui — vedi «Ingegnerizzazione» sopra. - Direzione prodotto — 3D spatial labeling navigando la scena: 3DGS come layer di display, label calcolate nel world frame condiviso (che abbiamo preservato apposta), grounding su RGB-D posato.
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.