import * as THREE from './vendor/three.module.min.js'; import { OrbitControls } from './vendor/OrbitControls.js'; const $ = id => document.getElementById(id); const names = ['Thumb', 'Index', 'Middle', 'Ring', 'Little', 'Palm']; const colors = ['#f87171','#fbbf24','#34d399','#22d3ee','#818cf8','#d8b4fe']; const state = { samples: [], filtered: [], index: 0, sample: null, selectedSensor: -1, layer: 'depth', angle: 0, playing: false, loadToken: 0, drawToken: 0, images: new Map(), canonical: false }; const stage = $('pointcloud'); let renderer, scene, camera, controls, cloud, model, specimen, sensorMeshes = [], sensorAxes = [], labelElements = [], rawPoints, center; let manifest; const fmt = n => Number(n).toLocaleString('en-US'); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1); const safePath = s => s && !/^(?:[a-z]+:|\/\/)/i.test(s) ? s : null; function init3D() { scene = new THREE.Scene(); scene.background = new THREE.Color('#132338'); renderer = new THREE.WebGLRenderer({antialias:true,alpha:false,powerPreference:'low-power'}); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; stage.prepend(renderer.domElement); camera = new THREE.PerspectiveCamera(33, 1, .1, 3000); camera.position.set(105, 70, 305); controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = .09; controls.minDistance = 45; controls.maxDistance = 1100; controls.enablePan = true; controls.maxPolarAngle = Math.PI; specimen = new THREE.Group(); model = new THREE.Group(); specimen.add(model); scene.add(specimen); const grid = new THREE.GridHelper(360, 18, '#3d526c', '#223750'); grid.rotation.x = Math.PI / 2; grid.position.z = -95; grid.material.transparent = true; grid.material.opacity = .4; scene.add(grid); const dots = []; for(let x=-180;x<=180;x+=20) for(let y=-180;y<=180;y+=20) dots.push(x,y,-94.9); const dotsGeometry = new THREE.BufferGeometry(); dotsGeometry.setAttribute('position',new THREE.Float32BufferAttribute(dots,3)); scene.add(new THREE.Points(dotsGeometry,new THREE.PointsMaterial({color:'#566d89',size:1.2,sizeAttenuation:false,transparent:true,opacity:.35}))); new ResizeObserver(() => { const {width,height}=stage.getBoundingClientRect(); renderer.setSize(width,height);camera.aspect=width/Math.max(height,1);camera.updateProjectionMatrix(); }).observe(stage); const tick=()=> { requestAnimationFrame(tick);controls.update();renderer.render(scene,camera); updateLabels(); }; tick(); } function disposeModel() { if(!model) return; for(const obj of [...model.children]) { model.remove(obj); obj.traverse(child=> {child.geometry?.dispose(); if(Array.isArray(child.material))child.material.forEach(m=>m.dispose());else child.material?.dispose();}); } sensorMeshes=[];sensorAxes=[]; $('sceneLabels').replaceChildren();labelElements=[]; } function buildCloud(points, sample) { if(!renderer) return; disposeModel();rawPoints=points; const transformed = new Float32Array(points.length); const bounds = new THREE.Box3(); for(let i=0;i { const mesh = new THREE.Mesh(new THREE.SphereGeometry(2.1,14,10),new THREE.MeshBasicMaterial({color:colors[i]})); mesh.position.set(sensor.x,-sensor.y,-sensor.z);model.add(mesh);sensorMeshes.push(mesh); const halo = new THREE.Mesh(new THREE.SphereGeometry(3.2,14,10),new THREE.MeshBasicMaterial({color:colors[i],transparent:true,opacity:.13,wireframe:true}));halo.position.copy(mesh.position);model.add(halo); const axisGroup = new THREE.Group();axisGroup.position.copy(mesh.position); const r=sensor.rotation; if(r?.length===9) for(let a=0;a<3;a++) { const direction = new THREE.Vector3(r[a],-r[3+a],-r[6+a]).normalize(); const arrow = new THREE.ArrowHelper(direction,new THREE.Vector3(),13,[0xe98971,0x9cc67e,0x74a7dd][a],3,1.7);axisGroup.add(arrow); } axisGroup.visible=$('showAxes').checked;model.add(axisGroup);sensorAxes.push(axisGroup); const el=document.createElement('span');el.className='scene-label';el.style.setProperty('--sensor-color',colors[i]);el.textContent=sensor.name || names[i];$('sceneLabels').append(el);labelElements.push(el); }); applyRotation();resetView(bounds.getSize(new THREE.Vector3()));updateSensorSelection(); } function colorCloud() { if(!cloud || !rawPoints) return; const mode=$('colorMode').value, c=new THREE.Color(), out=new Float32Array(rawPoints.length); const values=[];for(let i=0;inew THREE.Color(s)); for(let j=0;j{const p=mesh.getWorldPosition(new THREE.Vector3()).project(camera),el=labelElements[i];el.style.left=`${(p.x*.5+.5)*w}px`;el.style.top=`${(-p.y*.5+.5)*h}px`;el.style.display=p.z>1||p.z<0||Math.abs(p.x)>1.1||Math.abs(p.y)>1.1?'none':'';}); } function applyRotation() { const rad=-state.angle*Math.PI/180; if(model && center){model.rotation.z=rad;const centered=center.clone().applyAxisAngle(new THREE.Vector3(0,0,1),rad);specimen.position.copy(centered).multiplyScalar(-1);} $('rotation').value=state.angle;$('rotationValue').textContent=`${state.angle>0?'+':''}${Math.round(state.angle)}°`; $('rotationDisclaimer').textContent=state.canonical?`Ground-truth demonstration: ${state.angle.toFixed(1)}° from the measured palm frame, following the original derotate.m convention. No learned prediction is run.`:'This geometric demonstration uses measured palm orientation. It does not run the paper’s learned network.'; drawCamera();drawMethodPreview();updateSensorReadout(); } async function drawMethodPreview(){ const sample=state.sample;if(!sample)return; const angle=state.angle,img=await imageFor(sample.depth.path); if(!img||sample!==state.sample||angle!==state.angle)return; const K=manifest.dataset.cameraIntrinsics||{},w=sample.depth.width,h=sample.depth.height; ['rotationOriginal','rotationCurrent'].forEach((id,index)=>{const canvas=$(id),ctx=canvas.getContext('2d');canvas.width=w;canvas.height=h;ctx.fillStyle='#152a29';ctx.fillRect(0,0,w,h);ctx.save();if(index){ctx.translate(K.cx??w/2,K.cy??h/2);ctx.rotate(angle*Math.PI/180);ctx.translate(-(K.cx??w/2),-(K.cy??h/2));}ctx.imageSmoothingEnabled=false;ctx.drawImage(img,0,0,w,h);ctx.restore();}); } function imageFor(path) { if(!safePath(path))return Promise.resolve(null); if(state.images.has(path))return state.images.get(path); const task=new Promise(resolve=>{const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>resolve(null);img.src=path;});state.images.set(path,task);return task; } async function drawCamera() { const s=state.sample;if(!s)return; const token=++state.drawToken; const selected=state.selectedSensor<0?0:state.selectedSensor; const path=state.layer==='heatmap'?s.heatmaps?.[selected]?.path:s[state.layer]?.path; const img=await imageFor(path); if(s!==state.sample||token!==state.drawToken)return; const canvas=$('depthCanvas'),ctx=canvas.getContext('2d'); const w=s.depth?.width||320,h=s.depth?.height||240,scale=2;canvas.width=w*scale;canvas.height=h*scale; ctx.fillStyle='#080d16';ctx.fillRect(0,0,canvas.width,canvas.height);$('depthEmpty').hidden=Boolean(img); if(!img)return; const K=manifest.dataset.cameraIntrinsics||{},cx=(K.cx??w/2)*scale,cy=(K.cy??h/2)*scale; ctx.save();ctx.translate(cx,cy);ctx.rotate(state.angle*Math.PI/180);ctx.translate(-cx,-cy);ctx.imageSmoothingEnabled=false;ctx.drawImage(img,0,0,w*scale,h*scale); if($('projectedLabels').checked){ (s.sensors || []).forEach((sensor,i)=>{ if(state.selectedSensor!==-1&&i!==state.selectedSensor)return; const u=sensor.u??sensor.projectedU,v=sensor.v??sensor.projectedV; if(!Number.isFinite(u)||!Number.isFinite(v))return; ctx.beginPath();ctx.arc(u*scale,v*scale,6,0,Math.PI*2);ctx.fillStyle=colors[i];ctx.fill();ctx.lineWidth=2;ctx.strokeStyle='#142a24';ctx.stroke(); ctx.save();ctx.translate(u*scale+9,v*scale-8);ctx.rotate(-state.angle*Math.PI/180);ctx.font='bold 12px Arial';ctx.lineWidth=3;ctx.strokeStyle='#152a29';ctx.strokeText(names[i],0,0);ctx.fillStyle=colors[i];ctx.fillText(names[i],0,0);ctx.restore(); }); } ctx.restore(); $('depthNear').textContent=state.layer==='depth'?`${Math.round(s.depth.rangeMin)} mm`:'LOW'; $('depthFar').textContent=state.layer==='depth'?`${Math.round(s.depth.rangeMax)} mm`:'HIGH'; const gradient=document.querySelector('.depth-gradient'); gradient.style.background=state.layer==='depth'?'linear-gradient(90deg,#f7f4b7,#8ee7bc,#1cb4b5,#337ec2,#5643b6,#351966)':state.layer==='ir'?'linear-gradient(90deg,#080d16,#fff)':state.layer==='labels'?'linear-gradient(90deg,#080d16 12.5%,#f87171 12.5% 25%,#fbbf24 25% 37.5%,#34d399 37.5% 50%,#22d3ee 50% 62.5%,#818cf8 62.5% 75%,#d8b4fe 75% 87.5%,#f472b6 87.5%)':'linear-gradient(90deg,#000,#fff)'; if(state.layer==='labels'){$('depthNear').textContent='LABEL ID';$('depthFar').textContent='';} if(state.layer==='heatmap'){$('depthNear').textContent='0';$('depthFar').textContent='1';} const descriptions={depth:'Distance from the camera, colored per frame. Markers show the annotation heatmap peaks; 3D spheres show measured sensor positions.',ir:'The original infrared image. IR supplies visual context and was not used for the paper’s fingertip classification.',labels:'Original per-pixel part labels, generated by raycasting the oriented sensor ellipsoids. These are sparse annotations, not full hand segmentation.',heatmap:`Original ${names[selected].toLowerCase()} annotation heatmap, normalized on the source 0–1 scale. Select a sensor below to change channels.`}; $('layerDescription').textContent=descriptions[state.layer]; } function updateSensorSelection(){ document.querySelectorAll('#sensorLegend button').forEach((el,i)=>el.setAttribute('aria-pressed',String(i-1===state.selectedSensor))); sensorMeshes.forEach((mesh,i)=>{mesh.material.opacity=state.selectedSensor===-1||i===state.selectedSensor?1:.2;mesh.material.transparent=true;mesh.scale.setScalar(i===state.selectedSensor?1.5:1);}); labelElements.forEach((el,i)=>el.classList.toggle('dimmed',state.selectedSensor!==-1&&i!==state.selectedSensor)); document.querySelectorAll('#measurementTable tr').forEach((row,i)=>row.classList.toggle('selected',i===state.selectedSensor)); sensorAxes.forEach((axis,i)=>axis.visible=$('showAxes').checked&&(state.selectedSensor===-1||i===state.selectedSensor)); updateSensorReadout();drawCamera(); } function updateSensorReadout(){ if(state.selectedSensor<0){$('sensorReadoutName').textContent='ALL SIX SENSORS';$('sensorReadoutValue').textContent='Select a fingertip below to inspect its coordinates.';return;} const s=state.sample?.sensors[state.selectedSensor];if(!s)return; const theta=state.angle*Math.PI/180,x=s.x*Math.cos(theta)-s.y*Math.sin(theta),y=s.x*Math.sin(theta)+s.y*Math.cos(theta); $('sensorReadoutName').textContent=`${s.name.toUpperCase()} · ${state.angle?'ROTATED':'SOURCE'} CAMERA XYZ`; $('sensorReadoutValue').textContent=`X ${x.toFixed(1)} Y ${y.toFixed(1)} Z ${s.z.toFixed(1)} mm`; } function renderMeasurements(sample){ const table=$('measurementTable'); if(table)table.innerHTML=(sample.sensors||[]).map((s,i)=>`${Number(s.x).toFixed(1)}${Number(s.y).toFixed(1)}${Number(s.z).toFixed(1)}${esc(s.u??'—')}${esc(s.v??'—')}`).join(''); const source=sample.source||{}; const rows=[['Archive',source.archive],['Member',source.member],['Session',source.session],['Indices',`idx ${source.idx??'—'} · gidx ${source.gidx??'—'}`],['Variant',`${capitalize(sample.variant)}${sample.variant!=='original'?` · ${Number(sample.rotationDegrees).toFixed(2)}° recorded rotation`:''}`],['MAT SHA-256',source.matSha256]]; if($('provenanceFields'))$('provenanceFields').innerHTML=rows.map(([name,value])=>`
${esc(name)}
${esc(value??'—')}
`).join(''); } function rebuildFilter(keepId) { const value=$('splitSelect').value; state.filtered=state.samples.filter(s=>value==='all'||`${s.split}|${s.variant}`===value); $('sampleSelect').innerHTML=state.filtered.map(s=>``).join(''); const index=state.filtered.findIndex(s=>s.id===keepId);state.index=Math.max(0,index);loadSample(state.index); } async function loadSample(index) { if(!state.filtered.length)return; state.index=(index+state.filtered.length)%state.filtered.length; const s=state.filtered[state.index],token=++state.loadToken;state.sample=s;state.angle=0;state.canonical=false; $('sampleSelect').value=s.id;$('sampleCounter').textContent=`${state.index+1} / ${state.filtered.length}`; $('loadingState').hidden=false;$('loadingState').querySelector('strong').textContent='Reading depth & geometry';$('loadingState').querySelector('span:last-child').textContent=s.title||s.id; $('pointCount').textContent=`${fmt(s.pointCount)} POINTS · 6 SENSORS`; $('depthDimensions').textContent=`${s.depth.width} × ${s.depth.height}`; $('frameSource').textContent=s.source?.member||s.id; $('frameDescription').textContent=`${s.source?.session||'Session'} · ${capitalize(s.variant)}${s.variant!=='original'?` · archive rotation ${Number(s.rotationDegrees).toFixed(1)}°`:''}`; renderMeasurements(s); $('frameFileLink').href=`https://huggingface.co/datasets/twerdster/HandNet/blob/main/archives/${encodeURIComponent(s.source.archive)}`; const paired=state.samples.filter(p=>p.pairKey===s.pairKey&&p.id!==s.id);const button=$('comparePair');button.hidden=!paired.length;button.textContent=paired.length?`Compare ${capitalize(paired[0].variant)} ⇄`:'';button.dataset.target=paired[0]?.id||''; $('applyCanonical').disabled=!s.sensors?.[5]?.rotation; document.querySelector('[data-layer="heatmap"]').hidden=!s.heatmaps?.length; if(state.layer==='heatmap'&&!s.heatmaps?.length)selectLayer('depth'); renderGallery();applyRotation(); try{ const response=await fetch(s.points);if(!response.ok)throw new Error(`Geometry unavailable (${response.status})`); const buffer=await response.arrayBuffer();if(token!==state.loadToken)return; buildCloud(new Float32Array(buffer),s); if(renderer){$('loadingState').hidden=true;}else{ $('loadingState').hidden=false;$('loadingState').querySelector('.loader').hidden=true; $('loadingState').querySelector('strong').textContent='3D is unavailable in this browser'; $('loadingState').querySelector('span:last-child').textContent='The original camera images and sensor coordinates remain available. Enable WebGL to inspect the surface.'; } }catch(error){if(token!==state.loadToken)return;$('loadingState').querySelector('strong').textContent='Geometry could not load';$('loadingState').querySelector('span:last-child').textContent=error.message;$('loadingState').querySelector('.loader').hidden=true;} } function renderGallery(){ const list=state.filtered, count=Math.min(8,list.length);let start=Math.max(0,Math.min(state.index-3,list.length-count)); $('sampleGallery').innerHTML=list.slice(start,start+count).map(s=>``).join(''); } function selectLayer(layer){state.layer=layer;document.querySelectorAll('[data-layer]').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.layer===layer)));drawCamera();} function setPlaying(playing){state.playing=playing;$('play').textContent=playing?'Ⅱ':'▶';$('play').setAttribute('aria-label',playing?'Pause samples':'Play samples');$('play').title=playing?'Pause samples':'Play samples';clearInterval(state.timer);if(playing)state.timer=setInterval(()=>loadSample(state.index+1),2200);} function canonicalAngle(){ const r=state.sample?.sensors?.[5]?.rotation;if(!r?.length)return null; const abs=[Math.abs(r[6]),Math.abs(r[7]),Math.abs(r[8])],align=abs.indexOf(Math.max(...abs)); let alpha=align===1?Math.atan2(r[2],r[5])*180/Math.PI+90+(r[7]<0?180:0):Math.atan2(r[1],r[4])*180/Math.PI+90; return ((alpha+180)%360+360)%360-180; } function bindEvents(){ $('splitSelect').addEventListener('change',()=>{setPlaying(false);rebuildFilter();}); $('sampleSelect').addEventListener('change',e=>loadSample(state.filtered.findIndex(s=>s.id===e.target.value))); $('previous').onclick=()=>loadSample(state.index-1);$('next').onclick=()=>loadSample(state.index+1);$('shuffle').onclick=()=>loadSample(Math.floor(Math.random()*state.filtered.length));$('play').onclick=()=>setPlaying(!state.playing); $('resetView').onclick=()=>resetView();$('colorMode').onchange=colorCloud;$('pointSize').oninput=e=>{if(cloud)cloud.material.size=Number(e.target.value);};$('showAxes').onchange=updateSensorSelection; $('projectedLabels').onchange=drawCamera; document.querySelectorAll('[data-layer]').forEach(b=>b.onclick=()=>selectLayer(b.dataset.layer)); $('sensorLegend').innerHTML=``+names.map((name,i)=>``).join(''); $('sensorLegend').onclick=e=>{const b=e.target.closest('[data-sensor]');if(b){state.selectedSensor=Number(b.dataset.sensor);updateSensorSelection();}}; if($('measurementTable'))$('measurementTable').onclick=e=>{const b=e.target.closest('[data-sensor]');if(b){state.selectedSensor=Number(b.dataset.sensor);updateSensorSelection();}}; $('sampleGallery').onclick=e=>{const b=e.target.closest('[data-sample]');if(b)loadSample(state.filtered.findIndex(s=>s.id===b.dataset.sample));}; $('rotation').oninput=e=>{state.angle=Number(e.target.value);state.canonical=false;applyRotation();}; $('resetRotation').onclick=()=>{state.angle=0;state.canonical=false;applyRotation();}; $('applyCanonical').onclick=()=>{const a=canonicalAngle();if(a===null)return;state.angle=a;state.canonical=true;applyRotation();}; $('comparePair').onclick=e=>{const target=state.samples.find(s=>s.id===e.currentTarget.dataset.target);if(!target)return;setPlaying(false);$('splitSelect').value='all';rebuildFilter(target.id);}; $('copyCitation').onclick=async()=>{const text='@inproceedings{wetzler2015rule,\n title={Rule of Thumb: Deep Derotation for Improved Fingertip Detection},\n author={Wetzler, Aaron and Slossberg, Ron and Kimmel, Ron},\n booktitle={British Machine Vision Conference},\n year={2015}\n}';try{await navigator.clipboard.writeText(text);$('copyCitation').textContent='Copied ✓';setTimeout(()=>$('copyCitation').innerHTML='Copy BibTeX ⧉',2000);}catch{$('copyCitation').textContent='Select BibTeX text alongside';}}; document.addEventListener('keydown',e=>{if(/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)||e.metaKey||e.ctrlKey||e.altKey)return;if(e.key==='ArrowRight'){e.preventDefault();loadSample(state.index+1);}if(e.key==='ArrowLeft'){e.preventDefault();loadSample(state.index-1);}if(e.key===' '){e.preventDefault();setPlaying(!state.playing);}if(e.key.toLowerCase()==='r')resetView();}); document.addEventListener('visibilitychange',()=>{if(document.hidden)setPlaying(false);}); } async function archiveList(){ try{ const response=await fetch('data/archive-manifest.json');if(!response.ok)return; const release=await response.json(); const notes={'TrainData.rar':'Training · 202,198 frames','TestData.zip':'Test · 10,000 frames','ValidationData.zip':'Validation · 2,773 frames','TrainData_DerotGT.zip':'Training · ground-truth derotation','ValidationData_DerotGT.zip':'Validation · ground-truth derotation','TestData_Perturbed.zip':'Test · rotation perturbed','ValidationData_Perturbed.zip':'Validation · rotation perturbed'}; $('archiveList').innerHTML=release.archives.map(a=>{const size=a.bytes>=1073741824?`${(a.bytes/1073741824).toFixed(2)} GiB`:`${(a.bytes/1048576).toFixed(1)} MiB`;return `${esc(a.filename)}${esc(notes[a.filename]||'Original archive')}${size}↓`;}).join(''); }catch(error){console.warn('Archive metadata unavailable:',error);} } async function init(){ bindEvents();archiveList(); try{init3D();}catch(error){console.warn('3D renderer unavailable:',error);document.body.classList.add('webgl-unavailable');$('loadingState').querySelector('strong').textContent='3D is unavailable in this browser';$('loadingState').querySelector('span:last-child').textContent='The original camera images and sensor coordinates remain available. Enable WebGL to inspect the surface.';} try{ const response=await fetch('data/samples.json');if(!response.ok)throw new Error(`Sample manifest unavailable (${response.status})`);manifest=await response.json();state.samples=manifest.samples;if(!state.samples?.length)throw new Error('No samples found in the manifest.'); const groups=[...new Set(state.samples.map(s=>`${s.split}|${s.variant}`))];$('splitSelect').innerHTML=''+groups.map(g=>``).join(''); $('sampleTotal').textContent=`${state.samples.length} stored source frames`; const K=manifest.dataset.cameraIntrinsics;if(K&&$('calibrationValues'))$('calibrationValues').textContent=`fₓ = ${K.fx} · fᵧ = ${K.fy}\ncₓ = ${K.cx} · cᵧ = ${K.cy}`; rebuildFilter('test-original-0008694'); }catch(error){$('loadingState').hidden=false;$('loadingState').querySelector('strong').textContent='Samples could not be loaded';$('loadingState').querySelector('span:last-child').textContent=`${error.message}. The complete original archives remain available below.`;$('loadingState').querySelector('.loader').hidden=true;} } function initEmbeddedSizing(){ if(document.documentElement.dataset.embedded!=='true'||window.parent===window)return; let previous=0,scheduled=false; const report=()=>{scheduled=false;const explorer=$('explore');if(!explorer)return;const height=Math.ceil(explorer.getBoundingClientRect().bottom+window.scrollY);if(Math.abs(height-previous)>1){previous=height;window.parent.postMessage({type:'handnet-explorer-height',height},window.location.origin);}}; const schedule=()=>{if(!scheduled){scheduled=true;requestAnimationFrame(report);}}; new ResizeObserver(schedule).observe(document.querySelector('main')); window.addEventListener('load',schedule);window.addEventListener('resize',schedule);schedule(); } initEmbeddedSizing(); init();