From fe0f70ab11de9ce2e1b3f075f6d64bb0ade20923 Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Fri, 28 Aug 2026 20:57:38 +0900 Subject: [PATCH] feat: compare localization algorithms in camera --- apps/acoustic-camera/index.html | 18 +-- apps/acoustic-camera/main.js | 158 +++++++++++++++++----- apps/acoustic-camera/model.js | 58 +++++++- apps/acoustic-camera/simulation-worker.js | 78 ++++++++--- tests/integration/camera-ui.test.js | 56 +++++++- tests/unit/camera.test.js | 64 ++++++++- 6 files changed, 362 insertions(+), 70 deletions(-) diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index 1387135..7461252 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -89,6 +89,7 @@
+ @@ -134,7 +135,6 @@ -
@@ -205,7 +205,7 @@
4 -

Map과 결과 확인

Canvas가 실제 음향 에너지와 추정 위치를 표시합니다.

+

Map과 결과 확인

Canvas가 위치추정 응답과 추정 위치를 표시합니다.

아직 실행하지 않음
@@ -219,30 +219,30 @@
- +

측정 요약

peak · 오차 · beam
-
실행 후 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.
+
실행 후 위치 오차, truth concentration, sidelobe와 timing을 표시합니다.
- 배열 비교 기록같은 설정의 재실행 결과 + 비교 기록배열 · 알고리즘 재실행 결과
-

배열을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 비교합니다.

+

같은 scene·scan·STFT에서 배열이나 알고리즘을 바꾼 결과를 비교합니다.

- 표시 해석과 한계DAS · beam spread · live + 표시 해석과 한계localization · beam spread · live
-

HeatmapDAS 공간 power이며 위치 확률분포가 아닙니다.

-

빔 분포Half-power covariance 폭이며 신뢰구간이 아닙니다.

+

HeatmapDAS는 power, PHAT 계열은 무차원 score이며 위치 확률분포가 아닙니다.

+

빔 분포Half-power 또는 half-maximum 응답 폭이며 신뢰구간이 아닙니다.

이동 재생준정적 frame 시뮬레이션이며 실제 장치 스트림이 아닙니다.

diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index 53ce254..a58803b 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -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 { + localizationAlgorithmDefinition, + localizationAlgorithmLabel, + localizationAlgorithmSupportsBackend, +} from "/packages/acoustics-core/localization-algorithms.js"; import { downloadJson, readJsonFile } from "/packages/contracts/io.js"; import { validateArrayDefinition, @@ -21,7 +26,7 @@ import { } from "./timeline.js"; import { alignExperimentToScene, - canonicalStringify, + createComparisonSignature, createHistorySummary, createResultDocument, estimateExperimentWorkload, @@ -105,6 +110,7 @@ const elements = { acousticProfile: byId("acoustic-profile"), acousticEnvironmentSummary: byId("acoustic-environment-summary"), frequencyResponseMode: byId("frequency-response-mode"), + algorithm: byId("algorithm"), backend: byId("backend"), gridPreset: byId("grid-preset"), scanColumns: byId("scan-columns"), @@ -119,7 +125,6 @@ const elements = { normalization: byId("normalization"), dynamicRange: byId("dynamic-range"), detectionThreshold: byId("detection-threshold"), - postprocess: byId("postprocess"), addBandButton: byId("add-band-button"), bandList: byId("band-list"), exportSceneButton: byId("export-scene-button"), @@ -179,10 +184,7 @@ function formatIssue(issue) { } function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) { - if (!scene || !sourceExperiment) return ""; - const experiment = structuredClone(sourceExperiment); - experiment.arrayId = "$array"; - return canonicalStringify({ scene, experiment }); + return createComparisonSignature(scene, sourceExperiment); } function applyExperimentDefaults(experiment) { @@ -294,6 +296,9 @@ function renderLiveControls() { if (preparing) liveState = "preparing"; document.documentElement.dataset.liveState = liveState; 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"; } @@ -365,6 +370,21 @@ function markChanged(options = {}) { 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() { const { scene, experiment } = state; if (!scene || !experiment) return; @@ -376,7 +396,9 @@ function syncControls() { elements.acousticProfile.value = detectAcousticProfile(scene); elements.frequencyResponseMode.value = experiment.beamforming.compensateFrequencyResponse === false ? "raw" : "compensate"; + elements.algorithm.value = experiment.beamforming.algorithm; elements.backend.value = experiment.beamforming.backend; + syncAlgorithmBackendControls(); elements.scanColumns.value = experiment.scan.columns; elements.scanRows.value = experiment.scan.rows; elements.scanXMin.value = experiment.scan.xMin; @@ -550,15 +572,25 @@ function renderWorkload() { const maximumPaths = maximumSparsePathsPerReceiver(state.scene); const signalPathBudget = 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 = - `${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` + - `${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` + - `약 ${formatCount(workload.complexMacCount)} complex MAC · ` + - `signal ≤ ${formatCount(signalPathBudget)} paths · steering ${formatNumber(workload.distanceMiB, 1)} MiB`; - const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8; + `${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` + + `${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` + + `${scanCost} · signal ≤ ${formatCount(signalPathBudget)} paths · ` + + `distance cache ${formatNumber(workload.distanceMiB, 1)} MiB`; + 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( "is-warning", - heavyCpu || workload.pointCount >= 40000 || signalPathBudget > 100000, + heavyCpu || narrowGcc || workload.pointCount >= 40000 || signalPathBudget > 100000, ); } catch (error) { elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`; @@ -630,8 +662,11 @@ function renderSceneVisualization() { showTruth: elements.showTruth.checked, }); elements.canvasMessage.classList.add("is-hidden"); + const algorithmLabel = localizationAlgorithmLabel( + live.frames[0]?.run.algorithm ?? live.snapshot.experiment.beamforming.algorithm, + ); 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()}`; return; } @@ -652,9 +687,12 @@ function renderSceneVisualization() { if (!displayed) { elements.canvasBadge.textContent = "결과 없음"; } 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 { elements.canvasBadge.textContent = + `${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` + `${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` + `${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`; } @@ -684,15 +722,19 @@ function renderResult() { elements.resultBadge.textContent = "아직 실행하지 않음"; return; } + const algorithm = displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm; + const algorithmLabel = localizationAlgorithmLabel(algorithm); elements.resultBadge.textContent = displayed.stale - ? `이전 설정 · ${displayed.run.backend.toUpperCase()}` - : `${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`; + ? `이전 설정 · ${algorithmLabel} · ${displayed.run.backend.toUpperCase()}` + : `${algorithmLabel} · ${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`; appendMetric( - "Backend", - displayed.run.backend.toUpperCase(), - displayed.run.fallback ? "WebGPU → CPU fallback" : "요청한 계산 경로", + "위치 추정", + algorithmLabel, + 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( "신호 합성", `${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`, @@ -708,8 +750,8 @@ function renderResult() { ); } 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`, ); const detectionThreshold = @@ -718,7 +760,7 @@ function renderResult() { appendMetric( "Peak 검출 임계값", `${formatNumber(detectionThreshold * 100, 0)}%`, - "최강 peak power 기준 · 낮을수록 민감", + "최강 map 값 기준 · 낮을수록 민감", ); for (const band of displayed.snapshot.experiment.frequencyBands) { const evaluation = displayed.evaluations.get(band.id); @@ -734,7 +776,7 @@ function renderResult() { band.color, ); appendMetric( - `${band.id} · Truth energy`, + `${band.id} · Truth concentration`, `${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`, "ground-truth 반경 내", band.color, @@ -754,7 +796,9 @@ function renderResult() { appendMetric( `${band.id} · Main lobe`, `${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, ); const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread; @@ -786,10 +830,10 @@ function renderHistory() { const card = document.createElement("article"); card.className = "history-card"; const name = document.createElement("strong"); - name.textContent = item.arrayName; + name.textContent = `${localizationAlgorithmLabel(item.algorithm)} · ${item.arrayName}`; const fields = [ `${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.peakToSidelobeDb === null ? "PSR —" : `PSR ${formatNumber(item.peakToSidelobeDb, 1)} dB`, ]; @@ -822,8 +866,19 @@ function validationWarnings() { } try { const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment); - if (state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8) { - warnings.push("CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다."); + if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8) { + 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) { warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다."); } @@ -946,7 +1001,7 @@ function progressMessage(message) { if (message.message) return message.message; const progress = message.progress; if (progress?.totalBins) { - return `DAS 계산 중 · ${progress.completedBins}/${progress.totalBins} bins`; + return `위치 추정 중 · ${progress.completedBins}/${progress.totalBins} bins`; } if (progress?.totalChunks) { return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`; @@ -1078,8 +1133,12 @@ async function runSimulation() { const definition = snapshot.experiment.frequencyBands.find( (candidate) => candidate.id === band.id, ); - const evaluation = evaluateDasBand({ + const evaluation = evaluateLocalizationBand({ rawMap: band.rawMap, + mapScale: + band.rawScale ?? + localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ?? + "power", scan: snapshot.experiment.scan, sources: sourcesForBand(snapshot.scene, definition), dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, @@ -1132,8 +1191,11 @@ async function runSimulation() { syncUnsavedState(); document.documentElement.dataset.runState = "done"; document.documentElement.dataset.runBackend = run.backend; + document.documentElement.dataset.runAlgorithm = + run.algorithm ?? snapshot.experiment.beamforming.algorithm; document.documentElement.dataset.resultBandCount = String(run.bands.length); setRunStatus( + `${localizationAlgorithmLabel(run.algorithm ?? snapshot.experiment.beamforming.algorithm)} · ` + `${run.backend.toUpperCase()} 완료 · ${formatNumber(run.timingMs.total, 0)} ms · ` + `hash ${inputHash.slice(0, 10)}`, ); @@ -1204,8 +1266,14 @@ function evaluateLiveFrames(snapshot, plan, frames) { if (!definition) continue; evaluations.set( band.id, - evaluateDasBand({ + evaluateLocalizationBand({ rawMap: band.rawMap, + mapScale: + band.rawScale ?? + localizationAlgorithmDefinition( + frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm, + )?.rawScale ?? + "power", scan: snapshot.experiment.scan, sources: sourcesForBand(frameScene, definition, frame.sampleTime), dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, @@ -1551,7 +1619,29 @@ function installDocumentInputListeners() { elements.frequencyResponseMode.value === "compensate"; 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", () => { + 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; markChanged(); }); @@ -1770,7 +1860,7 @@ function installActionListeners() { ); for (const item of state.history) item.comparisonSaved = true; syncUnsavedState(); - setRunStatus("전체 배열 비교 기록 JSON을 저장했습니다."); + setRunStatus("전체 비교 기록 JSON을 저장했습니다."); }); elements.clearHistoryButton.addEventListener("click", () => { const signature = comparisonSignature(); diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js index f8cc7f7..2721ce1 100644 --- a/apps/acoustic-camera/model.js +++ b/apps/acoustic-camera/model.js @@ -1,4 +1,8 @@ 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 = [ "peakToSidelobeDb", @@ -25,6 +29,17 @@ export function canonicalStringify(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 }) { const canonical = canonicalStringify({ array: arrayDefinition, experiment, scene }); 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) => 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 pointCount = experiment.scan.columns * experiment.scan.rows; 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 { + algorithm, + algorithmLabel: algorithmDefinition?.label ?? algorithm, + rawScale: algorithmDefinition?.rawScale ?? "power", pointCount, microphoneCount, frameCount, selectedBinCount, + bandBinSelectionCount, + minimumBandBinCount: Math.min(...bandBins.map((bins) => bins.length)), 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, distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2, }; @@ -207,6 +243,14 @@ function environmentForResult({ arrayDefinition, experiment, run }) { if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture; if (adapter?.device) environment.gpuDevice = adapter.device; 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; } @@ -220,6 +264,8 @@ export function createResultDocument(options) { inputHash, createdAt = new Date().toISOString(), } = options; + const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM; + const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power"; return { schemaVersion: "1.0.0", kind: "micarrsim.result", @@ -227,6 +273,7 @@ export function createResultDocument(options) { arrayId: arrayDefinition.id, sceneId: scene.id, createdAt, + algorithm, backend: run.backend, inputHash, signalStats: { @@ -246,7 +293,8 @@ export function createResultDocument(options) { timingMs: { total: run.timingMs.total, 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 }), bands: run.bands.map((band) => { @@ -257,7 +305,7 @@ export function createResultDocument(options) { rawMap: { rows: experiment.scan.rows, columns: experiment.scan.columns, - scale: "power", + scale: band.rawScale ?? rawScale, values: Array.from(band.rawMap), }, displayMap: { @@ -286,9 +334,11 @@ export function createHistorySummary({ arrayDefinition, scene, experiment, run, arrayName: arrayDefinition.name, sceneId: scene.id, experimentId: experiment.id, + algorithm: run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM, backend: run.backend, pointCount: experiment.scan.rows * experiment.scan.columns, totalMs: run.timingMs.total, + localizationMs: run.timingMs.localization ?? run.timingMs.beamforming, localizationErrorM: firstMatchedPeak?.localizationErrorM ?? null, peakToSidelobeDb: evaluation?.metrics?.peakToSidelobeDb ?? null, inputHash, diff --git a/apps/acoustic-camera/simulation-worker.js b/apps/acoustic-camera/simulation-worker.js index 3a528a9..2ecda9a 100644 --- a/apps/acoustic-camera/simulation-worker.js +++ b/apps/acoustic-camera/simulation-worker.js @@ -1,6 +1,7 @@ import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.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 = []) { self.postMessage({ type, jobId, ...payload }, transfer); @@ -46,7 +47,20 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf const frames = []; const transfer = []; 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, { stage: "timeline-start", @@ -82,13 +96,14 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf const frameSignalGeneration = performance.now() - signalStartedAt; signalGeneration += frameSignalGeneration; - const beamformingStartedAt = performance.now(); - const das = await computeDasWithBackend({ + const localizationStartedAt = performance.now(); + const localization = await computeLocalizationWithBackend({ channels: synthesis.channels, sampleRate: synthesis.sampleRate, arrayDefinition, - scene: synthesisWindow.scene, - experiment, + scene: localizationScene, + experiment: localizationExperiment, + ...(analysisPlan ? { analysisPlan } : {}), onFallback: ({ error }) => post("fallback", jobId, { code: error.code, @@ -105,9 +120,32 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf progress, }), }); - const frameBeamforming = performance.now() - beamformingStartedAt; - beamforming += frameBeamforming; - collectBandTransfers(das, transfer); + const frameLocalization = performance.now() - localizationStartedAt; + localizationTime += frameLocalization; + 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({ index: plannedFrame.index ?? frameIndex, @@ -116,12 +154,13 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf sampleTime: synthesisWindow.sampleTime, duration: synthesisWindow.duration, run: { - ...das, + ...localization, signalStats: signalStats(synthesis), timingMs: { total: performance.now() - frameStartedAt, signalGeneration: frameSignalGeneration, - beamforming: frameBeamforming, + localization: frameLocalization, + beamforming: frameLocalization, }, }, }); @@ -145,6 +184,7 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf { timeline: { backend: backends.size === 1 ? frames[0].run.backend : "mixed", + algorithm: experiment.beamforming.algorithm ?? "das", requestedBackend: experiment.beamforming.backend ?? "auto", duration: scene.duration, frameCount: frames.length, @@ -153,7 +193,8 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf timingMs: { total: performance.now() - totalStartedAt, signalGeneration, - beamforming, + localization: localizationTime, + beamforming: localizationTime, }, }, }, @@ -184,8 +225,8 @@ self.addEventListener("message", async (event) => { message: `마이크 신호 합성 완료 · ${signalGeneration.toFixed(0)} ms`, }); - const beamformingStartedAt = performance.now(); - const das = await computeDasWithBackend({ + const localizationStartedAt = performance.now(); + const localization = await computeLocalizationWithBackend({ channels: synthesis.channels, sampleRate: synthesis.sampleRate, arrayDefinition, @@ -198,20 +239,21 @@ self.addEventListener("message", async (event) => { }), onProgress: (progress) => post("progress", jobId, { stage: progress.stage, progress }), }); - const beamforming = performance.now() - beamformingStartedAt; + const localizationTime = performance.now() - localizationStartedAt; const transfer = []; - collectBandTransfers(das, transfer); + collectBandTransfers(localization, transfer); post( "result", jobId, { run: { - ...das, + ...localization, signalStats: signalStats(synthesis), timingMs: { total: performance.now() - totalStartedAt, signalGeneration, - beamforming, + localization: localizationTime, + beamforming: localizationTime, }, }, }, diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js index 92eab88..95f8360 100644 --- a/tests/integration/camera-ui.test.js +++ b/tests/integration/camera-ui.test.js @@ -76,6 +76,9 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed"); assert(!documentObject.querySelector("#array-details").open, "array developer detail 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("#frequency-response-mode").value, @@ -90,6 +93,13 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 documentObject.querySelector("#acoustic-environment-summary").textContent.includes("≤ 24 dB"), "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"); await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더"); @@ -125,6 +135,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 30000, ); 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"); assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 10, "result metrics 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}`) .join(" · ")}`); 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.columns, 8, "exported map columns"); 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", ); + 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 = [ ["rectangular-2x2-450mm", "4 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, ); 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"); const historyText = documentObject.querySelector("#history-list").textContent; assert(historyText.includes("rectangular baseline"), "baseline history retained"); 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(); 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()); assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind"); 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( !documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"), "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-rows"), 8); - dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu"); + dispatchValue(windowObject, documentObject.querySelector("#backend"), "auto"); dispatchValue(windowObject, documentObject.querySelector("#live-map-rate"), 2); await waitFor( () => documentObject.documentElement.dataset.cameraValid === "true" && documentObject.documentElement.dataset.liveState === "idle" && documentObject.querySelector("#scan-columns").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("#prepare-live-button").disabled, - "8x8 CPU 2 Hz live configuration", + "8x8 Auto 2 Hz live configuration", ); 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); 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"); dispatchValue(windowObject, documentObject.querySelector("#live-time"), 0.6, "input"); diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js index fe289bb..6ef1122 100644 --- a/tests/unit/camera.test.js +++ b/tests/unit/camera.test.js @@ -14,6 +14,8 @@ import { import { alignExperimentToScene, canonicalStringify, + createComparisonSignature, + createHistorySummary, createResultDocument, estimateExperimentWorkload, hashSimulationInputs, @@ -247,9 +249,49 @@ test("Camera workload counts unique overlapping FFT bins", () => { assertEqual(workload.frameCount, 62, "STFT frame count"); 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.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"); assertNear(workload.scalingVs50, 80 / 2500, 1e-15, "50x50 scaling"); 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", () => { @@ -415,11 +457,13 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( arrayId: arrayDefinition.id, sceneId: scene.id, 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" }], }; const run = { + algorithm: "das-phat", backend: "cpu", + referenceMic: "mic-1", signalStats: { preNormalizationPeak: 0.75, appliedScale: 1, @@ -433,6 +477,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( bands: [{ id: "band-1", color: "#65f2bf", + rawScale: "score", rawMap: Float32Array.of(0.1, 0.2, 0.3, 1), displayMap: Float32Array.of(0.1, 0.2, 0.3, 1), displayScale: "normalized", @@ -448,7 +493,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( localizationErrorPixels: 0.1, beamSpread: { calibrated: false, - method: "connected-half-power-power-weighted-covariance", + method: "connected-half-maximum-score-weighted-covariance", halfPowerRatio: 0.5, thresholdValue: 0.5, 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"); + 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"); assertEqual( 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.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 }); assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);