Skip to content

Commit 84c0cde

Browse files
committed
Patchwork+TRAVEL: tighten VLP-16 ground gate, sensor-specific TRAVEL thresholds
Patchwork fix — match upstream determine_ground_likelihood_estimation_status: - elevation gate now indexes by *global* ring (not by zone), so elevation_thresholds[0..3] map to the inner four rings as in patchwork.hpp:818-827. - FLAT_ENOUGH override added: high but very flat patches (flatness < flat_thr) still count as ground (ramps, curbs). - Outer rings now apply the global-elevation veto when useGlobalElevation is on. This is what rejects ceilings on VLP-16 indoor (z_centroid > 0 in sensor frame). - FEW_POINTS branch (idxs ≤ num_min_pts) now marks all bin points as ground, matching the upstream denoising heuristic. CzmConfig grew useGlobalElevation + globalElevationThr (true/0.0 for VLP-16, false/-0.5 for HDL-64). TRAVEL clustering — separate horz/vert merge thresholds (0.4/0.5 for HDL-64 from kitti_params.yaml; tighter 0.3/0.4 for VLP-16 indoor). Defaults read from sensor.rangeImage so each chapter pulls sensor-appropriate values. Smoke test (scripts/smoke-patchwork.mjs) confirms the fix: VLP-16 indoor: floor 96.2% ground, ceiling 0%, walls 0% HDL-64 outdoor: ground 97.7%, cars 0%
1 parent 667d09e commit 84c0cde

4 files changed

Lines changed: 262 additions & 26 deletions

File tree

web/scripts/smoke-patchwork.mjs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Smoke test: synthetic indoor scene → Patchwork should classify floor as
2+
// ground and reject ceiling/walls. Run with:
3+
// npx tsx scripts/smoke-patchwork.mjs
4+
5+
const { runPatchwork } = await import("../src/lib/filters/patchwork.ts");
6+
const { SENSOR_VLP16, SENSOR_HDL64 } = await import("../src/lib/sensorConfig.ts");
7+
const { cloudFromPositions } = await import("../src/lib/types.ts");
8+
9+
function makeIndoorScene() {
10+
// VLP-16 sensor at origin, mounted 0.6 m above floor (so floor z = -0.6).
11+
// 8 m × 8 m × 3 m room, ceiling at z = +2.4. Add some object boxes.
12+
const pts = [];
13+
const rand = mulberry32(42);
14+
// Floor: scatter 3000 points across the room.
15+
for (let i = 0; i < 3000; i++) {
16+
pts.push((rand() - 0.5) * 6, (rand() - 0.5) * 6, -0.6 + (rand() - 0.5) * 0.02);
17+
}
18+
// Ceiling: 1500 points.
19+
for (let i = 0; i < 1500; i++) {
20+
pts.push((rand() - 0.5) * 6, (rand() - 0.5) * 6, 2.4 + (rand() - 0.5) * 0.02);
21+
}
22+
// 4 walls, 800 points each.
23+
for (let w = 0; w < 4; w++) {
24+
for (let i = 0; i < 800; i++) {
25+
const along = (rand() - 0.5) * 6;
26+
const up = -0.6 + rand() * 3;
27+
const wallX = w === 0 ? 3 : w === 1 ? -3 : along;
28+
const wallY = w === 0 ? along : w === 1 ? along : w === 2 ? 3 : -3;
29+
pts.push(wallX, wallY, up);
30+
}
31+
}
32+
// 3 box-shaped obstacles on the floor.
33+
const objs = [
34+
[1.2, 0.5, 0.4],
35+
[-1.5, 1.8, 0.6],
36+
[0.5, -1.6, 0.5],
37+
];
38+
for (const [cx, cy, height] of objs) {
39+
for (let i = 0; i < 600; i++) {
40+
pts.push(cx + (rand() - 0.5) * 0.6, cy + (rand() - 0.5) * 0.6, -0.6 + rand() * height);
41+
}
42+
}
43+
return cloudFromPositions(new Float32Array(pts));
44+
}
45+
46+
function makeOutdoorKittiLike() {
47+
// HDL-64 sensor at origin, mounted 1.723 m above ground (so ground z ≈ -1.7).
48+
// Flat ground out to 80 m, plus a few "vehicle" boxes.
49+
const pts = [];
50+
const rand = mulberry32(7);
51+
for (let i = 0; i < 8000; i++) {
52+
const r = 2 + rand() * 70;
53+
const a = rand() * 2 * Math.PI;
54+
pts.push(r * Math.cos(a), r * Math.sin(a), -1.723 + (rand() - 0.5) * 0.05);
55+
}
56+
const cars = [
57+
[10, 0, 1.5, 4, 1.6],
58+
[-15, 5, 1.5, 4, 1.7],
59+
[3, -8, 1.6, 4, 1.7],
60+
];
61+
for (const [cx, cy, w, l, h] of cars) {
62+
for (let i = 0; i < 600; i++) {
63+
pts.push(
64+
cx + (rand() - 0.5) * w,
65+
cy + (rand() - 0.5) * l,
66+
-1.723 + rand() * h,
67+
);
68+
}
69+
}
70+
return cloudFromPositions(new Float32Array(pts));
71+
}
72+
73+
function mulberry32(seed) {
74+
let a = seed;
75+
return () => {
76+
a |= 0;
77+
a = (a + 0x6d2b79f5) | 0;
78+
let t = a;
79+
t = Math.imul(t ^ (t >>> 15), t | 1);
80+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
81+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
82+
};
83+
}
84+
85+
function classify(cloud, isGround, zMin, zMax) {
86+
let groundIn = 0,
87+
nonGroundIn = 0,
88+
total = 0;
89+
for (let i = 0; i < cloud.count; i++) {
90+
const z = cloud.positions[3 * i + 2];
91+
if (z < zMin || z > zMax) continue;
92+
total++;
93+
if (isGround[i]) groundIn++;
94+
else nonGroundIn++;
95+
}
96+
return { groundIn, nonGroundIn, total };
97+
}
98+
99+
console.log("=== VLP-16 indoor synthetic ===");
100+
const indoor = makeIndoorScene();
101+
const ind = runPatchwork(indoor, SENSOR_VLP16);
102+
const floorBand = classify(indoor, ind.isGround, -0.7, -0.5);
103+
const ceilingBand = classify(indoor, ind.isGround, 2.3, 2.5);
104+
const wallBand = classify(indoor, ind.isGround, 0.0, 1.5);
105+
console.log(
106+
` floor z∈[-0.7, -0.5]: ground=${floorBand.groundIn}/${floorBand.total} (${pct(floorBand)})`,
107+
);
108+
console.log(
109+
` ceiling z∈[ 2.3, 2.5]: ground=${ceilingBand.groundIn}/${ceilingBand.total} (${pct(ceilingBand)})`,
110+
);
111+
console.log(
112+
` walls z∈[ 0.0, 1.5]: ground=${wallBand.groundIn}/${wallBand.total} (${pct(wallBand)})`,
113+
);
114+
console.log(
115+
` TOTAL: ${ind.groundCloud.count}/${indoor.count} marked ground; ${ind.uniqueRegions.length} regions`,
116+
);
117+
118+
console.log("");
119+
console.log("=== HDL-64 outdoor synthetic ===");
120+
const outdoor = makeOutdoorKittiLike();
121+
const out = runPatchwork(outdoor, SENSOR_HDL64);
122+
const groundBand = classify(outdoor, out.isGround, -1.8, -1.65);
123+
const carBand = classify(outdoor, out.isGround, -0.8, 0.3);
124+
console.log(
125+
` ground z∈[-1.80, -1.65]: ground=${groundBand.groundIn}/${groundBand.total} (${pct(groundBand)})`,
126+
);
127+
console.log(
128+
` car z∈[-0.80, 0.30]: ground=${carBand.groundIn}/${carBand.total} (${pct(carBand)})`,
129+
);
130+
console.log(
131+
` TOTAL: ${out.groundCloud.count}/${outdoor.count} marked ground; ${out.uniqueRegions.length} regions`,
132+
);
133+
134+
console.log("");
135+
const indoorOk = floorBand.groundIn / floorBand.total > 0.7
136+
&& ceilingBand.groundIn / Math.max(1, ceilingBand.total) < 0.2
137+
&& wallBand.groundIn / Math.max(1, wallBand.total) < 0.2;
138+
const outdoorOk = groundBand.groundIn / groundBand.total > 0.7
139+
&& carBand.groundIn / Math.max(1, carBand.total) < 0.2;
140+
console.log(`indoor OK: ${indoorOk}`);
141+
console.log(`outdoor OK: ${outdoorOk}`);
142+
process.exit(indoorOk && outdoorOk ? 0 : 1);
143+
144+
function pct(b) {
145+
if (b.total === 0) return "—";
146+
return `${((100 * b.groundIn) / b.total).toFixed(1)}%`;
147+
}

web/src/lib/filters/patchwork.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,33 @@ export function runPatchwork(
127127
arr.push(i);
128128
}
129129

130+
// Pre-compute global ring index per (zone, ring) — used for indexing into
131+
// elevation_thresholds / flatness_thresholds (whose length is the number
132+
// of "rings of interest", not the total ring count).
133+
const ringOffsetByZone: number[] = [];
134+
{
135+
let acc = 0;
136+
for (let z = 0; z < c.numRingsPerZone.length; z++) {
137+
ringOffsetByZone.push(acc);
138+
acc += c.numRingsPerZone[z];
139+
}
140+
}
141+
const numRingsOfInterest = c.elevationThresholds.length;
142+
130143
// R-GPF per bin.
131144
for (const [, idxs] of bins) {
132-
if (idxs.length < c.numMinPts) continue;
133145
const sampleZone = pointZone[idxs[0]];
146+
const sampleRing = pointRing[idxs[0]];
147+
148+
// FEW_POINTS branch (line 601-610 of upstream patchwork.hpp): when a bin
149+
// has too few points, the original treats the entire bin as ground. The
150+
// rationale (per the upstream comment): these sparse points are usually
151+
// noise, and dumping them into ground keeps them out of downstream
152+
// clustering / object detection.
153+
if (idxs.length <= c.numMinPts) {
154+
for (const i of idxs) isGround[i] = 1;
155+
continue;
156+
}
134157

135158
// Sort by z ascending to find lowest-point representatives.
136159
const sorted = idxs.slice().sort(
@@ -214,14 +237,37 @@ export function runPatchwork(
214237
if (curSeeds.length < 3) continue;
215238

216239
const upright = Math.abs(normal[2]) > c.uprightnessThr;
217-
const elevThr = c.elevationThresholds[Math.min(sampleZone, c.elevationThresholds.length - 1)];
218-
const flatThr = c.flatnessThresholds[Math.min(sampleZone, c.flatnessThresholds.length - 1)];
219-
// Patchwork applies the elevation gate strictly only on the outer two zones.
220-
const elevOk = sampleZone < 2 || centroidZ < -c.sensorHeight + elevThr;
240+
if (!upright) continue; // TOO_TILTED — reject
241+
221242
const flatness = smallestEig / sumEig;
222-
const flatOk = flatness < flatThr;
243+
const globalRing = ringOffsetByZone[sampleZone] + sampleRing;
244+
245+
// Mirror upstream determine_ground_likelihood_estimation_status:
246+
// inner rings (globalRing < numRingsOfInterest):
247+
// elevation > -h + elev_thr[ring]?
248+
// no → UPRIGHT_ENOUGH (ground)
249+
// yes → flatness < flat_thr[ring]?
250+
// yes → FLAT_ENOUGH (ground — flat patches at unusual
251+
// elevation are kept; e.g. ramps, curbs)
252+
// no → TOO_HIGH_ELEVATION (reject, e.g. car roofs)
253+
// outer rings:
254+
// useGlobalElevation && z_centroid > global_thr → reject
255+
// else → UPRIGHT_ENOUGH (ground)
256+
let acceptAsGround: boolean;
257+
if (globalRing < numRingsOfInterest) {
258+
const elevThr = c.elevationThresholds[globalRing];
259+
const flatThr = c.flatnessThresholds[globalRing];
260+
const tooHigh = centroidZ > -c.sensorHeight + elevThr;
261+
if (!tooHigh) {
262+
acceptAsGround = true;
263+
} else {
264+
acceptAsGround = flatness < flatThr;
265+
}
266+
} else {
267+
acceptAsGround = !(c.useGlobalElevation && centroidZ > c.globalElevationThr);
268+
}
223269

224-
if (!(upright && elevOk && flatOk)) continue;
270+
if (!acceptAsGround) continue;
225271

226272
for (const i of curSeeds) isGround[i] = 1;
227273
}

web/src/lib/filters/travel.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,22 +45,30 @@ export type TravelResult = {
4545
};
4646

4747
export type TravelOptions = {
48-
/** Discontinuity threshold in meters between adjacent range pixels. */
49-
depthDiffThresh: number;
48+
/** Discontinuity threshold in meters between horizontally-adjacent range
49+
* pixels (azimuth direction). */
50+
horzMerge: number;
51+
/** Discontinuity threshold in meters between vertically-adjacent range
52+
* pixels (elevation direction). */
53+
vertMerge: number;
5054
/** Minimum pixels for a cluster to be kept. */
5155
minClusterSize: number;
5256
};
5357

54-
export const DEFAULT_TRAVEL_OPTS: TravelOptions = {
55-
depthDiffThresh: 0.5,
56-
minClusterSize: 10,
57-
};
58+
export function defaultTravelOptions(sensor: SensorConfig): TravelOptions {
59+
return {
60+
horzMerge: sensor.rangeImage.travelHorzMerge,
61+
vertMerge: sensor.rangeImage.travelVertMerge,
62+
minClusterSize: sensor.rangeImage.travelMinClusterSize,
63+
};
64+
}
5865

5966
export function runTravel(
6067
input: PointCloud,
6168
sensor: SensorConfig,
62-
opts: TravelOptions = DEFAULT_TRAVEL_OPTS,
69+
opts?: TravelOptions,
6370
): TravelResult {
71+
const o = opts ?? defaultTravelOptions(sensor);
6472
const ri = sensor.rangeImage;
6573
const rows = ri.rows;
6674
const cols = ri.cols;
@@ -144,18 +152,24 @@ export function runTravel(
144152
const curD = depths[cur];
145153

146154
// 4-connected neighbors. Wrap horizontally — azimuth is periodic.
147-
const neighbors = [
148-
r > 0 ? (r - 1) * cols + c : -1,
149-
r < rows - 1 ? (r + 1) * cols + c : -1,
150-
r * cols + (c === 0 ? cols - 1 : c - 1),
151-
r * cols + (c === cols - 1 ? 0 : c + 1),
152-
];
153-
for (const np of neighbors) {
154-
if (np < 0) continue;
155+
// Vertical (row ±1) uses vertMerge; horizontal (col ±1) uses horzMerge.
156+
const neighbors: { idx: number; thresh: number }[] = [];
157+
if (r > 0) neighbors.push({ idx: (r - 1) * cols + c, thresh: o.vertMerge });
158+
if (r < rows - 1) neighbors.push({ idx: (r + 1) * cols + c, thresh: o.vertMerge });
159+
neighbors.push({
160+
idx: r * cols + (c === 0 ? cols - 1 : c - 1),
161+
thresh: o.horzMerge,
162+
});
163+
neighbors.push({
164+
idx: r * cols + (c === cols - 1 ? 0 : c + 1),
165+
thresh: o.horzMerge,
166+
});
167+
168+
for (const { idx: np, thresh } of neighbors) {
155169
if (imagePointIdx[np] === -1) continue;
156170
if (imageClusterIds[np] !== 0) continue;
157171
const nd = depths[np];
158-
if (Math.abs(nd - curD) > opts.depthDiffThresh) continue;
172+
if (Math.abs(nd - curD) > thresh) continue;
159173
imageClusterIds[np] = id;
160174
queue[tail++] = np;
161175
members.push(np);
@@ -169,7 +183,7 @@ export function runTravel(
169183
const idRemap = new Int32Array(nextClusterId + 1); // 1-based
170184
for (let id = 1; id <= nextClusterId; id++) {
171185
const pixels = clusterPixels[id - 1];
172-
if (pixels.length >= opts.minClusterSize) {
186+
if (pixels.length >= o.minClusterSize) {
173187
survivors.push(pixels);
174188
idRemap[id] = survivors.length;
175189
} else {

web/src/lib/sensorConfig.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,19 @@ export type CzmConfig = {
2121
numRingsPerZone: number[];
2222
/** Length numZones — number of angular sectors within each zone. */
2323
numSectorsPerZone: number[];
24-
/** Per-zone elevation thresholds (m, w.r.t. ground frame). */
24+
/** Per-ring elevation thresholds (length = "rings of interest", typically 4).
25+
* Indexed by *global* ring index across all zones. Bins with global ring
26+
* beyond this length fall back to the global elevation gate. */
2527
elevationThresholds: number[];
26-
/** Per-zone flatness thresholds (eigenvalue ratio). */
28+
/** Per-ring flatness thresholds (eigenvalue ratio). Same indexing as
29+
* elevationThresholds. */
2730
flatnessThresholds: number[];
31+
/** Whether outer rings use the global-elevation veto. Enable for indoor
32+
* sensors (VLP-16) where ceilings would otherwise pass as ground. */
33+
useGlobalElevation: boolean;
34+
/** Global elevation threshold (sensor frame). Plane centroid Z above this
35+
* is rejected as ground when useGlobalElevation = true. */
36+
globalElevationThr: number;
2837
/** Sensor mount height above ground (positive). */
2938
sensorHeight: number;
3039
/** R-GPF: lowest-point-representative count (seed initialization). */
@@ -48,6 +57,14 @@ export type RangeImageConfig = {
4857
maxRange: number;
4958
/** Vertical FOV bounds in degrees (lower, upper). */
5059
vertFovDeg: [number, number];
60+
/** TRAVEL AOS: depth-difference threshold (m) for horizontally-adjacent
61+
* range-image pixels to join the same cluster. */
62+
travelHorzMerge: number;
63+
/** TRAVEL AOS: depth-difference threshold (m) for vertically-adjacent
64+
* range-image pixels to join the same cluster. */
65+
travelVertMerge: number;
66+
/** TRAVEL AOS: minimum cluster size (pixels). */
67+
travelMinClusterSize: number;
5168
};
5269

5370
export type SensorConfig = {
@@ -69,6 +86,8 @@ export const SENSOR_VLP16: SensorConfig = {
6986
numSectorsPerZone: [16, 32, 56, 32],
7087
elevationThresholds: [0.523, 0.746, 0.879, 1.125],
7188
flatnessThresholds: [0.0005, 0.000725, 0.001, 0.001],
89+
useGlobalElevation: true,
90+
globalElevationThr: 0.0,
7291
sensorHeight: 0.6,
7392
numLpr: 20,
7493
numIter: 3,
@@ -83,6 +102,10 @@ export const SENSOR_VLP16: SensorConfig = {
83102
minRange: 0.6,
84103
maxRange: 60.0,
85104
vertFovDeg: [-15.0, 15.0],
105+
// Indoor scenes have tighter depth structure than outdoor.
106+
travelHorzMerge: 0.3,
107+
travelVertMerge: 0.4,
108+
travelMinClusterSize: 10,
86109
},
87110
};
88111

@@ -98,6 +121,8 @@ export const SENSOR_HDL64: SensorConfig = {
98121
numSectorsPerZone: [16, 32, 56, 32],
99122
elevationThresholds: [0.523, 0.746, 0.879, 1.125],
100123
flatnessThresholds: [0.0005, 0.000725, 0.001, 0.001],
124+
useGlobalElevation: false,
125+
globalElevationThr: -0.5,
101126
sensorHeight: 1.723,
102127
numLpr: 20,
103128
numIter: 3,
@@ -112,6 +137,10 @@ export const SENSOR_HDL64: SensorConfig = {
112137
minRange: 1.0,
113138
maxRange: 80.0,
114139
vertFovDeg: [-24.8, 2.0],
140+
// From kitti_params.yaml in the upstream TRAVEL repo.
141+
travelHorzMerge: 0.4,
142+
travelVertMerge: 0.5,
143+
travelMinClusterSize: 10,
115144
},
116145
};
117146

0 commit comments

Comments
 (0)