feat: visualize beam spread around peaks

This commit is contained in:
Codex
2026-08-28 15:48:02 +09:00
parent 95f2ca19a6
commit b678d1f9c7
8 changed files with 414 additions and 2 deletions
+116
View File
@@ -3,6 +3,7 @@ import {
evaluateDasBand,
exportBandEvaluation,
findLocalPeaks,
measureBeamSpread,
measureMainLobe,
selectResultMetrics,
} from "/packages/acoustics-core/evaluation.js";
@@ -137,6 +138,111 @@ test("flat plateau와 main-lobe width가 deterministic하다", () => {
assertNear(lobe.equivalentDiameterM, 6 / Math.sqrt(Math.PI), 1e-12, "equivalent main-lobe diameter");
});
test("beam spread가 회전된 비등방성 half-power lobe의 covariance ellipse를 측정한다", () => {
const scan = { xMin: -3, xMax: 3, yMin: -3, yMax: 3, z: 2, columns: 7, rows: 7 };
const rawMap = new Float32Array(49);
const setPower = (x, y, value) => {
rawMap[(3 - y) * scan.columns + x + 3] = value;
};
setPower(0, 0, 10);
setPower(1, 1, 8);
setPower(-1, -1, 8);
setPower(2, 2, 6);
setPower(-2, -2, 6);
setPower(-1, 1, 5.5);
setPower(1, -1, 5.5);
const spread = measureBeamSpread(rawMap, scan, 24);
assertEqual(spread.calibrated, false, "beam spread is explicitly uncalibrated");
assertEqual(
spread.method,
"connected-half-power-power-weighted-covariance",
"beam spread method",
);
assertEqual(spread.pixelCount, 7, "connected half-power pixels");
assertNear(spread.thresholdValue, 5, 1e-12, "half-power threshold");
assertNear(spread.centroid[0], 0, 1e-12, "weighted centroid x");
assertNear(spread.centroid[1], 0, 1e-12, "weighted centroid y");
assertEqual(spread.centroid[2], 2, "weighted centroid z");
assertNear(spread.covarianceM2.xx, 75 / 49, 1e-12, "rotated covariance xx");
assertNear(spread.covarianceM2.xy, 53 / 49, 1e-12, "rotated covariance xy");
assertNear(spread.covarianceM2.yy, 75 / 49, 1e-12, "rotated covariance yy");
assertNear(spread.ellipse.majorRmsRadiusM, Math.sqrt(128 / 49), 1e-12, "major RMS radius");
assertNear(spread.ellipse.minorRmsRadiusM, Math.sqrt(22 / 49), 1e-12, "minor RMS radius");
assertNear(spread.ellipse.angleDeg, 45, 1e-12, "major-axis angle");
assertEqual(spread.ellipse.axisScale, "rms", "ellipse scale is explicit");
const evaluation = evaluateDasBand({
rawMap,
scan,
sources: [],
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.detections.length, 1, "single selected detection");
assertEqual(
JSON.stringify(evaluation.detections[0].beamSpread),
JSON.stringify(spread),
"selected detection exposes its beam spread",
);
});
test("beam spread가 1-row, 1-column, single-pixel lobe에서 유한하고 결정적이다", () => {
const powers = Float32Array.of(5, 8, 10, 8, 5);
const rowSpread = measureBeamSpread(
powers,
{ xMin: -2, xMax: 2, yMin: 7, yMax: 7, z: 3, columns: 5, rows: 1 },
2,
);
const columnSpread = measureBeamSpread(
powers,
{ xMin: 4, xMax: 4, yMin: 5, yMax: 9, z: 3, columns: 1, rows: 5 },
2,
);
const pointScan = { xMin: 4, xMax: 4, yMin: 7, yMax: 7, z: 3, columns: 1, rows: 1 };
const pointSpread = measureBeamSpread(Float32Array.of(10), pointScan, 0);
const silentSpread = measureBeamSpread(Float32Array.of(0), pointScan, 0);
assertEqual(rowSpread.pixelCount, 5, "one-row lobe size");
assertNear(rowSpread.covarianceM2.xx, 14 / 9, 1e-12, "one-row x variance");
assertEqual(rowSpread.covarianceM2.yy, 0, "one-row y variance");
assertNear(rowSpread.ellipse.majorRmsRadiusM, Math.sqrt(14 / 9), 1e-12, "one-row major radius");
assertEqual(rowSpread.ellipse.minorRmsRadiusM, 0, "one-row minor radius");
assertEqual(rowSpread.ellipse.angleDeg, 0, "one-row angle");
assertEqual(columnSpread.covarianceM2.xx, 0, "one-column x variance");
assertNear(columnSpread.covarianceM2.yy, 14 / 9, 1e-12, "one-column y variance");
assertNear(columnSpread.ellipse.majorRmsRadiusM, Math.sqrt(14 / 9), 1e-12, "one-column major radius");
assertEqual(columnSpread.ellipse.minorRmsRadiusM, 0, "one-column minor radius");
assertEqual(columnSpread.ellipse.angleDeg, 90, "one-column angle");
assertEqual(pointSpread.centroid.join(","), "4,7,3", "single-pixel centroid");
assertEqual(pointSpread.pixelCount, 1, "single-pixel lobe size");
assertEqual(pointSpread.ellipse.majorRmsRadiusM, 0, "single-pixel major radius");
assertEqual(pointSpread.ellipse.minorRmsRadiusM, 0, "single-pixel minor radius");
assertEqual(silentSpread.pixelCount, 0, "silent lobe has no pixels");
for (const spread of [rowSpread, columnSpread, pointSpread, silentSpread]) {
const numbers = [
spread.halfPowerRatio,
spread.thresholdValue,
spread.pixelCount,
...spread.centroid,
...Object.values(spread.covarianceM2),
spread.ellipse.majorRmsRadiusM,
spread.ellipse.minorRmsRadiusM,
spread.ellipse.angleDeg,
];
assert(numbers.every(Number.isFinite), "every degenerate beam-spread number is finite");
}
assertEqual(
JSON.stringify(measureBeamSpread(powers, { xMin: -2, xMax: 2, yMin: 7, yMax: 7, z: 3, columns: 5, rows: 1 }, 2)),
JSON.stringify(rowSpread),
"degenerate measurement is repeatable",
);
});
test("result exporter가 schema 허용 metric만 선택하고 내부 필드를 제거한다", () => {
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
@@ -154,6 +260,16 @@ test("result exporter가 schema 허용 metric만 선택하고 내부 필드를
assertEqual(exported.peaks[0].sourceId, "truth", "schema peak source match");
assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error");
assertEqual(exported.peaks[0].localizationErrorPixels, 0, "schema peak pixel error");
assertEqual(
JSON.stringify(exported.peaks[0].beamSpread),
JSON.stringify(evaluation.detections[0].beamSpread),
"schema peak retains beam-spread descriptor",
);
assert(exported.peaks[0].beamSpread !== evaluation.detections[0].beamSpread, "beam spread is copied");
assert(
exported.peaks[0].beamSpread.covarianceM2 !== evaluation.detections[0].beamSpread.covarianceM2,
"beam-spread covariance is copied",
);
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");