feat: complete acoustic camera workflow
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user