js-subpcd
Version:
🌟 High-performance JavaScript/TypeScript QuadTree point cloud filtering and processing library. Published on npm as js-subpcd with PCL.js compatible API for spatial filtering, subsampling, and nearest neighbor search.
903 lines (759 loc) • 31.9 kB
text/typescript
/**
* JavaScript QuadTree Point Cloud Filtering Package - Browser Version
* PCL.js compatible API for point cloud processing with QuadTree spatial indexing
* This version removes Node.js dependencies for browser compatibility
*/
// Type definitions for browser environment
export interface QuadTreeBoundary {
x: number;
y: number;
width: number;
height: number;
}
export interface QuadTreePoint {
x: number;
y: number;
data?: {
index: number;
z: number;
};
}
export interface TreeInfo {
type: 'wasm' | 'javascript';
instance: any;
pointCount: number;
}
// Browser-specific WASM wrapper interface
interface WASMWrapper {
buildTree(points: Array<{x: number, y: number, z: number}>, bounds: {minX: number, minY: number, maxX: number, maxY: number}): Promise<TreeInfo>;
filterByDepth(treeInfo: TreeInfo, targetDepth: number): Array<{x: number, y: number, z: number}>;
}
// Extend Window interface for browser globals
declare global {
interface Window {
quadTreeWasmWrapper?: WASMWrapper;
PointXYZ: typeof PointXYZ;
PointCloud: typeof PointCloud;
Point3D: typeof Point3D;
DataLoader: typeof DataLoader;
QuadTreeManager: typeof QuadTreeManager;
QuadTreeFilter: typeof QuadTreeFilter;
}
}
/**
* Simple QuadTree implementation for browser use
*/
export class SimpleQuadTree {
public boundary: QuadTreeBoundary;
public capacity: number;
public maxDepth: number;
public depth: number;
public points: QuadTreePoint[];
public divided: boolean;
public northeast: SimpleQuadTree | null;
public northwest: SimpleQuadTree | null;
public southeast: SimpleQuadTree | null;
public southwest: SimpleQuadTree | null;
constructor(boundary: QuadTreeBoundary, capacity: number = 50, maxDepth: number = 12, depth: number = 0) {
this.boundary = boundary;
this.capacity = capacity;
this.maxDepth = maxDepth;
this.depth = depth;
this.points = [];
this.divided = false;
this.northeast = null;
this.northwest = null;
this.southeast = null;
this.southwest = null;
}
insert(point: QuadTreePoint): boolean {
if (!this.contains(point)) {
return false;
}
// Force insertion at max depth to prevent infinite recursion
if (this.depth >= this.maxDepth) {
this.points.push(point);
return true;
}
if (this.points.length < this.capacity) {
this.points.push(point);
return true;
}
if (!this.divided) {
this.subdivide();
}
return (this.northeast!.insert(point) ||
this.northwest!.insert(point) ||
this.southeast!.insert(point) ||
this.southwest!.insert(point));
}
contains(point: QuadTreePoint): boolean {
return (point.x >= this.boundary.x &&
point.x <= this.boundary.x + this.boundary.width &&
point.y >= this.boundary.y &&
point.y <= this.boundary.y + this.boundary.height);
}
subdivide(): void {
// Prevent subdivision if area becomes too small (prevents infinite recursion)
const minSize = 0.001;
if (this.boundary.width <= minSize || this.boundary.height <= minSize) {
return;
}
const x = this.boundary.x;
const y = this.boundary.y;
const w = this.boundary.width / 2;
const h = this.boundary.height / 2;
const neRect: QuadTreeBoundary = { x: x + w, y: y, width: w, height: h };
const nwRect: QuadTreeBoundary = { x: x, y: y, width: w, height: h };
const seRect: QuadTreeBoundary = { x: x + w, y: y + h, width: w, height: h };
const swRect: QuadTreeBoundary = { x: x, y: y + h, width: w, height: h };
this.northeast = new SimpleQuadTree(neRect, this.capacity, this.maxDepth, this.depth + 1);
this.northwest = new SimpleQuadTree(nwRect, this.capacity, this.maxDepth, this.depth + 1);
this.southeast = new SimpleQuadTree(seRect, this.capacity, this.maxDepth, this.depth + 1);
this.southwest = new SimpleQuadTree(swRect, this.capacity, this.maxDepth, this.depth + 1);
this.divided = true;
// Redistribute existing points
const pointsToRedistribute = [...this.points];
this.points = [];
for (const point of pointsToRedistribute) {
// Try to insert in child nodes, keep in parent if failed
if (!(this.northeast.insert(point) ||
this.northwest.insert(point) ||
this.southeast.insert(point) ||
this.southwest.insert(point))) {
this.points.push(point);
}
}
}
query(range: QuadTreeBoundary, found: QuadTreePoint[] = []): QuadTreePoint[] {
if (!this.intersects(range)) {
return found;
}
for (const point of this.points) {
if (this.inRange(point, range)) {
found.push(point);
}
}
if (this.divided) {
this.northeast!.query(range, found);
this.northwest!.query(range, found);
this.southeast!.query(range, found);
this.southwest!.query(range, found);
}
return found;
}
intersects(range: QuadTreeBoundary): boolean {
return !(range.x >= this.boundary.x + this.boundary.width ||
range.x + range.width <= this.boundary.x ||
range.y >= this.boundary.y + this.boundary.height ||
range.y + range.height <= this.boundary.y);
}
inRange(point: QuadTreePoint, range: QuadTreeBoundary): boolean {
return (point.x >= range.x &&
point.x <= range.x + range.width &&
point.y >= range.y &&
point.y <= range.y + range.height);
}
getAllPoints(found: QuadTreePoint[] = []): QuadTreePoint[] {
found.push(...this.points);
if (this.divided) {
this.northeast!.getAllPoints(found);
this.northwest!.getAllPoints(found);
this.southeast!.getAllPoints(found);
this.southwest!.getAllPoints(found);
}
return found;
}
clear(): void {
this.points = [];
this.divided = false;
this.northeast = null;
this.northwest = null;
this.southeast = null;
this.southwest = null;
}
}
/**
* Point class representing XYZ coordinates, compatible with PCL.js PointXYZ
*/
export class PointXYZ {
public x: number;
public y: number;
public z: number;
constructor(x: number = 0, y: number = 0, z: number = 0) {
this.x = x;
this.y = y;
this.z = z;
}
}
/**
* Legacy Point3D class for backward compatibility
*/
export class Point3D extends PointXYZ {
public data?: any;
constructor(x: number, y: number, z: number, data: any = null) {
super(x, y, z);
this.data = data;
}
}
/**
* Point Cloud data structure, compatible with PCL.js patterns
*/
export class PointCloud {
public points: PointXYZ[];
public pointType: typeof PointXYZ;
public width: number;
public height: number;
public isOrganized: boolean;
constructor(pointType: typeof PointXYZ = PointXYZ) {
this.points = [];
this.pointType = pointType;
this.width = 0;
this.height = 1; // Unorganized point cloud
this.isOrganized = false;
}
/**
* Get the number of points in the cloud
*/
size(): number {
return this.points.length;
}
/**
* Check if the point cloud is empty
*/
empty(): boolean {
return this.points.length === 0;
}
/**
* Add a point to the cloud
*/
addPoint(point: PointXYZ): void {
this.points.push(point);
this.width = this.points.length;
}
/**
* Clear all points
*/
clear(): void {
this.points = [];
this.width = 0;
}
}
/**
* Data loader for point cloud files, browser-compatible version
*/
export class DataLoader {
public cloud: PointCloud;
public bounds: number[] | null;
constructor() {
this.cloud = new PointCloud(PointXYZ);
this.bounds = null;
}
/**
* Load point cloud data from text string
*/
loadFromText(data: string): PointXYZ[] {
const cloud = this._parsePointCloudData(data);
return cloud.points;
}
/**
* Parse point cloud data from string
*/
_parsePointCloudData(data: string): PointCloud {
const lines = data.split('\n').filter(line => line.trim() !== '');
const cloud = new PointCloud(PointXYZ);
for (const line of lines) {
const coords = line.trim().split(/\s+/).map(Number);
if (coords.length >= 3 && !isNaN(coords[0]) && !isNaN(coords[1]) && !isNaN(coords[2])) {
const point = new PointXYZ(coords[0], coords[1], coords[2]);
cloud.addPoint(point);
}
}
this.cloud = cloud;
this._calculateBounds();
return cloud;
}
/**
* Calculate bounding box from loaded points
*/
private _calculateBounds(): void {
if (this.cloud.empty()) {
this.bounds = null;
return;
}
const points = this.cloud.points;
let minX = points[0].x, minY = points[0].y, maxX = points[0].x, maxY = points[0].y;
for (const point of points) {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
}
this.bounds = [minX, minY, maxX, maxY];
}
/**
* Get coordinate arrays (legacy compatibility)
*/
getCoordinateArrays(): {x: number[], y: number[], z: number[]} {
const coordinateArrays = { x: [] as number[], y: [] as number[], z: [] as number[] };
for (const point of this.cloud.points) {
coordinateArrays.x.push(point.x);
coordinateArrays.y.push(point.y);
coordinateArrays.z.push(point.z);
}
return coordinateArrays;
}
/**
* Get bounding box
*/
getBounds(): number[] | null {
return this.bounds;
}
/**
* Get points array (legacy compatibility)
*/
get points(): PointXYZ[] {
return this.cloud.points;
}
/**
* Set points array (legacy compatibility)
*/
set points(points: PointXYZ[]) {
this.cloud.clear();
for (const point of points) {
this.cloud.addPoint(point);
}
this._calculateBounds();
}
}
/**
* QuadTree Manager using SimpleQuadTree with WebAssembly optimization
*/
export class QuadTreeManager {
public quadtree: SimpleQuadTree | null;
public points: Point3D[];
public bounds: number[] | null;
public coordinateArrays: {x: number[], y: number[], z: number[]} | null;
public sparseThreshold: number;
public treeInfo: TreeInfo | null;
public wasmWrapper: WASMWrapper | null;
constructor() {
this.quadtree = null;
this.points = [];
this.bounds = null;
this.coordinateArrays = null;
this.sparseThreshold = 0.5;
this.treeInfo = null;
this.wasmWrapper = null;
// Initialize WASM wrapper if available
if (typeof window !== 'undefined' && window.quadTreeWasmWrapper) {
this.wasmWrapper = window.quadTreeWasmWrapper;
}
}
/**
* Build quadtree from Point3D objects
*/
buildTree(points: Point3D[], bounds: number[], maxElements: number = 10, maxDepth: number = 8): void {
this.points = points;
this.bounds = bounds;
const [minX, minY, maxX, maxY] = bounds;
const width = maxX - minX;
const height = maxY - minY;
// Create quadtree with bounding box
const boundingArea: QuadTreeBoundary = { x: minX, y: minY, width: width, height: height };
this.quadtree = new SimpleQuadTree(boundingArea, maxElements, maxDepth);
// Insert all points with their index as data
for (let i = 0; i < points.length; i++) {
const point = points[i];
const qtPoint: QuadTreePoint = { x: point.x, y: point.y, data: { index: i, z: point.z } };
this.quadtree.insert(qtPoint);
}
}
/**
* Build quadtree from coordinate arrays with automatic WASM/JS selection
*/
async buildTreeFromArrays(xCoords: number[], yCoords: number[], zCoords: number[], bounds: number[], maxElements: number = 100, maxDepth: number = 12): Promise<void> {
console.log(`Building QuadTree for ${xCoords.length} points...`);
const startTime = performance.now();
this.coordinateArrays = { x: xCoords, y: yCoords, z: zCoords };
this.bounds = bounds;
const [minX, minY, maxX, maxY] = bounds;
// Convert arrays to points for WASM wrapper
const points = [];
for (let i = 0; i < xCoords.length; i++) {
points.push({
x: xCoords[i],
y: yCoords[i],
z: zCoords[i]
});
}
// Use WASM wrapper if available, otherwise fallback to pure JS
if (this.wasmWrapper) {
this.treeInfo = await this.wasmWrapper.buildTree(points, { minX, minY, maxX, maxY });
// For backward compatibility, also set the quadtree property
if (this.treeInfo.type === 'javascript') {
this.quadtree = this.treeInfo.instance;
}
} else {
// Pure JavaScript fallback
this.treeInfo = this._buildTreeJS(xCoords, yCoords, zCoords, bounds, maxElements, maxDepth);
this.quadtree = this.treeInfo.instance;
}
const buildTime = performance.now() - startTime;
console.log(`QuadTree built in ${buildTime.toFixed(2)}ms using ${this.treeInfo.type} implementation`);
}
/**
* Pure JavaScript tree building (fallback)
*/
private _buildTreeJS(xCoords: number[], yCoords: number[], zCoords: number[], bounds: number[], maxElements: number, maxDepth: number): TreeInfo {
const [minX, minY, maxX, maxY] = bounds;
const width = maxX - minX;
const height = maxY - minY;
// Create quadtree with bounding box and appropriate parameters for large datasets
const boundingArea: QuadTreeBoundary = { x: minX, y: minY, width: width, height: height };
const quadtree = new SimpleQuadTree(boundingArea, maxElements, maxDepth);
// Insert all points using coordinate arrays with batch processing
let insertedCount = 0;
const batchSize = 10000;
for (let start = 0; start < xCoords.length; start += batchSize) {
const end = Math.min(start + batchSize, xCoords.length);
for (let i = start; i < end; i++) {
const qtPoint: QuadTreePoint = { x: xCoords[i], y: yCoords[i], data: { index: i, z: zCoords[i] } };
const inserted = quadtree.insert(qtPoint);
if (inserted) insertedCount++;
}
// Log progress for large datasets
if (xCoords.length > 100000 && (start + batchSize) % 50000 === 0) {
console.log(`QuadTree building progress: ${Math.min(end, xCoords.length)}/${xCoords.length} points processed`);
}
}
console.log(`QuadTree built: ${insertedCount}/${xCoords.length} points inserted (${((insertedCount/xCoords.length)*100).toFixed(1)}%)`);
return {
type: 'javascript',
instance: quadtree,
pointCount: xCoords.length
};
}
/**
* Query points within a rectangular region
*/
queryRegion(minX: number, minY: number, maxX: number, maxY: number): Point3D[] {
if (!this.quadtree) {
return [];
}
const width = maxX - minX;
const height = maxY - minY;
const queryBox: QuadTreeBoundary = { x: minX, y: minY, width: width, height: height };
const results = this.quadtree.query(queryBox);
const resultPoints: Point3D[] = [];
for (const qtPoint of results) {
const index = qtPoint.data?.index;
if (index === undefined) continue;
if (this.coordinateArrays) {
// Using coordinate arrays
if (index >= 0 && index < this.coordinateArrays.x.length) {
const point = new Point3D(
this.coordinateArrays.x[index],
this.coordinateArrays.y[index],
this.coordinateArrays.z[index]
);
resultPoints.push(point);
}
} else if (this.points) {
// Using Point3D objects
if (index >= 0 && index < this.points.length) {
resultPoints.push(this.points[index]);
}
}
}
return resultPoints;
}
/**
* Query point indices within a rectangular region (optimized)
*/
queryRegionIndices(minX: number, minY: number, maxX: number, maxY: number): number[] {
if (!this.quadtree) {
return [];
}
const width = maxX - minX;
const height = maxY - minY;
const queryBox: QuadTreeBoundary = { x: minX, y: minY, width: width, height: height };
const results = this.quadtree.query(queryBox);
const indices: number[] = [];
for (const qtPoint of results) {
const index = qtPoint.data?.index;
if (index === undefined) continue;
// Validate index bounds
if (this.coordinateArrays) {
if (index >= 0 && index < this.coordinateArrays.x.length) {
indices.push(index);
}
} else if (this.points) {
if (index >= 0 && index < this.points.length) {
indices.push(index);
}
}
}
return indices;
}
/**
* Check if quadtree is built
*/
isBuilt(): boolean {
return (this.quadtree !== null) || (this.treeInfo !== null);
}
/**
* Clear the quadtree
*/
clear(): void {
if (this.quadtree) {
this.quadtree.clear();
}
this.quadtree = null;
this.points = [];
this.bounds = null;
this.coordinateArrays = null;
}
}
// Layer info interface
interface LayerInfo {
layer: number;
gridSize: number;
totalCells: number;
cellSize: number;
spacingX: number;
spacingY: number;
depth: number;
}
/**
* QuadTree Filter for point cloud filtering operations
*/
export class QuadTreeFilter {
public minCellSize: number;
public dataLoader: DataLoader;
public quadtreeManager: QuadTreeManager;
public layerSchedule: LayerInfo[] | null;
public points: PointXYZ[];
public bounds: number[] | null;
// Caching for performance
private _treeBuilt: boolean;
constructor(minCellSize: number) {
this.minCellSize = minCellSize;
this.dataLoader = new DataLoader();
this.quadtreeManager = new QuadTreeManager();
this.layerSchedule = null;
this.points = [];
this.bounds = null;
// Caching for performance
this._treeBuilt = false;
}
/**
* Calculate layer schedule for power-of-2 grids
*/
calculateLayerSchedule(bounds: number[], minCellSize: number): LayerInfo[] {
const [minX, minY, maxX, maxY] = bounds;
const width = maxX - minX;
const height = maxY - minY;
const maxDimension = Math.max(width, height);
const layers: LayerInfo[] = [];
let layerNum = 0;
// Power-of-2 grid sizes
const gridSizes = [2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048];
for (const gridSize of gridSizes) {
// Calculate cell size for this grid
const cellSize = maxDimension / gridSize;
// Stop if cell size is smaller than minimum
if (cellSize < minCellSize) {
break;
}
// Calculate actual spacing
const spacingX = width / gridSize;
const spacingY = height / gridSize;
const layerInfo: LayerInfo = {
layer: layerNum,
gridSize: gridSize,
totalCells: gridSize * gridSize,
cellSize: cellSize,
spacingX: spacingX,
spacingY: spacingY,
depth: Math.log2(gridSize)
};
layers.push(layerInfo);
layerNum++;
}
return layers;
}
/**
* Filter points by quadtree depth with WASM optimization and caching
*/
async filterByDepth(targetDepth: number): Promise<Point3D[]> {
console.log(`FilterByDepth: targetDepth=${targetDepth}, treeBuilt=${this.quadtreeManager.isBuilt()}, _treeBuilt=${this._treeBuilt}`);
// Build QuadTree if not already built (respecting cache)
if (!this.quadtreeManager.isBuilt() || !this._treeBuilt) {
console.log('QuadTree not built or cache invalid, building now...');
if (!this.dataLoader.cloud || this.dataLoader.cloud.empty()) {
console.log('No data loaded, returning empty array');
return [];
}
// Get coordinates and build tree
const coords = this.dataLoader.getCoordinateArrays();
const bounds = this.dataLoader.getBounds();
if (!bounds) {
console.log('No bounds calculated, returning empty array');
return [];
}
console.log(`Building QuadTree with ${coords.x.length} points`);
await this.quadtreeManager.buildTreeFromArrays(coords.x, coords.y, coords.z, bounds);
this.bounds = bounds;
this._treeBuilt = true;
} else {
console.log('Using cached QuadTree for grid-based subsampling');
}
const startTime = performance.now();
let filteredPoints: Point3D[] = [];
// Use WASM wrapper if available
if (this.quadtreeManager.wasmWrapper && this.quadtreeManager.treeInfo) {
const wasmResult = this.quadtreeManager.wasmWrapper.filterByDepth(
this.quadtreeManager.treeInfo,
targetDepth
);
// Convert WASM result to Point3D objects
for (const wasmPoint of wasmResult) {
filteredPoints.push(new Point3D(wasmPoint.x, wasmPoint.y, wasmPoint.z));
}
} else {
// Optimized JavaScript implementation for high-resolution grids
const gridSize = Math.pow(2, targetDepth);
if (this.bounds) {
const [minX, minY, maxX, maxY] = this.bounds;
const width = maxX - minX;
const height = maxY - minY;
const cellWidth = width / gridSize;
const cellHeight = height / gridSize;
// Grid calculation complete
// For very high resolution grids (>1024), use optimized point-based approach
if (gridSize > 1024) {
console.log('Using optimized point-based grid assignment for high resolution');
// Use Map for sparse grid storage
const gridCells = new Map<string, Point3D>();
const coords = this.quadtreeManager.coordinateArrays;
if (coords) {
// Process points in batches to avoid blocking
const batchSize = 10000;
const totalPoints = coords.x.length;
for (let start = 0; start < totalPoints; start += batchSize) {
const end = Math.min(start + batchSize, totalPoints);
for (let idx = start; idx < end; idx++) {
const x = coords.x[idx];
const y = coords.y[idx];
const z = coords.z[idx];
// Calculate grid cell
let cellX = Math.floor((x - minX) / cellWidth);
let cellY = Math.floor((y - minY) / cellHeight);
// Clamp to grid bounds
cellX = Math.max(0, Math.min(gridSize - 1, cellX));
cellY = Math.max(0, Math.min(gridSize - 1, cellY));
const cellKey = `${cellX},${cellY}`;
// Only keep first point in each cell
if (!gridCells.has(cellKey)) {
gridCells.set(cellKey, new Point3D(x, y, z));
}
}
// Yield control every batch to prevent blocking
if (start + batchSize < totalPoints) {
await new Promise(resolve => setTimeout(resolve, 1));
}
}
// Convert map to array
filteredPoints = Array.from(gridCells.values());
}
} else {
// Original cell-by-cell approach for smaller grids
const usedIndices = new Set<number>();
for (let i = 0; i < gridSize; i++) {
for (let j = 0; j < gridSize; j++) {
const cellMinX = minX + i * cellWidth;
const cellMaxX = minX + (i + 1) * cellWidth;
const cellMinY = minY + j * cellHeight;
const cellMaxY = minY + (j + 1) * cellHeight;
const cellIndices = this.quadtreeManager.queryRegionIndices(
cellMinX, cellMinY, cellMaxX, cellMaxY
);
let selectedIndex = -1;
for (const index of cellIndices) {
if (!usedIndices.has(index)) {
selectedIndex = index;
break;
}
}
if (selectedIndex >= 0) {
const coords = this.quadtreeManager.coordinateArrays;
if (coords && selectedIndex < coords.x.length) {
const point = new Point3D(
coords.x[selectedIndex],
coords.y[selectedIndex],
coords.z[selectedIndex]
);
filteredPoints.push(point);
usedIndices.add(selectedIndex);
}
}
}
}
}
}
}
const filterTime = performance.now() - startTime;
const implementation = this.quadtreeManager.treeInfo ? this.quadtreeManager.treeInfo.type : 'javascript';
console.log(`Filtered to ${filteredPoints.length} points (depth ${targetDepth}) in ${filterTime.toFixed(2)}ms using ${implementation}`);
return filteredPoints;
}
}
/**
* PCL.js compatible initialization function
*/
export async function init(options: any = {}): Promise<any> {
// Simulate PCL.js async initialization
return new Promise((resolve) => {
setTimeout(() => {
resolve({
PointXYZ,
PointCloud,
DataLoader,
QuadTreeManager,
QuadTreeFilter,
loadPCDData: async (data: string) => {
const loader = new DataLoader();
return loader._parsePointCloudData(data);
},
savePCDDataASCII: (cloud: PointCloud) => {
// Convert point cloud to PCD ASCII format
let pcdData = '# .PCD v0.7 - Point Cloud Data file format\n';
pcdData += 'VERSION 0.7\n';
pcdData += 'FIELDS x y z\n';
pcdData += 'SIZE 4 4 4\n';
pcdData += 'TYPE F F F\n';
pcdData += 'COUNT 1 1 1\n';
pcdData += `WIDTH ${cloud.width}\n`;
pcdData += `HEIGHT ${cloud.height}\n`;
pcdData += 'VIEWPOINT 0 0 0 1 0 0 0\n';
pcdData += `POINTS ${cloud.size()}\n`;
pcdData += 'DATA ascii\n';
for (const point of cloud.points) {
pcdData += `${point.x} ${point.y} ${point.z}\n`;
}
return new TextEncoder().encode(pcdData).buffer;
}
});
}, 10); // Small delay to simulate async initialization
});
}
// Make classes available globally for browser use
if (typeof window !== 'undefined') {
window.PointXYZ = PointXYZ;
window.PointCloud = PointCloud;
window.Point3D = Point3D;
window.DataLoader = DataLoader;
window.QuadTreeManager = QuadTreeManager;
window.QuadTreeFilter = QuadTreeFilter;
}