Files
Myxeliums_Battlemap_Importe…/scripts/lib/signal-processing-utils.js
2026-01-08 04:35:14 +01:00

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;
}