feat: visualize beam spread around peaks
This commit is contained in:
@@ -51,6 +51,7 @@
|
|||||||
<div class="overlay-toolbar">
|
<div class="overlay-toolbar">
|
||||||
<div class="band-legend" id="band-legend" aria-label="표시 주파수 대역"></div>
|
<div class="band-legend" id="band-legend" aria-label="표시 주파수 대역"></div>
|
||||||
<label class="range-field"><span>Overlay</span><input id="overlay-opacity" type="range" min="0" max="1" step="0.05" value="0.72" /></label>
|
<label class="range-field"><span>Overlay</span><input id="overlay-opacity" type="range" min="0" max="1" step="0.05" value="0.72" /></label>
|
||||||
|
<label class="check-field" title="점선은 1× RMS, 실선 영역은 2× RMS 빔 분포입니다. 확률 또는 신뢰구간은 아닙니다."><input id="show-beam-spread" type="checkbox" checked /><span>빔 분포</span></label>
|
||||||
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
|
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const elements = {
|
|||||||
canvasBadge: byId("canvas-badge"),
|
canvasBadge: byId("canvas-badge"),
|
||||||
bandLegend: byId("band-legend"),
|
bandLegend: byId("band-legend"),
|
||||||
overlayOpacity: byId("overlay-opacity"),
|
overlayOpacity: byId("overlay-opacity"),
|
||||||
|
showBeamSpread: byId("show-beam-spread"),
|
||||||
showTruth: byId("show-truth"),
|
showTruth: byId("show-truth"),
|
||||||
resultBadge: byId("result-badge"),
|
resultBadge: byId("result-badge"),
|
||||||
emptyResult: byId("empty-result"),
|
emptyResult: byId("empty-result"),
|
||||||
@@ -430,6 +431,7 @@ function renderSceneVisualization() {
|
|||||||
evaluations: displayed?.evaluations,
|
evaluations: displayed?.evaluations,
|
||||||
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
||||||
opacity: Number(elements.overlayOpacity.value),
|
opacity: Number(elements.overlayOpacity.value),
|
||||||
|
showBeamSpread: elements.showBeamSpread.checked,
|
||||||
showTruth: elements.showTruth.checked,
|
showTruth: elements.showTruth.checked,
|
||||||
});
|
});
|
||||||
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
|
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
|
||||||
@@ -1156,6 +1158,7 @@ function installActionListeners() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
elements.overlayOpacity.addEventListener("input", renderSceneVisualization);
|
elements.overlayOpacity.addEventListener("input", renderSceneVisualization);
|
||||||
|
elements.showBeamSpread.addEventListener("change", renderSceneVisualization);
|
||||||
elements.showTruth.addEventListener("change", renderSceneVisualization);
|
elements.showTruth.addEventListener("change", renderSceneVisualization);
|
||||||
elements.exportSceneButton.addEventListener("click", async () => {
|
elements.exportSceneButton.addEventListener("click", async () => {
|
||||||
const issues = await validateScene(state.scene);
|
const issues = await validateScene(state.scene);
|
||||||
|
|||||||
@@ -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) {
|
function peakForResult(peak) {
|
||||||
const result = {
|
const result = {
|
||||||
position: peak.position.map(Number),
|
position: peak.position.map(Number),
|
||||||
value: Number(peak.value),
|
value: Number(peak.value),
|
||||||
};
|
};
|
||||||
if (peak.sourceId) result.sourceId = peak.sourceId;
|
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.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM;
|
||||||
if (Number.isFinite(peak.localizationErrorPixels)) {
|
if (Number.isFinite(peak.localizationErrorPixels)) {
|
||||||
result.localizationErrorPixels = peak.localizationErrorPixels;
|
result.localizationErrorPixels = peak.localizationErrorPixels;
|
||||||
|
|||||||
@@ -411,6 +411,74 @@ function peaksForBand(band, evaluations) {
|
|||||||
return Array.isArray(peaks) ? peaks : [];
|
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) {
|
function drawBandPeakMarkers(context, bands, evaluations, scan, plot) {
|
||||||
for (const band of bands) {
|
for (const band of bands) {
|
||||||
if (!band || band.visible === false) continue;
|
if (!band || band.visible === false) continue;
|
||||||
@@ -483,6 +551,7 @@ function normalizeSceneModel(model) {
|
|||||||
value.dynamicRangeDb ?? value.experiment?.beamforming?.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB,
|
value.dynamicRangeDb ?? value.experiment?.beamforming?.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB,
|
||||||
opacity: value.opacity ?? 0.78,
|
opacity: value.opacity ?? 0.78,
|
||||||
showTruth: value.showTruth ?? true,
|
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.
|
// 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.drawImage(heatmap.canvas, plot.x, plot.y, plot.width, plot.height);
|
||||||
context.restore();
|
context.restore();
|
||||||
|
if (model.showBeamSpread) drawBeamSpreadContours(context, bands, model.evaluations, scan, plot);
|
||||||
drawBandPeakMarkers(context, bands, model.evaluations, scan, plot);
|
drawBandPeakMarkers(context, bands, model.evaluations, scan, plot);
|
||||||
drawBandLegend(context, heatmap.activeBands, 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
|
* `render()` coalesces repeated updates into one animation frame. Use
|
||||||
* `renderNow()` only for deterministic capture/tests. Accepted model fields:
|
* `renderNow()` only for deterministic capture/tests. Accepted model fields:
|
||||||
* `{ experiment|scan, result|bands, scene|sources, image, imagePlane,
|
* `{ experiment|scan, result|bands, scene|sources, image, imagePlane,
|
||||||
* evaluations, dynamicRangeDb, opacity, showTruth }`.
|
* evaluations, dynamicRangeDb, opacity, showTruth, showBeamSpread }`.
|
||||||
*/
|
*/
|
||||||
export function createSceneVisualization(canvas) {
|
export function createSceneVisualization(canvas) {
|
||||||
if (!canvas) throw new TypeError("scene canvas가 필요합니다.");
|
if (!canvas) throw new TypeError("scene canvas가 필요합니다.");
|
||||||
|
|||||||
@@ -386,6 +386,129 @@ export function measureMainLobe(rawMap, scan, peakIndex, halfPowerRatio = 0.5) {
|
|||||||
return { peakIndex, thresholdValue, pixelCount: queueEnd, widthXM, widthYM, equivalentDiameterM };
|
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
|
* Evaluate one linear-power DAS band. Source z coordinates are projected onto
|
||||||
* scan.z, matching the image-plane ground-truth convention.
|
* scan.z, matching the image-plane ground-truth convention.
|
||||||
@@ -432,7 +555,12 @@ export function evaluateDasBand(options) {
|
|||||||
const localPeaks = findLocalPeaks(values, scan);
|
const localPeaks = findLocalPeaks(values, scan);
|
||||||
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
|
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
|
||||||
const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio;
|
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 { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
|
||||||
const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0;
|
const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0;
|
||||||
const recall = truths.length > 0 ? truePositiveCount / truths.length : 1;
|
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 peaks = (evaluation.detections ?? []).slice(0, maximumPeaks).map((peak) => {
|
||||||
const resultPeak = { position: [...peak.position], value: peak.value };
|
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);
|
const match = matchByPeakIndex.get(peak.index);
|
||||||
if (match) {
|
if (match) {
|
||||||
resultPeak.sourceId = match.sourceId;
|
resultPeak.sourceId = match.sourceId;
|
||||||
|
|||||||
@@ -66,10 +66,48 @@
|
|||||||
"sourceId": { "type": "string", "minLength": 1 },
|
"sourceId": { "type": "string", "minLength": 1 },
|
||||||
"position": { "$ref": "#/$defs/position" },
|
"position": { "$ref": "#/$defs/position" },
|
||||||
"value": { "type": "number", "minimum": 0 },
|
"value": { "type": "number", "minimum": 0 },
|
||||||
|
"beamSpread": { "$ref": "#/$defs/beamSpread" },
|
||||||
"localizationErrorM": { "type": "number", "minimum": 0 },
|
"localizationErrorM": { "type": "number", "minimum": 0 },
|
||||||
"localizationErrorPixels": { "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": {
|
"evaluation": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -191,6 +191,22 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
value: 1,
|
value: 1,
|
||||||
localizationErrorM: 0.05,
|
localizationErrorM: 0.05,
|
||||||
localizationErrorPixels: 0.1,
|
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: {
|
metrics: {
|
||||||
peakToSidelobeDb: 10,
|
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",
|
"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].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.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported");
|
||||||
assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
|
assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
|
||||||
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
|
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
evaluateDasBand,
|
evaluateDasBand,
|
||||||
exportBandEvaluation,
|
exportBandEvaluation,
|
||||||
findLocalPeaks,
|
findLocalPeaks,
|
||||||
|
measureBeamSpread,
|
||||||
measureMainLobe,
|
measureMainLobe,
|
||||||
selectResultMetrics,
|
selectResultMetrics,
|
||||||
} from "/packages/acoustics-core/evaluation.js";
|
} 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");
|
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만 선택하고 내부 필드를 제거한다", () => {
|
test("result exporter가 schema 허용 metric만 선택하고 내부 필드를 제거한다", () => {
|
||||||
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
|
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
|
||||||
const evaluation = evaluateDasBand({
|
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].sourceId, "truth", "schema peak source match");
|
||||||
assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error");
|
assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error");
|
||||||
assertEqual(exported.peaks[0].localizationErrorPixels, 0, "schema peak pixel 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(!("index" in exported.peaks[0]), "internal peak index omitted");
|
||||||
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
|
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
|
||||||
assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");
|
assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");
|
||||||
|
|||||||
Reference in New Issue
Block a user