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
JavaScript
// 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