feat: complete acoustic camera workflow

This commit is contained in:
Codex
2026-08-28 12:11:10 +09:00
parent d5f7530620
commit d04a4d9f65
13 changed files with 3632 additions and 16 deletions
+197
View File
@@ -0,0 +1,197 @@
import {
RESULT_METRIC_KEYS,
evaluateDasBand,
exportBandEvaluation,
findLocalPeaks,
measureMainLobe,
selectResultMetrics,
} from "/packages/acoustics-core/evaluation.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
);
}
function captureError(callback) {
try {
callback();
} catch (error) {
return error;
}
return undefined;
}
test("evaluation이 row-major 첫 행을 yMax로 해석한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 3, rows: 3 };
const rawMap = Float32Array.of(
4, 0, 0,
0, 0, 0,
0, 0, 1,
);
const evaluation = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "top-left", position: [-1, 1, 9] }],
truthRadiusM: 0.1,
matchToleranceM: 0.1,
sidelobeExclusionRadiusM: 0.1,
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.globalPeak.index, 0, "row-major global index");
assertEqual(evaluation.globalPeak.position.join(","), "-1,1,2", "first row position");
assertEqual(evaluation.sourceMatches[0].sourceId, "top-left", "source match id");
assertNear(evaluation.sourceMatches[0].localizationErrorM, 0, 1e-12, "projected localization error");
assertNear(evaluation.metrics.energyInsideTruthRatio, 0.8, 1e-12, "truth energy ratio");
assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(4), 1e-12, "peak-to-sidelobe");
assertEqual(evaluation.metrics.precision, 1, "single-source precision");
assertEqual(evaluation.metrics.recall, 1, "single-source recall");
});
test("복수 source와 threshold detection을 one-to-one matching한다", () => {
const scan = { xMin: -2, xMax: 2, yMin: -2, yMax: 2, z: 1, columns: 5, rows: 5 };
const rawMap = new Float32Array(25);
rawMap[6] = 10; // (-1, +1)
rawMap[18] = 8; // (+1, -1)
rawMap[4] = 6; // unmatched (+2, +2)
const evaluation = evaluateDasBand({
rawMap,
scan,
sources: [
{ id: "a", position: [-1, 1, 1] },
{ id: "b", position: [1, -1, 1] },
],
truthRadiusM: 0.25,
matchToleranceM: 0.25,
sidelobeExclusionRadiusM: 0.25,
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.detections.length, 3, "threshold detections");
assertEqual(evaluation.counts.truePositive, 2, "true positives");
assertEqual(evaluation.counts.falsePositive, 1, "false positives");
assertEqual(evaluation.counts.falseNegative, 0, "false negatives");
assertNear(evaluation.metrics.precision, 2 / 3, 1e-12, "multi-source precision");
assertNear(evaluation.metrics.recall, 1, 1e-12, "multi-source recall");
assertNear(evaluation.metrics.energyInsideTruthRatio, 18 / 24, 1e-12, "union truth energy");
assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(10 / 6), 1e-12, "multi-source PSLR");
assert(
evaluation.sourceMatches.every((match) => match.withinTolerance && match.localizationErrorM === 0),
"both sources matched exactly",
);
});
test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
rawMap: new Float32Array(4),
scan,
sources: [{ id: "silent", position: [0, 0, 1] }],
});
assertEqual(evaluation.globalPeak, null, "zero-map global peak");
assertEqual(evaluation.localPeaks.length, 0, "zero-map local peaks");
assertEqual(evaluation.detections.length, 0, "zero-map detections");
assertEqual(evaluation.sourceMatches[0].peak, null, "zero-map source remains unmatched");
assertEqual(evaluation.metrics.energyInsideTruthRatio, 0, "zero-map energy ratio");
assertEqual(evaluation.metrics.peakToSidelobeDb, 0, "zero-map PSLR");
assertEqual(evaluation.metrics.precision, 0, "zero-map precision");
assertEqual(evaluation.metrics.recall, 0, "zero-map recall");
assertEqual(evaluation.diagnostics.mainLobeWidthM, 0, "zero-map main-lobe width");
Object.values(evaluation.metrics).forEach((value) => assert(Number.isFinite(value), "finite zero-map metric"));
});
test("flat plateau와 main-lobe width가 deterministic하다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 0, columns: 3, rows: 3 };
const rawMap = Float32Array.from({ length: 9 }, () => 1);
const peaks = findLocalPeaks(rawMap, scan);
assertEqual(peaks.length, 1, "flat plateau represented once");
assertEqual(peaks[0].index, 0, "flat plateau uses lowest row-major index");
const first = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "corner", position: [-1, 1, 0] }],
truthRadiusM: 0,
matchToleranceM: 0,
minimumPeakDistanceM: 0,
});
const second = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "corner", position: [-1, 1, 0] }],
truthRadiusM: 0,
matchToleranceM: 0,
minimumPeakDistanceM: 0,
});
assertEqual(JSON.stringify(first), JSON.stringify(second), "repeatable evaluation result");
const lobe = measureMainLobe(rawMap, scan, 0, 0.5);
assertEqual(lobe.pixelCount, 9, "flat half-power component size");
assertEqual(lobe.widthXM, 3, "main-lobe x pixel footprint");
assertEqual(lobe.widthYM, 3, "main-lobe y pixel footprint");
assertNear(lobe.equivalentDiameterM, 6 / Math.sqrt(Math.PI), 1e-12, "equivalent main-lobe diameter");
});
test("result exporter가 schema 허용 metric만 선택하고 내부 필드를 제거한다", () => {
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
rawMap: Float32Array.of(5, 0, 0, 1),
scan,
sources: [{ id: "truth", position: [0, 1, 3] }],
truthRadiusM: 0,
matchToleranceM: 0,
sidelobeExclusionRadiusM: 0,
minimumPeakDistanceM: 0,
});
const exported = exportBandEvaluation(evaluation, { metricNames: ["precision", "recall"] });
assertEqual(Object.keys(exported.metrics).join(","), "precision,recall", "selected metric keys");
assertEqual(exported.peaks[0].sourceId, "truth", "schema peak source match");
assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error");
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
assert(!("mainLobeWidthM" in exported.metrics), "diagnostic omitted from schema metrics");
assertEqual(
Object.keys(selectResultMetrics(evaluation)).join(","),
RESULT_METRIC_KEYS.join(","),
"default schema metric selection",
);
const unsupported = captureError(() => selectResultMetrics(evaluation, ["mainLobeWidthM"]));
assert(unsupported instanceof RangeError, "unsupported result metric is rejected");
});
test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서 거부한다", () => {
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
for (const invalidValue of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
const rawMap = [0, 0, 0, invalidValue];
assert(
captureError(() => evaluateDasBand({ rawMap, scan, sources: [] })) instanceof RangeError,
`invalid power rejected: ${invalidValue}`,
);
}
assert(
captureError(() => evaluateDasBand({ rawMap: [0, 0, 0], scan, sources: [] })) instanceof RangeError,
"map shape mismatch rejected",
);
assert(
captureError(() => evaluateDasBand({
rawMap: [0, 0, 0, 0],
scan,
sources: [{ id: "bad", position: [0, Number.NaN, 1] }],
})) instanceof RangeError,
"non-finite source position rejected",
);
assert(
captureError(() => evaluateDasBand({
rawMap: { rows: 2, columns: 2, scale: "normalized", values: [0, 0, 0, 0] },
scan,
sources: [],
})) instanceof RangeError,
"non-power map rejected",
);
});