feat: compare localization algorithms in camera
This commit is contained in:
+124
-34
@@ -1,5 +1,10 @@
|
||||
import { evaluateDasBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
|
||||
import { evaluateLocalizationBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
|
||||
import { DEFAULT_MAX_COMPENSATION_DB } from "/packages/acoustics-core/frequency-response.js";
|
||||
import {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user