feat: compare localization algorithms in camera

This commit is contained in:
2026-08-28 20:57:38 +09:00
parent e8dac792d9
commit fe0f70ab11
6 changed files with 362 additions and 70 deletions
+9 -9
View File
@@ -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
View File
@@ -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();
+54 -4
View File
@@ -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,
+60 -18
View File
@@ -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,
}, },
}, },
}, },
+53 -3
View File
@@ -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");
+62 -2
View File
@@ -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)}`);