Skip to content

Commit 200f6cd

Browse files
committed
Patchwork: Z-up axis transform, per-patch (zone × ring × sector) color, VLP-16 global Z hard cap
ensureZUp(cloud, presetId) flips Z for NaverLabs (PCD ships Z-down with floor mass at z ≈ +0.6); KITTI is already Z-up. Both Patchwork and TRAVEL pages now run the transform before invoking their algorithms. Without this, the indoor scan's ceiling sat at the bottom of the sensor frame and the ground gate had nothing to catch. Region coloring: regionId now packs (zone, ring, sector); regionColor() walks zones × rings × sectors so every CZM patch gets its own golden-angle hue. Previously all sectors within a (zone, ring) shared one color and visually read as concentric rings rather than patches. VLP-16 hard cap: applies the global elevation threshold (0.0 in sensor frame) as a defensive gate even on inner rings. Without this, a flat ceiling could squeak past the FLAT_ENOUGH override on inner rings and get marked as ground. HDL-64's useGlobalElevation=false so this is a no-op there. Smoke verification on the actual presets: NaverLabs (Z-flipped): 27% ground, 81 patches across 7 rings, ground z p50 = -0.40 (sensor h = 0.6) KITTI: 59% ground, 230 patches across 15 rings, ground z p50 = -1.77 (sensor h = 1.723)
1 parent 84c0cde commit 200f6cd

6 files changed

Lines changed: 223 additions & 25 deletions

File tree

web/scripts/inspect-axes.mjs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Quick axis-orientation check on the bundled presets — prints per-axis
2+
// percentile ranges so we can decide which axis is "up" for each sensor.
3+
import { readFileSync } from "node:fs";
4+
import { resolve } from "node:path";
5+
const { parseFromBuffer } = await import("../src/lib/pcd.ts");
6+
7+
async function dump(label, relPath) {
8+
const buf = readFileSync(resolve(relPath));
9+
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
10+
const c = parseFromBuffer(ab, relPath);
11+
const N = c.count;
12+
const xs = new Float32Array(N), ys = new Float32Array(N), zs = new Float32Array(N);
13+
for (let i = 0; i < N; i++) {
14+
xs[i] = c.positions[3 * i];
15+
ys[i] = c.positions[3 * i + 1];
16+
zs[i] = c.positions[3 * i + 2];
17+
}
18+
const sortedX = xs.slice().sort();
19+
const sortedY = ys.slice().sort();
20+
const sortedZ = zs.slice().sort();
21+
const pct = (a, p) => a[Math.min(a.length - 1, Math.max(0, Math.floor(p * a.length)))];
22+
console.log(`\n=== ${label} (${N} pts) ===`);
23+
console.log(
24+
` X: p1=${pct(sortedX, 0.01).toFixed(2)} p50=${pct(sortedX, 0.5).toFixed(2)} p99=${pct(sortedX, 0.99).toFixed(2)}`,
25+
);
26+
console.log(
27+
` Y: p1=${pct(sortedY, 0.01).toFixed(2)} p50=${pct(sortedY, 0.5).toFixed(2)} p99=${pct(sortedY, 0.99).toFixed(2)}`,
28+
);
29+
console.log(
30+
` Z: p1=${pct(sortedZ, 0.01).toFixed(2)} p50=${pct(sortedZ, 0.5).toFixed(2)} p99=${pct(sortedZ, 0.99).toFixed(2)}`,
31+
);
32+
// Histogram of each axis to see which one has a clear "ground" (a heavy
33+
// mass at one extreme).
34+
for (const [name, arr] of [["X", sortedX], ["Y", sortedY], ["Z", sortedZ]]) {
35+
const lo = arr[0],
36+
hi = arr[arr.length - 1];
37+
const span = hi - lo || 1;
38+
const bins = new Array(10).fill(0);
39+
for (let i = 0; i < N; i++) {
40+
const v = arr[i];
41+
const b = Math.min(9, Math.floor(((v - lo) / span) * 10));
42+
bins[b]++;
43+
}
44+
const max = Math.max(...bins);
45+
const bar = (n) => "█".repeat(Math.round((n / max) * 18)) || "·";
46+
console.log(` ${name} hist:`);
47+
for (let i = 0; i < 10; i++) {
48+
const lov = (lo + (span * i) / 10).toFixed(2).padStart(7);
49+
const hiv = (lo + (span * (i + 1)) / 10).toFixed(2).padStart(7);
50+
console.log(` [${lov}, ${hiv}] ${bar(bins[i])} ${bins[i]}`);
51+
}
52+
}
53+
}
54+
55+
await dump("NaverLabs VLP-16", "public/data/naverlabs_vel16.pcd");
56+
await dump("KITTI HDL-64", "public/data/kitti00_000000.bin");

web/scripts/smoke-presets.mjs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Smoke test on the actual bundled preset PCDs — confirms that after the
2+
// per-preset ensureZUp transform, Patchwork finds reasonable ground and
3+
// produces many distinct patches.
4+
import { readFileSync } from "node:fs";
5+
import { resolve } from "node:path";
6+
7+
const { parseFromBuffer } = await import("../src/lib/pcd.ts");
8+
const { runPatchwork, unpackPatch } = await import("../src/lib/filters/patchwork.ts");
9+
const { ensureZUp } = await import("../src/lib/axisTransform.ts");
10+
const { SENSOR_VLP16, SENSOR_HDL64 } = await import("../src/lib/sensorConfig.ts");
11+
12+
function loadLocal(relPath) {
13+
const buf = readFileSync(resolve(relPath));
14+
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
15+
return parseFromBuffer(ab, relPath);
16+
}
17+
18+
function summarize(label, result) {
19+
const groundN = result.groundCloud.count;
20+
const ngN = result.nonGroundCloud.count;
21+
const total = groundN + ngN;
22+
const ratio = total === 0 ? 0 : groundN / total;
23+
const sectorsPresent = new Set();
24+
for (let i = 0; i < result.groundRegionIds.length; i++) {
25+
sectorsPresent.add(result.groundRegionIds[i]);
26+
}
27+
// How many distinct sectors per (zone, ring)?
28+
const ringKeys = new Set();
29+
for (const rid of sectorsPresent) {
30+
const { zone, ring } = unpackPatch(rid);
31+
ringKeys.add(`${zone}-${ring}`);
32+
}
33+
34+
console.log(`\n=== ${label} ===`);
35+
console.log(` ground / total: ${groundN.toLocaleString()} / ${total.toLocaleString()} (${(100 * ratio).toFixed(1)}%)`);
36+
console.log(` patches with ground: ${sectorsPresent.size} (across ${ringKeys.size} distinct rings)`);
37+
// Show ground z-band distribution.
38+
const zs = [];
39+
for (let i = 0; i < groundN; i++) {
40+
zs.push(result.groundCloud.positions[3 * i + 2]);
41+
}
42+
zs.sort((a, b) => a - b);
43+
if (zs.length > 0) {
44+
const p = (q) => zs[Math.min(zs.length - 1, Math.max(0, Math.floor(q * zs.length)))];
45+
console.log(
46+
` ground z: p1=${p(0.01).toFixed(2)} p50=${p(0.5).toFixed(2)} p99=${p(0.99).toFixed(2)}`,
47+
);
48+
}
49+
}
50+
51+
const naverRaw = loadLocal("public/data/naverlabs_vel16.pcd");
52+
console.log(`NaverLabs raw z range: [${naverRaw.bbox.min[2].toFixed(2)}, ${naverRaw.bbox.max[2].toFixed(2)}]`);
53+
const naverFlipped = ensureZUp(naverRaw, "naverlabs");
54+
console.log(`NaverLabs flipped z range: [${naverFlipped.bbox.min[2].toFixed(2)}, ${naverFlipped.bbox.max[2].toFixed(2)}]`);
55+
summarize("NaverLabs VLP-16 (Z flipped)", runPatchwork(naverFlipped, SENSOR_VLP16));
56+
57+
const kittiRaw = loadLocal("public/data/kitti00_000000.bin");
58+
console.log(`KITTI raw z range: [${kittiRaw.bbox.min[2].toFixed(2)}, ${kittiRaw.bbox.max[2].toFixed(2)}]`);
59+
const kittiZup = ensureZUp(kittiRaw, "kitti");
60+
summarize("KITTI HDL-64 (no flip)", runPatchwork(kittiZup, SENSOR_HDL64));

web/src/lib/axisTransform.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { cloudFromPositions, type PointCloud } from "./types";
2+
3+
/**
4+
* Patchwork and TRAVEL both assume the LiDAR cloud is given in a Z-up frame
5+
* (sensor at z = +sensor_height above the floor, gravity along -Z). Some
6+
* preset datasets ship in different conventions:
7+
*
8+
* - KITTI HDL-64: Z-up already (X forward, Y left, Z up).
9+
* - NaverLabs VLP-16 PCD: Z-down (floor mass sits at z ≈ +0.6, ceiling at
10+
* large negative z) — needs Z to be flipped before either algorithm
11+
* will produce meaningful ground / cluster output.
12+
*
13+
* Custom user uploads are disabled on the Patchwork / TRAVEL chapters, so
14+
* a small per-preset switch is enough.
15+
*/
16+
export function ensureZUp(cloud: PointCloud, presetId: string): PointCloud {
17+
if (presetId !== "naverlabs") return cloud;
18+
const N = cloud.count;
19+
const out = new Float32Array(N * 3);
20+
for (let i = 0; i < N; i++) {
21+
out[3 * i] = cloud.positions[3 * i];
22+
out[3 * i + 1] = cloud.positions[3 * i + 1];
23+
out[3 * i + 2] = -cloud.positions[3 * i + 2];
24+
}
25+
return cloudFromPositions(out);
26+
}

web/src/lib/filters/patchwork.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export function runPatchwork(
115115
pointZone[i] = zone;
116116
pointRing[i] = ring;
117117
pointSector[i] = sector;
118-
regionId[i] = zone * 100 + ring;
118+
regionId[i] = packPatch(zone, ring, sector);
119119

120120
// Pack bin key as zone × 1e6 + ring × 1e3 + sector — fits in 32 bits.
121121
const key = zone * 1_000_000 + ring * 1_000 + sector;
@@ -267,6 +267,15 @@ export function runPatchwork(
267267
acceptAsGround = !(c.useGlobalElevation && centroidZ > c.globalElevationThr);
268268
}
269269

270+
// Defensive hard cap: when useGlobalElevation is on (e.g. indoor
271+
// sensors like VLP-16), reject anything above the global threshold even
272+
// if it slipped through the inner-ring FLAT_ENOUGH override. This is
273+
// what reliably keeps ceilings and high horizontal surfaces out on
274+
// indoor scans.
275+
if (acceptAsGround && c.useGlobalElevation && centroidZ > c.globalElevationThr) {
276+
acceptAsGround = false;
277+
}
278+
270279
if (!acceptAsGround) continue;
271280

272281
for (const i of curSeeds) isGround[i] = 1;
@@ -312,16 +321,54 @@ export function runPatchwork(
312321
};
313322
}
314323

315-
/** Pack (zone, ring) → stable color via the golden-angle hue spiral.
316-
* Note: three.js's Color parser needs the comma-separated hsl() form. */
317-
export function regionColor(zone: number, ring: number, czm: CzmConfig): string {
318-
const totalRingsBefore = czm.numRingsPerZone
319-
.slice(0, zone)
320-
.reduce((a, b) => a + b, 0);
321-
const idx = totalRingsBefore + ring;
324+
/** Pack (zone, ring, sector) into a single 32-bit-safe integer:
325+
* zone × 1_000_000 + ring × 1_000 + sector.
326+
* Sectors max out at 56 (HDL-64 zone 2), rings at 5 (HDL-64 zone 3),
327+
* zones at 4 — all fit comfortably. */
328+
export function packPatch(zone: number, ring: number, sector: number): number {
329+
return zone * 1_000_000 + ring * 1_000 + sector;
330+
}
331+
332+
export function unpackPatch(rid: number): { zone: number; ring: number; sector: number } {
333+
if (rid < 0) return { zone: -1, ring: -1, sector: -1 };
334+
const zone = Math.floor(rid / 1_000_000);
335+
const r2 = rid - zone * 1_000_000;
336+
const ring = Math.floor(r2 / 1_000);
337+
const sector = r2 - ring * 1_000;
338+
return { zone, ring, sector };
339+
}
340+
341+
/** Compute the global patch index — i.e. how many patches come before this
342+
* one when iterating zones × rings × sectors in order. Used to assign a
343+
* golden-angle hue to every (zone, ring, sector) bin so each Patchwork
344+
* region is visibly distinct in the viewer. */
345+
export function globalPatchIndex(
346+
zone: number,
347+
ring: number,
348+
sector: number,
349+
czm: CzmConfig,
350+
): number {
351+
let idx = 0;
352+
for (let z = 0; z < zone; z++) {
353+
idx += czm.numRingsPerZone[z] * czm.numSectorsPerZone[z];
354+
}
355+
idx += ring * czm.numSectorsPerZone[zone] + sector;
356+
return idx;
357+
}
358+
359+
/** Stable per-region color via the golden-angle hue spiral.
360+
* Note: three.js's Color parser only matches the comma-separated hsl() form. */
361+
export function regionColor(
362+
zone: number,
363+
ring: number,
364+
sector: number,
365+
czm: CzmConfig,
366+
): string {
367+
const idx = globalPatchIndex(zone, ring, sector, czm);
322368
const hue = (idx * 137.508) % 360;
323-
// Outer zones get slightly desaturated to keep the palette cohesive.
324-
const sat = 78 - zone * 4;
369+
// Tiny zone-driven shading so the four CZM zones still read at a glance
370+
// even amid the per-patch hue scatter.
371+
const sat = 78 - zone * 3;
325372
const lit = 60 - zone * 2;
326373
return `hsl(${hue.toFixed(0)}, ${sat}%, ${lit}%)`;
327374
}

web/src/pages/Extra03Patchwork.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import DemoAbout from "../components/DemoAbout";
88
import DemoParams from "../components/DemoParams";
99
import PointCloudViewer from "../components/PointCloudViewer";
1010
import { useT } from "../i18n";
11-
import { regionColor, runPatchwork } from "../lib/filters/patchwork";
11+
import { regionColor, runPatchwork, unpackPatch } from "../lib/filters/patchwork";
12+
import { ensureZUp } from "../lib/axisTransform";
1213
import { SENSOR_BY_PRESET, type SensorConfig } from "../lib/sensorConfig";
1314
import { emptyCloud, type PointCloud } from "../lib/types";
1415

@@ -22,22 +23,25 @@ export default function Extra03Patchwork() {
2223
const [presetId, setPresetId] = useState<PresetId>("kitti");
2324
const sensor: SensorConfig | null = SENSOR_BY_PRESET[presetId] ?? null;
2425

26+
// Patchwork assumes Z-up; some presets (NaverLabs) ship Z-down.
27+
// ensureZUp normalizes per-preset before we run the algorithm.
28+
const zUpCloud = useMemo(() => ensureZUp(raw, presetId), [raw, presetId]);
29+
2530
const result = useMemo(() => {
26-
if (!sensor || raw.count === 0) return null;
27-
return runPatchwork(raw, sensor);
28-
}, [raw, sensor]);
31+
if (!sensor || zUpCloud.count === 0) return null;
32+
return runPatchwork(zUpCloud, sensor);
33+
}, [zUpCloud, sensor]);
2934

30-
// Per-vertex color buffer for the ground cloud — every point gets its
31-
// (zone, ring) region color so adjacent CZM bins are clearly visible.
35+
// Per-vertex color buffer for the ground cloud — every point gets a
36+
// unique (zone, ring, sector) patch color so individual CZM patches are
37+
// distinguishable, not just rings.
3238
const groundColors = useMemo(() => {
3339
if (!result || !sensor) return new Float32Array(0);
3440
const tmp = new THREE.Color();
3541
const out = new Float32Array(result.groundCloud.count * 3);
3642
for (let i = 0; i < result.groundCloud.count; i++) {
37-
const rid = result.groundRegionIds[i];
38-
const zone = Math.floor(rid / 100);
39-
const ring = rid - zone * 100;
40-
tmp.setStyle(regionColor(zone, ring, sensor.czm));
43+
const { zone, ring, sector } = unpackPatch(result.groundRegionIds[i]);
44+
tmp.setStyle(regionColor(zone, ring, sector, sensor.czm));
4145
out[i * 3] = tmp.r;
4246
out[i * 3 + 1] = tmp.g;
4347
out[i * 3 + 2] = tmp.b;
@@ -47,7 +51,7 @@ export default function Extra03Patchwork() {
4751

4852
const groundCount = result?.groundCloud.count ?? 0;
4953
const nonGroundCount = result?.nonGroundCloud.count ?? 0;
50-
const numRegions = result?.uniqueRegions.length ?? 0;
54+
const numPatches = result?.uniqueRegions.length ?? 0;
5155

5256
const layers = useMemo(() => {
5357
if (!result || !sensor) return [];
@@ -80,7 +84,7 @@ export default function Extra03Patchwork() {
8084
</span>
8185
<span className="text-[var(--mut)]">·</span>
8286
<span>
83-
<span className="text-[var(--text-strong)]">{numRegions}</span> regions
87+
<span className="text-[var(--text-strong)]">{numPatches}</span> patches
8488
</span>
8589
</div>
8690
<span className="code-font text-[var(--dim)]">

web/src/pages/Extra04Travel.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import RangeImageView from "../components/RangeImageView";
1010
import { useT } from "../i18n";
1111
import { ClusterColorMatcher, type Cluster } from "../lib/filters/euclideanCluster";
1212
import { runTravel, travelClusterColor } from "../lib/filters/travel";
13+
import { ensureZUp } from "../lib/axisTransform";
1314
import { SENSOR_BY_PRESET, type SensorConfig } from "../lib/sensorConfig";
1415
import { emptyCloud, type PointCloud } from "../lib/types";
1516

@@ -23,10 +24,14 @@ export default function Extra04Travel() {
2324
const [presetId, setPresetId] = useState<PresetId>("kitti");
2425
const sensor: SensorConfig | null = SENSOR_BY_PRESET[presetId] ?? null;
2526

27+
// TRAVEL inherits the Z-up assumption from its Patchwork-based ground
28+
// pre-pass; normalize per-preset before passing to runTravel.
29+
const zUpCloud = useMemo(() => ensureZUp(raw, presetId), [raw, presetId]);
30+
2631
const result = useMemo(() => {
27-
if (!sensor || raw.count === 0) return null;
28-
return runTravel(raw, sensor);
29-
}, [raw, sensor]);
32+
if (!sensor || zUpCloud.count === 0) return null;
33+
return runTravel(zUpCloud, sensor);
34+
}, [zUpCloud, sensor]);
3035

3136
// Persistent color matcher so cluster colors stay stable when the user
3237
// re-selects the same preset (avoids flicker on remount).

0 commit comments

Comments
 (0)