| <!doctype html> |
| <html lang="ko"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>S3GS Waymo Eval Viewer</title> |
| <style> |
| :root { color-scheme: dark; --bg:#0b0f14; --panel:#121922; --text:#e7edf5; --muted:#9fb0c3; --accent:#6ee7ff; --line:#263342; } |
| * { box-sizing: border-box; } |
| body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 system-ui, -apple-system, Segoe UI, sans-serif; } |
| header { position:sticky; top:0; z-index:10; background:rgba(18,25,34,.96); border-bottom:1px solid var(--line); padding:10px 14px; } |
| .row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } |
| h1 { font-size:16px; margin:0 14px 0 0; color:var(--accent); white-space:nowrap; } |
| label { color:var(--muted); font-size:12px; } |
| select, input[type="number"], input[type="range"], button { background:#0f1620; color:var(--text); border:1px solid #344456; border-radius:8px; padding:7px 9px; } |
| button { cursor:pointer; font-weight:650; } |
| button:hover { border-color:var(--accent); } |
| input[type="range"] { width:min(520px, 42vw); padding:0; } |
| .hint { color:var(--muted); margin-left:auto; font-size:12px; } |
| main { height:calc(100vh - 78px); display:grid; grid-template-rows:1fr auto; padding:10px; } |
| #stage { width:100%; height:100%; overflow:hidden; background:#05080c; border:1px solid #1e2a36; border-radius:14px; padding:10px; display:flex; align-items:center; justify-content:center; } |
| .viewer { width:100%; height:100%; display:grid; grid-template-rows:auto 1fr 1fr; gap:8px; } |
| .cam-labels { display:grid; grid-template-columns:78px repeat(3, 1fr); gap:8px; color:#b9c7d7; font-weight:700; text-align:center; letter-spacing:.02em; } |
| .image-row { display:grid; grid-template-columns:78px repeat(3, 1fr); gap:8px; min-height:0; } |
| .row-label { display:flex; align-items:center; justify-content:center; writing-mode:vertical-rl; transform:rotate(180deg); color:var(--accent); font-weight:800; border:1px solid var(--line); border-radius:10px; background:#08101a; } |
| .cell { position:relative; min-width:0; min-height:0; display:flex; align-items:center; justify-content:center; background:#02050a; border:1px solid #172231; border-radius:10px; overflow:hidden; } |
| .cell img { max-width:100%; max-height:100%; width:100%; height:100%; object-fit:contain; } |
| .badge { position:absolute; left:8px; top:7px; padding:2px 6px; border-radius:999px; background:rgba(0,0,0,.58); color:#dce8f5; font:11px ui-monospace, SFMono-Regular, Menlo, monospace; } |
| #status { padding:8px 12px 0; color:var(--muted); font-family:ui-monospace, SFMono-Regular, Menlo, monospace; } |
| .pill { padding:4px 8px; border:1px solid #344456; border-radius:999px; color:#cbd7e5; background:#0f1620; } |
| a { color:var(--accent); text-decoration:none; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <div class="row"> |
| <h1>S3GS Waymo Eval Viewer</h1> |
| <label>Scene<br><select id="scene"></select></label> |
| <label>Split<br><select id="split"></select></label> |
| <label>Render Type<br><select id="kind"></select></label> |
| <button id="prev">◀ Prev</button> |
| <button id="next">Next ▶</button> |
| <label>Frame<br><input id="frame" type="number" min="0" value="0" style="width:88px"></label> |
| <input id="slider" type="range" min="0" value="0"> |
| <span id="count" class="pill">-</span> |
| <span class="hint">Eval 기준은 test_videos 75 images. 한 프레임 = FRONT_LEFT / FRONT / FRONT_RIGHT. ←/→ or A/D, Home/End, Space autoplay</span> |
| </div> |
| </header> |
| <main> |
| <div id="stage"> |
| <div class="viewer"> |
| <div class="cam-labels"><div></div><div>FRONT_LEFT</div><div>FRONT</div><div>FRONT_RIGHT</div></div> |
| <div class="image-row" id="gtRow"><div class="row-label">GT</div></div> |
| <div class="image-row" id="predRow"><div class="row-label" id="predLabel">RENDER</div></div> |
| </div> |
| </div> |
| <div id="status"></div> |
| </main> |
| <script> |
| const camNames = ['FRONT_LEFT', 'FRONT', 'FRONT_RIGHT']; |
| const orderKinds = ['rgbs','depths','dynamic_rgbs','static_rgbs']; |
| const $ = id => document.getElementById(id); |
| let manifest, state = {scene:null, split:null, kind:null, idx:0, timer:null}; |
| function frameName(i){ return String(i).padStart(3,'0') + '.png'; } |
| function fillSelect(sel, values, preferred){ |
| sel.innerHTML = ''; |
| values.forEach(v => { const o=document.createElement('option'); o.value=v; o.textContent=v; sel.appendChild(o); }); |
| if(values.includes(preferred)) sel.value = preferred; |
| } |
| function sceneData(){ return manifest.scenes[state.scene] || {}; } |
| function splitData(){ return sceneData()[state.split] || {}; } |
| function info(kind){ return splitData()[kind]; } |
| function timeCount(){ |
| const gt = info('gt_rgbs'); |
| const kd = info(state.kind); |
| const count = Math.min(gt?.count || 0, kd?.count || 0); |
| return Math.floor(count / 3); |
| } |
| function updateCombos(changed){ |
| const scenes = Object.keys(manifest.scenes).sort((a,b)=>Number(a)-Number(b)); |
| if(!state.scene) state.scene = scenes.includes('103') ? '103' : scenes[0]; |
| fillSelect($('scene'), scenes, state.scene); state.scene = $('scene').value; |
| |
| const splits = Object.keys(sceneData()); |
| if(changed === 'scene' || !splits.includes(state.split)) state.split = splits.includes('test_videos') ? 'test_videos' : splits[0]; |
| fillSelect($('split'), splits, state.split); state.split = $('split').value; |
| |
| let kinds = Object.keys(splitData()).filter(k => k !== 'gt_rgbs'); |
| kinds.sort((a,b)=>orderKinds.indexOf(a)-orderKinds.indexOf(b)); |
| if(changed === 'scene' || changed === 'split' || !kinds.includes(state.kind)) state.kind = kinds.includes('rgbs') ? 'rgbs' : kinds[0]; |
| fillSelect($('kind'), kinds, state.kind); state.kind = $('kind').value; |
| } |
| function clampIdx(){ |
| const max = Math.max(0, timeCount() - 1); |
| state.idx = Math.max(0, Math.min(max, Number(state.idx)||0)); |
| $('frame').max = max; $('slider').max = max; |
| $('frame').value = state.idx; $('slider').value = state.idx; |
| $('count').textContent = `${state.idx+1} / ${max+1}`; |
| } |
| function imgSrc(kind, camIdx){ |
| const inf = info(kind); |
| if(!inf) return ''; |
| return inf.prefix + frameName(state.idx * 3 + camIdx); |
| } |
| function ensureCells(rowId){ |
| const row=$(rowId); |
| while(row.children.length < 4){ |
| const cell=document.createElement('div'); cell.className='cell'; |
| const badge=document.createElement('div'); badge.className='badge'; |
| const img=document.createElement('img'); |
| cell.appendChild(badge); cell.appendChild(img); row.appendChild(cell); |
| } |
| } |
| function renderRow(rowId, kind){ |
| ensureCells(rowId); |
| const row=$(rowId); |
| for(let c=0;c<3;c++){ |
| const cell=row.children[c+1]; |
| cell.querySelector('.badge').textContent = camNames[c]; |
| const img=cell.querySelector('img'); |
| img.src = imgSrc(kind, c); |
| img.alt = `${state.scene} ${state.split} ${kind} ${camNames[c]} frame ${state.idx}`; |
| } |
| } |
| function render(){ |
| updateCombos(); clampIdx(); |
| renderRow('gtRow', 'gt_rgbs'); |
| renderRow('predRow', state.kind); |
| $('predLabel').textContent = state.kind.toUpperCase(); |
| const gt0 = imgSrc('gt_rgbs', 0), pred0 = imgSrc(state.kind, 0); |
| $('status').innerHTML = `${state.scene} / ${state.split} / frame ${state.idx} GT top, ${state.kind} bottom <a href="${pred0}" target="_blank">open current render LEFT</a> · <a href="${gt0}" target="_blank">open current GT LEFT</a>`; |
| const url = new URL(location.href); url.searchParams.set('scene', state.scene); url.searchParams.set('split', state.split); url.searchParams.set('kind', state.kind); url.searchParams.set('idx', state.idx); history.replaceState(null,'',url); |
| } |
| function step(d){ state.idx += d; clampIdx(); render(); } |
| function setFromControls(changed){ |
| state.scene=$('scene').value; state.split=$('split').value; state.kind=$('kind').value; state.idx=Number($('frame').value)||0; |
| updateCombos(changed); clampIdx(); render(); |
| } |
| function togglePlay(){ |
| if(state.timer){ clearInterval(state.timer); state.timer=null; return; } |
| state.timer=setInterval(()=>{ const n=timeCount(); if(!n) return; state.idx=(state.idx+1)%n; render(); }, 350); |
| } |
| $('prev').onclick=()=>step(-1); $('next').onclick=()=>step(1); |
| $('scene').onchange=()=>setFromControls('scene'); $('split').onchange=()=>setFromControls('split'); $('kind').onchange=()=>setFromControls('kind'); |
| $('frame').onchange=()=>setFromControls('frame'); $('slider').oninput=e=>{ state.idx=Number(e.target.value); render(); }; |
| document.addEventListener('keydown', e=>{ |
| if(['INPUT','SELECT'].includes(document.activeElement.tagName)) return; |
| if(e.key==='ArrowLeft'||e.key.toLowerCase()==='a') step(-1); |
| else if(e.key==='ArrowRight'||e.key.toLowerCase()==='d') step(1); |
| else if(e.key==='Home'){ state.idx=0; render(); } |
| else if(e.key==='End'){ state.idx=Math.max(0,timeCount()-1); render(); } |
| else if(e.key===' ') { e.preventDefault(); togglePlay(); } |
| }); |
| fetch('manifest.json').then(r=>r.json()).then(m=>{ |
| manifest=m; |
| const p=new URLSearchParams(location.search); |
| state.scene=p.get('scene'); state.split=p.get('split'); state.kind=p.get('kind'); state.idx=Number(p.get('idx')||0); |
| render(); |
| }); |
| </script> |
| </body> |
| </html> |
|
|