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.

847 lines (721 loc) • 26.6 kB
/** * JavaScript QuadTree Point Cloud Filtering Package * PCL.js compatible API for point cloud processing with QuadTree spatial indexing */ import { QuadTree, Box, Point, Circle } from 'js-quadtree'; import * as fs from 'fs'; // Type definitions export interface Point3DData { x: number; y: number; z: number; data?: any; } export interface CoordinateArrays { x: number[]; y: number[]; z: number[]; } export interface Bounds { minX: number; minY: number; maxX: number; maxY: number; } export interface LayerInfo { layer: number; gridSize: number; totalCells: number; cellSize: number; spacingX: number; spacingY: number; depth: number; } export interface InitOptions { url?: string; [key: string]: any; } /** * 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; } /** * Create a copy of this point */ clone(): PointXYZ { return new PointXYZ(this.x, this.y, this.z); } /** * Calculate distance to another point */ distanceTo(other: PointXYZ): number { const dx = this.x - other.x; const dy = this.y - other.y; const dz = this.z - other.z; return Math.sqrt(dx * dx + dy * dy + dz * dz); } } /** * 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; } /** * Get point at index */ at(index: number): PointXYZ { return this.points[index]; } /** * Clear all points */ clear(): void { this.points = []; this.width = 0; } } /** * Data loader for point cloud files, following PCL.js async patterns */ export class DataLoader { public cloud: PointCloud; public bounds: number[] | null; constructor() { this.cloud = new PointCloud(PointXYZ); this.bounds = null; } /** * Load point cloud data from file (async, PCL.js style) */ async loadPCDData(input: string | ArrayBuffer): Promise<PointCloud> { try { let data: string; if (typeof input === 'string') { // File path data = fs.readFileSync(input, 'utf8'); } else if (input instanceof ArrayBuffer) { // ArrayBuffer data (PCL.js style) data = new TextDecoder().decode(input); } else { throw new Error('Input must be file path or ArrayBuffer'); } return this._parsePointCloudData(data); } catch (error: any) { throw new Error(`Failed to load point cloud data: ${error.message}`); } } /** * Load points from a text file (legacy method) */ loadFromFile(filename: string): PointXYZ[] { try { const data = fs.readFileSync(filename, 'utf8'); const cloud = this._parsePointCloudData(data); return cloud.points; } catch (error: any) { throw new Error(`Failed to load from file: ${error.message}`); } } /** * 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(): CoordinateArrays { const coordinateArrays: CoordinateArrays = { x: [], y: [], z: [] }; 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 js-quadtree library */ export class QuadTreeManager { public quadtree: QuadTree | null; public points: Point3D[]; public bounds: number[] | null; public coordinateArrays: CoordinateArrays | null; public sparseThreshold: number; constructor() { this.quadtree = null; this.points = []; this.bounds = null; this.coordinateArrays = null; this.sparseThreshold = 0.5; } /** * 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 (x, y, width, height) const boundingArea = new Box(minX, minY, width, height); const config = { capacity: maxElements, maximumDepth: maxDepth, removeEmptyNodes: false }; this.quadtree = new QuadTree(boundingArea, config); // Insert all points with their index as data for (let i = 0; i < points.length; i++) { const point = points[i]; const qtPoint = new Point(point.x, point.y, { index: i, z: point.z }); this.quadtree.insert(qtPoint); } } /** * Build quadtree from coordinate arrays */ buildTreeFromArrays(xCoords: number[], yCoords: number[], zCoords: number[], bounds: number[], maxElements: number = 10, maxDepth: number = 8): void { this.coordinateArrays = { x: xCoords, y: yCoords, z: zCoords }; this.bounds = bounds; const [minX, minY, maxX, maxY] = bounds; const width = maxX - minX; const height = maxY - minY; // Create quadtree with bounding box (x, y, width, height) const boundingArea = new Box(minX, minY, width, height); const config = { capacity: maxElements, maximumDepth: maxDepth, removeEmptyNodes: false }; this.quadtree = new QuadTree(boundingArea, config); // Insert all points using coordinate arrays for (let i = 0; i < xCoords.length; i++) { const qtPoint = new Point(xCoords[i], yCoords[i], { index: i, z: zCoords[i] }); this.quadtree.insert(qtPoint); } } /** * 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 = new Box(minX, minY, width, height); const results = this.quadtree.query(queryBox); const resultPoints: Point3D[] = []; for (const qtPoint of results) { const index = qtPoint.data.index; 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 = new Box(minX, minY, width, height); const results = this.quadtree.query(queryBox); const indices: number[] = []; for (const qtPoint of results) { const index = qtPoint.data.index; // 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; } /** * Find nearest neighbors to a point */ findNearestNeighbors(x: number, y: number, count: number = 1, maxDistance: number | null = null): Point3D[] { if (!this.quadtree) { return []; } // Get all points and calculate distances manually // js-quadtree doesn't have built-in nearest neighbor search const allPoints = this.quadtree.getAllPoints(); const distances: [Point3D, number][] = []; for (const qtPoint of allPoints) { const distance = Math.sqrt( Math.pow(qtPoint.x - x, 2) + Math.pow(qtPoint.y - y, 2) ); if (maxDistance === null || distance <= maxDistance) { const index = qtPoint.data.index; let point3d: Point3D | undefined; if (this.coordinateArrays) { point3d = new Point3D( this.coordinateArrays.x[index], this.coordinateArrays.y[index], this.coordinateArrays.z[index] ); } else if (this.points) { point3d = this.points[index]; } if (point3d) { distances.push([point3d, distance]); } } } // Sort by distance and return top count distances.sort((a, b) => a[1] - b[1]); return distances.slice(0, count).map(([point, distance]) => point); } /** * Get statistics about the quadtree */ getStatistics(): { totalPoints: number } { if (!this.quadtree) { return { totalPoints: 0 }; } const totalPoints = this.coordinateArrays ? this.coordinateArrays.x.length : (this.points ? this.points.length : 0); return { totalPoints: totalPoints }; } /** * Check if quadtree is built */ isBuilt(): boolean { return this.quadtree !== null; } /** * Clear the quadtree */ clear(): void { if (this.quadtree) { this.quadtree.clear(); } this.quadtree = null; this.points = []; this.bounds = null; this.coordinateArrays = null; } } /** * 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 private _cachedPointsHash: string | null; private _cachedBounds: number[] | null; private _cachedLayerSchedule: LayerInfo[] | null; 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 this._cachedPointsHash = null; this._cachedBounds = null; this._cachedLayerSchedule = null; this._treeBuilt = false; } /** * Load points from file */ loadFromFile(filename: string): void { this.points = this.dataLoader.loadFromFile(filename); this.bounds = this.dataLoader.getBounds(); } /** * Filter points by grid spacing */ filterPointsByGridSpacing(xCoords: number[], yCoords: number[], zCoords: number[], minLateralSpacing: number): Point3D[] { // Calculate points hash for caching const pointsHash = this._calculatePointsHash(xCoords, yCoords, zCoords); if (this._cachedPointsHash !== pointsHash || !this._treeBuilt) { // Calculate bounds if (xCoords.length === 0) { return []; } // Use efficient loops instead of spread operator to avoid stack overflow let minX = xCoords[0], maxX = xCoords[0]; let minY = yCoords[0], maxY = yCoords[0]; for (let i = 1; i < xCoords.length; i++) { if (xCoords[i] < minX) minX = xCoords[i]; if (xCoords[i] > maxX) maxX = xCoords[i]; } for (let i = 1; i < yCoords.length; i++) { if (yCoords[i] < minY) minY = yCoords[i]; if (yCoords[i] > maxY) maxY = yCoords[i]; } this.bounds = [minX, minY, maxX, maxY]; // Calculate layer schedule this.layerSchedule = this.calculateLayerSchedule(this.bounds, this.minCellSize); // Build quadtree this.quadtreeManager.buildTreeFromArrays(xCoords, yCoords, zCoords, this.bounds); // Update cache this._cachedPointsHash = pointsHash; this._cachedBounds = this.bounds; this._cachedLayerSchedule = this.layerSchedule; this._treeBuilt = true; } else { // Use cached values this.bounds = this._cachedBounds; this.layerSchedule = this._cachedLayerSchedule; } // Filter points based on grid spacing return this._filterByGridSpacing(minLateralSpacing); } /** * Calculate hash for coordinate arrays */ private _calculatePointsHash(xCoords: number[], yCoords: number[], zCoords: number[]): string { if (xCoords.length === 0) { return 'empty'; } // Simple hash based on length and sample points const sampleData: number[] = []; // Add first few points for (let i = 0; i < Math.min(5, xCoords.length); i++) { sampleData.push(xCoords[i], yCoords[i], zCoords[i]); } // Add last few points if different from first if (xCoords.length > 5) { for (let i = Math.max(xCoords.length - 5, 5); i < xCoords.length; i++) { sampleData.push(xCoords[i], yCoords[i], zCoords[i]); } } // Create hash from length and sample data const hashStr = `${xCoords.length}_${JSON.stringify(sampleData).split('').reduce((a, b) => { a = ((a << 5) - a) + b.charCodeAt(0); return a & a; }, 0)}`; return hashStr; } /** * 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 grid spacing (internal method) */ private _filterByGridSpacing(minLateralSpacing: number): Point3D[] { if (!this.bounds || !this.layerSchedule) { return []; } // Find appropriate layer based on min_lateral_spacing let targetLayer: LayerInfo | null = null; for (const layer of this.layerSchedule) { if (Math.min(layer.spacingX, layer.spacingY) >= minLateralSpacing) { targetLayer = layer; break; } } if (!targetLayer) { // Use the finest layer if no suitable layer found targetLayer = this.layerSchedule[this.layerSchedule.length - 1]; } return this.filterByDepth(targetLayer.depth); } /** * Filter points by quadtree depth */ filterByDepth(targetDepth: number): Point3D[] { if (!this.quadtreeManager.isBuilt()) { return []; } const filteredPoints: Point3D[] = []; 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; // Sample one point per cell at target depth 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; // Query point indices in this cell const cellIndices = this.quadtreeManager.queryRegionIndices( cellMinX, cellMinY, cellMaxX, cellMaxY ); // Take the first point in the cell if (cellIndices.length > 0) { const firstIndex = cellIndices[0]; const coords = this.quadtreeManager.coordinateArrays; if (coords && firstIndex < coords.x.length) { const point = new Point3D( coords.x[firstIndex], coords.y[firstIndex], coords.z[firstIndex] ); filteredPoints.push(point); } } } } } return filteredPoints; } } // PCL.js compatible types export interface PCLObject { PointXYZ: typeof PointXYZ; PointCloud: typeof PointCloud; DataLoader: typeof DataLoader; QuadTreeManager: typeof QuadTreeManager; QuadTreeFilter: typeof QuadTreeFilter; loadPCDData: (data: ArrayBuffer) => Promise<PointCloud>; savePCDDataASCII: (cloud: PointCloud) => ArrayBuffer; } /** * PCL.js compatible initialization function */ export async function init(options: InitOptions = {}): Promise<PCLObject> { // Simulate PCL.js async initialization return new Promise((resolve) => { setTimeout(() => { resolve({ PointXYZ, PointCloud, DataLoader, QuadTreeManager, QuadTreeFilter, loadPCDData: async (data: ArrayBuffer): Promise<PointCloud> => { const loader = new DataLoader(); return await loader.loadPCDData(data); }, savePCDDataASCII: (cloud: PointCloud): ArrayBuffer => { // 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 }); } /** * Load PCD data (global function, PCL.js style) */ export function loadPCDData(data: ArrayBuffer, pointType: typeof PointXYZ = PointXYZ): PointCloud { const loader = new DataLoader(); const textData = new TextDecoder().decode(data); return loader._parsePointCloudData(textData); } /** * Save point cloud as PCD ASCII data */ export function savePCDDataASCII(cloud: PointCloud): ArrayBuffer { 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; } // Default export const _default = { PointXYZ, PointCloud, Point3D, DataLoader, QuadTreeManager, QuadTreeFilter, init, loadPCDData, savePCDDataASCII }; export default _default;