fix: stabilize localization comparison semantics
This commit is contained in:
@@ -601,15 +601,24 @@ export function estimateDasWorkload(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finalizeDasBands(rawMaps, workload, experiment, rawScale = "power") {
|
export function finalizeDasBands(
|
||||||
|
rawMaps,
|
||||||
|
workload,
|
||||||
|
experiment,
|
||||||
|
rawScale = "power",
|
||||||
|
options = {},
|
||||||
|
) {
|
||||||
if (rawScale !== "power" && rawScale !== "score") {
|
if (rawScale !== "power" && rawScale !== "score") {
|
||||||
throw new RangeError('rawScale must be "power" or "score".');
|
throw new RangeError('rawScale must be "power" or "score".');
|
||||||
}
|
}
|
||||||
|
const averageBandBins = options.averageBandBins !== false;
|
||||||
return experiment.frequencyBands.map((band, bandIndex) => {
|
return experiment.frequencyBands.map((band, bandIndex) => {
|
||||||
const rawMap = rawMaps[bandIndex];
|
const rawMap = rawMaps[bandIndex];
|
||||||
|
if (averageBandBins) {
|
||||||
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
|
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
|
||||||
rawMap[pointIndex] /= workload.bandBins[bandIndex].length;
|
rawMap[pointIndex] /= workload.bandBins[bandIndex].length;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const rawPeak = findPeak(rawMap);
|
const rawPeak = findPeak(rawMap);
|
||||||
const displayMap = Float32Array.from(rawMap);
|
const displayMap = Float32Array.from(rawMap);
|
||||||
let displayScale = rawScale;
|
let displayScale = rawScale;
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
|
|
||||||
export const GCC_PHAT_ANALYSIS_PLAN_KIND = "micarrsim.gcc-phat-analysis-plan/1";
|
export const GCC_PHAT_ANALYSIS_PLAN_KIND = "micarrsim.gcc-phat-analysis-plan/1";
|
||||||
export const DEFAULT_GCC_PHAT_FLOOR_RATIO = 1e-6;
|
export const DEFAULT_GCC_PHAT_FLOOR_RATIO = 1e-6;
|
||||||
|
export const DEFAULT_GCC_PHAT_POINT_BATCH_SIZE = 4096;
|
||||||
|
export const DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE = 16384;
|
||||||
export const DEFAULT_MAXIMUM_GCC_PHAT_PLAN_BYTES = 512 * 1024 * 1024;
|
export const DEFAULT_MAXIMUM_GCC_PHAT_PLAN_BYTES = 512 * 1024 * 1024;
|
||||||
|
export const DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
function createAbortError(signal) {
|
function createAbortError(signal) {
|
||||||
if (signal?.reason instanceof Error && signal.reason.name === "AbortError") {
|
if (signal?.reason instanceof Error && signal.reason.name === "AbortError") {
|
||||||
@@ -241,6 +244,60 @@ function requirePhatFloorRatio(value) {
|
|||||||
return ratio;
|
return ratio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireMaximumWorkingBytes(value) {
|
||||||
|
const maximumWorkingBytes = value ?? DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES;
|
||||||
|
if (!Number.isSafeInteger(maximumWorkingBytes) || maximumWorkingBytes <= 0) {
|
||||||
|
throw new RangeError("maximumWorkingBytes must be a positive safe integer.");
|
||||||
|
}
|
||||||
|
return maximumWorkingBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject an oversized no-plan analysis before createDistanceMatrix allocates
|
||||||
|
* its P x M table. This mirrors estimateWithGeometry's retained/spectrum/
|
||||||
|
* calibration/output accounting without requiring geometry to exist first.
|
||||||
|
*/
|
||||||
|
function preflightUnplannedWorkingSet(options, configuration) {
|
||||||
|
const microphoneCount = configuration.arrayDefinition.microphones.length;
|
||||||
|
const sampleCount = validateChannels(options.channels, microphoneCount);
|
||||||
|
const { bandBins, selectedBins } = createBandGeometry(configuration);
|
||||||
|
const frameCount = getFrameCount(
|
||||||
|
sampleCount,
|
||||||
|
configuration.fftSize,
|
||||||
|
configuration.hopSize,
|
||||||
|
);
|
||||||
|
const bandCount = bandBins.length;
|
||||||
|
const distanceBytes =
|
||||||
|
configuration.pointCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
const stftWorkspaceBytes =
|
||||||
|
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
|
||||||
|
const correlationWorkspaceBytes =
|
||||||
|
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||||
|
const spectrumBytes =
|
||||||
|
selectedBins.length * frameCount * microphoneCount *
|
||||||
|
Float32Array.BYTES_PER_ELEMENT * 2;
|
||||||
|
const calibrationPhaseBytes =
|
||||||
|
selectedBins.length * microphoneCount * Float64Array.BYTES_PER_ELEMENT;
|
||||||
|
const outputBytes =
|
||||||
|
bandCount * configuration.pointCount * Float32Array.BYTES_PER_ELEMENT * 2;
|
||||||
|
const estimatedWorkingBytes =
|
||||||
|
distanceBytes + stftWorkspaceBytes + correlationWorkspaceBytes +
|
||||||
|
spectrumBytes + calibrationPhaseBytes + outputBytes;
|
||||||
|
if (!Number.isSafeInteger(estimatedWorkingBytes)) {
|
||||||
|
throw new RangeError("GCC-PHAT estimated working set exceeds the safe integer range.");
|
||||||
|
}
|
||||||
|
const maximumWorkingBytes = requireMaximumWorkingBytes(options.maximumWorkingBytes);
|
||||||
|
if (estimatedWorkingBytes > maximumWorkingBytes) {
|
||||||
|
throw new RangeError(
|
||||||
|
"GCC-PHAT estimated working set " +
|
||||||
|
(estimatedWorkingBytes / 1024 ** 2).toFixed(1) +
|
||||||
|
" MiB exceeds maximumWorkingBytes " +
|
||||||
|
(maximumWorkingBytes / 1024 ** 2).toFixed(1) + " MiB before geometry allocation.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { estimatedWorkingBytes, maximumWorkingBytes };
|
||||||
|
}
|
||||||
|
|
||||||
function estimateWithGeometry(options, configuration, geometry) {
|
function estimateWithGeometry(options, configuration, geometry) {
|
||||||
const sampleCount = validateChannels(options.channels, geometry.microphoneCount);
|
const sampleCount = validateChannels(options.channels, geometry.microphoneCount);
|
||||||
const frameCount = getFrameCount(
|
const frameCount = getFrameCount(
|
||||||
@@ -332,6 +389,7 @@ export function estimateGccPhatWorkload(options) {
|
|||||||
assertCompatibleAnalysisPlan(options.analysisPlan, options);
|
assertCompatibleAnalysisPlan(options.analysisPlan, options);
|
||||||
geometry = options.analysisPlan;
|
geometry = options.analysisPlan;
|
||||||
} else {
|
} else {
|
||||||
|
preflightUnplannedWorkingSet(options, configuration);
|
||||||
geometry = createGeometry(configuration);
|
geometry = createGeometry(configuration);
|
||||||
}
|
}
|
||||||
return estimateWithGeometry(options, configuration, geometry);
|
return estimateWithGeometry(options, configuration, geometry);
|
||||||
@@ -467,7 +525,7 @@ function circularLinearSample(values, lagSamples) {
|
|||||||
return values[firstIndex] * (1 - fraction) + values[secondIndex] * fraction;
|
return values[firstIndex] * (1 - fraction) + values[secondIndex] * fraction;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillBandPairCorrelation({
|
async function fillBandPairCorrelation({
|
||||||
correlationReal,
|
correlationReal,
|
||||||
correlationImaginary,
|
correlationImaginary,
|
||||||
bins,
|
bins,
|
||||||
@@ -476,11 +534,14 @@ function fillBandPairCorrelation({
|
|||||||
referenceIndex,
|
referenceIndex,
|
||||||
phasesByBin,
|
phasesByBin,
|
||||||
phatFloorRatio,
|
phatFloorRatio,
|
||||||
|
signal,
|
||||||
|
crossSpectrumBatchSize,
|
||||||
}) {
|
}) {
|
||||||
correlationReal.fill(0);
|
correlationReal.fill(0);
|
||||||
correlationImaginary.fill(0);
|
correlationImaginary.fill(0);
|
||||||
const microphoneCount = stft.microphoneCount;
|
const microphoneCount = stft.microphoneCount;
|
||||||
let maximumMagnitude = 0;
|
let maximumMagnitude = 0;
|
||||||
|
let operationsUntilYield = crossSpectrumBatchSize;
|
||||||
|
|
||||||
for (const bin of bins) {
|
for (const bin of bins) {
|
||||||
const spectrum = stft.spectra.get(bin);
|
const spectrum = stft.spectra.get(bin);
|
||||||
@@ -498,6 +559,12 @@ function fillBandPairCorrelation({
|
|||||||
maximumMagnitude,
|
maximumMagnitude,
|
||||||
Math.hypot(crossReal, crossImaginary),
|
Math.hypot(crossReal, crossImaginary),
|
||||||
);
|
);
|
||||||
|
operationsUntilYield -= 1;
|
||||||
|
if (operationsUntilYield === 0) {
|
||||||
|
await cooperativeYield();
|
||||||
|
throwIfAborted(signal);
|
||||||
|
operationsUntilYield = crossSpectrumBatchSize;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +602,12 @@ function fillBandPairCorrelation({
|
|||||||
normalizedReal * correctionReal - normalizedImaginary * correctionImaginary;
|
normalizedReal * correctionReal - normalizedImaginary * correctionImaginary;
|
||||||
accumulatedImaginary +=
|
accumulatedImaginary +=
|
||||||
normalizedReal * correctionImaginary + normalizedImaginary * correctionReal;
|
normalizedReal * correctionImaginary + normalizedImaginary * correctionReal;
|
||||||
|
operationsUntilYield -= 1;
|
||||||
|
if (operationsUntilYield === 0) {
|
||||||
|
await cooperativeYield();
|
||||||
|
throwIfAborted(signal);
|
||||||
|
operationsUntilYield = crossSpectrumBatchSize;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const averagedReal = accumulatedReal / stft.frameCount;
|
const averagedReal = accumulatedReal / stft.frameCount;
|
||||||
@@ -552,13 +625,38 @@ function fillBandPairCorrelation({
|
|||||||
fftInPlace(correlationReal, correlationImaginary, true);
|
fftInPlace(correlationReal, correlationImaginary, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftAndSquareScores(rawMaps) {
|
async function normalizePositiveScores(
|
||||||
for (const rawMap of rawMaps) {
|
rawMaps,
|
||||||
let minimum = Infinity;
|
bandBins,
|
||||||
for (const value of rawMap) minimum = Math.min(minimum, value);
|
fftSize,
|
||||||
|
pairCount,
|
||||||
|
pointBatchSize,
|
||||||
|
signal,
|
||||||
|
) {
|
||||||
|
for (let bandIndex = 0; bandIndex < rawMaps.length; bandIndex += 1) {
|
||||||
|
const rawMap = rawMaps[bandIndex];
|
||||||
|
const coefficientCount = bandBins[bandIndex].reduce(
|
||||||
|
(count, bin) => count + (bin === 0 || bin === fftSize / 2 ? 1 : 2),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const normalization = fftSize / (pairCount * coefficientCount);
|
||||||
for (let pointIndex = 0; pointIndex < rawMap.length; pointIndex += 1) {
|
for (let pointIndex = 0; pointIndex < rawMap.length; pointIndex += 1) {
|
||||||
const shifted = rawMap[pointIndex] - minimum;
|
// PHAT coefficients have magnitude <= 1. After Hermitian completion and
|
||||||
rawMap[pointIndex] = shifted * shifted;
|
// inverse-FFT scaling, coefficientCount / fftSize is therefore a strict
|
||||||
|
// per-pair bound. Pair averaging makes the positive score stable across
|
||||||
|
// microphone count, band width, and scan extent without implying a
|
||||||
|
// probability. Negative correlation is evidence against the hypothesis.
|
||||||
|
rawMap[pointIndex] = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, rawMap[pointIndex] * normalization),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(pointIndex + 1) % pointBatchSize === 0 &&
|
||||||
|
pointIndex + 1 < rawMap.length
|
||||||
|
) {
|
||||||
|
await cooperativeYield();
|
||||||
|
throwIfAborted(signal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,16 +666,16 @@ async function computeGccPhatMapInternal(options, configuration, geometry, plan)
|
|||||||
channels,
|
channels,
|
||||||
signal,
|
signal,
|
||||||
onProgress,
|
onProgress,
|
||||||
maximumWorkingBytes = 512 * 1024 * 1024,
|
maximumWorkingBytes = DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES,
|
||||||
maximumFftButterflies = 1_000_000_000,
|
maximumFftButterflies = 1_000_000_000,
|
||||||
pointBatchSize = 256,
|
pointBatchSize = DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||||
|
crossSpectrumBatchSize = DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
||||||
stftTransformBatchSize = 128,
|
stftTransformBatchSize = 128,
|
||||||
} = options;
|
} = options;
|
||||||
requirePositiveInteger(pointBatchSize, "pointBatchSize");
|
requirePositiveInteger(pointBatchSize, "pointBatchSize");
|
||||||
|
requirePositiveInteger(crossSpectrumBatchSize, "crossSpectrumBatchSize");
|
||||||
requirePositiveInteger(stftTransformBatchSize, "stftTransformBatchSize");
|
requirePositiveInteger(stftTransformBatchSize, "stftTransformBatchSize");
|
||||||
if (!Number.isSafeInteger(maximumWorkingBytes) || maximumWorkingBytes <= 0) {
|
requireMaximumWorkingBytes(maximumWorkingBytes);
|
||||||
throw new RangeError("maximumWorkingBytes must be a positive safe integer.");
|
|
||||||
}
|
|
||||||
const phatFloorRatio = requirePhatFloorRatio(
|
const phatFloorRatio = requirePhatFloorRatio(
|
||||||
options.phatFloorRatio ??
|
options.phatFloorRatio ??
|
||||||
options.phatRelativeFloor ??
|
options.phatRelativeFloor ??
|
||||||
@@ -666,7 +764,7 @@ async function computeGccPhatMapInternal(options, configuration, geometry, plan)
|
|||||||
const rawMap = rawMaps[bandIndex];
|
const rawMap = rawMaps[bandIndex];
|
||||||
for (const microphoneIndex of geometry.pairMicrophoneIndexes) {
|
for (const microphoneIndex of geometry.pairMicrophoneIndexes) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
fillBandPairCorrelation({
|
await fillBandPairCorrelation({
|
||||||
correlationReal,
|
correlationReal,
|
||||||
correlationImaginary,
|
correlationImaginary,
|
||||||
bins,
|
bins,
|
||||||
@@ -675,6 +773,8 @@ async function computeGccPhatMapInternal(options, configuration, geometry, plan)
|
|||||||
referenceIndex: geometry.reference.index,
|
referenceIndex: geometry.reference.index,
|
||||||
phasesByBin,
|
phasesByBin,
|
||||||
phatFloorRatio,
|
phatFloorRatio,
|
||||||
|
signal,
|
||||||
|
crossSpectrumBatchSize,
|
||||||
});
|
});
|
||||||
if (plan) {
|
if (plan) {
|
||||||
plan.statistics.inverseFftTransforms += 1;
|
plan.statistics.inverseFftTransforms += 1;
|
||||||
@@ -715,12 +815,20 @@ async function computeGccPhatMapInternal(options, configuration, geometry, plan)
|
|||||||
}
|
}
|
||||||
|
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
shiftAndSquareScores(rawMaps);
|
await normalizePositiveScores(
|
||||||
|
rawMaps,
|
||||||
|
geometry.bandBins,
|
||||||
|
configuration.fftSize,
|
||||||
|
geometry.pairCount,
|
||||||
|
pointBatchSize,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
const bands = finalizeDasBands(
|
const bands = finalizeDasBands(
|
||||||
rawMaps,
|
rawMaps,
|
||||||
workload,
|
workload,
|
||||||
configuration.experiment,
|
configuration.experiment,
|
||||||
"score",
|
"score",
|
||||||
|
{ averageBandBins: false },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
backend: "cpu",
|
backend: "cpu",
|
||||||
@@ -753,6 +861,7 @@ export async function computeGccPhatMap(options) {
|
|||||||
const configuration = validateConfiguration(options);
|
const configuration = validateConfiguration(options);
|
||||||
const plan = options.analysisPlan;
|
const plan = options.analysisPlan;
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
|
preflightUnplannedWorkingSet(options, configuration);
|
||||||
const geometry = createGeometry(configuration);
|
const geometry = createGeometry(configuration);
|
||||||
return computeGccPhatMapInternal(options, configuration, geometry, undefined);
|
return computeGccPhatMapInternal(options, configuration, geometry, undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ function requestedAlgorithm(options) {
|
|||||||
*/
|
*/
|
||||||
export function estimateLocalizationWorkload(options) {
|
export function estimateLocalizationWorkload(options) {
|
||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
return isDasAlgorithm(algorithm.id)
|
if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(options);
|
||||||
? estimateDasWorkload(options)
|
if (algorithm.id === "gcc-phat") return estimateGccPhatWorkload(options);
|
||||||
: estimateGccPhatWorkload(options);
|
throw new RangeError("No workload estimator is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,9 +35,9 @@ export function estimateLocalizationWorkload(options) {
|
|||||||
*/
|
*/
|
||||||
export function createLocalizationAnalysisPlan(options) {
|
export function createLocalizationAnalysisPlan(options) {
|
||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
return isDasAlgorithm(algorithm.id)
|
if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(options);
|
||||||
? createDasAnalysisPlan(options)
|
if (algorithm.id === "gcc-phat") return createGccPhatAnalysisPlan(options);
|
||||||
: createGccPhatAnalysisPlan(options);
|
throw new RangeError("No analysis-plan factory is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +47,7 @@ export function createLocalizationAnalysisPlan(options) {
|
|||||||
*/
|
*/
|
||||||
export async function computeLocalizationMap(options) {
|
export async function computeLocalizationMap(options) {
|
||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
return isDasAlgorithm(algorithm.id)
|
if (isDasAlgorithm(algorithm.id)) return computeDasMap(options);
|
||||||
? computeDasMap(options)
|
if (algorithm.id === "gcc-phat") return computeGccPhatMap(options);
|
||||||
: computeGccPhatMap(options);
|
throw new RangeError("No CPU localization implementation is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
computeLocalizationMap,
|
computeLocalizationMap,
|
||||||
createLocalizationAnalysisPlan,
|
createLocalizationAnalysisPlan,
|
||||||
} from "./localization.js";
|
} from "./localization.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
||||||
|
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||||
|
} from "./gcc-phat.js";
|
||||||
import {
|
import {
|
||||||
isDasAlgorithm,
|
isDasAlgorithm,
|
||||||
requireLocalizationAlgorithmDefinition,
|
requireLocalizationAlgorithmDefinition,
|
||||||
@@ -355,8 +359,13 @@ export function createLocalizationPlan(config) {
|
|||||||
? { dasPlan: analysisPlan }
|
? { dasPlan: analysisPlan }
|
||||||
: { gccPhatPlan: analysisPlan }),
|
: { gccPhatPlan: analysisPlan }),
|
||||||
computeOptions: Object.freeze({
|
computeOptions: Object.freeze({
|
||||||
pointBatchSize: DEFAULT_REALTIME_POINT_BATCH_SIZE,
|
pointBatchSize: isDasAlgorithm(algorithm.id)
|
||||||
|
? DEFAULT_REALTIME_POINT_BATCH_SIZE
|
||||||
|
: DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||||
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
|
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
|
||||||
|
...(!isDasAlgorithm(algorithm.id)
|
||||||
|
? { crossSpectrumBatchSize: DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE }
|
||||||
|
: {}),
|
||||||
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
|
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
|
||||||
...(phatFloorRatio !== undefined ? { phatFloorRatio } : {}),
|
...(phatFloorRatio !== undefined ? { phatFloorRatio } : {}),
|
||||||
...(config.computeOptions ?? {}),
|
...(config.computeOptions ?? {}),
|
||||||
|
|||||||
@@ -555,6 +555,19 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
}
|
}
|
||||||
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
||||||
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
||||||
|
const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm;
|
||||||
|
const expectedRawScale =
|
||||||
|
resultAlgorithm === "das"
|
||||||
|
? "power"
|
||||||
|
: resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat"
|
||||||
|
? "score"
|
||||||
|
: undefined;
|
||||||
|
const expectedBeamSpreadMethod =
|
||||||
|
expectedRawScale === "power"
|
||||||
|
? "connected-half-power-power-weighted-covariance"
|
||||||
|
: expectedRawScale === "score"
|
||||||
|
? "connected-half-maximum-score-weighted-covariance"
|
||||||
|
: undefined;
|
||||||
const bandIds = [];
|
const bandIds = [];
|
||||||
if (Array.isArray(value.bands)) {
|
if (Array.isArray(value.bands)) {
|
||||||
value.bands.forEach((band, bandIndex) => {
|
value.bands.forEach((band, bandIndex) => {
|
||||||
@@ -576,12 +589,33 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
});
|
});
|
||||||
if (isRecord(band.rawMap) && !["power", "score"].includes(band.rawMap.scale)) {
|
if (isRecord(band.rawMap) && !["power", "score"].includes(band.rawMap.scale)) {
|
||||||
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power 또는 score scale이어야 합니다.");
|
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power 또는 score scale이어야 합니다.");
|
||||||
|
} else if (
|
||||||
|
isRecord(band.rawMap) &&
|
||||||
|
expectedRawScale &&
|
||||||
|
band.rawMap.scale !== expectedRawScale
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.rawMap.scale`,
|
||||||
|
`${resultAlgorithm} 결과의 rawMap scale은 ${expectedRawScale}여야 합니다.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (Array.isArray(band.peaks)) {
|
if (Array.isArray(band.peaks)) {
|
||||||
band.peaks.forEach((peak, peakIndex) => {
|
band.peaks.forEach((peak, peakIndex) => {
|
||||||
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
|
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
|
||||||
addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다.");
|
addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다.");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
expectedBeamSpreadMethod &&
|
||||||
|
isRecord(peak?.beamSpread) &&
|
||||||
|
peak.beamSpread.method !== expectedBeamSpreadMethod
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.peaks[${peakIndex}].beamSpread.method`,
|
||||||
|
`${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ export async function computeLocalizationWithBackend(options, dependencies = {})
|
|||||||
if (isDasAlgorithm(algorithm.id)) {
|
if (isDasAlgorithm(algorithm.id)) {
|
||||||
return computeDasBackend(options, dependencies);
|
return computeDasBackend(options, dependencies);
|
||||||
}
|
}
|
||||||
|
if (algorithm.id !== "gcc-phat") {
|
||||||
|
throw new RangeError("No backend dispatcher is registered for " + algorithm.id + ".");
|
||||||
|
}
|
||||||
|
|
||||||
const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto";
|
const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto";
|
||||||
if (requestedBackend === "webgpu") {
|
if (requestedBackend === "webgpu") {
|
||||||
|
|||||||
@@ -118,6 +118,38 @@ test("WebGPU DAS가 CPU 수치 기준과 일치하거나 auto CPU fallback한다
|
|||||||
document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3);
|
document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("WebGPU DAS-PHAT가 CPU score map과 일치하거나 auto CPU fallback한다", async () => {
|
||||||
|
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
|
||||||
|
const fixture = createFixture(adapter ? "webgpu" : "auto");
|
||||||
|
fixture.experiment.beamforming.algorithm = "das-phat";
|
||||||
|
fixture.experiment.frequencyBands = [
|
||||||
|
{ id: "phase-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!adapter) {
|
||||||
|
const result = await computeDasWithBackend(fixture);
|
||||||
|
assertEqual(result.backend, "cpu", "DAS-PHAT auto fallback backend");
|
||||||
|
assertEqual(result.algorithm, "das-phat", "DAS-PHAT fallback algorithm");
|
||||||
|
assertEqual(result.bands[0].rawScale, "score", "DAS-PHAT fallback scale");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpuResult = await computeDasMap(fixture);
|
||||||
|
const gpuResult = await computeDasWebGpu(fixture);
|
||||||
|
const cpuBand = cpuResult.bands[0];
|
||||||
|
const gpuBand = gpuResult.bands[0];
|
||||||
|
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
|
||||||
|
|
||||||
|
assertEqual(gpuResult.algorithm, "das-phat", "actual WebGPU DAS-PHAT algorithm");
|
||||||
|
assertEqual(gpuBand.rawScale, "score", "actual WebGPU DAS-PHAT scale");
|
||||||
|
assert(!("rawPower" in gpuBand.peak), "DAS-PHAT GPU peak is not mislabeled as power");
|
||||||
|
assert(error.rms <= 0.005, `DAS-PHAT CPU/GPU normalized RMS error=${error.rms}`);
|
||||||
|
assert(
|
||||||
|
error.maximumAbsoluteError <= 0.02,
|
||||||
|
`DAS-PHAT CPU/GPU normalized max error=${error.maximumAbsoluteError}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => {
|
test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => {
|
||||||
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
|
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
|
|||||||
@@ -410,7 +410,28 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
|||||||
id: "tone-1k",
|
id: "tone-1k",
|
||||||
color: "#65f2bf",
|
color: "#65f2bf",
|
||||||
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
||||||
peaks: [{ sourceId: "source-1", position: [0.08, 0.04, 2], value: 1, localizationErrorM: 0 }],
|
peaks: [{
|
||||||
|
sourceId: "source-1",
|
||||||
|
position: [0.08, 0.04, 2],
|
||||||
|
value: 1,
|
||||||
|
localizationErrorM: 0,
|
||||||
|
beamSpread: {
|
||||||
|
calibrated: false,
|
||||||
|
method: "connected-half-power-power-weighted-covariance",
|
||||||
|
halfPowerRatio: 0.5,
|
||||||
|
thresholdValue: 0.5,
|
||||||
|
pixelCount: 1,
|
||||||
|
centroid: [0.08, 0.04, 2],
|
||||||
|
covarianceM2: { xx: 0, xy: 0, yy: 0 },
|
||||||
|
ellipse: {
|
||||||
|
axisScale: "rms",
|
||||||
|
angleReference: "counterclockwise-from-positive-x",
|
||||||
|
majorRmsRadiusM: 0,
|
||||||
|
minorRmsRadiusM: 0,
|
||||||
|
angleDeg: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
|
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
@@ -419,17 +440,39 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
|||||||
experiment.beamforming.algorithm = "das-phat";
|
experiment.beamforming.algorithm = "das-phat";
|
||||||
result.algorithm = "das-phat";
|
result.algorithm = "das-phat";
|
||||||
result.bands[0].rawMap.scale = "score";
|
result.bands[0].rawMap.scale = "score";
|
||||||
|
result.bands[0].peaks[0].beamSpread.method =
|
||||||
|
"connected-half-maximum-score-weighted-covariance";
|
||||||
assertEqual(
|
assertEqual(
|
||||||
(await validateResult(result, { experiment, scene })).length,
|
(await validateResult(result, { experiment, scene })).length,
|
||||||
0,
|
0,
|
||||||
"phase-weighted score result issues",
|
"phase-weighted score result issues",
|
||||||
);
|
);
|
||||||
|
|
||||||
result.algorithm = "das";
|
result.bands[0].rawMap.scale = "power";
|
||||||
let issues = await validateResult(result, { experiment, scene });
|
let issues = await validateResult(result, { experiment, scene });
|
||||||
|
assert(
|
||||||
|
issues.some(({ path }) => path.endsWith("rawMap.scale")),
|
||||||
|
"algorithm and raw map scale must agree",
|
||||||
|
);
|
||||||
|
result.bands[0].rawMap.scale = "score";
|
||||||
|
result.bands[0].peaks[0].beamSpread.method =
|
||||||
|
"connected-half-power-power-weighted-covariance";
|
||||||
|
issues = await validateResult(result, { experiment, scene });
|
||||||
|
assert(
|
||||||
|
issues.some(({ path }) => path.endsWith("beamSpread.method")),
|
||||||
|
"score map and beam spread method must agree",
|
||||||
|
);
|
||||||
|
result.bands[0].peaks[0].beamSpread.method =
|
||||||
|
"connected-half-maximum-score-weighted-covariance";
|
||||||
|
|
||||||
|
result.algorithm = "das";
|
||||||
|
issues = await validateResult(result, { experiment, scene });
|
||||||
assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment");
|
assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment");
|
||||||
|
|
||||||
experiment.beamforming.algorithm = "das";
|
experiment.beamforming.algorithm = "das";
|
||||||
|
result.bands[0].rawMap.scale = "power";
|
||||||
|
result.bands[0].peaks[0].beamSpread.method =
|
||||||
|
"connected-half-power-power-weighted-covariance";
|
||||||
result.bands[0].rawMap.values.pop();
|
result.bands[0].rawMap.values.pop();
|
||||||
result.bands[0].rawMap.scale = "normalized";
|
result.bands[0].rawMap.scale = "normalized";
|
||||||
result.bands[0].peaks[0].sourceId = "missing-source";
|
result.bands[0].peaks[0].sourceId = "missing-source";
|
||||||
|
|||||||
@@ -174,6 +174,18 @@ test("GCC-PHAT workload reports reference-pair scan reduction and rejects aliase
|
|||||||
"maximum geometric lag is exposed and unaliased",
|
"maximum geometric lag is exposed and unaliased",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let preflightError;
|
||||||
|
try {
|
||||||
|
estimateGccPhatWorkload({ ...fixture, maximumWorkingBytes: 1 });
|
||||||
|
} catch (error) {
|
||||||
|
preflightError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
preflightError instanceof RangeError &&
|
||||||
|
preflightError.message.includes("before geometry allocation"),
|
||||||
|
"unplanned workload is rejected before allocating its distance matrix",
|
||||||
|
);
|
||||||
|
|
||||||
let memoryError;
|
let memoryError;
|
||||||
try {
|
try {
|
||||||
createGccPhatAnalysisPlan({ ...fixture, maximumPlanBytes: 1 });
|
createGccPhatAnalysisPlan({ ...fixture, maximumPlanBytes: 1 });
|
||||||
@@ -241,8 +253,8 @@ test("GCC-PHAT corrected lag sign finds a deterministic broadband source and emi
|
|||||||
assertNear(result.maximumLagSamples, result.workload.maximumLagSamples, 0, "lag metadata");
|
assertNear(result.maximumLagSamples, result.workload.maximumLagSamples, 0, "lag metadata");
|
||||||
assertEqual(band.rawScale, "score", "raw map scale");
|
assertEqual(band.rawScale, "score", "raw map scale");
|
||||||
assert(
|
assert(
|
||||||
band.rawMap.every((value) => Number.isFinite(value) && value >= 0),
|
band.rawMap.every((value) => Number.isFinite(value) && value >= 0 && value <= 1),
|
||||||
"shifted and squared score map is finite and nonnegative",
|
"pair- and bandwidth-normalized score map is finite and bounded",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
distance3(peakPosition, fixture.target) <= 0.15,
|
distance3(peakPosition, fixture.target) <= 0.15,
|
||||||
@@ -263,6 +275,48 @@ test("GCC-PHAT corrected lag sign finds a deterministic broadband source and emi
|
|||||||
"X_m * conj(X_ref) correlation uses the physical arrival-lag sign",
|
"X_m * conj(X_ref) correlation uses the physical arrival-lag sign",
|
||||||
);
|
);
|
||||||
assertNear(Math.max(...band.displayMap), 1, 1e-6, "normalized score display");
|
assertNear(Math.max(...band.displayMap), 1, 1e-6, "normalized score display");
|
||||||
|
|
||||||
|
const expandedFixture = createBroadbandFixture();
|
||||||
|
expandedFixture.experiment.scan = {
|
||||||
|
...expandedFixture.experiment.scan,
|
||||||
|
xMin: -0.8,
|
||||||
|
xMax: 0.8,
|
||||||
|
yMin: -0.8,
|
||||||
|
yMax: 0.8,
|
||||||
|
columns: 17,
|
||||||
|
rows: 17,
|
||||||
|
};
|
||||||
|
const expanded = await computeGccPhatMap({
|
||||||
|
...expandedFixture,
|
||||||
|
phatFloorRatio: 1e-5,
|
||||||
|
pointBatchSize: 1024,
|
||||||
|
stftTransformBatchSize: 1024,
|
||||||
|
});
|
||||||
|
const expandedTargetIndex = scanIndexFor(
|
||||||
|
expandedFixture.experiment.scan,
|
||||||
|
expandedFixture.target[0],
|
||||||
|
expandedFixture.target[1],
|
||||||
|
);
|
||||||
|
assertNear(
|
||||||
|
expanded.bands[0].rawMap[expandedTargetIndex],
|
||||||
|
band.rawMap[targetIndex],
|
||||||
|
1e-6,
|
||||||
|
"raw score at a fixed hypothesis is independent of scan extent",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GCC-PHAT compute rejects an oversized unplanned working set before geometry", async () => {
|
||||||
|
const fixture = createBroadbandFixture();
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await computeGccPhatMap({ ...fixture, maximumWorkingBytes: 1 });
|
||||||
|
} catch (candidate) {
|
||||||
|
error = candidate;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
error instanceof RangeError && error.message.includes("before geometry allocation"),
|
||||||
|
"compute preflight error",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GCC-PHAT analysis plan reuses geometry and FFT workspaces and releases its busy lock", async () => {
|
test("GCC-PHAT analysis plan reuses geometry and FFT workspaces and releases its busy lock", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
createLocalizationAnalysisPlan,
|
createLocalizationAnalysisPlan,
|
||||||
estimateLocalizationWorkload,
|
estimateLocalizationWorkload,
|
||||||
} from "/packages/acoustics-core/localization.js";
|
} from "/packages/acoustics-core/localization.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
||||||
|
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||||
|
} from "/packages/acoustics-core/gcc-phat.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||||
LOCALIZATION_ALGORITHM_IDS,
|
LOCALIZATION_ALGORITHM_IDS,
|
||||||
@@ -167,10 +171,22 @@ test("portable and streaming API accept an algorithm choice without Scene synthe
|
|||||||
computeOptions: { pointBatchSize: 1024, stftTransformBatchSize: 1024 },
|
computeOptions: { pointBatchSize: 1024, stftTransformBatchSize: 1024 },
|
||||||
};
|
};
|
||||||
const plan = createLocalizationPlan(config);
|
const plan = createLocalizationPlan(config);
|
||||||
|
const defaultBatchPlan = createLocalizationPlan({ ...config, computeOptions: undefined });
|
||||||
const localized = await localizeMultichannel({ plan, channels: fixture.channels });
|
const localized = await localizeMultichannel({ plan, channels: fixture.channels });
|
||||||
assertEqual(plan.algorithm, "gcc-phat", "portable plan algorithm");
|
assertEqual(plan.algorithm, "gcc-phat", "portable plan algorithm");
|
||||||
assertEqual(plan.computeOptions.phatFloorRatio, 1e-5, "portable PHAT floor forwarding");
|
assertEqual(plan.computeOptions.phatFloorRatio, 1e-5, "portable PHAT floor forwarding");
|
||||||
assertEqual(plan.analysisPlan, plan.gccPhatPlan, "portable GCC plan alias");
|
assertEqual(plan.analysisPlan, plan.gccPhatPlan, "portable GCC plan alias");
|
||||||
|
assertEqual(plan.computeOptions.pointBatchSize, 1024, "explicit portable point batch override");
|
||||||
|
assertEqual(
|
||||||
|
defaultBatchPlan.computeOptions.pointBatchSize,
|
||||||
|
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||||
|
"GCC lookup-oriented default batch",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
defaultBatchPlan.computeOptions.crossSpectrumBatchSize,
|
||||||
|
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
||||||
|
"GCC cross-spectrum cancellation batch",
|
||||||
|
);
|
||||||
assertEqual(localized.algorithm, "gcc-phat", "portable result algorithm");
|
assertEqual(localized.algorithm, "gcc-phat", "portable result algorithm");
|
||||||
assertEqual(localized.phatFloorRatio, 1e-5, "portable PHAT floor reaches GCC core");
|
assertEqual(localized.phatFloorRatio, 1e-5, "portable PHAT floor reaches GCC core");
|
||||||
assertEqual(localized.bands[0].rawScale, "score", "portable score map");
|
assertEqual(localized.bands[0].rawScale, "score", "portable score map");
|
||||||
|
|||||||
Reference in New Issue
Block a user