feat: add physically explicit narrowband MUSIC

This commit is contained in:
2026-08-29 12:40:48 +09:00
parent 044be3cf92
commit 11fee76280
40 changed files with 6473 additions and 275 deletions
+169 -37
View File
@@ -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,