feat: compare localization algorithms in camera
This commit is contained in:
@@ -89,6 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="quick-field-grid">
|
<div class="quick-field-grid">
|
||||||
|
<label class="field field--wide"><span>위치 추정 알고리즘</span><select id="algorithm"><option value="das">DAS · 기준 power</option><option value="das-phat">DAS-PHAT · 위상 기반</option><option value="gcc-phat">GCC-PHAT · TDOA 기반 / CPU</option></select></label>
|
||||||
<label class="field"><span>Backend</span><select id="backend"><option value="auto">Auto (GPU→CPU)</option><option value="webgpu">WebGPU 강제</option><option value="cpu">CPU 강제</option></select></label>
|
<label class="field"><span>Backend</span><select id="backend"><option value="auto">Auto (GPU→CPU)</option><option value="webgpu">WebGPU 강제</option><option value="cpu">CPU 강제</option></select></label>
|
||||||
<label class="field"><span>Grid preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
|
<label class="field"><span>Grid preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
|
||||||
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
|
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
|
||||||
@@ -134,7 +135,6 @@
|
|||||||
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
|
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
|
||||||
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
|
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
|
||||||
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
|
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
|
||||||
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
<div class="inspect-heading">
|
<div class="inspect-heading">
|
||||||
<div class="step-heading step-heading--horizontal">
|
<div class="step-heading step-heading--horizontal">
|
||||||
<span class="step-number">4</span>
|
<span class="step-number">4</span>
|
||||||
<div><h2 id="inspect-title">Map과 결과 확인</h2><p>Canvas가 실제 음향 에너지와 추정 위치를 표시합니다.</p></div>
|
<div><h2 id="inspect-title">Map과 결과 확인</h2><p>Canvas가 위치추정 응답과 추정 위치를 표시합니다.</p></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="result-chip" id="result-badge">아직 실행하지 않음</span>
|
<span class="result-chip" id="result-badge">아직 실행하지 않음</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,30 +219,30 @@
|
|||||||
<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" 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>
|
||||||
|
|
||||||
<section class="result-section" aria-live="polite">
|
<section class="result-section" aria-live="polite">
|
||||||
<div class="compact-section-heading"><h3>측정 요약</h3><span>peak · 오차 · beam</span></div>
|
<div class="compact-section-heading"><h3>측정 요약</h3><span>peak · 오차 · beam</span></div>
|
||||||
<div class="empty-result" id="empty-result">실행 후 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
|
<div class="empty-result" id="empty-result">실행 후 위치 오차, truth concentration, sidelobe와 timing을 표시합니다.</div>
|
||||||
<div class="metric-grid is-hidden" id="metric-grid"></div>
|
<div class="metric-grid is-hidden" id="metric-grid"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<details class="history-section utility-details" id="comparison-history">
|
<details class="history-section utility-details" id="comparison-history">
|
||||||
<summary><span>배열 비교 기록</span><small>같은 설정의 재실행 결과</small></summary>
|
<summary><span>비교 기록</span><small>배열 · 알고리즘 재실행 결과</small></summary>
|
||||||
<div class="history-body">
|
<div class="history-body">
|
||||||
<div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
|
<div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
|
||||||
<p class="section-copy">배열을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 비교합니다.</p>
|
<p class="section-copy">같은 scene·scan·STFT에서 배열이나 알고리즘을 바꾼 결과를 비교합니다.</p>
|
||||||
<div class="history-list" id="history-list"></div>
|
<div class="history-list" id="history-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="help-panel utility-details" id="camera-help">
|
<details class="help-panel utility-details" id="camera-help">
|
||||||
<summary><span>표시 해석과 한계</span><small>DAS · beam spread · live</small></summary>
|
<summary><span>표시 해석과 한계</span><small>localization · beam spread · live</small></summary>
|
||||||
<div class="help-grid">
|
<div class="help-grid">
|
||||||
<p><strong>Heatmap</strong>DAS 공간 power이며 위치 확률분포가 아닙니다.</p>
|
<p><strong>Heatmap</strong>DAS는 power, PHAT 계열은 무차원 score이며 위치 확률분포가 아닙니다.</p>
|
||||||
<p><strong>빔 분포</strong>Half-power covariance 폭이며 신뢰구간이 아닙니다.</p>
|
<p><strong>빔 분포</strong>Half-power 또는 half-maximum 응답 폭이며 신뢰구간이 아닙니다.</p>
|
||||||
<p><strong>이동 재생</strong>준정적 frame 시뮬레이션이며 실제 장치 스트림이 아닙니다.</p>
|
<p><strong>이동 재생</strong>준정적 frame 시뮬레이션이며 실제 장치 스트림이 아닙니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+124
-34
@@ -1,5 +1,10 @@
|
|||||||
import { evaluateDasBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
|
import { evaluateLocalizationBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
|
||||||
import { DEFAULT_MAX_COMPENSATION_DB } from "/packages/acoustics-core/frequency-response.js";
|
import { DEFAULT_MAX_COMPENSATION_DB } from "/packages/acoustics-core/frequency-response.js";
|
||||||
|
import {
|
||||||
|
localizationAlgorithmDefinition,
|
||||||
|
localizationAlgorithmLabel,
|
||||||
|
localizationAlgorithmSupportsBackend,
|
||||||
|
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||||
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
||||||
import {
|
import {
|
||||||
validateArrayDefinition,
|
validateArrayDefinition,
|
||||||
@@ -21,7 +26,7 @@ import {
|
|||||||
} from "./timeline.js";
|
} from "./timeline.js";
|
||||||
import {
|
import {
|
||||||
alignExperimentToScene,
|
alignExperimentToScene,
|
||||||
canonicalStringify,
|
createComparisonSignature,
|
||||||
createHistorySummary,
|
createHistorySummary,
|
||||||
createResultDocument,
|
createResultDocument,
|
||||||
estimateExperimentWorkload,
|
estimateExperimentWorkload,
|
||||||
@@ -105,6 +110,7 @@ const elements = {
|
|||||||
acousticProfile: byId("acoustic-profile"),
|
acousticProfile: byId("acoustic-profile"),
|
||||||
acousticEnvironmentSummary: byId("acoustic-environment-summary"),
|
acousticEnvironmentSummary: byId("acoustic-environment-summary"),
|
||||||
frequencyResponseMode: byId("frequency-response-mode"),
|
frequencyResponseMode: byId("frequency-response-mode"),
|
||||||
|
algorithm: byId("algorithm"),
|
||||||
backend: byId("backend"),
|
backend: byId("backend"),
|
||||||
gridPreset: byId("grid-preset"),
|
gridPreset: byId("grid-preset"),
|
||||||
scanColumns: byId("scan-columns"),
|
scanColumns: byId("scan-columns"),
|
||||||
@@ -119,7 +125,6 @@ const elements = {
|
|||||||
normalization: byId("normalization"),
|
normalization: byId("normalization"),
|
||||||
dynamicRange: byId("dynamic-range"),
|
dynamicRange: byId("dynamic-range"),
|
||||||
detectionThreshold: byId("detection-threshold"),
|
detectionThreshold: byId("detection-threshold"),
|
||||||
postprocess: byId("postprocess"),
|
|
||||||
addBandButton: byId("add-band-button"),
|
addBandButton: byId("add-band-button"),
|
||||||
bandList: byId("band-list"),
|
bandList: byId("band-list"),
|
||||||
exportSceneButton: byId("export-scene-button"),
|
exportSceneButton: byId("export-scene-button"),
|
||||||
@@ -179,10 +184,7 @@ function formatIssue(issue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) {
|
function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) {
|
||||||
if (!scene || !sourceExperiment) return "";
|
return createComparisonSignature(scene, sourceExperiment);
|
||||||
const experiment = structuredClone(sourceExperiment);
|
|
||||||
experiment.arrayId = "$array";
|
|
||||||
return canonicalStringify({ scene, experiment });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyExperimentDefaults(experiment) {
|
function applyExperimentDefaults(experiment) {
|
||||||
@@ -294,6 +296,9 @@ function renderLiveControls() {
|
|||||||
if (preparing) liveState = "preparing";
|
if (preparing) liveState = "preparing";
|
||||||
document.documentElement.dataset.liveState = liveState;
|
document.documentElement.dataset.liveState = liveState;
|
||||||
document.documentElement.dataset.liveFrameCount = ready ? String(state.live.frames.length) : "0";
|
document.documentElement.dataset.liveFrameCount = ready ? String(state.live.frames.length) : "0";
|
||||||
|
document.documentElement.dataset.livePlanAnalyses = ready
|
||||||
|
? String(state.live.frames.at(-1)?.run.analysisCache?.completedAnalyses ?? 0)
|
||||||
|
: "0";
|
||||||
document.documentElement.dataset.liveTime = ready ? String(state.live.time) : "0";
|
document.documentElement.dataset.liveTime = ready ? String(state.live.time) : "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +370,21 @@ function markChanged(options = {}) {
|
|||||||
scheduleValidation();
|
scheduleValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncAlgorithmBackendControls() {
|
||||||
|
const algorithm = state.experiment?.beamforming?.algorithm;
|
||||||
|
const definition = localizationAlgorithmDefinition(algorithm);
|
||||||
|
const webGpuOption = elements.backend.querySelector('option[value="webgpu"]');
|
||||||
|
const autoOption = elements.backend.querySelector('option[value="auto"]');
|
||||||
|
const supportsWebGpu = definition?.backends.includes("webgpu") ?? true;
|
||||||
|
webGpuOption.disabled = !supportsWebGpu;
|
||||||
|
webGpuOption.title = supportsWebGpu ? "" : `${definition?.label ?? algorithm}는 현재 CPU에서만 실행됩니다.`;
|
||||||
|
autoOption.textContent = supportsWebGpu ? "Auto (GPU→CPU)" : "Auto (CPU)";
|
||||||
|
elements.backend.title = supportsWebGpu
|
||||||
|
? "지원되는 GPU를 우선 사용하고 필요하면 CPU로 전환합니다."
|
||||||
|
: `${definition?.label ?? algorithm}는 Auto와 CPU만 지원합니다.`;
|
||||||
|
document.documentElement.dataset.localizationAlgorithm = algorithm ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function syncControls() {
|
function syncControls() {
|
||||||
const { scene, experiment } = state;
|
const { scene, experiment } = state;
|
||||||
if (!scene || !experiment) return;
|
if (!scene || !experiment) return;
|
||||||
@@ -376,7 +396,9 @@ function syncControls() {
|
|||||||
elements.acousticProfile.value = detectAcousticProfile(scene);
|
elements.acousticProfile.value = detectAcousticProfile(scene);
|
||||||
elements.frequencyResponseMode.value =
|
elements.frequencyResponseMode.value =
|
||||||
experiment.beamforming.compensateFrequencyResponse === false ? "raw" : "compensate";
|
experiment.beamforming.compensateFrequencyResponse === false ? "raw" : "compensate";
|
||||||
|
elements.algorithm.value = experiment.beamforming.algorithm;
|
||||||
elements.backend.value = experiment.beamforming.backend;
|
elements.backend.value = experiment.beamforming.backend;
|
||||||
|
syncAlgorithmBackendControls();
|
||||||
elements.scanColumns.value = experiment.scan.columns;
|
elements.scanColumns.value = experiment.scan.columns;
|
||||||
elements.scanRows.value = experiment.scan.rows;
|
elements.scanRows.value = experiment.scan.rows;
|
||||||
elements.scanXMin.value = experiment.scan.xMin;
|
elements.scanXMin.value = experiment.scan.xMin;
|
||||||
@@ -550,15 +572,25 @@ function renderWorkload() {
|
|||||||
const maximumPaths = maximumSparsePathsPerReceiver(state.scene);
|
const maximumPaths = maximumSparsePathsPerReceiver(state.scene);
|
||||||
const signalPathBudget =
|
const signalPathBudget =
|
||||||
workload.microphoneCount * state.scene.sources.length * maximumPaths;
|
workload.microphoneCount * state.scene.sources.length * maximumPaths;
|
||||||
|
const scanCost = workload.algorithm === "gcc-phat"
|
||||||
|
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
|
||||||
|
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
|
||||||
|
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
|
||||||
|
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
|
||||||
|
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
|
||||||
elements.workloadSummary.textContent =
|
elements.workloadSummary.textContent =
|
||||||
`${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` +
|
`${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` +
|
||||||
`${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` +
|
`${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` +
|
||||||
`약 ${formatCount(workload.complexMacCount)} complex MAC · ` +
|
`${scanCost} · signal ≤ ${formatCount(signalPathBudget)} paths · ` +
|
||||||
`signal ≤ ${formatCount(signalPathBudget)} paths · steering ${formatNumber(workload.distanceMiB, 1)} MiB`;
|
`distance cache ${formatNumber(workload.distanceMiB, 1)} MiB`;
|
||||||
const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8;
|
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
|
||||||
|
const heavyCpu =
|
||||||
|
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
|
||||||
|
workload.scanOperationCount > 5e8;
|
||||||
|
const narrowGcc = workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8;
|
||||||
elements.workloadSummary.classList.toggle(
|
elements.workloadSummary.classList.toggle(
|
||||||
"is-warning",
|
"is-warning",
|
||||||
heavyCpu || workload.pointCount >= 40000 || signalPathBudget > 100000,
|
heavyCpu || narrowGcc || workload.pointCount >= 40000 || signalPathBudget > 100000,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`;
|
elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`;
|
||||||
@@ -630,8 +662,11 @@ function renderSceneVisualization() {
|
|||||||
showTruth: elements.showTruth.checked,
|
showTruth: elements.showTruth.checked,
|
||||||
});
|
});
|
||||||
elements.canvasMessage.classList.add("is-hidden");
|
elements.canvasMessage.classList.add("is-hidden");
|
||||||
|
const algorithmLabel = localizationAlgorithmLabel(
|
||||||
|
live.frames[0]?.run.algorithm ?? live.snapshot.experiment.beamforming.algorithm,
|
||||||
|
);
|
||||||
elements.canvasBadge.textContent =
|
elements.canvasBadge.textContent =
|
||||||
`LIVE · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
|
`LIVE · ${algorithmLabel} · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
|
||||||
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}`;
|
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -652,9 +687,12 @@ function renderSceneVisualization() {
|
|||||||
if (!displayed) {
|
if (!displayed) {
|
||||||
elements.canvasBadge.textContent = "결과 없음";
|
elements.canvasBadge.textContent = "결과 없음";
|
||||||
} else if (displayed.stale) {
|
} else if (displayed.stale) {
|
||||||
elements.canvasBadge.textContent = `이전 설정 결과 · ${displayed.run.backend.toUpperCase()}`;
|
elements.canvasBadge.textContent =
|
||||||
|
`이전 설정 결과 · ${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
|
||||||
|
displayed.run.backend.toUpperCase();
|
||||||
} else {
|
} else {
|
||||||
elements.canvasBadge.textContent =
|
elements.canvasBadge.textContent =
|
||||||
|
`${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
|
||||||
`${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` +
|
`${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` +
|
||||||
`${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`;
|
`${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`;
|
||||||
}
|
}
|
||||||
@@ -684,15 +722,19 @@ function renderResult() {
|
|||||||
elements.resultBadge.textContent = "아직 실행하지 않음";
|
elements.resultBadge.textContent = "아직 실행하지 않음";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const algorithm = displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm;
|
||||||
|
const algorithmLabel = localizationAlgorithmLabel(algorithm);
|
||||||
elements.resultBadge.textContent = displayed.stale
|
elements.resultBadge.textContent = displayed.stale
|
||||||
? `이전 설정 · ${displayed.run.backend.toUpperCase()}`
|
? `이전 설정 · ${algorithmLabel} · ${displayed.run.backend.toUpperCase()}`
|
||||||
: `${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`;
|
: `${algorithmLabel} · ${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`;
|
||||||
appendMetric(
|
appendMetric(
|
||||||
"Backend",
|
"위치 추정",
|
||||||
displayed.run.backend.toUpperCase(),
|
algorithmLabel,
|
||||||
displayed.run.fallback ? "WebGPU → CPU fallback" : "요청한 계산 경로",
|
displayed.run.fallback
|
||||||
|
? `실제 ${displayed.run.backend.toUpperCase()} · WebGPU fallback`
|
||||||
|
: `실제 ${displayed.run.backend.toUpperCase()} backend`,
|
||||||
);
|
);
|
||||||
appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + DAS");
|
appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + localization");
|
||||||
appendMetric(
|
appendMetric(
|
||||||
"신호 합성",
|
"신호 합성",
|
||||||
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
|
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
|
||||||
@@ -708,8 +750,8 @@ function renderResult() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
appendMetric(
|
appendMetric(
|
||||||
"Beamforming",
|
"위치 추정 시간",
|
||||||
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
|
`${formatNumber(displayed.run.timingMs.localization ?? displayed.run.timingMs.beamforming, 0)} ms`,
|
||||||
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`,
|
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`,
|
||||||
);
|
);
|
||||||
const detectionThreshold =
|
const detectionThreshold =
|
||||||
@@ -718,7 +760,7 @@ function renderResult() {
|
|||||||
appendMetric(
|
appendMetric(
|
||||||
"Peak 검출 임계값",
|
"Peak 검출 임계값",
|
||||||
`${formatNumber(detectionThreshold * 100, 0)}%`,
|
`${formatNumber(detectionThreshold * 100, 0)}%`,
|
||||||
"최강 peak power 기준 · 낮을수록 민감",
|
"최강 map 값 기준 · 낮을수록 민감",
|
||||||
);
|
);
|
||||||
for (const band of displayed.snapshot.experiment.frequencyBands) {
|
for (const band of displayed.snapshot.experiment.frequencyBands) {
|
||||||
const evaluation = displayed.evaluations.get(band.id);
|
const evaluation = displayed.evaluations.get(band.id);
|
||||||
@@ -734,7 +776,7 @@ function renderResult() {
|
|||||||
band.color,
|
band.color,
|
||||||
);
|
);
|
||||||
appendMetric(
|
appendMetric(
|
||||||
`${band.id} · Truth energy`,
|
`${band.id} · Truth concentration`,
|
||||||
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
|
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
|
||||||
"ground-truth 반경 내",
|
"ground-truth 반경 내",
|
||||||
band.color,
|
band.color,
|
||||||
@@ -754,7 +796,9 @@ function renderResult() {
|
|||||||
appendMetric(
|
appendMetric(
|
||||||
`${band.id} · Main lobe`,
|
`${band.id} · Main lobe`,
|
||||||
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
|
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
|
||||||
"half-power equivalent diameter",
|
evaluation.diagnostics.mapScale === "score"
|
||||||
|
? "half-maximum score diameter"
|
||||||
|
: "half-power equivalent diameter",
|
||||||
band.color,
|
band.color,
|
||||||
);
|
);
|
||||||
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
|
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
|
||||||
@@ -786,10 +830,10 @@ function renderHistory() {
|
|||||||
const card = document.createElement("article");
|
const card = document.createElement("article");
|
||||||
card.className = "history-card";
|
card.className = "history-card";
|
||||||
const name = document.createElement("strong");
|
const name = document.createElement("strong");
|
||||||
name.textContent = item.arrayName;
|
name.textContent = `${localizationAlgorithmLabel(item.algorithm)} · ${item.arrayName}`;
|
||||||
const fields = [
|
const fields = [
|
||||||
`${item.backend.toUpperCase()} · ${item.pointCount.toLocaleString("ko-KR")} pt`,
|
`${item.backend.toUpperCase()} · ${item.pointCount.toLocaleString("ko-KR")} pt`,
|
||||||
`${formatNumber(item.totalMs, 0)} ms`,
|
`위치 ${formatNumber(item.localizationMs, 0)} / 전체 ${formatNumber(item.totalMs, 0)} ms`,
|
||||||
item.localizationErrorM === null ? "오차 —" : `오차 ${formatNumber(item.localizationErrorM * 100, 1)} cm`,
|
item.localizationErrorM === null ? "오차 —" : `오차 ${formatNumber(item.localizationErrorM * 100, 1)} cm`,
|
||||||
item.peakToSidelobeDb === null ? "PSR —" : `PSR ${formatNumber(item.peakToSidelobeDb, 1)} dB`,
|
item.peakToSidelobeDb === null ? "PSR —" : `PSR ${formatNumber(item.peakToSidelobeDb, 1)} dB`,
|
||||||
];
|
];
|
||||||
@@ -822,8 +866,19 @@ function validationWarnings() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
|
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
|
||||||
if (state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8) {
|
if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8) {
|
||||||
warnings.push("CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다.");
|
warnings.push("GCC-PHAT는 안정적인 광대역 상관을 위해 선택 FFT bin을 8개 이상 권장합니다.");
|
||||||
|
}
|
||||||
|
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
|
||||||
|
if (
|
||||||
|
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
|
||||||
|
workload.scanOperationCount > 5e8
|
||||||
|
) {
|
||||||
|
warnings.push(
|
||||||
|
cpuOnly
|
||||||
|
? "CPU 계산량이 큽니다. 더 작은 grid 또는 더 적은 band를 권장합니다."
|
||||||
|
: "CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다.",
|
||||||
|
);
|
||||||
} else if (workload.pointCount >= 40000) {
|
} else if (workload.pointCount >= 40000) {
|
||||||
warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다.");
|
warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다.");
|
||||||
}
|
}
|
||||||
@@ -946,7 +1001,7 @@ function progressMessage(message) {
|
|||||||
if (message.message) return message.message;
|
if (message.message) return message.message;
|
||||||
const progress = message.progress;
|
const progress = message.progress;
|
||||||
if (progress?.totalBins) {
|
if (progress?.totalBins) {
|
||||||
return `DAS 계산 중 · ${progress.completedBins}/${progress.totalBins} bins`;
|
return `위치 추정 중 · ${progress.completedBins}/${progress.totalBins} bins`;
|
||||||
}
|
}
|
||||||
if (progress?.totalChunks) {
|
if (progress?.totalChunks) {
|
||||||
return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`;
|
return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`;
|
||||||
@@ -1078,8 +1133,12 @@ async function runSimulation() {
|
|||||||
const definition = snapshot.experiment.frequencyBands.find(
|
const definition = snapshot.experiment.frequencyBands.find(
|
||||||
(candidate) => candidate.id === band.id,
|
(candidate) => candidate.id === band.id,
|
||||||
);
|
);
|
||||||
const evaluation = evaluateDasBand({
|
const evaluation = evaluateLocalizationBand({
|
||||||
rawMap: band.rawMap,
|
rawMap: band.rawMap,
|
||||||
|
mapScale:
|
||||||
|
band.rawScale ??
|
||||||
|
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
|
||||||
|
"power",
|
||||||
scan: snapshot.experiment.scan,
|
scan: snapshot.experiment.scan,
|
||||||
sources: sourcesForBand(snapshot.scene, definition),
|
sources: sourcesForBand(snapshot.scene, definition),
|
||||||
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
||||||
@@ -1132,8 +1191,11 @@ async function runSimulation() {
|
|||||||
syncUnsavedState();
|
syncUnsavedState();
|
||||||
document.documentElement.dataset.runState = "done";
|
document.documentElement.dataset.runState = "done";
|
||||||
document.documentElement.dataset.runBackend = run.backend;
|
document.documentElement.dataset.runBackend = run.backend;
|
||||||
|
document.documentElement.dataset.runAlgorithm =
|
||||||
|
run.algorithm ?? snapshot.experiment.beamforming.algorithm;
|
||||||
document.documentElement.dataset.resultBandCount = String(run.bands.length);
|
document.documentElement.dataset.resultBandCount = String(run.bands.length);
|
||||||
setRunStatus(
|
setRunStatus(
|
||||||
|
`${localizationAlgorithmLabel(run.algorithm ?? snapshot.experiment.beamforming.algorithm)} · ` +
|
||||||
`${run.backend.toUpperCase()} 완료 · ${formatNumber(run.timingMs.total, 0)} ms · ` +
|
`${run.backend.toUpperCase()} 완료 · ${formatNumber(run.timingMs.total, 0)} ms · ` +
|
||||||
`hash ${inputHash.slice(0, 10)}`,
|
`hash ${inputHash.slice(0, 10)}`,
|
||||||
);
|
);
|
||||||
@@ -1204,8 +1266,14 @@ function evaluateLiveFrames(snapshot, plan, frames) {
|
|||||||
if (!definition) continue;
|
if (!definition) continue;
|
||||||
evaluations.set(
|
evaluations.set(
|
||||||
band.id,
|
band.id,
|
||||||
evaluateDasBand({
|
evaluateLocalizationBand({
|
||||||
rawMap: band.rawMap,
|
rawMap: band.rawMap,
|
||||||
|
mapScale:
|
||||||
|
band.rawScale ??
|
||||||
|
localizationAlgorithmDefinition(
|
||||||
|
frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm,
|
||||||
|
)?.rawScale ??
|
||||||
|
"power",
|
||||||
scan: snapshot.experiment.scan,
|
scan: snapshot.experiment.scan,
|
||||||
sources: sourcesForBand(frameScene, definition, frame.sampleTime),
|
sources: sourcesForBand(frameScene, definition, frame.sampleTime),
|
||||||
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
||||||
@@ -1551,7 +1619,29 @@ function installDocumentInputListeners() {
|
|||||||
elements.frequencyResponseMode.value === "compensate";
|
elements.frequencyResponseMode.value === "compensate";
|
||||||
markChanged();
|
markChanged();
|
||||||
});
|
});
|
||||||
|
elements.algorithm.addEventListener("change", () => {
|
||||||
|
const algorithm = elements.algorithm.value;
|
||||||
|
state.experiment.beamforming.algorithm = algorithm;
|
||||||
|
const backendReset =
|
||||||
|
state.experiment.beamforming.backend === "webgpu" &&
|
||||||
|
!localizationAlgorithmSupportsBackend(algorithm, "webgpu");
|
||||||
|
if (backendReset) state.experiment.beamforming.backend = "auto";
|
||||||
|
markChanged({ syncControls: true });
|
||||||
|
if (backendReset) {
|
||||||
|
setRunStatus(`${localizationAlgorithmLabel(algorithm)}는 CPU 전용이므로 Backend를 Auto (CPU)로 바꿨습니다.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
elements.backend.addEventListener("change", () => {
|
elements.backend.addEventListener("change", () => {
|
||||||
|
if (
|
||||||
|
elements.backend.value === "webgpu" &&
|
||||||
|
!localizationAlgorithmSupportsBackend(state.experiment.beamforming.algorithm, "webgpu")
|
||||||
|
) {
|
||||||
|
elements.backend.value = state.experiment.beamforming.backend;
|
||||||
|
setRunStatus(
|
||||||
|
`${localizationAlgorithmLabel(state.experiment.beamforming.algorithm)}는 Auto 또는 CPU를 사용하세요.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.experiment.beamforming.backend = elements.backend.value;
|
state.experiment.beamforming.backend = elements.backend.value;
|
||||||
markChanged();
|
markChanged();
|
||||||
});
|
});
|
||||||
@@ -1770,7 +1860,7 @@ function installActionListeners() {
|
|||||||
);
|
);
|
||||||
for (const item of state.history) item.comparisonSaved = true;
|
for (const item of state.history) item.comparisonSaved = true;
|
||||||
syncUnsavedState();
|
syncUnsavedState();
|
||||||
setRunStatus("전체 배열 비교 기록 JSON을 저장했습니다.");
|
setRunStatus("전체 비교 기록 JSON을 저장했습니다.");
|
||||||
});
|
});
|
||||||
elements.clearHistoryButton.addEventListener("click", () => {
|
elements.clearHistoryButton.addEventListener("click", () => {
|
||||||
const signature = comparisonSignature();
|
const signature = comparisonSignature();
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||||
|
localizationAlgorithmDefinition,
|
||||||
|
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||||
|
|
||||||
const RESULT_METRIC_KEYS = [
|
const RESULT_METRIC_KEYS = [
|
||||||
"peakToSidelobeDb",
|
"peakToSidelobeDb",
|
||||||
@@ -25,6 +29,17 @@ export function canonicalStringify(value) {
|
|||||||
return JSON.stringify(sortForCanonicalJson(value));
|
return JSON.stringify(sortForCanonicalJson(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createComparisonSignature(scene, sourceExperiment) {
|
||||||
|
if (!scene || !sourceExperiment) return "";
|
||||||
|
const experiment = structuredClone(sourceExperiment);
|
||||||
|
experiment.arrayId = "$array";
|
||||||
|
if (experiment.beamforming) {
|
||||||
|
experiment.beamforming.algorithm = "$algorithm";
|
||||||
|
experiment.beamforming.backend = "$backend";
|
||||||
|
}
|
||||||
|
return canonicalStringify({ scene, experiment });
|
||||||
|
}
|
||||||
|
|
||||||
export async function hashSimulationInputs({ arrayDefinition, scene, experiment }) {
|
export async function hashSimulationInputs({ arrayDefinition, scene, experiment }) {
|
||||||
const canonical = canonicalStringify({ array: arrayDefinition, experiment, scene });
|
const canonical = canonicalStringify({ array: arrayDefinition, experiment, scene });
|
||||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
||||||
@@ -42,17 +57,38 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
|
|||||||
const bandBins = experiment.frequencyBands.map((band) =>
|
const bandBins = experiment.frequencyBands.map((band) =>
|
||||||
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
|
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
|
||||||
);
|
);
|
||||||
|
const bandBinSelectionCount = bandBins.reduce((total, bins) => total + bins.length, 0);
|
||||||
const selectedBinCount = new Set(bandBins.flat()).size;
|
const selectedBinCount = new Set(bandBins.flat()).size;
|
||||||
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
||||||
const microphoneCount = arrayDefinition.microphones.length;
|
const microphoneCount = arrayDefinition.microphones.length;
|
||||||
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBinCount;
|
const algorithm = experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
||||||
|
const algorithmDefinition = localizationAlgorithmDefinition(algorithm);
|
||||||
|
const referencePairCount = Math.max(0, microphoneCount - 1);
|
||||||
|
const stftTransformCount = frameCount * microphoneCount;
|
||||||
|
const dasComplexMacCount = pointCount * microphoneCount * frameCount * selectedBinCount;
|
||||||
|
const gccCrossSpectrumComplexOps =
|
||||||
|
2 * referencePairCount * frameCount * bandBinSelectionCount;
|
||||||
|
const gccScanScoreOps = pointCount * referencePairCount * bandBins.length;
|
||||||
|
const inverseFftTransformCount = referencePairCount * bandBins.length;
|
||||||
|
const scanOperationCount = algorithm === "gcc-phat" ? gccScanScoreOps : dasComplexMacCount;
|
||||||
return {
|
return {
|
||||||
|
algorithm,
|
||||||
|
algorithmLabel: algorithmDefinition?.label ?? algorithm,
|
||||||
|
rawScale: algorithmDefinition?.rawScale ?? "power",
|
||||||
pointCount,
|
pointCount,
|
||||||
microphoneCount,
|
microphoneCount,
|
||||||
frameCount,
|
frameCount,
|
||||||
selectedBinCount,
|
selectedBinCount,
|
||||||
|
bandBinSelectionCount,
|
||||||
|
minimumBandBinCount: Math.min(...bandBins.map((bins) => bins.length)),
|
||||||
bandBins,
|
bandBins,
|
||||||
complexMacCount,
|
stftTransformCount,
|
||||||
|
referencePairCount,
|
||||||
|
scanOperationCount,
|
||||||
|
scanOperationKind: algorithm === "gcc-phat" ? "reference-pair lookup" : "complex MAC",
|
||||||
|
gccCrossSpectrumComplexOps: algorithm === "gcc-phat" ? gccCrossSpectrumComplexOps : 0,
|
||||||
|
inverseFftTransformCount: algorithm === "gcc-phat" ? inverseFftTransformCount : 0,
|
||||||
|
complexMacCount: algorithm === "gcc-phat" ? 0 : dasComplexMacCount,
|
||||||
scalingVs50: pointCount / 2500,
|
scalingVs50: pointCount / 2500,
|
||||||
distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2,
|
distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2,
|
||||||
};
|
};
|
||||||
@@ -207,6 +243,14 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
|
|||||||
if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture;
|
if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture;
|
||||||
if (adapter?.device) environment.gpuDevice = adapter.device;
|
if (adapter?.device) environment.gpuDevice = adapter.device;
|
||||||
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
|
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
|
||||||
|
const referenceMic =
|
||||||
|
run.referenceMic ??
|
||||||
|
run.referenceMicrophoneId ??
|
||||||
|
run.workload?.referenceMic ??
|
||||||
|
run.workload?.referenceMicrophoneId;
|
||||||
|
if (typeof referenceMic === "string" || Number.isFinite(referenceMic)) {
|
||||||
|
environment.referenceMic = referenceMic;
|
||||||
|
}
|
||||||
return environment;
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +264,8 @@ export function createResultDocument(options) {
|
|||||||
inputHash,
|
inputHash,
|
||||||
createdAt = new Date().toISOString(),
|
createdAt = new Date().toISOString(),
|
||||||
} = options;
|
} = options;
|
||||||
|
const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
||||||
|
const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power";
|
||||||
return {
|
return {
|
||||||
schemaVersion: "1.0.0",
|
schemaVersion: "1.0.0",
|
||||||
kind: "micarrsim.result",
|
kind: "micarrsim.result",
|
||||||
@@ -227,6 +273,7 @@ export function createResultDocument(options) {
|
|||||||
arrayId: arrayDefinition.id,
|
arrayId: arrayDefinition.id,
|
||||||
sceneId: scene.id,
|
sceneId: scene.id,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
algorithm,
|
||||||
backend: run.backend,
|
backend: run.backend,
|
||||||
inputHash,
|
inputHash,
|
||||||
signalStats: {
|
signalStats: {
|
||||||
@@ -246,7 +293,8 @@ export function createResultDocument(options) {
|
|||||||
timingMs: {
|
timingMs: {
|
||||||
total: run.timingMs.total,
|
total: run.timingMs.total,
|
||||||
signalGeneration: run.timingMs.signalGeneration,
|
signalGeneration: run.timingMs.signalGeneration,
|
||||||
beamforming: run.timingMs.beamforming,
|
localization: run.timingMs.localization ?? run.timingMs.beamforming,
|
||||||
|
beamforming: run.timingMs.beamforming ?? run.timingMs.localization,
|
||||||
},
|
},
|
||||||
environment: environmentForResult({ arrayDefinition, experiment, run }),
|
environment: environmentForResult({ arrayDefinition, experiment, run }),
|
||||||
bands: run.bands.map((band) => {
|
bands: run.bands.map((band) => {
|
||||||
@@ -257,7 +305,7 @@ export function createResultDocument(options) {
|
|||||||
rawMap: {
|
rawMap: {
|
||||||
rows: experiment.scan.rows,
|
rows: experiment.scan.rows,
|
||||||
columns: experiment.scan.columns,
|
columns: experiment.scan.columns,
|
||||||
scale: "power",
|
scale: band.rawScale ?? rawScale,
|
||||||
values: Array.from(band.rawMap),
|
values: Array.from(band.rawMap),
|
||||||
},
|
},
|
||||||
displayMap: {
|
displayMap: {
|
||||||
@@ -286,9 +334,11 @@ export function createHistorySummary({ arrayDefinition, scene, experiment, run,
|
|||||||
arrayName: arrayDefinition.name,
|
arrayName: arrayDefinition.name,
|
||||||
sceneId: scene.id,
|
sceneId: scene.id,
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
|
algorithm: run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM,
|
||||||
backend: run.backend,
|
backend: run.backend,
|
||||||
pointCount: experiment.scan.rows * experiment.scan.columns,
|
pointCount: experiment.scan.rows * experiment.scan.columns,
|
||||||
totalMs: run.timingMs.total,
|
totalMs: run.timingMs.total,
|
||||||
|
localizationMs: run.timingMs.localization ?? run.timingMs.beamforming,
|
||||||
localizationErrorM: firstMatchedPeak?.localizationErrorM ?? null,
|
localizationErrorM: firstMatchedPeak?.localizationErrorM ?? null,
|
||||||
peakToSidelobeDb: evaluation?.metrics?.peakToSidelobeDb ?? null,
|
peakToSidelobeDb: evaluation?.metrics?.peakToSidelobeDb ?? null,
|
||||||
inputHash,
|
inputHash,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||||
import { sampleQuasiStaticWindow } from "/packages/acoustics-core/trajectory.js";
|
import { sampleQuasiStaticWindow } from "/packages/acoustics-core/trajectory.js";
|
||||||
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
|
import { createLocalizationAnalysisPlan } from "/packages/acoustics-core/localization.js";
|
||||||
|
import { computeLocalizationWithBackend } from "/packages/webgpu-kernels/compute.js";
|
||||||
|
|
||||||
function post(type, jobId, payload = {}, transfer = []) {
|
function post(type, jobId, payload = {}, transfer = []) {
|
||||||
self.postMessage({ type, jobId, ...payload }, transfer);
|
self.postMessage({ type, jobId, ...payload }, transfer);
|
||||||
@@ -46,7 +47,20 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
const frames = [];
|
const frames = [];
|
||||||
const transfer = [];
|
const transfer = [];
|
||||||
let signalGeneration = 0;
|
let signalGeneration = 0;
|
||||||
let beamforming = 0;
|
let localizationTime = 0;
|
||||||
|
const localizationScene = { speedOfSound: scene.speedOfSound };
|
||||||
|
const algorithm = experiment.beamforming.algorithm ?? "das";
|
||||||
|
const requestedBackend = experiment.beamforming.backend ?? "auto";
|
||||||
|
const reuseCpuPlan = algorithm === "gcc-phat" || requestedBackend === "cpu";
|
||||||
|
let localizationExperiment = experiment;
|
||||||
|
let analysisPlan = reuseCpuPlan
|
||||||
|
? createLocalizationAnalysisPlan({
|
||||||
|
sampleRate: scene.sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene: localizationScene,
|
||||||
|
experiment: localizationExperiment,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
post("progress", jobId, {
|
post("progress", jobId, {
|
||||||
stage: "timeline-start",
|
stage: "timeline-start",
|
||||||
@@ -82,13 +96,14 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
const frameSignalGeneration = performance.now() - signalStartedAt;
|
const frameSignalGeneration = performance.now() - signalStartedAt;
|
||||||
signalGeneration += frameSignalGeneration;
|
signalGeneration += frameSignalGeneration;
|
||||||
|
|
||||||
const beamformingStartedAt = performance.now();
|
const localizationStartedAt = performance.now();
|
||||||
const das = await computeDasWithBackend({
|
const localization = await computeLocalizationWithBackend({
|
||||||
channels: synthesis.channels,
|
channels: synthesis.channels,
|
||||||
sampleRate: synthesis.sampleRate,
|
sampleRate: synthesis.sampleRate,
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
scene: synthesisWindow.scene,
|
scene: localizationScene,
|
||||||
experiment,
|
experiment: localizationExperiment,
|
||||||
|
...(analysisPlan ? { analysisPlan } : {}),
|
||||||
onFallback: ({ error }) =>
|
onFallback: ({ error }) =>
|
||||||
post("fallback", jobId, {
|
post("fallback", jobId, {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
@@ -105,9 +120,32 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
progress,
|
progress,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const frameBeamforming = performance.now() - beamformingStartedAt;
|
const frameLocalization = performance.now() - localizationStartedAt;
|
||||||
beamforming += frameBeamforming;
|
localizationTime += frameLocalization;
|
||||||
collectBandTransfers(das, transfer);
|
collectBandTransfers(localization, transfer);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!analysisPlan &&
|
||||||
|
localization.backend === "cpu" &&
|
||||||
|
localization.fallback?.from === "webgpu"
|
||||||
|
) {
|
||||||
|
// Auto has established that WebGPU is unavailable for this worker. Keep
|
||||||
|
// the first frame's honest fallback metadata, then avoid retrying the GPU
|
||||||
|
// and rebuilding CPU geometry/workspaces on every remaining live frame.
|
||||||
|
localizationExperiment = {
|
||||||
|
...experiment,
|
||||||
|
beamforming: {
|
||||||
|
...experiment.beamforming,
|
||||||
|
backend: "cpu",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
analysisPlan = createLocalizationAnalysisPlan({
|
||||||
|
sampleRate: scene.sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene: localizationScene,
|
||||||
|
experiment: localizationExperiment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
frames.push({
|
frames.push({
|
||||||
index: plannedFrame.index ?? frameIndex,
|
index: plannedFrame.index ?? frameIndex,
|
||||||
@@ -116,12 +154,13 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
sampleTime: synthesisWindow.sampleTime,
|
sampleTime: synthesisWindow.sampleTime,
|
||||||
duration: synthesisWindow.duration,
|
duration: synthesisWindow.duration,
|
||||||
run: {
|
run: {
|
||||||
...das,
|
...localization,
|
||||||
signalStats: signalStats(synthesis),
|
signalStats: signalStats(synthesis),
|
||||||
timingMs: {
|
timingMs: {
|
||||||
total: performance.now() - frameStartedAt,
|
total: performance.now() - frameStartedAt,
|
||||||
signalGeneration: frameSignalGeneration,
|
signalGeneration: frameSignalGeneration,
|
||||||
beamforming: frameBeamforming,
|
localization: frameLocalization,
|
||||||
|
beamforming: frameLocalization,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -145,6 +184,7 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
{
|
{
|
||||||
timeline: {
|
timeline: {
|
||||||
backend: backends.size === 1 ? frames[0].run.backend : "mixed",
|
backend: backends.size === 1 ? frames[0].run.backend : "mixed",
|
||||||
|
algorithm: experiment.beamforming.algorithm ?? "das",
|
||||||
requestedBackend: experiment.beamforming.backend ?? "auto",
|
requestedBackend: experiment.beamforming.backend ?? "auto",
|
||||||
duration: scene.duration,
|
duration: scene.duration,
|
||||||
frameCount: frames.length,
|
frameCount: frames.length,
|
||||||
@@ -153,7 +193,8 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
timingMs: {
|
timingMs: {
|
||||||
total: performance.now() - totalStartedAt,
|
total: performance.now() - totalStartedAt,
|
||||||
signalGeneration,
|
signalGeneration,
|
||||||
beamforming,
|
localization: localizationTime,
|
||||||
|
beamforming: localizationTime,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -184,8 +225,8 @@ self.addEventListener("message", async (event) => {
|
|||||||
message: `마이크 신호 합성 완료 · ${signalGeneration.toFixed(0)} ms`,
|
message: `마이크 신호 합성 완료 · ${signalGeneration.toFixed(0)} ms`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const beamformingStartedAt = performance.now();
|
const localizationStartedAt = performance.now();
|
||||||
const das = await computeDasWithBackend({
|
const localization = await computeLocalizationWithBackend({
|
||||||
channels: synthesis.channels,
|
channels: synthesis.channels,
|
||||||
sampleRate: synthesis.sampleRate,
|
sampleRate: synthesis.sampleRate,
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
@@ -198,20 +239,21 @@ self.addEventListener("message", async (event) => {
|
|||||||
}),
|
}),
|
||||||
onProgress: (progress) => post("progress", jobId, { stage: progress.stage, progress }),
|
onProgress: (progress) => post("progress", jobId, { stage: progress.stage, progress }),
|
||||||
});
|
});
|
||||||
const beamforming = performance.now() - beamformingStartedAt;
|
const localizationTime = performance.now() - localizationStartedAt;
|
||||||
const transfer = [];
|
const transfer = [];
|
||||||
collectBandTransfers(das, transfer);
|
collectBandTransfers(localization, transfer);
|
||||||
post(
|
post(
|
||||||
"result",
|
"result",
|
||||||
jobId,
|
jobId,
|
||||||
{
|
{
|
||||||
run: {
|
run: {
|
||||||
...das,
|
...localization,
|
||||||
signalStats: signalStats(synthesis),
|
signalStats: signalStats(synthesis),
|
||||||
timingMs: {
|
timingMs: {
|
||||||
total: performance.now() - totalStartedAt,
|
total: performance.now() - totalStartedAt,
|
||||||
signalGeneration,
|
signalGeneration,
|
||||||
beamforming,
|
localization: localizationTime,
|
||||||
|
beamforming: localizationTime,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed");
|
assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed");
|
||||||
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
|
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
|
||||||
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
|
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
|
||||||
|
assertEqual(documentObject.querySelector("#algorithm").value, "das", "default DAS algorithm");
|
||||||
|
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 3, "three runnable algorithms");
|
||||||
|
assert(!documentObject.querySelector("#postprocess"), "disabled postprocess advertisement is absent");
|
||||||
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
|
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
|
||||||
assertEqual(
|
assertEqual(
|
||||||
documentObject.querySelector("#frequency-response-mode").value,
|
documentObject.querySelector("#frequency-response-mode").value,
|
||||||
@@ -90,6 +93,13 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
documentObject.querySelector("#acoustic-environment-summary").textContent.includes("≤ 24 dB"),
|
documentObject.querySelector("#acoustic-environment-summary").textContent.includes("≤ 24 dB"),
|
||||||
"safe inverse-response boost limit is visible",
|
"safe inverse-response boost limit is visible",
|
||||||
);
|
);
|
||||||
|
assert(documentObject.querySelector("#workload-summary").textContent.includes("DAS"), "algorithm workload label");
|
||||||
|
assert(documentObject.querySelector("#workload-summary").textContent.includes("STFT"), "STFT transform cost");
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#workload-summary").textContent.includes("distance cache") &&
|
||||||
|
!documentObject.querySelector("#workload-summary").textContent.includes("steering"),
|
||||||
|
"distance cache is labeled accurately",
|
||||||
|
);
|
||||||
|
|
||||||
const canvas = documentObject.querySelector("#scene-canvas");
|
const canvas = documentObject.querySelector("#scene-canvas");
|
||||||
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
|
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
|
||||||
@@ -125,6 +135,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
30000,
|
30000,
|
||||||
);
|
);
|
||||||
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
|
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
|
||||||
|
assertEqual(documentObject.documentElement.dataset.runAlgorithm, "das", "actual baseline algorithm");
|
||||||
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
|
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
|
||||||
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 10, "result metrics rendered");
|
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 10, "result metrics rendered");
|
||||||
assert(documentObject.querySelector("#metric-grid").textContent.includes("음향 모델"), "acoustic model metric rendered");
|
assert(documentObject.querySelector("#metric-grid").textContent.includes("음향 모델"), "acoustic model metric rendered");
|
||||||
@@ -169,6 +180,8 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
.map((issue) => `${issue.path}: ${issue.message}`)
|
.map((issue) => `${issue.path}: ${issue.message}`)
|
||||||
.join(" · ")}`);
|
.join(" · ")}`);
|
||||||
assertEqual(resultDocument.backend, "cpu", "exported backend");
|
assertEqual(resultDocument.backend, "cpu", "exported backend");
|
||||||
|
assertEqual(resultDocument.algorithm, "das", "exported baseline algorithm");
|
||||||
|
assertEqual(resultDocument.bands[0].rawMap.scale, "power", "DAS exports a power map");
|
||||||
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
|
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
|
||||||
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
|
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
|
||||||
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
|
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
|
||||||
@@ -197,6 +210,27 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
"original comparison configuration restore",
|
"original comparison configuration restore",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#backend"), "webgpu");
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#algorithm"), "gcc-phat");
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.querySelector("#algorithm").value === "gcc-phat" &&
|
||||||
|
documentObject.querySelector("#backend").value === "auto" &&
|
||||||
|
documentObject.querySelector('#backend option[value="webgpu"]').disabled &&
|
||||||
|
documentObject.querySelector("#validation-message").textContent.includes("8개 이상"),
|
||||||
|
"GCC-PHAT CPU capability and narrow-band warning",
|
||||||
|
);
|
||||||
|
assert(documentObject.querySelector("#workload-summary").textContent.includes("ref pairs"), "GCC pair cost");
|
||||||
|
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#algorithm"), "das-phat");
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||||
|
documentObject.querySelector("#algorithm").value === "das-phat" &&
|
||||||
|
!documentObject.querySelector('#backend option[value="webgpu"]').disabled &&
|
||||||
|
!documentObject.querySelector("#run-button").disabled,
|
||||||
|
"DAS-PHAT comparison configuration",
|
||||||
|
);
|
||||||
|
|
||||||
const comparisonPresets = [
|
const comparisonPresets = [
|
||||||
["rectangular-2x2-450mm", "4 ch", "0.450 × 0.450 m"],
|
["rectangular-2x2-450mm", "4 ch", "0.450 × 0.450 m"],
|
||||||
["rectangular-4x3-450mm", "12 ch", "0.450 × 0.450 m"],
|
["rectangular-4x3-450mm", "12 ch", "0.450 × 0.450 m"],
|
||||||
@@ -245,10 +279,15 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
30000,
|
30000,
|
||||||
);
|
);
|
||||||
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
|
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
|
||||||
|
assertEqual(documentObject.documentElement.dataset.runAlgorithm, "das-phat", "actual comparison algorithm");
|
||||||
|
assert(documentObject.querySelector("#result-badge").textContent.includes("DAS-PHAT"), "algorithm result badge");
|
||||||
|
assert(documentObject.querySelector("#canvas-badge").textContent.includes("DAS-PHAT"), "algorithm canvas badge");
|
||||||
|
assert(documentObject.querySelector("#metric-grid").textContent.includes("Truth concentration"), "generic concentration metric");
|
||||||
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
|
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
|
||||||
const historyText = documentObject.querySelector("#history-list").textContent;
|
const historyText = documentObject.querySelector("#history-list").textContent;
|
||||||
assert(historyText.includes("rectangular baseline"), "baseline history retained");
|
assert(historyText.includes("rectangular baseline"), "baseline history retained");
|
||||||
assert(historyText.includes("Fermat spiral"), "comparison array history added");
|
assert(historyText.includes("Fermat spiral"), "comparison array history added");
|
||||||
|
assert(historyText.includes("DAS-PHAT") && historyText.includes("DAS"), "both algorithms are compared");
|
||||||
|
|
||||||
documentObject.querySelector("#export-history-button").click();
|
documentObject.querySelector("#export-history-button").click();
|
||||||
const historyDownload = await waitFor(
|
const historyDownload = await waitFor(
|
||||||
@@ -258,6 +297,12 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
const historyDocument = await fetch(historyDownload.url).then((response) => response.json());
|
const historyDocument = await fetch(historyDownload.url).then((response) => response.json());
|
||||||
assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind");
|
assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind");
|
||||||
assertEqual(historyDocument.items.length, 2, "both comparison summaries exported");
|
assertEqual(historyDocument.items.length, 2, "both comparison summaries exported");
|
||||||
|
assertEqual(
|
||||||
|
historyDocument.items.map((item) => item.algorithm).sort().join(","),
|
||||||
|
"das,das-phat",
|
||||||
|
"both comparison algorithms exported",
|
||||||
|
);
|
||||||
|
assert(historyDocument.items.every((item) => Number.isFinite(item.localizationMs)), "localization timing exported");
|
||||||
assert(
|
assert(
|
||||||
!documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
|
!documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
|
||||||
"history export clears comparison dirty state",
|
"history export clears comparison dirty state",
|
||||||
@@ -324,17 +369,17 @@ test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source exam
|
|||||||
|
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
|
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
|
dispatchValue(windowObject, documentObject.querySelector("#backend"), "auto");
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#live-map-rate"), 2);
|
dispatchValue(windowObject, documentObject.querySelector("#live-map-rate"), 2);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||||
documentObject.documentElement.dataset.liveState === "idle" &&
|
documentObject.documentElement.dataset.liveState === "idle" &&
|
||||||
documentObject.querySelector("#scan-columns").value === "8" &&
|
documentObject.querySelector("#scan-columns").value === "8" &&
|
||||||
documentObject.querySelector("#scan-rows").value === "8" &&
|
documentObject.querySelector("#scan-rows").value === "8" &&
|
||||||
documentObject.querySelector("#backend").value === "cpu" &&
|
documentObject.querySelector("#backend").value === "auto" &&
|
||||||
documentObject.querySelector("#live-map-rate").value === "2" &&
|
documentObject.querySelector("#live-map-rate").value === "2" &&
|
||||||
!documentObject.querySelector("#prepare-live-button").disabled,
|
!documentObject.querySelector("#prepare-live-button").disabled,
|
||||||
"8x8 CPU 2 Hz live configuration",
|
"8x8 Auto 2 Hz live configuration",
|
||||||
);
|
);
|
||||||
|
|
||||||
documentObject.querySelector("#prepare-live-button").click();
|
documentObject.querySelector("#prepare-live-button").click();
|
||||||
@@ -345,6 +390,11 @@ test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source exam
|
|||||||
);
|
);
|
||||||
const frameCount = Number(documentObject.documentElement.dataset.liveFrameCount);
|
const frameCount = Number(documentObject.documentElement.dataset.liveFrameCount);
|
||||||
assert(frameCount > 1, `multiple live frames: ${frameCount}`);
|
assert(frameCount > 1, `multiple live frames: ${frameCount}`);
|
||||||
|
assertEqual(
|
||||||
|
Number(documentObject.documentElement.dataset.livePlanAnalyses),
|
||||||
|
frameCount - 1,
|
||||||
|
"Auto fallback retries GPU once then reuses one CPU localization plan",
|
||||||
|
);
|
||||||
assert(documentObject.querySelector("#live-status").textContent.includes("CPU"), "live backend is CPU");
|
assert(documentObject.querySelector("#live-status").textContent.includes("CPU"), "live backend is CPU");
|
||||||
|
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#live-time"), 0.6, "input");
|
dispatchValue(windowObject, documentObject.querySelector("#live-time"), 0.6, "input");
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
alignExperimentToScene,
|
alignExperimentToScene,
|
||||||
canonicalStringify,
|
canonicalStringify,
|
||||||
|
createComparisonSignature,
|
||||||
|
createHistorySummary,
|
||||||
createResultDocument,
|
createResultDocument,
|
||||||
estimateExperimentWorkload,
|
estimateExperimentWorkload,
|
||||||
hashSimulationInputs,
|
hashSimulationInputs,
|
||||||
@@ -247,9 +249,49 @@ test("Camera workload counts unique overlapping FFT bins", () => {
|
|||||||
assertEqual(workload.frameCount, 62, "STFT frame count");
|
assertEqual(workload.frameCount, 62, "STFT frame count");
|
||||||
assertEqual(JSON.stringify(workload.bandBins), JSON.stringify([[4, 5, 6], [6, 7, 8]]), "bins per band");
|
assertEqual(JSON.stringify(workload.bandBins), JSON.stringify([[4, 5, 6], [6, 7, 8]]), "bins per band");
|
||||||
assertEqual(workload.selectedBinCount, 5, "overlapping bin is counted once");
|
assertEqual(workload.selectedBinCount, 5, "overlapping bin is counted once");
|
||||||
|
assertEqual(workload.stftTransformCount, 186, "STFT transform count");
|
||||||
|
assertEqual(workload.scanOperationCount, 74_400, "DAS scan operation count");
|
||||||
|
assertEqual(workload.scanOperationKind, "complex MAC", "DAS operation kind");
|
||||||
assertEqual(workload.complexMacCount, 74_400, "estimated complex MAC count");
|
assertEqual(workload.complexMacCount, 74_400, "estimated complex MAC count");
|
||||||
assertNear(workload.scalingVs50, 80 / 2500, 1e-15, "50x50 scaling");
|
assertNear(workload.scalingVs50, 80 / 2500, 1e-15, "50x50 scaling");
|
||||||
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
|
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
|
||||||
|
|
||||||
|
const gccExperiment = structuredClone(experiment);
|
||||||
|
gccExperiment.beamforming = { algorithm: "gcc-phat" };
|
||||||
|
const gccWorkload = estimateExperimentWorkload(arrayDefinition, scene, gccExperiment);
|
||||||
|
assertEqual(gccWorkload.algorithmLabel, "GCC-PHAT", "GCC algorithm label");
|
||||||
|
assertEqual(gccWorkload.referencePairCount, 2, "GCC reference pairs");
|
||||||
|
assertEqual(gccWorkload.bandBinSelectionCount, 6, "GCC counts overlapping band bins per band");
|
||||||
|
assertEqual(gccWorkload.gccCrossSpectrumComplexOps, 1_488, "GCC two-pass cross-spectrum ops");
|
||||||
|
assertEqual(gccWorkload.inverseFftTransformCount, 4, "GCC inverse FFT count");
|
||||||
|
assertEqual(gccWorkload.minimumBandBinCount, 3, "GCC narrowest band bin count");
|
||||||
|
assertEqual(gccWorkload.scanOperationCount, 320, "GCC pair-band scan lookups");
|
||||||
|
assertEqual(gccWorkload.scanOperationKind, "reference-pair lookup", "GCC operation kind");
|
||||||
|
assertEqual(gccWorkload.complexMacCount, 0, "GCC is not mislabeled as DAS complex MAC");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Camera comparison signature groups array, algorithm, and backend variants only", () => {
|
||||||
|
const scene = { id: "scene", seed: 7, sources: [] };
|
||||||
|
const baseline = {
|
||||||
|
id: "experiment",
|
||||||
|
arrayId: "array-a",
|
||||||
|
scan: { rows: 8, columns: 8 },
|
||||||
|
beamforming: { algorithm: "das", backend: "cpu", normalization: "peak" },
|
||||||
|
};
|
||||||
|
const variant = structuredClone(baseline);
|
||||||
|
variant.arrayId = "array-b";
|
||||||
|
variant.beamforming.algorithm = "das-phat";
|
||||||
|
variant.beamforming.backend = "webgpu";
|
||||||
|
assertEqual(
|
||||||
|
createComparisonSignature(scene, variant),
|
||||||
|
createComparisonSignature(scene, baseline),
|
||||||
|
"comparison axes share one history group",
|
||||||
|
);
|
||||||
|
variant.scan.columns = 9;
|
||||||
|
assert(
|
||||||
|
createComparisonSignature(scene, variant) !== createComparisonSignature(scene, baseline),
|
||||||
|
"scan changes remain separate",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Camera acoustic presets bound current geometry and expose predictable sparse cost", () => {
|
test("Camera acoustic presets bound current geometry and expose predictable sparse cost", () => {
|
||||||
@@ -415,11 +457,13 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
arrayId: arrayDefinition.id,
|
arrayId: arrayDefinition.id,
|
||||||
sceneId: scene.id,
|
sceneId: scene.id,
|
||||||
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
||||||
beamforming: { backend: "auto" },
|
beamforming: { algorithm: "das-phat", backend: "auto" },
|
||||||
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
|
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
|
||||||
};
|
};
|
||||||
const run = {
|
const run = {
|
||||||
|
algorithm: "das-phat",
|
||||||
backend: "cpu",
|
backend: "cpu",
|
||||||
|
referenceMic: "mic-1",
|
||||||
signalStats: {
|
signalStats: {
|
||||||
preNormalizationPeak: 0.75,
|
preNormalizationPeak: 0.75,
|
||||||
appliedScale: 1,
|
appliedScale: 1,
|
||||||
@@ -433,6 +477,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
bands: [{
|
bands: [{
|
||||||
id: "band-1",
|
id: "band-1",
|
||||||
color: "#65f2bf",
|
color: "#65f2bf",
|
||||||
|
rawScale: "score",
|
||||||
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||||
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||||
displayScale: "normalized",
|
displayScale: "normalized",
|
||||||
@@ -448,7 +493,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
localizationErrorPixels: 0.1,
|
localizationErrorPixels: 0.1,
|
||||||
beamSpread: {
|
beamSpread: {
|
||||||
calibrated: false,
|
calibrated: false,
|
||||||
method: "connected-half-power-power-weighted-covariance",
|
method: "connected-half-maximum-score-weighted-covariance",
|
||||||
halfPowerRatio: 0.5,
|
halfPowerRatio: 0.5,
|
||||||
thresholdValue: 0.5,
|
thresholdValue: 0.5,
|
||||||
pixelCount: 3,
|
pixelCount: 3,
|
||||||
@@ -496,6 +541,10 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
|
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
|
||||||
|
assertEqual(result.algorithm, "das-phat", "actual algorithm is exported");
|
||||||
|
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
|
||||||
|
assertEqual(result.bands[0].rawMap.scale, "score", "algorithm raw score scale is exported");
|
||||||
|
assertEqual(result.environment.referenceMic, "mic-1", "reference microphone metadata is exported");
|
||||||
assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted");
|
assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted");
|
||||||
assertEqual(
|
assertEqual(
|
||||||
Object.keys(result.bands[0].metrics).join(","),
|
Object.keys(result.bands[0].metrics).join(","),
|
||||||
@@ -512,6 +561,17 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
|
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
|
||||||
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
|
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
|
||||||
|
|
||||||
|
const history = createHistorySummary({
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
run,
|
||||||
|
evaluations,
|
||||||
|
inputHash: "a".repeat(64),
|
||||||
|
});
|
||||||
|
assertEqual(history.algorithm, "das-phat", "history records the actual algorithm");
|
||||||
|
assertEqual(history.localizationMs, 10, "history records localization time");
|
||||||
|
|
||||||
const issues = await validateResult(result, { experiment, scene });
|
const issues = await validateResult(result, { experiment, scene });
|
||||||
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user