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 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>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>
@@ -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>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 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>
@@ -205,7 +205,7 @@
<div class="inspect-heading">
<div class="step-heading step-heading--horizontal">
<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>
<span class="result-chip" id="result-badge">아직 실행하지 않음</span>
</div>
@@ -219,30 +219,30 @@
<div class="overlay-toolbar">
<div class="band-legend" id="band-legend" aria-label="표시 주파수 대역"></div>
<label class="range-field"><span>Overlay</span><input id="overlay-opacity" type="range" min="0" max="1" step="0.05" value="0.72" /></label>
<label class="check-field" title="점선은 1× RMS, 실선 영역은 2× RMS 빔 분포입니다. 확률 또는 신뢰구간은 아닙니다."><input id="show-beam-spread" type="checkbox" checked /><span>빔 분포</span></label>
<label class="check-field" title="점선은 1× RMS, 실선 영역은 2× RMS 빔 응답 분포입니다. 확률 또는 신뢰구간은 아닙니다."><input id="show-beam-spread" type="checkbox" checked /><span>빔 분포</span></label>
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
</div>
<section class="result-section" aria-live="polite">
<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>
</section>
<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-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>
</details>
<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">
<p><strong>Heatmap</strong>DAS 공간 power이며 위치 확률분포가 아닙니다.</p>
<p><strong>빔 분포</strong>Half-power covariance 폭이며 신뢰구간이 아닙니다.</p>
<p><strong>Heatmap</strong>DAS는 power, PHAT 계열은 무차원 score이며 위치 확률분포가 아닙니다.</p>
<p><strong>빔 분포</strong>Half-power 또는 half-maximum 응답 폭이며 신뢰구간이 아닙니다.</p>
<p><strong>이동 재생</strong>준정적 frame 시뮬레이션이며 실제 장치 스트림이 아닙니다.</p>
</div>
</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 {
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();
+54 -4
View File
@@ -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,
+60 -18
View File
@@ -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,
},
},
},