UNPKG

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
/** * 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; }