analytics-api/convolution.ts

398 lines
No EOL
11 KiB
TypeScript

// convolution.ts - Convolution operations for 1D and 2D data
export interface ConvolutionOptions {
mode?: 'full' | 'same' | 'valid';
boundary?: 'zero' | 'reflect' | 'symmetric';
}
export interface ConvolutionResult1D {
values: number[];
originalLength: number;
kernelLength: number;
mode: string;
}
export interface ConvolutionResult2D {
matrix: number[][];
originalDimensions: [number, number];
kernelDimensions: [number, number];
mode: string;
}
/**
* Validates input array for convolution operations
*/
function validateArray(arr: number[], name: string): void {
if (!Array.isArray(arr) || arr.length === 0) {
throw new Error(`${name} must be a non-empty array`);
}
if (arr.some(val => typeof val !== 'number' || !isFinite(val))) {
throw new Error(`${name} must contain only finite numbers`);
}
}
/**
* Validates 2D matrix for convolution operations
*/
function validateMatrix(matrix: number[][], name: string): void {
if (!Array.isArray(matrix) || matrix.length === 0) {
throw new Error(`${name} must be a non-empty 2D array`);
}
const rowLength = matrix[0].length;
if (rowLength === 0) {
throw new Error(`${name} rows must be non-empty`);
}
for (let i = 0; i < matrix.length; i++) {
if (!Array.isArray(matrix[i]) || matrix[i].length !== rowLength) {
throw new Error(`${name} must be a rectangular matrix`);
}
if (matrix[i].some(val => typeof val !== 'number' || !isFinite(val))) {
throw new Error(`${name} must contain only finite numbers`);
}
}
}
/**
* Applies boundary conditions to extend an array
*/
function applyBoundary1D(signal: number[], padding: number, boundary: string): number[] {
if (padding <= 0) return signal;
let result = [...signal];
switch (boundary) {
case 'zero':
result = new Array(padding).fill(0).concat(result).concat(new Array(padding).fill(0));
break;
case 'reflect':
const leftPad = [];
const rightPad = [];
for (let i = 0; i < padding; i++) {
leftPad.unshift(signal[Math.min(i + 1, signal.length - 1)]);
rightPad.push(signal[Math.max(signal.length - 2 - i, 0)]);
}
result = leftPad.concat(result).concat(rightPad);
break;
case 'symmetric':
const leftSymPad = [];
const rightSymPad = [];
for (let i = 0; i < padding; i++) {
leftSymPad.unshift(signal[Math.min(i, signal.length - 1)]);
rightSymPad.push(signal[Math.max(signal.length - 1 - i, 0)]);
}
result = leftSymPad.concat(result).concat(rightSymPad);
break;
default:
throw new Error(`Unsupported boundary condition: ${boundary}`);
}
return result;
}
/**
* Performs 1D convolution between signal and kernel
*
* @param signal - Input signal array
* @param kernel - Convolution kernel array
* @param options - Convolution options (mode, boundary)
* @returns Convolution result with metadata
*/
export function convolve1D(
signal: number[],
kernel: number[],
options: ConvolutionOptions = {}
): ConvolutionResult1D {
validateArray(signal, 'Signal');
validateArray(kernel, 'Kernel');
const { mode = 'same', boundary = 'reflect' } = options;
// Flip kernel for convolution (not correlation)
const flippedKernel = [...kernel].reverse();
const signalLen = signal.length;
const kernelLen = flippedKernel.length;
let result: number[] = [];
let paddedSignal = signal;
// Apply boundary conditions based on mode
if (mode === 'same' || mode === 'full') {
const padding = mode === 'same' ? Math.floor(kernelLen / 2) : kernelLen - 1;
paddedSignal = applyBoundary1D(signal, padding, boundary);
}
// Perform convolution
const outputLength = mode === 'full' ? signalLen + kernelLen - 1 :
mode === 'same' ? signalLen :
signalLen - kernelLen + 1;
const startIdx = mode === 'valid' ? 0 :
mode === 'same' ? Math.floor(kernelLen / 2) : 0;
for (let i = 0; i < outputLength; i++) {
let sum = 0;
for (let j = 0; j < kernelLen; j++) {
const signalIdx = startIdx + i + j;
if (signalIdx >= 0 && signalIdx < paddedSignal.length) {
sum += paddedSignal[signalIdx] * flippedKernel[j];
}
}
result.push(sum);
}
return {
values: result,
originalLength: signalLen,
kernelLength: kernelLen,
mode
};
}
/**
* Performs 2D convolution between matrix and kernel
*
* @param matrix - Input 2D matrix
* @param kernel - 2D convolution kernel
* @param options - Convolution options (mode, boundary)
* @returns 2D convolution result with metadata
*/
export function convolve2D(
matrix: number[][],
kernel: number[][],
options: ConvolutionOptions = {}
): ConvolutionResult2D {
validateMatrix(matrix, 'Matrix');
validateMatrix(kernel, 'Kernel');
const { mode = 'same', boundary = 'reflect' } = options;
// Flip kernel for convolution
const flippedKernel = kernel.map(row => [...row].reverse()).reverse();
const matrixRows = matrix.length;
const matrixCols = matrix[0].length;
const kernelRows = flippedKernel.length;
const kernelCols = flippedKernel[0].length;
// Calculate output dimensions
let outputRows: number, outputCols: number;
let padTop: number, padLeft: number;
switch (mode) {
case 'full':
outputRows = matrixRows + kernelRows - 1;
outputCols = matrixCols + kernelCols - 1;
padTop = kernelRows - 1;
padLeft = kernelCols - 1;
break;
case 'same':
outputRows = matrixRows;
outputCols = matrixCols;
padTop = Math.floor(kernelRows / 2);
padLeft = Math.floor(kernelCols / 2);
break;
case 'valid':
outputRows = Math.max(0, matrixRows - kernelRows + 1);
outputCols = Math.max(0, matrixCols - kernelCols + 1);
padTop = 0;
padLeft = 0;
break;
default:
throw new Error(`Unsupported convolution mode: ${mode}`);
}
// Create padded matrix based on boundary conditions
const totalPadRows = mode === 'valid' ? 0 : kernelRows - 1;
const totalPadCols = mode === 'valid' ? 0 : kernelCols - 1;
const paddedMatrix: number[][] = [];
// Initialize padded matrix with boundary conditions
for (let i = -padTop; i < matrixRows + totalPadRows - padTop; i++) {
const row: number[] = [];
for (let j = -padLeft; j < matrixCols + totalPadCols - padLeft; j++) {
let value = 0;
if (i >= 0 && i < matrixRows && j >= 0 && j < matrixCols) {
value = matrix[i][j];
} else if (boundary !== 'zero') {
// Apply boundary conditions
let boundaryI = i;
let boundaryJ = j;
if (boundary === 'reflect') {
boundaryI = i < 0 ? -i - 1 : i >= matrixRows ? 2 * matrixRows - i - 1 : i;
boundaryJ = j < 0 ? -j - 1 : j >= matrixCols ? 2 * matrixCols - j - 1 : j;
} else if (boundary === 'symmetric') {
boundaryI = i < 0 ? -i : i >= matrixRows ? 2 * matrixRows - i - 2 : i;
boundaryJ = j < 0 ? -j : j >= matrixCols ? 2 * matrixCols - j - 2 : j;
}
boundaryI = Math.max(0, Math.min(boundaryI, matrixRows - 1));
boundaryJ = Math.max(0, Math.min(boundaryJ, matrixCols - 1));
value = matrix[boundaryI][boundaryJ];
}
row.push(value);
}
paddedMatrix.push(row);
}
// Perform 2D convolution
const result: number[][] = [];
for (let i = 0; i < outputRows; i++) {
const row: number[] = [];
for (let j = 0; j < outputCols; j++) {
let sum = 0;
for (let ki = 0; ki < kernelRows; ki++) {
for (let kj = 0; kj < kernelCols; kj++) {
const matrixI = i + ki;
const matrixJ = j + kj;
if (matrixI >= 0 && matrixI < paddedMatrix.length &&
matrixJ >= 0 && matrixJ < paddedMatrix[0].length) {
sum += paddedMatrix[matrixI][matrixJ] * flippedKernel[ki][kj];
}
}
}
row.push(sum);
}
result.push(row);
}
return {
matrix: result,
originalDimensions: [matrixRows, matrixCols],
kernelDimensions: [kernelRows, kernelCols],
mode
};
}
/**
* Creates common convolution kernels
*/
export class ConvolutionKernels {
/**
* Creates a Gaussian blur kernel
*/
static gaussian(size: number, sigma: number = 1.0): number[][] {
if (size % 2 === 0) {
throw new Error('Kernel size must be odd');
}
const kernel: number[][] = [];
const center = Math.floor(size / 2);
let sum = 0;
for (let i = 0; i < size; i++) {
const row: number[] = [];
for (let j = 0; j < size; j++) {
const x = i - center;
const y = j - center;
const value = Math.exp(-(x * x + y * y) / (2 * sigma * sigma));
row.push(value);
sum += value;
}
kernel.push(row);
}
// Normalize kernel
return kernel.map(row => row.map(val => val / sum));
}
/**
* Creates a Sobel edge detection kernel
*/
static sobel(direction: 'x' | 'y' = 'x'): number[][] {
if (direction === 'x') {
return [
[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]
];
} else {
return [
[-1, -2, -1],
[ 0, 0, 0],
[ 1, 2, 1]
];
}
}
/**
* Creates a Laplacian edge detection kernel
*/
static laplacian(): number[][] {
return [
[ 0, -1, 0],
[-1, 4, -1],
[ 0, -1, 0]
];
}
/**
* Creates a box/average blur kernel
*/
static box(size: number): number[][] {
if (size % 2 === 0) {
throw new Error('Kernel size must be odd');
}
const value = 1 / (size * size);
const kernel: number[][] = [];
for (let i = 0; i < size; i++) {
kernel.push(new Array(size).fill(value));
}
return kernel;
}
/**
* Creates a 1D Gaussian kernel
*/
static gaussian1D(size: number, sigma: number = 1.0): number[] {
if (size % 2 === 0) {
throw new Error('Kernel size must be odd');
}
const kernel: number[] = [];
const center = Math.floor(size / 2);
let sum = 0;
for (let i = 0; i < size; i++) {
const x = i - center;
const value = Math.exp(-(x * x) / (2 * sigma * sigma));
kernel.push(value);
sum += value;
}
// Normalize kernel
return kernel.map(val => val / sum);
}
/**
* Creates a 1D difference kernel for edge detection
*/
static difference1D(): number[] {
return [-1, 0, 1];
}
/**
* Creates a 1D moving average kernel
*/
static average1D(size: number): number[] {
if (size <= 0) {
throw new Error('Kernel size must be positive');
}
const value = 1 / size;
return new Array(size).fill(value);
}
}