| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>Point-cloud sampling strategies — bigpointcloud_001</title> |
| <style> |
| :root{ |
| --bg:#0d1017; --panel:#151a24; --line:#232b3a; --ink:#e6ebf5; --dim:#93a1bd; |
| --accent:#5db4ff; --good:#4ade80; --warn:#fbbf24; --bad:#f87171; |
| } |
| *{box-sizing:border-box} |
| body{margin:0;background:var(--bg);color:var(--ink); |
| font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} |
| .wrap{max-width:1180px;margin:0 auto;padding:32px 20px 72px} |
| h1{font-size:27px;line-height:1.25;margin:0 0 8px;letter-spacing:-.02em} |
| h2{font-size:19px;margin:44px 0 12px;letter-spacing:-.01em} |
| h3{font-size:15px;margin:26px 0 8px;color:var(--dim);text-transform:uppercase; |
| letter-spacing:.08em;font-weight:600} |
| p{margin:0 0 14px;color:#c7d2e5} |
| .lede{font-size:16.5px;color:var(--dim);margin-bottom:26px} |
| code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em; |
| background:#1b2230;border:1px solid var(--line);border-radius:4px;padding:1px 5px} |
| a{color:var(--accent)} |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px} |
| .note{border-left:3px solid var(--accent);background:#141c28;border-radius:0 8px 8px 0; |
| padding:12px 16px;margin:18px 0;color:#c7d2e5} |
| .note.warn{border-left-color:var(--warn)} |
| |
| |
| .viewer{display:grid;grid-template-columns:1fr 1fr;gap:12px} |
| .pane{background:#000;border:1px solid var(--line);border-radius:12px;overflow:hidden; |
| position:relative;aspect-ratio:4/3} |
| .pane canvas{display:block;width:100%;height:100%} |
| .pane .tag{position:absolute;left:10px;top:10px;background:rgba(13,16,23,.82); |
| border:1px solid var(--line);border-radius:7px;padding:5px 9px;font-size:12.5px; |
| color:var(--dim);pointer-events:none;backdrop-filter:blur(4px)} |
| .pane .tag b{color:var(--ink);font-weight:600} |
| select,button{background:#1b2230;color:var(--ink);border:1px solid var(--line); |
| border-radius:7px;padding:6px 10px;font:inherit;font-size:13.5px} |
| select{max-width:100%} |
| .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:12px 0 4px} |
| .controls label{font-size:13px;color:var(--dim)} |
| input[type=range]{accent-color:var(--accent);vertical-align:middle} |
| #fallback{display:none;padding:14px;color:var(--warn)} |
| |
| |
| .tablewrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel)} |
| table{border-collapse:collapse;width:100%;font-size:13.5px;min-width:720px} |
| th,td{padding:9px 13px;text-align:right;border-bottom:1px solid var(--line); |
| font-variant-numeric:tabular-nums} |
| th:first-child,td:first-child{text-align:left} |
| thead th{color:var(--dim);font-weight:600;font-size:12px;text-transform:uppercase; |
| letter-spacing:.05em;background:#121826;position:sticky;top:0} |
| tbody tr:last-child td{border-bottom:none} |
| tbody tr.hl{background:#16202e} |
| td.best{color:var(--good);font-weight:600} |
| .name{font-family:ui-monospace,Menlo,monospace;font-size:13px} |
| .sub{color:var(--dim);font-size:12px;font-weight:400} |
| .tabs{display:flex;gap:8px;margin:0 0 12px} |
| .tabs button[aria-selected=true]{border-color:var(--accent);color:var(--accent)} |
| img.shot{width:100%;border:1px solid var(--line);border-radius:12px;background:#fff} |
| .legend{display:flex;flex-wrap:wrap;gap:14px;margin:10px 0 0;font-size:12.5px;color:var(--dim)} |
| .legend span b{color:var(--ink)} |
| footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--line); |
| color:var(--dim);font-size:13px} |
| </style> |
| </head> |
| <body> |
| <div class="wrap"> |
|
|
| <h1>Sampling more points on the surface</h1> |
| <p class="lede">Four ways to turn <code>bigpointcloud_001.ply</code> (11,357 points) into a fixed-budget |
| point cloud, each scored against the object's true surface — <code>motherboard.stl</code>, |
| 477,957 triangles, 445.1 units² — rather than against the input cloud.</p> |
|
|
| <div class="note"> |
| <b>Why score against the mesh?</b> The input <code>.ply</code> is itself only a sparse sampling of the |
| board. Measuring a downsample against it would reward reproducing the input's own gaps. |
| Both files sit in the same coordinate frame, so the STL can serve as ground truth. |
| </div> |
|
|
| <h2>Interactive comparison</h2> |
| <p>Pick a cloud in each pane and drag to orbit — the two cameras are locked together. |
| Start with <b>voxel 4096</b> against <b>surface_pd 4096</b> to see the difference in evenness, |
| or put <b>input</b> next to <b>dense surface</b> to see what the extra points buy.</p> |
|
|
| <div class="controls"> |
| <select id="selL"></select> |
| <select id="selR"></select> |
| <label>point size <input id="psize" type="range" min="0.3" max="4" step="0.1" value="1.4"></label> |
| <label>colour |
| <select id="cmode"> |
| <option value="height">height</option> |
| <option value="scan">scan colour</option> |
| </select> |
| </label> |
| <button id="reset">reset view</button> |
| </div> |
| <div class="viewer"> |
| <div class="pane"><canvas id="cvL"></canvas><div class="tag" id="tagL"></div></div> |
| <div class="pane"><canvas id="cvR"></canvas><div class="tag" id="tagR"></div></div> |
| </div> |
| <div id="fallback">3-D viewer could not load three.js from the CDN. The static renders below |
| show the same clouds.</div> |
|
|
| <h2>The strategies</h2> |
| <div class="tablewrap"><table> |
| <thead><tr><th>name</th><th style="text-align:left">what it does</th></tr></thead> |
| <tbody> |
| <tr><td class="name">random</td><td style="text-align:left">Random subsample of the input cloud — the usual one-liner.</td></tr> |
| <tr><td class="name">voxel</td><td style="text-align:left">Voxel downsample of the input cloud; voxel size bisected to hit the budget exactly. The trivial <em>good</em> baseline.</td></tr> |
| <tr><td class="name">surface</td><td style="text-align:left">Draw 200,000 Poisson-disk points on the mesh surface, then voxel-downsample <em>that</em> to the budget. This is <code>voxel_downsample.py --surface-points 200000 --target-points N</code>.</td></tr> |
| <tr><td class="name">surface_pd</td><td style="text-align:left">Poisson-disk sample the mesh straight at the budget — no downsampling step at all.</td></tr> |
| </tbody></table></div> |
|
|
| <h2>Numbers</h2> |
| <div class="tabs" id="tabs"></div> |
| <div class="tablewrap"><table id="metrics"></table></div> |
| <div class="legend"> |
| <span><b>accuracy</b> — distance from each point to the true surface. Lower = points really lie on the object.</span> |
| <span><b>coverage</b> — distance from 500,000 uniform mesh samples to the nearest cloud point. Lower = fewer bald patches.</span> |
| <span><b>NN CV</b> — spread of nearest-neighbour spacing ÷ its mean. 0 = perfectly even.</span> |
| </div> |
|
|
| <div class="note warn" style="margin-top:22px"> |
| <b>Read <code>cov max</code> with care.</b> On this mesh the one-sided Hausdorff is decided by a handful |
| of reference samples landing on an isolated sliver at the base of the board — for the input cloud |
| it is a <em>single</em> point out of 500,000. <code>cov p99.9</code> is the honest worst-case column; |
| <code>cov max</code> is kept only because it is the number people usually quote. |
| </div> |
|
|
| <h2>What actually changed</h2> |
|
|
| <h3>The ceiling, not the budget</h3> |
| <p>At a fixed budget the surface route is <b>not</b> a coverage win over plain voxel downsampling: |
| at 4,096 points, <code>voxel</code> reaches 0.1295 mean / 0.2661 p99.9 coverage and <code>surface</code> |
| reaches 0.1316 / 0.2639. Those are the same number. Anyone hoping "sample the mesh instead" would |
| fix coverage at 4k points should not bother.</p> |
| <p>The win is that there <em>is</em> no ceiling. The input cloud tops out at 11,357 points with |
| 0.0799 mean coverage; the dense surface resample reaches <b>0.0187</b> — 4.3× better — and keeps |
| going as high as you ask. Screw holes, mounting bosses and pad edges that the scan simply never |
| sampled show up in the top-down render below.</p> |
|
|
| <h3>Accuracy is where sampling the mesh is unbeatable</h3> |
| <p><code>surface_pd</code> points lie on the surface by construction: accuracy is <b>0.0000</b> at every |
| budget, against 0.0156 mean / 0.0606 p95 for <code>voxel</code>. Both voxel-based routes average the |
| points inside each voxel, and near an edge or a corner that centroid floats off the surface — |
| which is also why <code>surface</code> (0.0196) is very slightly <em>worse</em> than <code>voxel</code> |
| here despite starting from points that were exactly on the mesh.</p> |
|
|
| <h3>Evenness</h3> |
| <p>Poisson-disk gives a nearest-neighbour CV of <b>0.09</b> versus 0.25 for voxel and 0.32 for random — |
| blue noise, visibly regular in the viewer. Voxel downsampling is the only method with a |
| <em>deterministic</em> coverage bound though (no occupied voxel is ever dropped), which is why |
| <code>surface</code> is the one strategy whose <code>cov max</code> stays near its p99.9 instead of blowing |
| up on the sliver.</p> |
|
|
| <h3>Which to use</h3> |
| <p>For feeding PointNet++ at 1,024–4,096 points from a cloud you already have, |
| <code>voxel</code> is fine and <code>random</code> is not. If a mesh exists, <code>surface_pd</code> at the budget |
| is strictly better on accuracy and evenness for the same cost. The dense surface resample is |
| worth it when the input's own sampling density — not the budget — is the limit.</p> |
|
|
| <h2>Static renders</h2> |
| <p>Isometric (top row) and top-down (bottom). All fixed-budget clouds are 4,096 points.</p> |
| <img class="shot" src="strategies.png" alt="Six point clouds rendered from two viewpoints: input, dense surface, random, voxel, surface, surface_pd"> |
|
|
| <footer> |
| Generated by <code>compare_sampling.py</code> on top of <code>voxel_downsample.py</code> |
| (Open3D 0.18, Poisson-disk surface sampling, exact point budgets by voxel-size bisection). |
| Viewer coordinates are quantised to uint16 (~0.0003 units, far below the 0.04 point spacing); |
| the <code>dense</code> cloud is drawn as a 120,000-point subset of 200,000. All distances are in |
| the model's own units. |
| </footer> |
| </div> |
|
|
| <script type="importmap"> |
| {"imports":{ |
| "three":"https://unpkg.com/three@0.160.0/build/three.module.js", |
| "three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/" |
| }} |
| </script> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import {OrbitControls} from 'three/addons/controls/OrbitControls.js'; |
| |
| const LABEL = { |
| input:'input (.ply scan)', dense:'dense surface 200k', |
| random:'random', voxel:'voxel', surface:'surface', surface_pd:'surface_pd' |
| }; |
| const ORDER = ['input','dense','random','voxel','surface','surface_pd']; |
| |
| const M = await (await fetch('metrics.json')).json(); |
| const budget = M.viewer.budget; |
| |
| |
| const ROWS = [ |
| ['acc_mean','accuracy mean','lo'], ['acc_p95','accuracy p95','lo'], |
| ['cov_mean','cov mean','lo'], ['cov_p95','cov p95','lo'], |
| ['cov_p999','cov p99.9','lo'], ['cov_max','cov max','lo'], |
| ['nn_mean','NN spacing','-'], ['nn_cv','NN CV','lo'], |
| ]; |
| const STRATS = ['random','voxel','surface','surface_pd']; |
| |
| function table(bkey){ |
| const d = M.budgets[bkey]; |
| let h = '<thead><tr><th>metric</th>' + |
| STRATS.map(s=>`<th class="name">${s}</th>`).join('') + |
| '<th class="name">input<span class="sub"><br>11,357 pts</span></th>' + |
| '<th class="name">dense<span class="sub"><br>200,000 pts</span></th></tr></thead><tbody>'; |
| for (const [k,label,dir] of ROWS){ |
| const vals = STRATS.map(s=>d[s][k]); |
| const best = dir==='lo' ? Math.min(...vals) : null; |
| h += `<tr${k==='cov_p999'||k==='acc_mean'?' class="hl"':''}><td>${label}</td>`; |
| h += vals.map(v=>`<td class="${best!==null && v<=best+1e-9?'best':''}">${v.toFixed(4)}</td>`).join(''); |
| h += `<td class="sub">${M.extra.input[k].toFixed(4)}</td>`; |
| h += `<td class="sub">${M.extra.dense[k].toFixed(4)}</td></tr>`; |
| } |
| return h + '</tbody>'; |
| } |
| const tabs = document.getElementById('tabs'); |
| const keys = Object.keys(M.budgets).sort((a,b)=>a-b); |
| keys.forEach(k=>{ |
| const b = document.createElement('button'); |
| b.textContent = `${Number(k).toLocaleString()} points`; |
| b.setAttribute('aria-selected', k===String(budget)); |
| b.onclick = ()=>{ |
| [...tabs.children].forEach(c=>c.setAttribute('aria-selected','false')); |
| b.setAttribute('aria-selected','true'); |
| document.getElementById('metrics').innerHTML = table(k); |
| }; |
| tabs.appendChild(b); |
| }); |
| document.getElementById('metrics').innerHTML = table(String(budget)); |
| |
| |
| const clouds = M.viewer.clouds; |
| const ref = clouds.input; |
| const CENTER = ref.lo.map((l,i)=>l + ref.span[i]/2); |
| |
| function ramp(t){ |
| const s=[[0.15,0.20,0.45],[0.13,0.45,0.62],[0.25,0.70,0.62],[0.75,0.82,0.42],[0.99,0.91,0.65]]; |
| const x=Math.min(0.999,Math.max(0,t))*(s.length-1), i=Math.floor(x), f=x-i; |
| return s[i].map((v,k)=>v+(s[i+1][k]-v)*f); |
| } |
| |
| const cache = new Map(); |
| async function getGeom(name){ |
| if (cache.has(name)) return cache.get(name); |
| const meta = clouds[name]; |
| const buf = await (await fetch('viewer/'+meta.file)).arrayBuffer(); |
| const n = meta.n_drawn; |
| const q = new Uint16Array(buf, 0, n*3); |
| const rgb = new Uint8Array(buf, n*6, n*3); |
| const pos = new Float32Array(n*3), scan = new Float32Array(n*3), hgt = new Float32Array(n*3); |
| let zlo=Infinity, zhi=-Infinity; |
| for (let i=0;i<n;i++){ |
| for (let k=0;k<3;k++){ |
| const v = meta.lo[k] + q[i*3+k]/65535*meta.span[k]; |
| pos[i*3+k] = v - CENTER[k]; |
| if (k===2){ if(v<zlo) zlo=v; if(v>zhi) zhi=v; } |
| } |
| for (let k=0;k<3;k++) scan[i*3+k] = rgb[i*3+k]/255; |
| } |
| for (let i=0;i<n;i++){ |
| const c = ramp((pos[i*3+2]+CENTER[2]-zlo)/Math.max(1e-9,zhi-zlo)); |
| hgt[i*3]=c[0]; hgt[i*3+1]=c[1]; hgt[i*3+2]=c[2]; |
| } |
| const g = new THREE.BufferGeometry(); |
| g.setAttribute('position', new THREE.BufferAttribute(pos,3)); |
| g.setAttribute('color', new THREE.BufferAttribute(hgt.slice(),3)); |
| const out = {geom:g, scan, hgt, meta}; |
| cache.set(name,out); |
| return out; |
| } |
| |
| const RADIUS = Math.hypot(...ref.span)/2; |
| function makePane(canvasId, tagId){ |
| const canvas = document.getElementById(canvasId); |
| const renderer = new THREE.WebGLRenderer({canvas, antialias:true}); |
| renderer.setPixelRatio(Math.min(devicePixelRatio,2)); |
| const scene = new THREE.Scene(); |
| const cam = new THREE.PerspectiveCamera(42, 4/3, 0.05, 5000); |
| const ctrl = new OrbitControls(cam, canvas); |
| ctrl.enableDamping = true; |
| const mat = new THREE.PointsMaterial({size:1.4, vertexColors:true, sizeAttenuation:false}); |
| const pts = new THREE.Points(new THREE.BufferGeometry(), mat); |
| scene.add(pts); |
| const pane = {renderer, scene, cam, ctrl, mat, pts, tag:document.getElementById(tagId), canvas}; |
| pane.home = ()=>{ cam.position.set(RADIUS*1.5, -RADIUS*1.9, RADIUS*1.35); |
| ctrl.target.set(0,0,0); cam.up.set(0,0,1); ctrl.update(); }; |
| pane.home(); |
| return pane; |
| } |
| const L = makePane('cvL','tagL'), R = makePane('cvR','tagR'); |
| |
| let syncing = false; |
| function sync(from, to){ |
| if (syncing) return; syncing = true; |
| to.cam.position.copy(from.cam.position); to.cam.up.copy(from.cam.up); |
| to.ctrl.target.copy(from.ctrl.target); to.ctrl.update(); |
| syncing = false; |
| } |
| L.ctrl.addEventListener('change', ()=>sync(L,R)); |
| R.ctrl.addEventListener('change', ()=>sync(R,L)); |
| |
| let colourMode = 'height'; |
| async function show(pane, name){ |
| const c = await getGeom(name); |
| pane.pts.geometry = c.geom; |
| c.geom.setAttribute('color', new THREE.BufferAttribute( |
| (colourMode==='scan'?c.scan:c.hgt).slice(), 3)); |
| const drawn = c.meta.n_drawn < c.meta.n_total |
| ? ` <span class="sub">(drawing ${c.meta.n_drawn.toLocaleString()})</span>` : ''; |
| const suffix = ['random','voxel','surface','surface_pd'].includes(name) ? ` @ ${budget}` : ''; |
| pane.tag.innerHTML = `<b>${LABEL[name]}${suffix}</b> — ${c.meta.n_total.toLocaleString()} pts${drawn}`; |
| pane.name = name; |
| } |
| |
| for (const [sel, pane, initial] of [['selL',L,'voxel'], ['selR',R,'surface_pd']]){ |
| const el = document.getElementById(sel); |
| el.innerHTML = ORDER.map(n=>`<option value="${n}">${LABEL[n]}</option>`).join(''); |
| el.value = initial; |
| el.onchange = ()=>show(pane, el.value); |
| await show(pane, initial); |
| } |
| document.getElementById('psize').oninput = e=>{ |
| L.mat.size = R.mat.size = Number(e.target.value); |
| }; |
| document.getElementById('cmode').onchange = e=>{ |
| colourMode = e.target.value; |
| show(L, L.name); show(R, R.name); |
| }; |
| document.getElementById('reset').onclick = ()=>{ L.home(); sync(L,R); }; |
| |
| function frame(){ |
| for (const p of [L,R]){ |
| const w = p.canvas.clientWidth, h = p.canvas.clientHeight; |
| if (p.canvas.width !== w*renderScale() || p.canvas.height !== h*renderScale()){ |
| p.renderer.setSize(w,h,false); p.cam.aspect = w/h; p.cam.updateProjectionMatrix(); |
| } |
| p.ctrl.update(); p.renderer.render(p.scene,p.cam); |
| } |
| requestAnimationFrame(frame); |
| } |
| function renderScale(){ return Math.min(devicePixelRatio,2); } |
| frame(); |
| </script> |
| <script> |
| |
| |
| function viewerFailed(){ document.getElementById('fallback').style.display='block'; } |
| window.addEventListener('error', e=>{ |
| if (String(e.message||'').match(/import|module|three|fetch/i)) viewerFailed(); |
| }); |
| window.addEventListener('unhandledrejection', viewerFailed); |
| setTimeout(()=>{ if (!document.getElementById('tagL').innerHTML.trim()) viewerFailed(); }, 8000); |
| </script> |
| </body> |
| </html> |
|
|