WitneyWW's picture
Surface sampling vs trivial downsampling on bigpointcloud_001
08dc587 verified
Raw
History Blame Contribute Delete
17.6 kB
<!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 ---- */
.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)}
/* ---- tables ---- */
.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;
/* ---------------- metrics tables ---------------- */
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));
/* ---------------- point clouds ---------------- */
const clouds = M.viewer.clouds;
const ref = clouds.input;
const CENTER = ref.lo.map((l,i)=>l + ref.span[i]/2);
function ramp(t){ // dark-blue -> teal -> warm, readable on black
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>
// The module above may fail if the CDN is unreachable; module errors do not always surface
// as window 'error', so also just check whether the viewer ever painted a label.
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>