feat: add physically explicit narrowband MUSIC
This commit is contained in:
@@ -58,9 +58,10 @@ function normalizeRawMap(rawMap, scan) {
|
||||
if (
|
||||
descriptor?.scale !== undefined &&
|
||||
descriptor.scale !== "power" &&
|
||||
descriptor.scale !== "score"
|
||||
descriptor.scale !== "score" &&
|
||||
descriptor.scale !== "noise-projection"
|
||||
) {
|
||||
throw new RangeError("rawMap scale must be power or score.");
|
||||
throw new RangeError("rawMap scale must be power, score, or noise-projection.");
|
||||
}
|
||||
if (descriptor?.rows !== undefined && descriptor.rows !== scan.rows) {
|
||||
throw new RangeError("rawMap rows do not match scan.rows.");
|
||||
@@ -85,6 +86,86 @@ function rawMapScale(rawMap) {
|
||||
return "power";
|
||||
}
|
||||
|
||||
function rawMapObjectiveDirection(rawMap, mapScale) {
|
||||
const descriptor =
|
||||
rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap)
|
||||
? rawMap
|
||||
: null;
|
||||
const direction =
|
||||
descriptor?.objectiveDirection ??
|
||||
(mapScale === "noise-projection" ? "minimum" : "maximum");
|
||||
if (direction !== "maximum" && direction !== "minimum") {
|
||||
throw new RangeError('rawMap objectiveDirection must be "maximum" or "minimum".');
|
||||
}
|
||||
if (mapScale === "noise-projection" && direction !== "minimum") {
|
||||
throw new RangeError("noise-projection maps must use objectiveDirection minimum.");
|
||||
}
|
||||
if (mapScale !== "noise-projection" && direction !== "maximum") {
|
||||
throw new RangeError("power and score maps must use objectiveDirection maximum.");
|
||||
}
|
||||
return direction;
|
||||
}
|
||||
|
||||
export function normalizedMusicPseudospectrum(values) {
|
||||
if (!Array.isArray(values) && !ArrayBuffer.isView(values)) {
|
||||
throw new TypeError("noise projection values must be an array or typed array.");
|
||||
}
|
||||
if (values.length === 0) {
|
||||
throw new RangeError("noise projection values must not be empty.");
|
||||
}
|
||||
let rawMinimum = Number.POSITIVE_INFINITY;
|
||||
let rawMaximum = Number.NEGATIVE_INFINITY;
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = requireNonNegative(
|
||||
`noiseProjection[${index}]`,
|
||||
values[index],
|
||||
);
|
||||
rawMinimum = Math.min(rawMinimum, value);
|
||||
rawMaximum = Math.max(rawMaximum, value);
|
||||
}
|
||||
const pseudospectrum = new Float64Array(values.length);
|
||||
if (rawMinimum === rawMaximum) {
|
||||
pseudospectrum.fill(1);
|
||||
return {
|
||||
values: pseudospectrum,
|
||||
transform: "normalized-reciprocal-noise-projection",
|
||||
rawMinimum,
|
||||
flat: true,
|
||||
};
|
||||
}
|
||||
if (rawMinimum === 0) {
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
pseudospectrum[index] = values[index] === 0 ? 1 : 0;
|
||||
}
|
||||
} else {
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
pseudospectrum[index] = rawMinimum / values[index];
|
||||
}
|
||||
}
|
||||
return {
|
||||
values: pseudospectrum,
|
||||
transform: "normalized-reciprocal-noise-projection",
|
||||
rawMinimum,
|
||||
flat: false,
|
||||
};
|
||||
}
|
||||
|
||||
function objectiveMerit(values, direction) {
|
||||
if (direction === "maximum") {
|
||||
return { values, transform: "identity", rawMinimum: null, flat: false };
|
||||
}
|
||||
return normalizedMusicPseudospectrum(values);
|
||||
}
|
||||
|
||||
function restoreObjectiveValue(candidate, rawValues) {
|
||||
if (!candidate) return null;
|
||||
return {
|
||||
...candidate,
|
||||
value: rawValues[candidate.index],
|
||||
meritValue: candidate.value,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSources(sources) {
|
||||
if (!Array.isArray(sources)) throw new TypeError("sources must be an array.");
|
||||
const ids = new Set();
|
||||
@@ -742,8 +823,11 @@ export function measureBeamSpread(rawMap, scan, peakIndex, halfPowerRatio = 0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one nonnegative spatial power or score map. Source z coordinates
|
||||
* are projected onto scan.z, matching the image-plane ground-truth convention.
|
||||
* Evaluate one nonnegative spatial map. Noise-projection minima are detected
|
||||
* through the standard MUSIC reciprocal pseudospectrum normalized as
|
||||
* P/Pmax=qmin/q, while exported peak values remain the unmodified q values.
|
||||
* Source z coordinates are projected onto scan.z, matching the image-plane
|
||||
* ground-truth convention.
|
||||
*/
|
||||
export function evaluateLocalizationBand(options) {
|
||||
if (!options || typeof options !== "object") throw new TypeError("evaluation options are required.");
|
||||
@@ -751,9 +835,20 @@ export function evaluateLocalizationBand(options) {
|
||||
validateScan(scan);
|
||||
const values = normalizeRawMap(rawMap, scan);
|
||||
const mapScale = options.mapScale ?? rawMapScale(rawMap);
|
||||
if (mapScale !== "power" && mapScale !== "score") {
|
||||
throw new RangeError("mapScale must be power or score.");
|
||||
if (mapScale !== "power" && mapScale !== "score" && mapScale !== "noise-projection") {
|
||||
throw new RangeError("mapScale must be power, score, or noise-projection.");
|
||||
}
|
||||
const objectiveDirection =
|
||||
options.objectiveDirection ?? rawMapObjectiveDirection(rawMap, mapScale);
|
||||
if (
|
||||
(mapScale === "noise-projection" && objectiveDirection !== "minimum") ||
|
||||
(mapScale !== "noise-projection" && objectiveDirection !== "maximum")
|
||||
) {
|
||||
throw new RangeError(
|
||||
"noise-projection uses a minimum objective; power and score use a maximum objective.",
|
||||
);
|
||||
}
|
||||
const merit = objectiveMerit(values, objectiveDirection);
|
||||
const sources = normalizeSources(options.sources ?? options.scene?.sources ?? []);
|
||||
const truths = sources.map((source) => ({
|
||||
sourceId: source.id,
|
||||
@@ -789,19 +884,32 @@ export function evaluateLocalizationBand(options) {
|
||||
throw new RangeError("maximumDetections must be an integer from 1 through 128.");
|
||||
}
|
||||
|
||||
const localPeaks = findLocalPeaks(values, scan);
|
||||
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
|
||||
const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio;
|
||||
const candidates = detectionCandidates(
|
||||
values,
|
||||
const localMeritPeaks = merit.flat
|
||||
? []
|
||||
: findLocalPeaks(merit.values, scan);
|
||||
const globalMeritPeak = localMeritPeaks[0] ?? null;
|
||||
const thresholdValue = (globalMeritPeak?.value ?? 0) * detectionThresholdRatio;
|
||||
const meritCandidates = detectionCandidates(
|
||||
merit.values,
|
||||
scan,
|
||||
localPeaks,
|
||||
localMeritPeaks,
|
||||
thresholdValue,
|
||||
detectionMethod,
|
||||
);
|
||||
const detections = selectDetections(candidates, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
|
||||
(detection) => ({
|
||||
...detection,
|
||||
const selectedMeritDetections = selectDetections(
|
||||
meritCandidates,
|
||||
thresholdValue,
|
||||
minimumPeakDistanceM,
|
||||
maximumDetections,
|
||||
);
|
||||
const localPeaks = localMeritPeaks.map((peak) =>
|
||||
restoreObjectiveValue(peak, values));
|
||||
const globalPeak = restoreObjectiveValue(globalMeritPeak, values);
|
||||
const detections = selectedMeritDetections.map((detection) => {
|
||||
const restored = restoreObjectiveValue(detection, values);
|
||||
if (objectiveDirection === "minimum") return restored;
|
||||
return {
|
||||
...restored,
|
||||
beamSpread: measureBeamSpreadFromValues(
|
||||
values,
|
||||
scan,
|
||||
@@ -809,12 +917,36 @@ export function evaluateLocalizationBand(options) {
|
||||
halfPowerRatio,
|
||||
mapScale,
|
||||
),
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
|
||||
const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0;
|
||||
const recall = truths.length > 0 ? truePositiveCount / truths.length : 1;
|
||||
const mainLobe = globalPeak ? measureMainLobe(values, scan, globalPeak.index, halfPowerRatio) : null;
|
||||
const mainLobe =
|
||||
objectiveDirection === "maximum" && globalPeak
|
||||
? measureMainLobe(values, scan, globalPeak.index, halfPowerRatio)
|
||||
: null;
|
||||
const metrics = { precision, recall };
|
||||
if (objectiveDirection === "maximum") {
|
||||
Object.assign(metrics, {
|
||||
peakToSidelobeDb: peakToSidelobeDb(
|
||||
values,
|
||||
scan,
|
||||
truths,
|
||||
globalPeak,
|
||||
sidelobeExclusionRadiusM,
|
||||
dynamicRangeDb,
|
||||
),
|
||||
energyInsideTruthRatio: energyInsideRatio(
|
||||
values,
|
||||
scan,
|
||||
truths,
|
||||
truthRadiusM,
|
||||
globalPeak?.value ?? 0,
|
||||
),
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
globalPeak,
|
||||
@@ -833,6 +965,10 @@ export function evaluateLocalizationBand(options) {
|
||||
matchToleranceM,
|
||||
detectionThresholdRatio,
|
||||
detectionThresholdValue: thresholdValue,
|
||||
detectionThresholdDomain:
|
||||
objectiveDirection === "minimum"
|
||||
? "normalized-music-pseudospectrum"
|
||||
: "raw-map-value",
|
||||
detectionMethod,
|
||||
minimumPeakDistanceM,
|
||||
sidelobeExclusionRadiusM,
|
||||
@@ -840,23 +976,14 @@ export function evaluateLocalizationBand(options) {
|
||||
dynamicRangeDb,
|
||||
maximumDetections,
|
||||
},
|
||||
metrics: {
|
||||
peakToSidelobeDb: peakToSidelobeDb(
|
||||
values,
|
||||
scan,
|
||||
truths,
|
||||
globalPeak,
|
||||
sidelobeExclusionRadiusM,
|
||||
dynamicRangeDb,
|
||||
),
|
||||
energyInsideTruthRatio: energyInsideRatio(values, scan, truths, truthRadiusM, globalPeak?.value ?? 0),
|
||||
precision,
|
||||
recall,
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
},
|
||||
metrics,
|
||||
diagnostics: {
|
||||
mapScale,
|
||||
detectionCandidateCount: candidates.length,
|
||||
objectiveDirection,
|
||||
detectionTransform: merit.transform,
|
||||
...(merit.rawMinimum === null ? {} : { rawMinimum: merit.rawMinimum }),
|
||||
flatObjectiveMap: merit.flat,
|
||||
detectionCandidateCount: meritCandidates.length,
|
||||
mainLobe,
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
gridResolutionM: resolution,
|
||||
@@ -870,12 +997,14 @@ export function evaluateDasBand(options) {
|
||||
}
|
||||
|
||||
/** Select only metric names accepted by result.schema.json. */
|
||||
export function selectResultMetrics(evaluationOrMetrics, metricNames = RESULT_METRIC_KEYS) {
|
||||
export function selectResultMetrics(evaluationOrMetrics, metricNames) {
|
||||
const metrics = evaluationOrMetrics?.metrics ?? evaluationOrMetrics;
|
||||
if (!metrics || typeof metrics !== "object") throw new TypeError("evaluation metrics are required.");
|
||||
if (!Array.isArray(metricNames)) throw new TypeError("metricNames must be an array.");
|
||||
const selectedMetricNames =
|
||||
metricNames ?? RESULT_METRIC_KEYS.filter((metricName) => metricName in metrics);
|
||||
if (!Array.isArray(selectedMetricNames)) throw new TypeError("metricNames must be an array.");
|
||||
const selected = {};
|
||||
for (const metricName of metricNames) {
|
||||
for (const metricName of selectedMetricNames) {
|
||||
if (!RESULT_METRIC_KEY_SET.has(metricName)) {
|
||||
throw new RangeError(`result.schema.json does not allow metric: ${metricName}`);
|
||||
}
|
||||
@@ -927,13 +1056,16 @@ export function exportBandEvaluation(evaluation, options = {}) {
|
||||
});
|
||||
return {
|
||||
peaks,
|
||||
metrics: selectResultMetrics(evaluation, options.metricNames ?? RESULT_METRIC_KEYS),
|
||||
metrics: selectResultMetrics(evaluation, options.metricNames),
|
||||
evaluation: {
|
||||
version: "1.0.0",
|
||||
truthRadiusM: evaluation.parameters.truthRadiusM,
|
||||
matchToleranceM: evaluation.parameters.matchToleranceM,
|
||||
detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
|
||||
detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
|
||||
objectiveDirection: evaluation.diagnostics?.objectiveDirection ?? "maximum",
|
||||
detectionThresholdDomain:
|
||||
evaluation.parameters.detectionThresholdDomain ?? "raw-map-value",
|
||||
detectionMethod: evaluation.parameters.detectionMethod ?? "local-max",
|
||||
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
|
||||
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
|
||||
|
||||
Reference in New Issue
Block a user