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
+18
View File
@@ -191,6 +191,22 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
value: 1,
localizationErrorM: 0.05,
localizationErrorPixels: 0.1,
beamSpread: {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
halfPowerRatio: 0.5,
thresholdValue: 0.5,
pixelCount: 3,
centroid: [0.9, -0.9, 2],
covarianceM2: { xx: 0.04, xy: 0.01, yy: 0.02 },
ellipse: {
axisScale: "rms",
angleReference: "counterclockwise-from-positive-x",
majorRmsRadiusM: 0.21,
minorRmsRadiusM: 0.12,
angleDeg: 22.5,
},
},
}],
metrics: {
peakToSidelobeDb: 10,
@@ -232,6 +248,8 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
"only finite schema metrics are exported",
);
assertEqual(result.bands[0].peaks[0].localizationErrorPixels, 0.1, "pixel error is exported");
assertEqual(result.bands[0].peaks[0].beamSpread.pixelCount, 3, "beam spread is exported");
assertEqual(result.bands[0].peaks[0].beamSpread.calibrated, false, "beam spread is not probability");
assertEqual(result.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported");
assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
+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");