UNPKG

pointcloud-zernike

Version:

A high-performance JavaScript library for Zernike polynomial decomposition of point cloud data

1,217 lines (1,044 loc) 53.4 kB
// pointcloud-zernike - High-performance Zernike decomposition for point cloud data // Extracted from SPDT-Twin-Claude project for standalone use import { QuadTreeFilter, PointXYZ } from 'js-subpcd'; /** * High-performance Zernike polynomial decomposer for point cloud surface analysis * * @class ZernikeDecomposer * @description Performs Zernike polynomial decomposition on 3D point cloud data * Features: * - 28-term Zernike polynomial decomposition (Z1-Z28) * - High-performance processing with chunked algorithms for large datasets * - Comprehensive error handling and validation * - Surface reconstruction capabilities * - Residual analysis */ class ZernikeDecomposer { constructor() { this.zernikeFunctions = []; this.zernikeNames = []; this.initializeZernikeFunctions(); } initializeZernikeFunctions() { // Z1-Z28 terms for comprehensive analysis this.zernikeFunctions = [ (p, t) => 1, // Z1: Piston (p, t) => 2 * p * Math.cos(t), // Z2: Tip (X-Tilt) (p, t) => 2 * p * Math.sin(t), // Z3: Tilt (Y-Tilt) (p, t) => Math.sqrt(3) * (2*p*p - 1), // Z4: Defocus (p, t) => Math.sqrt(6) * p*p * Math.sin(2*t), // Z5: Oblique Astigmatism (p, t) => Math.sqrt(6) * p*p * Math.cos(2*t), // Z6: Vertical Astigmatism (p, t) => Math.sqrt(8) * (3*p*p*p - 2*p) * Math.sin(t), // Z7: Vertical Coma (p, t) => Math.sqrt(8) * (3*p*p*p - 2*p) * Math.cos(t), // Z8: Horizontal Coma (p, t) => Math.sqrt(5) * (6*p*p*p*p - 6*p*p + 1), // Z9: Spherical Aberration (p, t) => Math.sqrt(10) * (4*p*p*p - 3*p) * p * Math.sin(2*t), // Z10: Oblique Trefoil (p, t) => Math.sqrt(10) * (4*p*p*p - 3*p) * p * Math.cos(2*t), // Z11: Vertical Trefoil (p, t) => Math.sqrt(12) * p*p*p*p * Math.sin(3*t), // Z12: Oblique Quadrafoil (p, t) => Math.sqrt(12) * p*p*p*p * Math.cos(3*t), // Z13: Vertical Quadrafoil (p, t) => Math.sqrt(7) * (20*p*p*p*p*p*p - 30*p*p*p*p + 12*p*p - 1), // Z14: Secondary Spherical (p, t) => Math.sqrt(14) * (5*p*p*p*p*p - 4*p*p*p) * p * Math.sin(t), // Z15: Secondary Coma (Y) (p, t) => Math.sqrt(14) * (5*p*p*p*p*p - 4*p*p*p) * p * Math.cos(t), // Z16: Secondary Coma (X) (p, t) => Math.sqrt(16) * (6*p*p*p*p*p - 5*p*p*p) * p*p * Math.sin(2*t), // Z17: Secondary Astigmatism (45°) (p, t) => Math.sqrt(16) * (6*p*p*p*p*p - 5*p*p*p) * p*p * Math.cos(2*t), // Z18: Secondary Astigmatism (0°) (p, t) => Math.sqrt(18) * (5*p*p*p*p - 4*p*p) * p*p*p * Math.sin(3*t), // Z19: Oblique Pentafoil (p, t) => Math.sqrt(18) * (5*p*p*p*p - 4*p*p) * p*p*p * Math.cos(3*t), // Z20: Vertical Pentafoil (p, t) => Math.sqrt(20) * p*p*p*p*p*p * Math.sin(4*t), // Z21: Oblique Hexafoil (p, t) => Math.sqrt(20) * p*p*p*p*p*p * Math.cos(4*t), // Z22: Vertical Hexafoil (p, t) => Math.sqrt(9) * (70*p*p*p*p*p*p*p*p - 140*p*p*p*p*p*p + 90*p*p*p*p - 20*p*p + 1), // Z23: Tertiary Spherical (p, t) => Math.sqrt(18) * (21*p*p*p*p*p*p*p - 30*p*p*p*p*p + 10*p*p*p) * p * Math.sin(t), // Z24: Tertiary Coma (Y) (p, t) => Math.sqrt(18) * (21*p*p*p*p*p*p*p - 30*p*p*p*p*p + 10*p*p*p) * p * Math.cos(t), // Z25: Tertiary Coma (X) (p, t) => Math.sqrt(20) * (7*p*p*p*p*p*p - 6*p*p*p*p) * p*p * Math.sin(2*t), // Z26: Tertiary Astigmatism (45°) (p, t) => Math.sqrt(20) * (7*p*p*p*p*p*p - 6*p*p*p*p) * p*p * Math.cos(2*t), // Z27: Tertiary Astigmatism (0°) (p, t) => Math.sqrt(22) * (8*p*p*p*p*p*p*p - 7*p*p*p*p*p) * p*p * Math.sin(3*t) // Z28: Tertiary Trefoil (Oblique) ]; this.zernikeNames = [ "Piston", "Tip", "Tilt", "Defocus", "Astigmatism (45°)", "Astigmatism (0°)", "Coma (Y)", "Coma (X)", "Spherical", "Trefoil (Oblique)", "Trefoil (Vertical)", "Quadrafoil (Oblique)", "Quadrafoil (Vertical)", "Secondary Spherical", "Secondary Coma (Y)", "Secondary Coma (X)", "Secondary Astigmatism (45°)", "Secondary Astigmatism (0°)", "Pentafoil (Oblique)", "Pentafoil (Vertical)", "Hexafoil (Oblique)", "Hexafoil (Vertical)", "Tertiary Spherical", "Tertiary Coma (Y)", "Tertiary Coma (X)", "Tertiary Astigmatism (45°)", "Tertiary Astigmatism (0°)", "Tertiary Trefoil (Oblique)" ]; } /** * Get the name of a Zernike term by index * @param {number} index - Zero-based index of the Zernike term * @returns {string} The name of the Zernike term */ getZernikeName(index) { return this.zernikeNames[index] || `Z${index + 1}`; } /** * Validate input surface data * @param {Object} surfaceData - Object containing x, y, z coordinate arrays * @throws {Error} If data is invalid */ validateInputData(surfaceData) { if (!surfaceData || typeof surfaceData !== 'object') { throw new Error('Invalid surfaceData: must be an object'); } const { x, y, z } = surfaceData; if (!x || !Array.isArray(x)) { throw new Error('Invalid or missing x coordinate array'); } if (!y || !Array.isArray(y)) { throw new Error('Invalid or missing y coordinate array'); } if (!z || !Array.isArray(z)) { throw new Error('Invalid or missing z coordinate array'); } if (x.length === 0 || y.length === 0 || z.length === 0) { throw new Error('Empty coordinate arrays provided'); } if (x.length !== y.length || x.length !== z.length) { throw new Error(`Coordinate array length mismatch: x=${x.length}, y=${y.length}, z=${z.length}`); } // Check for NaN and infinite values const hasInvalidX = x.some(val => isNaN(val) || !isFinite(val)); const hasInvalidY = y.some(val => isNaN(val) || !isFinite(val)); const hasInvalidZ = z.some(val => isNaN(val) || !isFinite(val)); if (hasInvalidX) { throw new Error('X coordinates contain NaN or infinite values'); } if (hasInvalidY) { throw new Error('Y coordinates contain NaN or infinite values'); } if (hasInvalidZ) { throw new Error('Z coordinates contain NaN or infinite values'); } return true; } /** * Preprocess surface data for Zernike decomposition * @param {Object} surfaceData - Raw surface data * @returns {Object} Processed surface data */ preprocessSurfaceData(surfaceData) { const { x, y, z } = surfaceData; // Calculate mean and remove it const mean = z.reduce((sum, val) => sum + val, 0) / z.length; const processZ = z.map(val => val - mean); // Copy coordinates let processX = [...x]; let processY = [...y]; // Check coordinate units and convert if necessary let maxCoordinate = 0; for (let i = 0; i < processX.length; i++) { const absX = Math.abs(processX[i]); const absY = Math.abs(processY[i]); if (absX > maxCoordinate) maxCoordinate = absX; if (absY > maxCoordinate) maxCoordinate = absY; } if (maxCoordinate > 100) { // Convert from μm to mm processX = processX.map(coord => coord / 1000); processY = processY.map(coord => coord / 1000); } // Calculate normalization radius let maxRadiusSq = 0; for (let i = 0; i < processX.length; i++) { const rSq = processX[i] * processX[i] + processY[i] * processY[i]; if (rSq > maxRadiusSq) { maxRadiusSq = rSq; } } const radius = Math.sqrt(maxRadiusSq); if (radius === 0) { throw new Error("Cannot decompose data with zero radius."); } // Check for reasonable data ranges let zMin = processZ[0], zMax = processZ[0]; for (let i = 1; i < processZ.length; i++) { if (processZ[i] < zMin) zMin = processZ[i]; if (processZ[i] > zMax) zMax = processZ[i]; } const zRange = zMax - zMin; if (zRange === 0) { throw new Error("Z data has zero range after mean removal"); } if (!isFinite(radius) || radius <= 0) { throw new Error(`Invalid radius calculated: ${radius}`); } return { x: processX, y: processY, z: processZ, radius, mean }; } /** * Calculate polar coordinates from Cartesian coordinates * @param {number[]} x - X coordinates * @param {number[]} y - Y coordinates * @param {number} radius - Normalization radius * @returns {Object} Polar coordinates and valid indices */ calculatePolarCoordinates(x, y, radius) { const rho = new Array(x.length); const theta = new Array(x.length); const validIndices = []; for (let i = 0; i < x.length; i++) { const rSq = x[i] * x[i] + y[i] * y[i]; rho[i] = Math.sqrt(rSq) / radius; theta[i] = Math.atan2(y[i], x[i]); // Clamp points outside unit circle if (rho[i] > 0.99) { rho[i] = 0.99; } if (rho[i] <= 0.99 && isFinite(rho[i]) && isFinite(theta[i])) { validIndices.push(i); } } return { rho, theta, validIndices }; } /** * Build Zernike matrix for least squares solving * @param {number[]} rho - Radial coordinates * @param {number[]} theta - Angular coordinates * @param {number} numTerms - Number of Zernike terms * @returns {number[][]} Zernike matrix */ buildZernikeMatrix(rho, theta, numTerms = 28) { const Z = new Array(rho.length); for (let i = 0; i < rho.length; i++) { Z[i] = new Array(numTerms); for (let j = 0; j < numTerms; j++) { const rhoVal = Math.max(0, Math.min(1, rho[i])); const thetaVal = theta[i]; Z[i][j] = this.zernikeFunctions[j](rhoVal, thetaVal); if (isNaN(Z[i][j]) || !isFinite(Z[i][j])) { Z[i][j] = 0; } } } return Z; } /** * Solve for Zernike coefficients using least squares * @param {number[][]} Z - Zernike matrix * @param {number[]} processZ - Processed Z coordinates * @returns {number[]} Zernike coefficients */ solveCoefficients(Z, processZ) { const numTerms = Z[0].length; const numPoints = Z.length; const lambda = 1e-6; // Regularization parameter // Build normal equations: A^T * A * x = A^T * b const ATA = new Array(numTerms); const ATb = new Array(numTerms); for (let i = 0; i < numTerms; i++) { ATA[i] = new Array(numTerms).fill(0); ATb[i] = 0; for (let j = 0; j < numTerms; j++) { for (let k = 0; k < numPoints; k++) { const product = Z[k][i] * Z[k][j]; if (isFinite(product)) { ATA[i][j] += product; } } // Add regularization to diagonal if (i === j) { ATA[i][j] += lambda; } } for (let k = 0; k < numPoints; k++) { const product = Z[k][i] * processZ[k]; if (isFinite(product)) { ATb[i] += product; } } } // Solve using Gaussian elimination return this.gaussianElimination(ATA, ATb); } /** * Gaussian elimination solver * @param {number[][]} A - Coefficient matrix * @param {number[]} b - Right-hand side vector * @returns {number[]} Solution vector */ gaussianElimination(A, b) { const n = A.length; const x = new Array(n).fill(0); const EPSILON = 1e-12; // Forward elimination for (let i = 0; i < n; i++) { // Find pivot let maxRow = i; for (let k = i + 1; k < n; k++) { if (Math.abs(A[k][i]) > Math.abs(A[maxRow][i])) { maxRow = k; } } // Swap rows [A[i], A[maxRow]] = [A[maxRow], A[i]]; [b[i], b[maxRow]] = [b[maxRow], b[i]]; // Skip if pivot is too small if (Math.abs(A[i][i]) < EPSILON) { continue; } // Eliminate column for (let k = i + 1; k < n; k++) { const factor = A[k][i] / A[i][i]; for (let j = i; j < n; j++) { A[k][j] -= factor * A[i][j]; } b[k] -= factor * b[i]; } } // Back substitution for (let i = n - 1; i >= 0; i--) { if (Math.abs(A[i][i]) < EPSILON) { x[i] = 0; continue; } x[i] = b[i]; for (let j = i + 1; j < n; j++) { x[i] -= A[i][j] * x[j]; } x[i] /= A[i][i]; if (isNaN(x[i]) || !isFinite(x[i])) { x[i] = 0; } } return x; } /** * Calculate residual analysis * @param {number[]} originalZ - Original Z coordinates * @param {number[]} coeffs - Zernike coefficients * @param {number[]} rho - Radial coordinates * @param {number[]} theta - Angular coordinates * @param {number} numTerms - Number of terms * @returns {Object} Residual analysis results */ calculateResidualAnalysis(originalZ, coeffs, rho, theta, numTerms) { const reconstructedZ = new Array(originalZ.length); for (let i = 0; i < originalZ.length; i++) { reconstructedZ[i] = 0; for (let j = 0; j < numTerms; j++) { const contribution = coeffs[j] * this.zernikeFunctions[j](rho[i], theta[i]); if (isFinite(contribution)) { reconstructedZ[i] += contribution; } } } const residualZ = originalZ.map((val, i) => val - reconstructedZ[i]); const residualRMS = Math.sqrt(residualZ.reduce((sum, val) => sum + val * val, 0) / residualZ.length); // Calculate PV let residualMin = residualZ[0], residualMax = residualZ[0]; for (let i = 1; i < residualZ.length; i++) { if (residualZ[i] < residualMin) residualMin = residualZ[i]; if (residualZ[i] > residualMax) residualMax = residualZ[i]; } const residualPV = residualMax - residualMin; return { reconstructedZ, residualZ, residualRMS, residualPV }; } /** * Main decomposition method with automatic scheduling * @param {Object} surfaceData - Surface data with x, y, z arrays * @param {Object} options - Options object * @returns {Object} Decomposition results */ async decompose(surfaceData, options = {}) { const { wavelength_nm = 632.8, numTerms = 28, progressCallback = null, forceIterative = false } = options; // Determine processing strategy based on dataset size const datasetSize = surfaceData.x.length; const useIterative = forceIterative || datasetSize > 1000000; // 1M point threshold if (useIterative) { return this.iterativeDecompose(surfaceData, options); } else { return this.directDecompose(surfaceData, options); } } /** * Direct decomposition method for smaller datasets * @param {Object} surfaceData - Surface data with x, y, z arrays * @param {Object} options - Options object * @returns {Object} Decomposition results */ async directDecompose(surfaceData, options = {}) { const { wavelength_nm = 632.8, numTerms = 28, progressCallback = null } = options; try { const startTime = Date.now(); // Validate input this.validateInputData(surfaceData); if (progressCallback) { progressCallback({ progress: 10, message: 'Preprocessing surface data...' }); } // Preprocess data const processed = this.preprocessSurfaceData(surfaceData); if (progressCallback) { progressCallback({ progress: 30, message: 'Calculating polar coordinates...' }); } // Calculate polar coordinates const { rho, theta, validIndices } = this.calculatePolarCoordinates(processed.x, processed.y, processed.radius); if (progressCallback) { progressCallback({ progress: 50, message: 'Building Zernike matrix...' }); } // Build Zernike matrix const Z = this.buildZernikeMatrix(rho, theta, numTerms); if (progressCallback) { progressCallback({ progress: 70, message: 'Solving for coefficients...' }); } // Solve for coefficients const coeffs = this.solveCoefficients(Z, processed.z); if (progressCallback) { progressCallback({ progress: 90, message: 'Performing residual analysis...' }); } // Calculate residual analysis const residualAnalysis = this.calculateResidualAnalysis( processed.z, coeffs, rho, theta, numTerms ); // Format results const decompositionResult = []; for (let i = 0; i < numTerms; i++) { decompositionResult.push({ term: this.getZernikeName(i), coeff: coeffs[i], index: i + 1 }); } if (progressCallback) { progressCallback({ progress: 100, message: 'Decomposition completed' }); } const totalTime = Date.now() - startTime; return { success: true, coefficients: decompositionResult, residualAnalysis: { rms: residualAnalysis.residualRMS, pv: residualAnalysis.residualPV, reconstructedZ: residualAnalysis.reconstructedZ, residualZ: residualAnalysis.residualZ }, metadata: { numPoints: surfaceData.x.length, numTerms: numTerms, wavelength_nm: wavelength_nm, radius: processed.radius, mean: processed.mean, processingTime_ms: totalTime } }; } catch (error) { return { success: false, error: error.message, coefficients: [], residualAnalysis: null, metadata: null }; } } /** * Reconstruct surface from Zernike coefficients * @param {number[]} x - X coordinates * @param {number[]} y - Y coordinates * @param {number} radius - Normalization radius * @param {number[]} coeffs - Zernike coefficients * @returns {number[]} Reconstructed Z coordinates */ reconstructSurface(x, y, radius, coeffs) { const reconstructedZ = new Array(x.length); for (let i = 0; i < x.length; i++) { reconstructedZ[i] = 0; const rho = Math.sqrt(x[i] * x[i] + y[i] * y[i]) / radius; const theta = Math.atan2(y[i], x[i]); for (let k = 0; k < coeffs.length; k++) { const coeff = typeof coeffs[k] === 'object' ? coeffs[k].coeff : coeffs[k]; if (typeof coeff === 'number' && isFinite(coeff)) { const contribution = coeff * this.zernikeFunctions[k](rho, theta); if (isFinite(contribution)) { reconstructedZ[i] += contribution; } } } } return reconstructedZ; } /** * Adaptive iterative decomposition with convergence monitoring * @param {Object} fullData - Full dataset * @param {number} maxIterations - Maximum iterations per round * @param {number} numTerms - Number of Zernike terms * @param {number} tolerance - Convergence tolerance * @param {Function} progressCallback - Progress callback * @returns {Object} Convergence result */ async adaptiveIterativeDecomposition(fullData, maxIterations = 8, numTerms = 8, tolerance = 0.0000001, progressCallback = null) { let allCoefficients = []; let convergenceHistory = []; // Determine sampling strategy based on dataset size const minPoints = 100000; let pct; if (fullData.x.length < 100000) { pct = 0.2; // Use 20% for datasets smaller than 100k points } else { pct = Math.max(0.015, minPoints / fullData.x.length); // At least 1.5% or enough for 100k points } const minIterations = 3; let adaptiveRound = 1; while (adaptiveRound <= 3) { // Max 3 adaptive rounds let roundCoefficients = []; let roundConverged = false; for (let iteration = 0; iteration < maxIterations; iteration++) { // Calculate target points based on current pct let targetPoints; if (fullData.x.length < 100000) { targetPoints = Math.floor(fullData.x.length * pct); // Use percentage directly for small datasets } else { targetPoints = Math.max(minPoints, Math.floor(fullData.x.length * pct)); // Ensure minimum 100k points for large datasets } const sampleData = this.gridSample(fullData, targetPoints, iteration); if (progressCallback) { const progress = 10 + (80 * ((adaptiveRound - 1) * maxIterations + iteration + 1) / (3 * maxIterations)); progressCallback({ progress: Math.min(90, progress), message: `Round ${adaptiveRound}, Iteration ${iteration + 1}/${maxIterations} (${sampleData.x.length} pts)` }); } // Decompose this iteration's sample const result = await this.directDecompose(sampleData, { numTerms: numTerms, wavelength_nm: 632.8 }); if (result.success) { // Check convergence against previous iteration in this round let convergence = { converged: false, maxChange: Infinity, meanChange: Infinity, termConvergence: [] }; if (roundCoefficients.length > 0) { convergence = this.calculateTermConvergence( roundCoefficients[roundCoefficients.length - 1], result.coefficients, tolerance ); } roundCoefficients.push(result.coefficients); allCoefficients.push(result.coefficients); convergenceHistory.push({ iteration: iteration + 1, round: adaptiveRound, pct: pct, points: sampleData.x.length, processingTime: result.metadata.processingTime_ms, rms: result.residualAnalysis.rms, maxChange: convergence.maxChange, meanChange: convergence.meanChange, converged: convergence.converged, termConvergence: convergence.termConvergence }); // Check adaptive convergence rule after minimum iterations if (iteration >= minIterations - 1 && convergence.termConvergence.length > 0) { const convergedCount = convergence.termConvergence.filter(t => t).length; const convergencePercent = (convergedCount / convergence.termConvergence.length) * 100; const adaptiveScore = (1 / pct) * (iteration + 1); const meetsRule = adaptiveScore > 50 && convergencePercent > 50; if (meetsRule) { roundConverged = true; break; } } } else { // Handle failed decomposition convergenceHistory.push({ iteration: iteration + 1, round: adaptiveRound, pct: pct, points: targetPoints, processingTime: 0, rms: 0, maxChange: Infinity, meanChange: Infinity, converged: false, termConvergence: [] }); } } // Check if this round achieved convergence if (roundConverged) { break; } else { pct = pct * 1.5; // Increase point percentage by 50% adaptiveRound++; } } return { finalCoefficients: allCoefficients[allCoefficients.length - 1], convergenceHistory, allCoefficients }; } /** * Calculate term-by-term convergence for real-time monitoring * @param {Array} coeffs1 - First coefficient set * @param {Array} coeffs2 - Second coefficient set * @param {number} tolerance - Convergence tolerance * @returns {Object} Convergence information */ calculateTermConvergence(coeffs1, coeffs2, tolerance = 0.0000001) { if (!coeffs1 || !coeffs2) return { converged: false, maxChange: Infinity, meanChange: Infinity, termConvergence: [] }; let maxChange = 0; let totalChange = 0; const termConvergence = []; for (let i = 0; i < Math.min(coeffs1.length, coeffs2.length); i++) { const change = Math.abs(coeffs1[i].coeff - coeffs2[i].coeff); maxChange = Math.max(maxChange, change); totalChange += change; // Mark term as converged if change is below tolerance termConvergence.push(change < tolerance); } const meanChange = totalChange / coeffs1.length; const allConverged = termConvergence.every(t => t); return { converged: allConverged, maxChange, meanChange, termConvergence }; } /** * High-performance QuadTree-based subsampling using js-subpcd * @param {Object} fullData - Full dataset * @param {number} targetPoints - Target number of points * @param {number} sampleOffset - Iteration offset for depth calculation * @returns {Object} Sampled data */ gridSample(fullData, targetPoints, sampleOffset = 0) { const { x, y, z } = fullData; const totalPoints = x.length; if (totalPoints <= targetPoints) { return { x: [...x], y: [...y], z: [...z] }; } try { // Use js-subpcd QuadTree-based subsampling const quadTreeFilter = new QuadTreeFilter(0.001); // Calculate optimal depth based on target points and iteration const baseDepth = Math.max(1, Math.floor(Math.log2(Math.sqrt(totalPoints / targetPoints)))); const depth = Math.min(10, baseDepth + (sampleOffset % 3)); // Add iteration variation // Apply grid-based filtering using js-subpcd const filteredPoints = quadTreeFilter.filterPointsByGridSpacing(x, y, z, this.calculateMinSpacing(targetPoints, x, y)); // Convert Point3D objects back to coordinate arrays const sampledX = []; const sampledY = []; const sampledZ = []; for (const point of filteredPoints) { sampledX.push(point.x); sampledY.push(point.y); sampledZ.push(point.z); } return { x: sampledX, y: sampledY, z: sampledZ }; } catch (error) { console.warn('QuadTree subsampling failed, falling back to simple sampling:', error.message); return this.fallbackSample(fullData, targetPoints); } } /** * Calculate minimum spacing based on target points and data bounds * @param {number} targetPoints - Target number of points * @param {number[]} x - X coordinates * @param {number[]} y - Y coordinates * @returns {number} Minimum lateral spacing */ calculateMinSpacing(targetPoints, x, y) { // Calculate bounds let minX = x[0], maxX = x[0], minY = y[0], maxY = y[0]; for (let i = 1; i < x.length; i++) { if (x[i] < minX) minX = x[i]; if (x[i] > maxX) maxX = x[i]; if (y[i] < minY) minY = y[i]; if (y[i] > maxY) maxY = y[i]; } const width = maxX - minX; const height = maxY - minY; const maxDimension = Math.max(width, height); // Calculate spacing for target grid size const gridSize = Math.sqrt(targetPoints); return maxDimension / gridSize; } /** * Fallback sampling method if QuadTree fails * @param {Object} fullData - Full dataset * @param {number} targetPoints - Target number of points * @returns {Object} Sampled data */ fallbackSample(fullData, targetPoints) { const { x, y, z } = fullData; const step = Math.ceil(x.length / targetPoints); const sampledX = []; const sampledY = []; const sampledZ = []; for (let i = 0; i < x.length && sampledX.length < targetPoints; i += step) { sampledX.push(x[i]); sampledY.push(y[i]); sampledZ.push(z[i]); } return { x: sampledX, y: sampledY, z: sampledZ }; } /** * Advanced QuadTree-based depth filtering using js-subpcd * @param {Object} fullData - Full dataset * @param {number} targetDepth - QuadTree depth for grid-based filtering * @returns {Object} Filtered data */ quadTreeDepthFilter(fullData, targetDepth) { const { x, y, z } = fullData; try { // Use js-subpcd QuadTree depth-based filtering const quadTreeFilter = new QuadTreeFilter(0.001); // Apply depth-based filtering using js-subpcd const filteredPoints = quadTreeFilter.filterByDepth(targetDepth); // Convert Point3D objects back to coordinate arrays const sampledX = []; const sampledY = []; const sampledZ = []; for (const point of filteredPoints) { sampledX.push(point.x); sampledY.push(point.y); sampledZ.push(point.z); } return { x: sampledX, y: sampledY, z: sampledZ }; } catch (error) { console.warn('QuadTree depth filtering failed, using fallback:', error.message); return this.fallbackSample(fullData, Math.pow(2, targetDepth)); } } /** * Iterative decomposition method for large datasets * @param {Object} surfaceData - Surface data with x, y, z arrays * @param {Object} options - Options object * @returns {Object} Decomposition results */ async iterativeDecompose(surfaceData, options = {}) { const { wavelength_nm = 632.8, numTerms = 28, progressCallback = null, maxIterations = 8, convergenceTolerance = 0.0000001 } = options; const startTime = Date.now(); try { // Input validation this.validateInputData(surfaceData); if (progressCallback) { progressCallback({ progress: 5, message: 'Starting iterative decomposition...' }); } // Use the adaptive iterative decomposition const convergenceResult = await this.adaptiveIterativeDecomposition( surfaceData, maxIterations, numTerms, convergenceTolerance, progressCallback ); if (!convergenceResult.finalCoefficients) { return { success: false, error: 'Iterative decomposition failed to converge', coefficients: [], residualAnalysis: { rms: 0, pv: 0, reconstructedZ: [], residualZ: [] }, metadata: { numPoints: surfaceData.x.length, numTerms, wavelength_nm, radius: 0, mean: 0, processingTime_ms: Date.now() - startTime } }; } if (progressCallback) { progressCallback({ progress: 90, message: 'Finalizing results...' }); } // Create final result using the converged coefficients const finalCoeffs = convergenceResult.finalCoefficients; // Calculate residual analysis on a sample of the full dataset for large datasets const preprocessedData = this.preprocessSurfaceData(surfaceData); // Calculate max radius without using spread operator to avoid stack overflow let maxRadius = 0; for (let i = 0; i < preprocessedData.x.length; i++) { const r = Math.sqrt(preprocessedData.x[i] * preprocessedData.x[i] + preprocessedData.y[i] * preprocessedData.y[i]); if (r > maxRadius) maxRadius = r; } // For large datasets, use a sample for residual analysis to avoid memory issues const maxAnalysisPoints = 100000; let analysisData = preprocessedData; if (preprocessedData.x.length > maxAnalysisPoints) { const step = Math.ceil(preprocessedData.x.length / maxAnalysisPoints); analysisData = { x: preprocessedData.x.filter((_, i) => i % step === 0), y: preprocessedData.y.filter((_, i) => i % step === 0), z: preprocessedData.z.filter((_, i) => i % step === 0), mean: preprocessedData.mean }; } const reconstructedZ = this.reconstructSurface( analysisData.x, analysisData.y, maxRadius, finalCoeffs ); const residualZ = analysisData.z.map((z, i) => z - reconstructedZ[i]); const rms = Math.sqrt(residualZ.reduce((sum, r) => sum + r * r, 0) / residualZ.length); const pv = Math.max(...residualZ) - Math.min(...residualZ); const processingTime = Date.now() - startTime; if (progressCallback) { progressCallback({ progress: 100, message: 'Decomposition complete!' }); } return { success: true, coefficients: finalCoeffs, residualAnalysis: { rms, pv, reconstructedZ, residualZ }, metadata: { numPoints: surfaceData.x.length, numTerms, wavelength_nm, radius: maxRadius, mean: preprocessedData.mean, processingTime_ms: processingTime }, convergenceHistory: convergenceResult.convergenceHistory }; } catch (error) { return { success: false, error: error.message, coefficients: [], residualAnalysis: { rms: 0, pv: 0, reconstructedZ: [], residualZ: [] }, metadata: { numPoints: surfaceData.x.length, numTerms, wavelength_nm, radius: 0, mean: 0, processingTime_ms: Date.now() - startTime } }; } } /** * Generates formatted output of decomposition results in various modes * @param {Object} result - Decomposition result object from decompose() * @param {Object} options - Output configuration options * @param {number} [options.mode=1] - Output mode (0-3) * - 0: Background computing (structured JSON) * - 1: Command line information (formatted console output) * - 2: Generate coefficients table file * - 3: Generate table and residual surface PLY * @param {number} [options.precision=6] - Decimal precision for output * @param {number} [options.wavelength_nm=632.8] - Wavelength in nanometers * @param {string} [options.outputFile] - Output file path (modes 2-3) * @param {string} [options.plyFile] - PLY file path (mode 3) * @param {Object} [options.surfaceData] - Original surface data (mode 3) * @returns {Promise<Object>} Promise resolving to output result object */ async dumpResults(result, options = {}) { const { mode = 1, // 0: background, 1: cmd info, 2: table, 3: table+PLY outputFile = null, // Optional output file plyFile = null, // PLY file for mode 3 surfaceData = null, // Original surface data for PLY generation wavelength_nm = 632.8, precision = 6 // Decimal precision for numbers } = options; switch (mode) { case 0: return this.dumpBackgroundMode(result, options); case 1: return this.dumpCommandLineMode(result, options); case 2: return await this.dumpTableMode(result, options); case 3: return await this.dumpTableAndPLYMode(result, options); default: throw new Error(`Invalid dump mode: ${mode}. Valid modes are 0-3.`); } } /** * Mode 0: Background computing (minimal output) */ dumpBackgroundMode(result, options = {}) { const { precision = 6 } = options; if (!result.success) { return { success: false, error: result.error, rms: 0, pv: 0, numTerms: 0, processingTime: 0 }; } return { success: true, rms: parseFloat(result.residualAnalysis.rms.toFixed(precision)), pv: parseFloat(result.residualAnalysis.pv.toFixed(precision)), numTerms: result.coefficients.length, processingTime: result.metadata.processingTime_ms, coefficients: result.coefficients.map(c => ({ term: c.term, coeff: parseFloat(c.coeff.toFixed(precision)), index: c.index })) }; } /** * Mode 1: Command line print information */ dumpCommandLineMode(result, options = {}) { const { wavelength_nm = 632.8, precision = 6 } = options; if (!result.success) { console.log('❌ Decomposition failed:', result.error); return { success: false, error: result.error }; } const wavelength_um = wavelength_nm / 1000; console.log('✅ Zernike Decomposition Results'); console.log('═'.repeat(50)); console.log(`📊 Processing time: ${result.metadata.processingTime_ms}ms`); console.log(`📊 Number of points: ${result.metadata.numPoints.toLocaleString()}`); console.log(`📊 Zernike terms: ${result.coefficients.length}`); console.log(`📊 Wavelength: ${wavelength_nm} nm`); console.log(`📊 Surface radius: ${result.metadata.radius.toFixed(3)} mm`); console.log(); console.log('📈 Residual Analysis:'); console.log(` RMS: ${(result.residualAnalysis.rms * 1000).toFixed(precision)} μm`); console.log(` PV: ${(result.residualAnalysis.pv * 1000).toFixed(precision)} μm`); console.log(); console.log('🔍 Significant Coefficients (>λ/50):'); let significantCount = 0; result.coefficients.forEach((coeff, index) => { const coeff_um = coeff.coeff * 1000; const coeff_lambda = coeff_um / wavelength_um; if (Math.abs(coeff_lambda) > 0.02) { console.log(` Z${index + 1}: ${coeff.term} = ${coeff_um.toFixed(precision)} μm (${coeff_lambda.toFixed(3)}λ)`); significantCount++; } }); if (significantCount === 0) { console.log(' No significant coefficients found'); } return this.dumpBackgroundMode(result, options); } /** * Mode 2: Generate Zernike coefficients table */ async dumpTableMode(result, options = {}) { const { wavelength_nm = 632.8, precision = 6, outputFile = null } = options; if (!result.success) { const errorOutput = `Error: ${result.error}`; if (outputFile) { const { writeFileSync } = await import('fs'); writeFileSync(outputFile, errorOutput); } return { success: false, error: result.error }; } const wavelength_um = wavelength_nm / 1000; // Generate table let tableOutput = ''; tableOutput += '# Zernike Polynomial Decomposition Results\n'; tableOutput += `# Date: ${new Date().toISOString()}\n`; tableOutput += `# Processing time: ${result.metadata.processingTime_ms}ms\n`; tableOutput += `# Number of points: ${result.metadata.numPoints}\n`; tableOutput += `# Wavelength: ${wavelength_nm} nm\n`; tableOutput += `# Surface radius: ${result.metadata.radius.toFixed(3)} mm\n`; tableOutput += `# Residual RMS: ${(result.residualAnalysis.rms * 1000).toFixed(precision)} μm\n`; tableOutput += `# Residual PV: ${(result.residualAnalysis.pv * 1000).toFixed(precision)} μm\n`; tableOutput += '#\n'; tableOutput += '# Columns: Index, Term, Coefficient_mm, Coefficient_μm, Coefficient_λ, Significant\n'; result.coefficients.forEach((coeff, index) => { const coeff_mm = coeff.coeff; const coeff_um = coeff_mm * 1000; const coeff_lambda = coeff_um / wavelength_um; const significant = Math.abs(coeff_lambda) > 0.02 ? 'YES' : 'NO'; tableOutput += `${index + 1}\t${coeff.term}\t${coeff_mm.toFixed(precision + 3)}\t${coeff_um.toFixed(precision)}\t${coeff_lambda.toFixed(precision)}\t${significant}\n`; }); // Write to file if specified if (outputFile) { const { writeFileSync } = await import('fs'); writeFileSync(outputFile, tableOutput); console.log(`📄 Zernike coefficients table written to: ${outputFile}`); } else { console.log(tableOutput); } return { success: true, tableOutput: tableOutput, backgroundData: this.dumpBackgroundMode(result, options) }; } /** * Mode 3: Generate table and residual surface PLY */ async dumpTableAndPLYMode(result, options = {}) { const { surfaceData = null, plyFile = null, outputFile = null, wavelength_nm = 632.8, precision = 6 } = options; if (!result.success) { console.log('❌ Cannot generate PLY: decomposition failed'); return { success: false, error: result.error }; } // First generate the table const tableResult = await this.dumpTableMode(result, options); // Then generate PLY file if surface data provided if (surfaceData && plyFile) { try { console.log('💾 Generating residual surface PLY...'); // Use the same preprocessing as decomposition const preprocessedData = this.preprocessSurfaceData(surfaceData); // Calculate max radius let maxRadius = 0; for (let i = 0; i < preprocessedData.x.length; i++) { const r = Math.sqrt(preprocessedData.x[i] * preprocessedData.x[i] + preprocessedData.y[i] * preprocessedData.y[i]); if (r > maxRadius) maxRadius = r; } // Reconstruct fitted surface const reconstructedZ = this.reconstructSurface( preprocessedData.x, preprocessedData.y, maxRadius, result.coefficients ); // Calculate residuals const residualZ = []; for (let i = 0; i < preprocessedData.z.length; i++) { residualZ.push(preprocessedData.z[i] - reconstructedZ[i]); } // Handle subsampling for large datasets let exportX, exportY, exportResidualZ; if (surfaceData.x.length > 100000) { const step = Math.ceil(surfaceData.x.length / 100000); exportX = preprocessedData.x.filter((_, i) => i % step === 0); exportY = preprocessedData.y.filter((_, i) => i % step === 0); exportResidualZ = residualZ.filter((_, i) => i % step === 0); } else { exportX = preprocessedData.x; exportY = preprocessedData.y; exportResidualZ = residualZ; } // Write PLY with residual as Z-coordinates await this.writeResidualToPLY( plyFile, exportX, exportY, exportResidualZ, // Use residuals as Z-coordinates exportResidualZ // Use residuals for color mapping ); console.log(`✅ Residual surface PLY written to: ${plyFile}`); console.log