288 lines
8.8 KiB
JavaScript
288 lines
8.8 KiB
JavaScript
/**
|
|
* Signal Processing Utilities
|
|
*
|
|
* Low-level signal processing functions for grid detection.
|
|
* Provides autocorrelation, filtering, and normalization algorithms
|
|
* used by the GridDetectionService.
|
|
*
|
|
* @module SignalProcessingUtils
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} AutocorrelationEntry
|
|
* @property {number} lag - The lag value (distance between compared samples)
|
|
* @property {number} val - The autocorrelation coefficient at this lag
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} PeriodCandidate
|
|
* @property {number} value - The detected period (lag) value
|
|
* @property {number} score - Confidence score for this period
|
|
*/
|
|
|
|
/**
|
|
* Compute normalized autocorrelation of a signal.
|
|
* Autocorrelation measures how similar a signal is to a delayed version of itself.
|
|
* Peaks in autocorrelation indicate periodic patterns.
|
|
*
|
|
* @param {Float32Array} signal - Input signal
|
|
* @param {number} minLag - Minimum lag to consider (avoids self-correlation peak)
|
|
* @param {number} maxLag - Maximum lag to consider
|
|
* @returns {AutocorrelationEntry[]} Array of autocorrelation values for each lag
|
|
*
|
|
* @example
|
|
* const signal = new Float32Array([1, 0, 1, 0, 1, 0]); // Periodic signal
|
|
* const autocorr = computeAutocorrelation(signal, 1, 3);
|
|
* // autocorr[1].val would be high (period of 2)
|
|
*/
|
|
export function computeAutocorrelation(signal, minLag, maxLag) {
|
|
const signalLength = signal.length;
|
|
|
|
// Calculate mean of signal
|
|
let sum = 0;
|
|
for (let i = 0; i < signalLength; i++) {
|
|
sum += signal[i];
|
|
}
|
|
const mean = sum / signalLength;
|
|
|
|
// Calculate denominator (variance-like term for normalization)
|
|
let denominator = 0;
|
|
for (let i = 0; i < signalLength; i++) {
|
|
const deviation = signal[i] - mean;
|
|
denominator += deviation * deviation;
|
|
}
|
|
denominator = denominator || 1; // Prevent division by zero
|
|
|
|
// Calculate autocorrelation for each lag value
|
|
const result = [];
|
|
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
let numerator = 0;
|
|
|
|
for (let i = 0; i + lag < signalLength; i++) {
|
|
numerator += (signal[i] - mean) * (signal[i + lag] - mean);
|
|
}
|
|
|
|
result.push({
|
|
lag: lag,
|
|
val: numerator / denominator
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Apply a high-pass filter by subtracting a moving average.
|
|
* This removes low-frequency trends and emphasizes periodic patterns (like grid lines).
|
|
*
|
|
* @param {Float32Array} signal - Input signal
|
|
* @param {number} windowSize - Size of the averaging window
|
|
* @returns {Float32Array} High-pass filtered signal
|
|
*
|
|
* @example
|
|
* const signal = new Float32Array([10, 11, 10, 11, 10]); // Signal with DC offset
|
|
* const filtered = applyHighPassFilter(signal, 3);
|
|
* // filtered values will oscillate around 0
|
|
*/
|
|
export function applyHighPassFilter(signal, windowSize) {
|
|
const length = signal.length;
|
|
const effectiveWindow = Math.max(3, windowSize | 0);
|
|
const halfWindow = (effectiveWindow / 2) | 0;
|
|
const output = new Float32Array(length);
|
|
|
|
let runningSum = 0;
|
|
|
|
// Initialize running sum with first window
|
|
const initialWindowSize = Math.min(length, effectiveWindow);
|
|
for (let i = 0; i < initialWindowSize; i++) {
|
|
runningSum += signal[i];
|
|
}
|
|
|
|
// Compute high-passed values using sliding window
|
|
for (let i = 0; i < length; i++) {
|
|
const leftBoundary = i - halfWindow - 1;
|
|
const rightBoundary = i + halfWindow;
|
|
|
|
// Update running sum with sliding window
|
|
if (rightBoundary < length && i + halfWindow < length) {
|
|
runningSum += signal[rightBoundary];
|
|
}
|
|
if (leftBoundary >= 0) {
|
|
runningSum -= signal[leftBoundary];
|
|
}
|
|
|
|
// Calculate local average
|
|
const spanStart = Math.max(0, i - halfWindow);
|
|
const spanEnd = Math.min(length - 1, i + halfWindow);
|
|
const spanSize = (spanEnd - spanStart + 1) || 1;
|
|
const localAverage = runningSum / spanSize;
|
|
|
|
// High-pass = original value minus local average
|
|
output[i] = signal[i] - localAverage;
|
|
}
|
|
|
|
// Suppress negative values (edge responses are positive peaks)
|
|
for (let i = 0; i < length; i++) {
|
|
if (output[i] < 0) {
|
|
output[i] *= 0.2;
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Normalize a signal to the range [0, 1].
|
|
*
|
|
* @param {Float32Array} signal - Input signal
|
|
* @returns {Float32Array} Normalized signal with values between 0 and 1
|
|
*
|
|
* @example
|
|
* const signal = new Float32Array([10, 20, 30]);
|
|
* const normalized = normalizeSignal(signal);
|
|
* // normalized = [0, 0.5, 1]
|
|
*/
|
|
export function normalizeSignal(signal) {
|
|
let maxValue = -Infinity;
|
|
let minValue = Infinity;
|
|
|
|
// Find min and max values
|
|
for (let i = 0; i < signal.length; i++) {
|
|
if (signal[i] > maxValue) maxValue = signal[i];
|
|
if (signal[i] < minValue) minValue = signal[i];
|
|
}
|
|
|
|
const range = (maxValue - minValue) || 1; // Prevent division by zero
|
|
const normalized = new Float32Array(signal.length);
|
|
|
|
// Apply min-max normalization
|
|
for (let i = 0; i < signal.length; i++) {
|
|
normalized[i] = (signal[i] - minValue) / range;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Find the best period from autocorrelation data.
|
|
* Looks for the first significant peak in the autocorrelation.
|
|
*
|
|
* @param {AutocorrelationEntry[]} autocorrelation - Autocorrelation data
|
|
* @returns {PeriodCandidate|null} Best period candidate or null if none found
|
|
*/
|
|
export function findBestPeriodFromAutocorrelation(autocorrelation) {
|
|
if (!autocorrelation || !autocorrelation.length) {
|
|
return null;
|
|
}
|
|
|
|
// Find all local peaks (values higher than both neighbors)
|
|
const peaks = [];
|
|
for (let i = 1; i < autocorrelation.length - 1; i++) {
|
|
const isPeak = autocorrelation[i].val > autocorrelation[i - 1].val &&
|
|
autocorrelation[i].val >= autocorrelation[i + 1].val;
|
|
|
|
if (isPeak) {
|
|
peaks.push(autocorrelation[i]);
|
|
}
|
|
}
|
|
|
|
if (!peaks.length) {
|
|
return null;
|
|
}
|
|
|
|
// Sort by value (strongest peaks first)
|
|
peaks.sort((a, b) => b.val - a.val);
|
|
|
|
// Take top peaks and sort by lag (prefer smaller periods = fundamental frequency)
|
|
const topPeaks = peaks.slice(0, 5).sort((a, b) => a.lag - b.lag);
|
|
const bestPeak = topPeaks[0];
|
|
|
|
return {
|
|
value: bestPeak.lag,
|
|
score: bestPeak.val
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Combine period candidates from X and Y axis analysis.
|
|
* Uses the more confident estimate, or averages if both agree.
|
|
*
|
|
* @param {PeriodCandidate|null} periodX - X-axis period candidate
|
|
* @param {PeriodCandidate|null} periodY - Y-axis period candidate
|
|
* @returns {number|null} Combined period estimate or null
|
|
*/
|
|
export function combinePeriodCandidates(periodX, periodY) {
|
|
if (periodX && periodY) {
|
|
// If both axes agree (within 2 pixels), average them for better accuracy
|
|
if (Math.abs(periodX.value - periodY.value) <= 2) {
|
|
return (periodX.value + periodY.value) / 2;
|
|
}
|
|
// Otherwise, use the one with higher confidence score
|
|
return periodX.score >= periodY.score ? periodX.value : periodY.value;
|
|
}
|
|
|
|
// Use whichever axis gave a result
|
|
if (periodX) return periodX.value;
|
|
if (periodY) return periodY.value;
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Estimate the optimal grid offset from a projection signal.
|
|
* Finds the shift value that best aligns with periodic peaks.
|
|
*
|
|
* @param {Float32Array} signal - Normalized projection signal
|
|
* @param {number} period - Detected grid period
|
|
* @returns {number} Optimal offset value (0 to period-1)
|
|
*/
|
|
export function estimateGridOffset(signal, period) {
|
|
if (!period || period < 2) {
|
|
return 0;
|
|
}
|
|
|
|
const length = signal.length;
|
|
let bestOffset = 0;
|
|
let bestScore = -Infinity;
|
|
|
|
// Normalize signal for fair scoring
|
|
let maxValue = -Infinity;
|
|
for (const value of signal) {
|
|
if (value > maxValue) maxValue = value;
|
|
}
|
|
const normalizer = maxValue ? 1 / maxValue : 1;
|
|
|
|
// Try each possible offset and find the one with highest sum at periodic intervals
|
|
for (let offset = 0; offset < period; offset++) {
|
|
let sum = 0;
|
|
let count = 0;
|
|
|
|
// Sum signal values at periodic intervals starting from this offset
|
|
for (let i = offset; i < length; i += period) {
|
|
sum += signal[i] * normalizer;
|
|
count++;
|
|
}
|
|
|
|
const score = count ? sum / count : -Infinity;
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestOffset = offset;
|
|
}
|
|
}
|
|
|
|
return bestOffset;
|
|
}
|
|
|
|
/**
|
|
* Clamp a numeric value to a specified range.
|
|
*
|
|
* @param {number} value - The value to clamp
|
|
* @param {number} min - Minimum allowed value
|
|
* @param {number} max - Maximum allowed value
|
|
* @returns {number} The clamped value
|
|
*/
|
|
export function clampValue(value, min, max) {
|
|
return value < min ? min : value > max ? max : value;
|
|
}
|