From b678d1f9c74987abe8f2cfa525fc291310a07216 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 15:48:02 +0900 Subject: [PATCH] feat: visualize beam spread around peaks --- apps/acoustic-camera/index.html | 1 + apps/acoustic-camera/main.js | 3 + apps/acoustic-camera/model.js | 26 +++++ apps/acoustic-camera/visualization.js | 72 ++++++++++++- packages/acoustics-core/evaluation.js | 142 +++++++++++++++++++++++++- schemas/result.schema.json | 38 +++++++ tests/unit/camera.test.js | 18 ++++ tests/unit/evaluation.test.js | 116 +++++++++++++++++++++ 8 files changed, 414 insertions(+), 2 deletions(-) diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index 6445aad..c81943d 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -51,6 +51,7 @@
+
diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index f2ef958..977d599 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -48,6 +48,7 @@ const elements = { canvasBadge: byId("canvas-badge"), bandLegend: byId("band-legend"), overlayOpacity: byId("overlay-opacity"), + showBeamSpread: byId("show-beam-spread"), showTruth: byId("show-truth"), resultBadge: byId("result-badge"), emptyResult: byId("empty-result"), @@ -430,6 +431,7 @@ function renderSceneVisualization() { evaluations: displayed?.evaluations, dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, opacity: Number(elements.overlayOpacity.value), + showBeamSpread: elements.showBeamSpread.checked, showTruth: elements.showTruth.checked, }); elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement)); @@ -1156,6 +1158,7 @@ function installActionListeners() { } }); elements.overlayOpacity.addEventListener("input", renderSceneVisualization); + elements.showBeamSpread.addEventListener("change", renderSceneVisualization); elements.showTruth.addEventListener("change", renderSceneVisualization); elements.exportSceneButton.addEventListener("click", async () => { const issues = await validateScene(state.scene); diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js index 2102db4..498d048 100644 --- a/apps/acoustic-camera/model.js +++ b/apps/acoustic-camera/model.js @@ -121,12 +121,38 @@ function finiteMetricSubset(metrics = {}) { ); } +function beamSpreadForResult(spread) { + if (!spread || typeof spread !== "object") return null; + return { + calibrated: false, + method: spread.method, + halfPowerRatio: Number(spread.halfPowerRatio), + thresholdValue: Number(spread.thresholdValue), + pixelCount: Number(spread.pixelCount), + centroid: spread.centroid.map(Number), + covarianceM2: { + xx: Number(spread.covarianceM2.xx), + xy: Number(spread.covarianceM2.xy), + yy: Number(spread.covarianceM2.yy), + }, + ellipse: { + axisScale: spread.ellipse.axisScale, + angleReference: spread.ellipse.angleReference, + majorRmsRadiusM: Number(spread.ellipse.majorRmsRadiusM), + minorRmsRadiusM: Number(spread.ellipse.minorRmsRadiusM), + angleDeg: Number(spread.ellipse.angleDeg), + }, + }; +} + function peakForResult(peak) { const result = { position: peak.position.map(Number), value: Number(peak.value), }; if (peak.sourceId) result.sourceId = peak.sourceId; + const beamSpread = beamSpreadForResult(peak.beamSpread); + if (beamSpread) result.beamSpread = beamSpread; if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM; if (Number.isFinite(peak.localizationErrorPixels)) { result.localizationErrorPixels = peak.localizationErrorPixels; diff --git a/apps/acoustic-camera/visualization.js b/apps/acoustic-camera/visualization.js index eae6630..601040f 100644 --- a/apps/acoustic-camera/visualization.js +++ b/apps/acoustic-camera/visualization.js @@ -411,6 +411,74 @@ function peaksForBand(band, evaluations) { return Array.isArray(peaks) ? peaks : []; } +function traceBeamSpreadEllipse(context, spread, scan, plot, radiusScale) { + const centroid = spread?.centroid; + const ellipse = spread?.ellipse; + if ( + !Array.isArray(centroid) || + !finiteNumber(centroid[0]) || + !finiteNumber(centroid[1]) || + !finiteNumber(ellipse?.majorRmsRadiusM) || + !finiteNumber(ellipse?.minorRmsRadiusM) || + !finiteNumber(ellipse?.angleDeg) || + ellipse.majorRmsRadiusM < 0 || + ellipse.minorRmsRadiusM < 0 + ) { + return false; + } + const major = ellipse.majorRmsRadiusM * radiusScale; + const minor = ellipse.minorRmsRadiusM * radiusScale; + if (!(major > 0) && !(minor > 0)) return false; + const angle = (ellipse.angleDeg * Math.PI) / 180; + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + const segmentCount = 64; + context.beginPath(); + for (let segment = 0; segment <= segmentCount; segment += 1) { + const phase = (segment / segmentCount) * Math.PI * 2; + const alongMajor = major * Math.cos(phase); + const alongMinor = minor * Math.sin(phase); + const position = [ + centroid[0] + alongMajor * cosine - alongMinor * sine, + centroid[1] + alongMajor * sine + alongMinor * cosine, + ]; + const point = scanPositionToCanvas(position, scan, plot); + if (segment === 0) context.moveTo(point.x, point.y); + else context.lineTo(point.x, point.y); + } + context.closePath(); + return true; +} + +function drawBeamSpreadContours(context, bands, evaluations, scan, plot) { + context.save(); + context.beginPath(); + context.rect(plot.x, plot.y, plot.width, plot.height); + context.clip(); + for (const band of bands) { + if (!band || band.visible === false) continue; + const color = /^#[0-9a-f]{6}$/i.test(band.color ?? "") ? band.color : "#65f2bf"; + for (const peak of peaksForBand(band, evaluations).slice(0, 128)) { + const spread = peak?.beamSpread; + if (!spread || spread.calibrated !== false) continue; + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = 1.2; + context.setLineDash([]); + if (traceBeamSpreadEllipse(context, spread, scan, plot, 2)) { + context.globalAlpha = 0.07; + context.fill(); + context.globalAlpha = 0.45; + context.stroke(); + } + context.setLineDash([4, 4]); + context.globalAlpha = 0.78; + if (traceBeamSpreadEllipse(context, spread, scan, plot, 1)) context.stroke(); + } + } + context.restore(); +} + function drawBandPeakMarkers(context, bands, evaluations, scan, plot) { for (const band of bands) { if (!band || band.visible === false) continue; @@ -483,6 +551,7 @@ function normalizeSceneModel(model) { value.dynamicRangeDb ?? value.experiment?.beamforming?.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB, opacity: value.opacity ?? 0.78, showTruth: value.showTruth ?? true, + showBeamSpread: value.showBeamSpread ?? true, }; } @@ -518,6 +587,7 @@ function drawScene(context, size, sourceModel, canvas) { // Map storage is row-major from yMax to yMin, which is the Canvas top-to-bottom order. context.drawImage(heatmap.canvas, plot.x, plot.y, plot.width, plot.height); context.restore(); + if (model.showBeamSpread) drawBeamSpreadContours(context, bands, model.evaluations, scan, plot); drawBandPeakMarkers(context, bands, model.evaluations, scan, plot); drawBandLegend(context, heatmap.activeBands, plot); } @@ -530,7 +600,7 @@ function drawScene(context, size, sourceModel, canvas) { * `render()` coalesces repeated updates into one animation frame. Use * `renderNow()` only for deterministic capture/tests. Accepted model fields: * `{ experiment|scan, result|bands, scene|sources, image, imagePlane, - * evaluations, dynamicRangeDb, opacity, showTruth }`. + * evaluations, dynamicRangeDb, opacity, showTruth, showBeamSpread }`. */ export function createSceneVisualization(canvas) { if (!canvas) throw new TypeError("scene canvas가 필요합니다."); diff --git a/packages/acoustics-core/evaluation.js b/packages/acoustics-core/evaluation.js index ef7beca..a7d651b 100644 --- a/packages/acoustics-core/evaluation.js +++ b/packages/acoustics-core/evaluation.js @@ -386,6 +386,129 @@ export function measureMainLobe(rawMap, scan, peakIndex, halfPowerRatio = 0.5) { return { peakIndex, thresholdValue, pixelCount: queueEnd, widthXM, widthYM, equivalentDiameterM }; } +function collectHalfPowerIndices(values, scan, peakIndex, halfPowerRatio) { + const peakValue = values[peakIndex]; + if (!(peakValue > 0)) return { indices: new Int32Array(0), thresholdValue: 0 }; + + const thresholdValue = peakValue * halfPowerRatio; + const visited = new Uint8Array(values.length); + const queue = new Int32Array(values.length); + let queueStart = 0; + let queueEnd = 1; + queue[0] = peakIndex; + visited[peakIndex] = 1; + while (queueStart < queueEnd) { + const index = queue[queueStart]; + queueStart += 1; + const row = Math.floor(index / scan.columns); + const column = index % scan.columns; + for (let neighborRow = Math.max(0, row - 1); neighborRow <= Math.min(scan.rows - 1, row + 1); neighborRow += 1) { + for ( + let neighborColumn = Math.max(0, column - 1); + neighborColumn <= Math.min(scan.columns - 1, column + 1); + neighborColumn += 1 + ) { + const neighborIndex = neighborRow * scan.columns + neighborColumn; + if (!visited[neighborIndex] && values[neighborIndex] >= thresholdValue) { + visited[neighborIndex] = 1; + queue[queueEnd] = neighborIndex; + queueEnd += 1; + } + } + } + } + return { indices: queue.subarray(0, queueEnd), thresholdValue }; +} + +function measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio) { + const component = collectHalfPowerIndices(values, scan, peakIndex, halfPowerRatio); + const peakPosition = scanIndexToPosition(peakIndex, scan); + const base = { + calibrated: false, + method: "connected-half-power-power-weighted-covariance", + halfPowerRatio, + thresholdValue: component.thresholdValue, + pixelCount: component.indices.length, + centroid: [...peakPosition], + covarianceM2: { xx: 0, xy: 0, yy: 0 }, + ellipse: { + axisScale: "rms", + angleReference: "counterclockwise-from-positive-x", + majorRmsRadiusM: 0, + minorRmsRadiusM: 0, + angleDeg: 0, + }, + }; + if (component.indices.length === 0) return base; + + // Normalizing by the component maximum changes no weighted moments and + // prevents a sum of otherwise finite power values from overflowing. + let maximum = 0; + for (const index of component.indices) maximum = Math.max(maximum, values[index]); + let totalWeight = 0; + let weightedX = 0; + let weightedY = 0; + for (const index of component.indices) { + const weight = values[index] / maximum; + const position = scanIndexToPosition(index, scan); + totalWeight += weight; + weightedX += weight * position[0]; + weightedY += weight * position[1]; + } + const centroidX = scan.columns === 1 ? scan.xMin : weightedX / totalWeight; + const centroidY = scan.rows === 1 ? scan.yMax : weightedY / totalWeight; + let xx = 0; + let xy = 0; + let yy = 0; + for (const index of component.indices) { + const weight = values[index] / maximum; + const position = scanIndexToPosition(index, scan); + const x = position[0] - centroidX; + const y = position[1] - centroidY; + xx += weight * x * x; + xy += weight * x * y; + yy += weight * y * y; + } + xx = scan.columns === 1 ? 0 : Math.max(0, xx / totalWeight); + xy = scan.columns === 1 || scan.rows === 1 ? 0 : xy / totalWeight; + yy = scan.rows === 1 ? 0 : Math.max(0, yy / totalWeight); + + const trace = xx + yy; + const discriminant = Math.hypot(xx - yy, 2 * xy); + const majorVariance = Math.max(0, (trace + discriminant) / 2); + const minorVariance = Math.max(0, (trace - discriminant) / 2); + const covarianceScale = Math.max(Math.abs(xx), Math.abs(xy), Math.abs(yy)); + const directionDefined = covarianceScale > 0 && discriminant > Number.EPSILON * covarianceScale * 8; + let angleDeg = directionDefined ? (Math.atan2(2 * xy, xx - yy) * 90) / Math.PI : 0; + if (Object.is(angleDeg, -0)) angleDeg = 0; + + return { + ...base, + centroid: [centroidX, centroidY, scan.z], + covarianceM2: { xx, xy, yy }, + ellipse: { + ...base.ellipse, + majorRmsRadiusM: Math.sqrt(majorVariance), + minorRmsRadiusM: Math.sqrt(minorVariance), + angleDeg, + }, + }; +} + +/** + * Fit a deterministic, power-weighted covariance ellipse to the connected + * half-power lobe. This describes beam spread, not probability or confidence. + */ +export function measureBeamSpread(rawMap, scan, peakIndex, halfPowerRatio = 0.5) { + validateScan(scan); + const values = normalizeRawMap(rawMap, scan); + if (!Number.isInteger(peakIndex) || peakIndex < 0 || peakIndex >= values.length) { + throw new RangeError("peakIndex is outside rawMap."); + } + requireRatio("halfPowerRatio", halfPowerRatio, { positive: true }); + return measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio); +} + /** * Evaluate one linear-power DAS band. Source z coordinates are projected onto * scan.z, matching the image-plane ground-truth convention. @@ -432,7 +555,12 @@ export function evaluateDasBand(options) { const localPeaks = findLocalPeaks(values, scan); const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null; const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio; - const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections); + const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections).map( + (detection) => ({ + ...detection, + beamSpread: measureBeamSpreadFromValues(values, scan, detection.index, halfPowerRatio), + }), + ); 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; @@ -513,6 +641,18 @@ export function exportBandEvaluation(evaluation, options = {}) { } const peaks = (evaluation.detections ?? []).slice(0, maximumPeaks).map((peak) => { const resultPeak = { position: [...peak.position], value: peak.value }; + if (peak.beamSpread) { + resultPeak.beamSpread = { + calibrated: peak.beamSpread.calibrated, + method: peak.beamSpread.method, + halfPowerRatio: peak.beamSpread.halfPowerRatio, + thresholdValue: peak.beamSpread.thresholdValue, + pixelCount: peak.beamSpread.pixelCount, + centroid: [...peak.beamSpread.centroid], + covarianceM2: { ...peak.beamSpread.covarianceM2 }, + ellipse: { ...peak.beamSpread.ellipse }, + }; + } const match = matchByPeakIndex.get(peak.index); if (match) { resultPeak.sourceId = match.sourceId; diff --git a/schemas/result.schema.json b/schemas/result.schema.json index 71c469c..9f1ca87 100644 --- a/schemas/result.schema.json +++ b/schemas/result.schema.json @@ -66,10 +66,48 @@ "sourceId": { "type": "string", "minLength": 1 }, "position": { "$ref": "#/$defs/position" }, "value": { "type": "number", "minimum": 0 }, + "beamSpread": { "$ref": "#/$defs/beamSpread" }, "localizationErrorM": { "type": "number", "minimum": 0 }, "localizationErrorPixels": { "type": "number", "minimum": 0 } } }, + "beamSpread": { + "type": "object", "additionalProperties": false, + "required": [ + "calibrated", "method", "halfPowerRatio", "thresholdValue", "pixelCount", + "centroid", "covarianceM2", "ellipse" + ], + "properties": { + "calibrated": { "const": false }, + "method": { "const": "connected-half-power-power-weighted-covariance" }, + "halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, + "thresholdValue": { "type": "number", "minimum": 0 }, + "pixelCount": { "type": "integer", "minimum": 0, "maximum": 262144 }, + "centroid": { "$ref": "#/$defs/position" }, + "covarianceM2": { + "type": "object", "additionalProperties": false, + "required": ["xx", "xy", "yy"], + "properties": { + "xx": { "type": "number", "minimum": 0 }, + "xy": { "type": "number" }, + "yy": { "type": "number", "minimum": 0 } + } + }, + "ellipse": { + "type": "object", "additionalProperties": false, + "required": [ + "axisScale", "angleReference", "majorRmsRadiusM", "minorRmsRadiusM", "angleDeg" + ], + "properties": { + "axisScale": { "const": "rms" }, + "angleReference": { "const": "counterclockwise-from-positive-x" }, + "majorRmsRadiusM": { "type": "number", "minimum": 0 }, + "minorRmsRadiusM": { "type": "number", "minimum": 0 }, + "angleDeg": { "type": "number", "minimum": -180, "maximum": 180 } + } + } + } + }, "evaluation": { "type": "object", "additionalProperties": false, "required": [ diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js index ba25aee..8f9492b 100644 --- a/tests/unit/camera.test.js +++ b/tests/unit/camera.test.js @@ -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"); diff --git a/tests/unit/evaluation.test.js b/tests/unit/evaluation.test.js index 3d415f2..fab76ea 100644 --- a/tests/unit/evaluation.test.js +++ b/tests/unit/evaluation.test.js @@ -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");