feat: visualize beam spread around peaks
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
<div class="overlay-toolbar">
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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가 필요합니다.");
|
||||
|
||||
Reference in New Issue
Block a user