VIPOSeg_final / app.js
黄薇洋
Publish archived VIPOSeg review UI
12f6966
Raw History Blame Contribute Delete
11.4 kB
const state={all:[],filtered:[],page:0,pageSize:30,current:null,split:document.body.dataset.split||'train',reviews:{},archiveUrls:{}};
const $=id=>document.getElementById(id);
const colors=['#2563eb','#db2777','#16a34a','#ea580c','#7c3aed','#0891b2','#ca8a04','#dc2626','#4f46e5','#0f766e'];
const reviewKey=()=>`viposeg-review-${state.split}`;
function loadReviews(){try{state.reviews=JSON.parse(localStorage.getItem(reviewKey())||'{}')}catch(_){state.reviews={}}}
function saveReviews(){localStorage.setItem(reviewKey(),JSON.stringify(state.reviews))}
function reviewOf(row){return state.reviews[row.id]||{decision:'pending',action_video:false,caption_override:null,note:''}}
function hasLow(row){return (row.confidence_counts?.low||0)+(row.confidence_counts?.medium||0)>0}
function statusOf(row){const r=reviewOf(row);if(r.action_video)return'action';if(r.decision==='reject'||row.validation?.needs_review||hasLow(row))return'review';return'clean'}
function applyFilters(){const q=($('search').value||'').toLowerCase().trim(),f=$('status').value;state.filtered=state.all.filter(row=>{const hit=!q||(row.video_id+' '+row.caption).toLowerCase().includes(q),s=statusOf(row);return hit&&(f==='all'||(f==='review'&&s==='review')||(f==='clean'&&s==='clean')||(f==='low'&&hasLow(row))||(f==='action'&&s==='action'))});state.page=0;renderList();loadAtPage()}
function renderList(){const start=state.page*state.pageSize,end=Math.min(start+state.pageSize,state.filtered.length);$('list-count').textContent=`${state.filtered.length} videos`;$('page-label').textContent=state.filtered.length?`${start+1}–${end}`:'0';$('entry-list').innerHTML='';for(const row of state.filtered.slice(start,end)){const item=document.createElement('div');item.className=`entry-item ${row.id===state.current?.id?'active':''}`;const dot=document.createElement('span');dot.className=`dot ${statusOf(row)}`;const id=document.createElement('span');id.className='id';id.textContent=row.video_id;const count=document.createElement('span');count.className='small';count.textContent=`${row.frames_sent||row.images.length}f`;item.append(dot,id,count);item.onclick=()=>loadRow(row.id);$('entry-list').append(item)}$('prev-page').disabled=state.page===0;$('next-page').disabled=end>=state.filtered.length}
function loadAtPage(){if(!state.filtered.length){showEmpty();return}loadRow(state.filtered[state.page*state.pageSize].id)}
function loadRow(id){const row=state.filtered.find(x=>x.id===id);if(!row)return;state.current=row;state.page=Math.floor(state.filtered.indexOf(row)/state.pageSize);renderList();renderEntry();$('loading').classList.add('hidden');$('entry').classList.remove('hidden')}
function showEmpty(){$('loading').textContent='No videos match this filter.';$('loading').classList.remove('hidden');$('entry').classList.add('hidden')}
function shortId(id){const m=String(id||'').match(/^obj_(\d+)$/);return m?String(Number(m[1])):String(id||'?')}
function renderBadges(){const row=state.current,v=row.validation||{},badges=[['span',v.span_valid],['thing coverage',v.coverage_valid],['forbidden terms',v.forbidden_term_valid],['confidence',!hasLow(row)]];$('badges').innerHTML='';for(const [label,ok] of badges){const el=document.createElement('span');el.className=`badge ${ok?'':'warn'}`;el.textContent=`${label}: ${ok?'ok':'check'}`;$('badges').append(el)}if(row.source_status==='repaired'){const el=document.createElement('span');el.className='badge warn';el.textContent='span: repaired';$('badges').append(el)}}
function renderCaption(){const row=state.current,review=reviewOf(row),el=$('caption');el.innerHTML='';if(review.caption_override){el.textContent=review.caption_override;return}const spans=[];for(const group of row.groups||[])for(const mention of group.mentions||[]){if(Array.isArray(mention.char_span)&&mention.char_span.length===2)spans.push({s:mention.char_span[0],e:mention.char_span[1],mention,group})}const points=[...new Set([0,row.caption.length,...spans.flatMap(x=>[x.s,x.e])])].sort((a,b)=>a-b);for(let i=0;i<points.length-1;i++){const s=points[i],e=points[i+1],active=spans.filter(x=>x.s<=s&&e<=x.e),text=document.createTextNode(row.caption.slice(s,e));if(!active.length)el.append(text);else{const first=active[0],wrap=document.createElement('span');wrap.className='mention';wrap.style.setProperty('--c',colors[first.group.group_id%colors.length]);wrap.title=active.map(x=>`${x.group.instance_ids.join(', ')} · ${x.mention.text} · ${x.mention.confidence}`).join('\n');wrap.append(text);const sup=document.createElement('sup');sup.textContent=[...new Set(active.flatMap(x=>x.group.instance_ids.map(shortId)))].join(',');wrap.append(sup);el.append(wrap)}}}
async function archiveImageUrl(row){if(!row.archive)return imageUrl(row.images[0]);if(state.archiveUrls[row.id])return state.archiveUrls[row.id];const a=row.archive,response=await fetch(a.url,{headers:{Range:`bytes=${a.offset}-${a.offset+a.size-1}`}});if(!response.ok)throw Error(`Could not load contact sheet: ${response.status}`);let blob;if(response.status===206){blob=await response.blob()}else{const buffer=await response.arrayBuffer();blob=new Blob([buffer.slice(a.offset,a.offset+a.size)],{type:'image/webp'})}const url=URL.createObjectURL(blob);state.archiveUrls[row.id]=url;return url}
function renderFrames(){const row=state.current,box=$('frames'),big=$('big-frame');box.innerHTML='';big.innerHTML='';const setSource=img=>{archiveImageUrl(row).then(url=>{img.src=url}).catch(error=>{img.alt=error.message})};const show=i=>{big.innerHTML='';const img=document.createElement('img');img.alt=row.contact_sheet?'all sampled frames with mask overlays':'original frame and mask overlay';big.append(img);setSource(img);[...box.children].forEach((x,j)=>x.classList.toggle('active',j===i))};row.images.forEach((file,i)=>{const img=document.createElement('img');img.loading='lazy';img.title=row.contact_sheet?`all ${row.overlay_frame_count||row.frames_sent||'?'} sampled frames; click`:`sampled sequence index ${row.selected_frame_indices[i]??'?'}; click`;box.append(img);setSource(img);img.onclick=()=>show(i)});if(row.images.length)show(0)}
function renderObjects(){const box=$('objects');box.innerHTML='';for(const obj of state.current.objects){const row=document.createElement('div');row.className='obj';const id=document.createElement('div');id.className='obj-id';id.textContent=obj.object_id;const body=document.createElement('div');const cat=document.createElement('div');cat.className='obj-cat';cat.textContent=`${obj.category_name} · class ${obj.category_id??'?'} · ${obj.isthing?'thing':'stuff'}`;const range=document.createElement('div');range.className='range';range.textContent=`visible: ${(obj.visible_frame_ranges||[]).map(x=>x[0]===x[1]?x[0]:`${x[0]}–${x[1]}`).join(', ')||'none'} · ${obj.visible_frame_count||0} frames`;body.append(cat,range);row.append(id,body);box.append(row)}}
function renderGroups(){const box=$('groups');box.innerHTML='';for(const group of state.current.groups||[]){const div=document.createElement('div');div.className='group';div.style.setProperty('--c',colors[group.group_id%colors.length]);const title=document.createElement('div');title.className='group-title';title.textContent=`Obj_${String(group.mark_id).padStart(3,'0')} · ${group.category}`;div.append(title);for(const mention of group.mentions||[]){const line=document.createElement('div');line.className='group-mention';const text=document.createElement('code');text.textContent=mention.text;const span=document.createElement('span');span.className='span-position';span.textContent=`[${(mention.char_span||[]).join(', ')}]`;const confidence=document.createElement('span');confidence.className=`confidence ${mention.confidence||'medium'}`;confidence.textContent=mention.confidence||'medium';line.append(text,span,confidence);div.append(line)}const foot=document.createElement('div');foot.className='group-objects';foot.textContent=`${group.chain_agreement||'unknown'}${(group.mentions||[]).some(x=>x.is_pronoun)?' · coreference':''}`;div.append(foot);box.append(div)}}
function renderReview(){const r=reviewOf(state.current);$('action-video').checked=!!r.action_video;$('decision').value=r.decision||'pending';$('review-note').value=r.note||'';$('caption-editor').value=r.caption_override||state.current.caption;$('edit-state').textContent=r.caption_override?'· edited locally':''}
function renderIssues(){const v=state.current.validation||{},box=$('issues'),issues=v.issues||[];if(!issues.length&&!v.category_disagreement){box.classList.add('hidden');return}box.classList.remove('hidden');box.innerHTML='<h3>Automatic checks</h3>';const ul=document.createElement('ul');for(const issue of issues){const li=document.createElement('li');li.textContent=issue;ul.append(li)}if(v.category_disagreement){const li=document.createElement('li');li.textContent=`${v.category_disagreement} category agreement check(s) need review`;ul.append(li)}box.append(ul)}
function renderEntry(){const row=state.current;$('split-name').textContent=row.split.toUpperCase();$('title').textContent=row.video_id;const sampled=row.overlay_frame_count||row.frames_sent||row.images.length;$('meta').textContent=`${sampled} sampled frames · ${row.contact_sheet?'1 contact sheet':'overlay images'} · ${row.objects.length} objects · thing coverage ${row.coverage?.things_grounded??0}/${row.coverage?.things_shown??0} · span numbers are object indices`;renderBadges();renderCaption();renderFrames();renderObjects();renderGroups();renderReview();renderIssues()}
function saveReview(){const r=reviewOf(state.current);r.action_video=$('action-video').checked;r.decision=$('decision').value;r.note=$('review-note').value;state.reviews[state.current.id]=r;saveReviews();renderList();renderBadges()}
function editCaption(){$('caption').classList.add('hidden');$('caption-editor').classList.remove('hidden');$('edit-caption').classList.add('hidden');$('save-caption').classList.remove('hidden')}
function saveCaption(){const r=reviewOf(state.current);r.caption_override=$('caption-editor').value;state.reviews[state.current.id]=r;saveReviews();$('caption-editor').classList.add('hidden');$('caption').classList.remove('hidden');$('edit-caption').classList.remove('hidden');$('save-caption').classList.add('hidden');$('edit-state').textContent='· edited locally';renderCaption()}
function exportReviews(){const blob=new Blob([JSON.stringify({schema:'viposeg_review_decisions/v1',split:state.split,generated_at:new Date().toISOString(),reviews:state.reviews},null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`viposeg_${state.split}_review_decisions.json`;a.click();URL.revokeObjectURL(a.href)}
async function init(){loadReviews();const response=await fetch(`../data/${state.split}.json`);if(!response.ok)throw Error(`Could not load data: ${response.status}`);state.data=await response.json();state.all=state.data.entries||[];applyFilters()}
$('search').oninput=applyFilters;$('status').onchange=applyFilters;$('prev-page').onclick=()=>{if(state.page>0){state.page--;renderList();loadAtPage()}};$('next-page').onclick=()=>{if((state.page+1)*state.pageSize<state.filtered.length){state.page++;renderList();loadAtPage()}};$('save-review').onclick=saveReview;$('edit-caption').onclick=editCaption;$('save-caption').onclick=saveCaption;$('export').onclick=exportReviews;init().catch(error=>{$('loading').textContent=error.message});