From d04a4d9f65e33b934e41bd90cbc221bfdf362a45 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 12:11:10 +0900 Subject: [PATCH] feat: complete acoustic camera workflow --- apps/acoustic-camera/asset-runtime.js | 261 +++++ apps/acoustic-camera/index.html | 135 ++- apps/acoustic-camera/main.js | 1139 +++++++++++++++++++++ apps/acoustic-camera/model.js | 177 ++++ apps/acoustic-camera/simulation-worker.js | 82 ++ apps/acoustic-camera/styles.css | 120 +++ apps/acoustic-camera/visualization.js | 681 ++++++++++++ packages/acoustics-core/evaluation.js | 523 ++++++++++ packages/acoustics-core/index.js | 1 + tests/integration/camera-ui.test.js | 147 +++ tests/test-runner.js | 3 + tests/unit/camera.test.js | 182 ++++ tests/unit/evaluation.test.js | 197 ++++ 13 files changed, 3632 insertions(+), 16 deletions(-) create mode 100644 apps/acoustic-camera/asset-runtime.js create mode 100644 apps/acoustic-camera/main.js create mode 100644 apps/acoustic-camera/model.js create mode 100644 apps/acoustic-camera/simulation-worker.js create mode 100644 apps/acoustic-camera/styles.css create mode 100644 apps/acoustic-camera/visualization.js create mode 100644 packages/acoustics-core/evaluation.js create mode 100644 tests/integration/camera-ui.test.js create mode 100644 tests/unit/camera.test.js create mode 100644 tests/unit/evaluation.test.js diff --git a/apps/acoustic-camera/asset-runtime.js b/apps/acoustic-camera/asset-runtime.js new file mode 100644 index 0000000..50833c0 --- /dev/null +++ b/apps/acoustic-camera/asset-runtime.js @@ -0,0 +1,261 @@ +import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js"; +import { sha256File } from "/packages/contracts/io.js"; + +const MAX_FILE_BYTES = 128 * 1024 * 1024; +const MAX_BOUND_FILE_BYTES = 512 * 1024 * 1024; + +function basename(path) { + return String(path).split(/[\\/]/).pop(); +} + +function kindForFile(file) { + if (file.type.startsWith("image/") || /\.(?:png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name)) return "image"; + if (file.type.startsWith("audio/") || /\.(?:wav|flac|mp3|ogg|m4a|aac)$/i.test(file.name)) return "audio"; + return null; +} + +function referencedAssetIds(scene) { + const ids = new Set(); + if (scene.image?.assetId) ids.add(scene.image.assetId); + for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) { + if (entry.signal?.type === "asset") ids.add(entry.signal.assetId); + } + return ids; +} + +function audioAssetIds(scene) { + const ids = new Set(); + for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) { + if (entry.signal?.type === "asset") ids.add(entry.signal.assetId); + } + return ids; +} + +async function loadImageElement(file) { + const url = URL.createObjectURL(file); + const image = new Image(); + image.decoding = "async"; + try { + await new Promise((resolve, reject) => { + image.addEventListener("load", resolve, { once: true }); + image.addEventListener("error", () => reject(new Error(`${file.name} 이미지를 decode하지 못했습니다.`)), { + once: true, + }); + image.src = url; + }); + return { image, url }; + } catch (error) { + URL.revokeObjectURL(url); + throw error; + } +} + +function waitForDecode(decodePromise, signal) { + if (!signal) return decodePromise; + if (signal.aborted) return Promise.reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError")); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError")); + signal.addEventListener("abort", onAbort, { once: true }); + decodePromise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +export function createCameraAssetRuntime(onChange = () => {}) { + const files = new Map(); + const errors = new Map(); + let imageElement = null; + let imageUrl = null; + let generation = 0; + + function revokeImage() { + if (imageUrl) URL.revokeObjectURL(imageUrl); + imageUrl = null; + imageElement = null; + } + + function reset() { + generation += 1; + files.clear(); + errors.clear(); + revokeImage(); + onChange(); + } + + function totalBoundBytes(exceptIds = new Set()) { + let total = 0; + const countedFiles = new Set(); + for (const [assetId, file] of files) { + if (!exceptIds.has(assetId) && !countedFiles.has(file)) { + total += file.size; + countedFiles.add(file); + } + } + return total; + } + + async function refreshImage(scene, expectedGeneration) { + const imageAssetId = scene.image?.assetId; + if (!imageAssetId || !files.has(imageAssetId)) { + revokeImage(); + return; + } + const file = files.get(imageAssetId); + const loaded = await loadImageElement(file); + if (generation !== expectedGeneration) { + URL.revokeObjectURL(loaded.url); + return; + } + revokeImage(); + imageElement = loaded.image; + imageUrl = loaded.url; + } + + async function bindFiles(selectedFiles, scene) { + const currentGeneration = ++generation; + const descriptors = scene.assets ?? []; + const bound = []; + const rejected = []; + for (const file of selectedFiles) { + try { + if (file.size > MAX_FILE_BYTES) { + throw new Error(`${file.name} 크기가 128 MiB 파일 한도를 넘습니다.`); + } + const kind = kindForFile(file); + if (!kind) throw new Error(`${file.name}의 image/audio 종류를 판별할 수 없습니다.`); + const hash = await sha256File(file); + if (generation !== currentGeneration) return { bound, rejected, stale: true }; + const candidates = descriptors.filter((descriptor) => descriptor.kind === kind); + let matches = candidates.filter((descriptor) => descriptor.sha256 === hash); + if (matches.length === 0) { + matches = candidates.filter( + (descriptor) => !descriptor.sha256 && basename(descriptor.fileName) === file.name, + ); + } + if (matches.length === 0) { + const conflict = candidates.some( + (descriptor) => basename(descriptor.fileName) === file.name && descriptor.sha256 !== hash, + ); + throw new Error( + conflict + ? `${file.name}의 SHA-256이 manifest와 다릅니다.` + : `${file.name}에 대응하는 ${kind} asset descriptor가 없습니다.`, + ); + } + const replacingIds = new Set(matches.map((descriptor) => descriptor.id)); + const nextTotal = totalBoundBytes(replacingIds) + file.size; + if (nextTotal > MAX_BOUND_FILE_BYTES) { + throw new Error(`연결 파일 합계가 512 MiB 한도를 넘습니다.`); + } + for (const descriptor of matches) { + descriptor.sha256 ??= hash; + files.set(descriptor.id, file); + errors.delete(descriptor.id); + bound.push(descriptor.id); + } + } catch (error) { + rejected.push({ fileName: file.name, message: error instanceof Error ? error.message : String(error) }); + } + } + try { + await refreshImage(scene, currentGeneration); + } catch (error) { + if (scene.image?.assetId) errors.set(scene.image.assetId, error.message); + rejected.push({ fileName: files.get(scene.image?.assetId)?.name ?? "image", message: error.message }); + } + if (generation === currentGeneration) onChange(); + return { bound: [...new Set(bound)], rejected, stale: generation !== currentGeneration }; + } + + function statuses(scene) { + const referenced = referencedAssetIds(scene); + return (scene.assets ?? []) + .filter((descriptor) => referenced.has(descriptor.id)) + .map((descriptor) => ({ + descriptor, + file: files.get(descriptor.id) ?? null, + error: errors.get(descriptor.id) ?? null, + ready: files.has(descriptor.id) && !errors.has(descriptor.id), + })); + } + + function missingAssetIds(scene) { + return statuses(scene).filter((status) => !status.ready).map(({ descriptor }) => descriptor.id); + } + + async function decodeAudioAssets(scene, signal, onProgress = () => {}) { + const ids = [...audioAssetIds(scene)]; + const entries = [...scene.sources, ...(scene.ambient ?? [])]; + const decoded = new Map(); + const decodedByFile = new Map(); + let retainedBytes = 0; + for (let index = 0; index < ids.length; index += 1) { + const assetId = ids[index]; + const file = files.get(assetId); + if (!file) throw new Error(`오디오 원본 asset을 연결하세요: ${assetId}`); + onProgress({ completed: index, total: ids.length, assetId, fileName: file.name }); + let audio = decodedByFile.get(file); + if (!audio) { + const remaining = MAX_DECODED_AUDIO_BYTES - retainedBytes; + audio = await waitForDecode( + decodeAudioFile(file, scene.sampleRate, remaining, signal), + signal, + ); + decodedByFile.set(file, audio); + retainedBytes += audio.channels.reduce((sum, channel) => sum + channel.byteLength, 0); + } + for (const entry of entries) { + if (entry.signal?.type !== "asset" || entry.signal.assetId !== assetId) continue; + const channel = entry.signal.channel ?? 0; + if (!audio.channels[channel]) { + throw new Error( + `${assetId}은 ${audio.channels.length}채널 오디오이므로 요청한 channel ${channel}을 사용할 수 없습니다.`, + ); + } + } + decoded.set(assetId, audio); + } + onProgress({ completed: ids.length, total: ids.length }); + return decoded; + } + + function transferListForAudio(assetBuffers) { + const transfer = []; + const seen = new Set(); + for (const audio of assetBuffers.values()) { + for (const channel of audio.channels) { + if (!seen.has(channel.buffer)) { + seen.add(channel.buffer); + transfer.push(channel.buffer); + } + } + } + return transfer; + } + + return { + bindFiles, + decodeAudioAssets, + get imageElement() { + return imageElement; + }, + missingAssetIds, + reset, + statuses, + transferListForAudio, + dispose() { + generation += 1; + revokeImage(); + files.clear(); + errors.clear(); + }, + }; +} diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index 4150469..cd61735 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -3,30 +3,133 @@ - + Acoustic Camera · micarrsim + -
-
- - micarrsim - Acoustic Camera - - 런타임 확인 중… +
+
+ micarrsimAcoustic Camera +
+ 런타임 확인 중… + 기본 fixture 준비 중… +
+
+ + + +
-
-
-

Beamforming laboratory

-

배열 성능 비교하기

-

- 이 화면에서 scene과 192채널 배열을 불러와 자유음장 전달 신호를 만들고 주파수 대역별 - DAS acoustic image를 ground truth와 비교하게 됩니다. -

+ +
+
+
+
+

Ground truth + acoustic overlay

+

음향 장면

+

장면을 불러오고 있습니다.

+
+
+ + + + +
+
+ +
+ +
기본 synthetic scene이 준비됩니다.실행하면 주파수별 acoustic image가 이 평면에 겹쳐집니다.
+
결과 없음
+
+ +
+
+ + +
+ +
+

Evaluation

실행 결과

아직 실행하지 않음
+
시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.
+ +
+ +
+

Same-scene comparison

배열 비교 기록

+

Array JSON을 바꿔 다시 실행하면 같은 scene의 결과를 이 세션에서 나란히 비교합니다.

+
+
+ +
+ diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js new file mode 100644 index 0000000..be4cf43 --- /dev/null +++ b/apps/acoustic-camera/main.js @@ -0,0 +1,1139 @@ +import { evaluateDasBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js"; +import { downloadJson, readJsonFile } from "/packages/contracts/io.js"; +import { + validateArrayDefinition, + validateExperiment, + validateResult, + validateScene, +} from "/packages/contracts/validation.js"; +import { createCameraAssetRuntime } from "./asset-runtime.js"; +import { + canonicalStringify, + createHistorySummary, + createResultDocument, + estimateExperimentWorkload, + hashSimulationInputs, + safeFileStem, + shiftSceneDistance, +} from "./model.js"; +import { createArrayVisualization, createSceneVisualization } from "./visualization.js"; + +const DEFAULT_DOCUMENT_URLS = { + arrayDefinition: "/assets/array-geometries/rectangular-16x12.array.json", + scene: "/assets/sample-scenes/single-tone.scene.json", + experiment: "/assets/sample-scenes/single-tone.experiment.json", +}; +const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"]; +const MAX_HISTORY_ITEMS = 24; + +const byId = (id) => document.getElementById(id); +const elements = { + runStatus: byId("run-status"), + runButton: byId("run-button"), + cancelButton: byId("cancel-button"), + exportResultButton: byId("export-result-button"), + sceneInput: byId("scene-input"), + arrayInput: byId("array-input"), + experimentInput: byId("experiment-input"), + assetInput: byId("asset-input"), + workspaceTitle: byId("workspace-title"), + sceneMeta: byId("scene-meta"), + sceneCanvas: byId("scene-canvas"), + canvasMessage: byId("canvas-message"), + canvasBadge: byId("canvas-badge"), + bandLegend: byId("band-legend"), + overlayOpacity: byId("overlay-opacity"), + showTruth: byId("show-truth"), + resultBadge: byId("result-badge"), + emptyResult: byId("empty-result"), + metricGrid: byId("metric-grid"), + clearHistoryButton: byId("clear-history-button"), + historyList: byId("history-list"), + arrayTitle: byId("array-title"), + microphoneCount: byId("microphone-count"), + arrayCanvas: byId("array-canvas"), + arrayMeta: byId("array-meta"), + sourceSummary: byId("source-summary"), + assetList: byId("asset-list"), + experimentName: byId("experiment-name"), + experimentId: byId("experiment-id"), + sceneDistance: byId("scene-distance"), + speedOfSound: byId("speed-of-sound"), + sceneSeed: byId("scene-seed"), + backend: byId("backend"), + gridPreset: byId("grid-preset"), + scanColumns: byId("scan-columns"), + scanRows: byId("scan-rows"), + scanXMin: byId("scan-x-min"), + scanXMax: byId("scan-x-max"), + scanYMin: byId("scan-y-min"), + scanYMax: byId("scan-y-max"), + workloadSummary: byId("workload-summary"), + fftSize: byId("fft-size"), + hopSize: byId("hop-size"), + normalization: byId("normalization"), + dynamicRange: byId("dynamic-range"), + postprocess: byId("postprocess"), + addBandButton: byId("add-band-button"), + bandList: byId("band-list"), + exportExperimentButton: byId("export-experiment-button"), + validationCard: document.querySelector(".validation-card"), + validationTitle: byId("validation-title"), + validationMessage: byId("validation-message"), +}; + +const state = { + arrayDefinition: null, + scene: null, + experiment: null, + revision: 0, + validationSequence: 0, + validation: { valid: false, issues: [] }, + visibleBandIds: new Set(), + lastRun: null, + history: [], + active: null, + pendingInputOperations: 0, + importGenerations: { array: 0, scene: 0, experiment: 0 }, +}; + +const sceneVisualization = createSceneVisualization(elements.sceneCanvas); +const arrayVisualization = createArrayVisualization(elements.arrayCanvas); +const assetRuntime = createCameraAssetRuntime(() => { + renderAssets(); + renderSceneVisualization(); + scheduleValidation(); +}); + +let validationQueued = false; +let jobSequence = 0; + +function formatNumber(value, digits = 1) { + return Number.isFinite(value) ? value.toFixed(digits) : "—"; +} + +function formatCount(value) { + if (!Number.isFinite(value)) return "—"; + return new Intl.NumberFormat("ko-KR", { + notation: value >= 1e6 ? "compact" : "standard", + maximumFractionDigits: 1, + }).format(value); +} + +function formatIssue(issue) { + return `${issue.path}: ${issue.message}`; +} + +function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) { + if (!scene || !sourceExperiment) return ""; + const experiment = structuredClone(sourceExperiment); + experiment.arrayId = "$array"; + return canonicalStringify({ scene, experiment }); +} + +function setRunStatus(message) { + elements.runStatus.textContent = message; +} + +function setTransientError(error, prefix = "오류") { + const message = error instanceof Error ? error.message : String(error); + setRunStatus(`${prefix} · ${message}`); + elements.validationCard.classList.remove("is-valid", "is-warning"); + elements.validationCard.classList.add("is-invalid"); + elements.validationTitle.textContent = prefix; + elements.validationMessage.textContent = message; +} + +function setConfigurationLocked(locked) { + setEditableControlsDisabled(locked); + elements.runButton.disabled = locked || !state.validation.valid; + elements.cancelButton.classList.toggle("is-hidden", !locked); + elements.exportResultButton.disabled = locked || !state.lastRun; +} + +function setEditableControlsDisabled(disabled) { + document.querySelectorAll( + ".control-panel input, .control-panel select, .control-panel button, .workspace-tools input", + ).forEach((control) => { + control.disabled = disabled; + }); +} + +function beginInputOperation() { + state.pendingInputOperations += 1; + document.documentElement.dataset.inputBusy = "true"; + elements.runButton.disabled = true; + document.querySelectorAll(".workspace-tools input").forEach((input) => { + input.disabled = true; + }); +} + +function endInputOperation() { + state.pendingInputOperations = Math.max(0, state.pendingInputOperations - 1); + document.documentElement.dataset.inputBusy = String(state.pendingInputOperations > 0); + if ( + state.pendingInputOperations === 0 && + !state.active && + document.documentElement.dataset.appReady === "true" + ) { + document.querySelectorAll(".workspace-tools input").forEach((input) => { + input.disabled = false; + }); + } + scheduleValidation(); +} + +function readFiniteInput(element) { + return Number.isFinite(element.valueAsNumber) ? element.valueAsNumber : null; +} + +function markChanged(options = {}) { + state.revision += 1; + if (state.lastRun) state.lastRun.stale = true; + if (["error", "canceled"].includes(document.documentElement.dataset.runState)) { + document.documentElement.dataset.runState = "idle"; + } + if (options.syncControls) syncControls(); + if (options.renderBands) renderBandEditors(); + renderDocumentSummary(); + renderLegend(); + renderWorkload(); + renderSceneVisualization(); + renderResult(); + renderHistory(); + scheduleValidation(); +} + +function syncControls() { + const { scene, experiment } = state; + if (!scene || !experiment) return; + elements.experimentName.value = experiment.name; + elements.experimentId.value = experiment.id; + elements.sceneDistance.value = experiment.scan.z; + elements.speedOfSound.value = scene.speedOfSound; + elements.sceneSeed.value = scene.seed; + elements.backend.value = experiment.beamforming.backend; + elements.scanColumns.value = experiment.scan.columns; + elements.scanRows.value = experiment.scan.rows; + elements.scanXMin.value = experiment.scan.xMin; + elements.scanXMax.value = experiment.scan.xMax; + elements.scanYMin.value = experiment.scan.yMin; + elements.scanYMax.value = experiment.scan.yMax; + elements.fftSize.value = experiment.stft.fftSize; + elements.hopSize.value = experiment.stft.hopSize; + elements.normalization.value = experiment.beamforming.normalization; + elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb; + elements.gridPreset.value = + experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows) + ? String(experiment.scan.rows) + : "custom"; +} + +function describeSignal(signal) { + if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`; + if (signal.type === "noise") return `${signal.color} noise`; + return `asset · ${signal.assetId}`; +} + +function renderDocumentSummary() { + const { arrayDefinition, scene, experiment } = state; + if (!arrayDefinition || !scene || !experiment) return; + elements.workspaceTitle.textContent = scene.name; + elements.sceneMeta.textContent = + `${scene.sampleRate.toLocaleString("ko-KR")} Hz · ${formatNumber(scene.duration, 3)} s · ` + + `${scene.sources.length} source · scan z ${formatNumber(experiment.scan.z, 2)} m`; + elements.arrayTitle.textContent = arrayDefinition.name; + elements.microphoneCount.textContent = `${arrayDefinition.microphones.length} ch`; + elements.arrayMeta.textContent = + `${arrayDefinition.id} · ${formatNumber(arrayDefinition.aperture.width, 3)} × ` + + `${formatNumber(arrayDefinition.aperture.height, 3)} m`; + elements.sourceSummary.replaceChildren(); + for (const source of scene.sources) { + const line = document.createElement("div"); + line.className = "source-line"; + const name = document.createElement("strong"); + name.textContent = source.name; + const detail = document.createElement("span"); + detail.textContent = + `${describeSignal(source.signal)} · ` + + `(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m`; + line.append(name, detail); + elements.sourceSummary.append(line); + } + arrayVisualization.render({ arrayDefinition }); +} + +function renderAssets() { + elements.assetList.replaceChildren(); + if (!state.scene) return; + const statuses = assetRuntime.statuses(state.scene); + if (statuses.length === 0) { + const empty = document.createElement("p"); + empty.className = "asset-empty"; + empty.textContent = "이 장면은 외부 asset 없이 바로 실행됩니다."; + elements.assetList.append(empty); + return; + } + for (const status of statuses) { + const row = document.createElement("div"); + row.className = `asset-status ${status.ready ? "is-ready" : status.error ? "is-error" : ""}`; + const label = document.createElement("strong"); + label.textContent = status.descriptor.fileName; + const value = document.createElement("span"); + value.textContent = status.ready ? "연결됨" : status.error ? "오류" : "파일 필요"; + value.title = status.error ?? ""; + row.append(label, value); + elements.assetList.append(row); + } +} + +function renderBandEditors() { + elements.bandList.replaceChildren(); + if (!state.experiment) return; + state.experiment.frequencyBands.forEach((band, index) => { + const editor = document.createElement("div"); + editor.className = "band-editor"; + const fields = [ + ["id", "text", band.id, "Band ID"], + ["minHz", "number", band.minHz, "Min Hz"], + ["maxHz", "number", band.maxHz, "Max Hz"], + ["color", "color", band.color, "Color"], + ]; + for (const [key, type, value, label] of fields) { + const input = document.createElement("input"); + input.type = type; + input.value = value; + input.dataset.bandIndex = String(index); + input.dataset.bandKey = key; + input.setAttribute("aria-label", `${band.id} ${label}`); + if (type === "number") { + input.min = "0"; + input.step = "1"; + } + editor.append(input); + } + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "band-editor__remove"; + remove.dataset.removeBand = String(index); + remove.textContent = "×"; + remove.title = "대역 삭제"; + remove.disabled = state.experiment.frequencyBands.length === 1 || Boolean(state.active); + editor.append(remove); + elements.bandList.append(editor); + }); + elements.addBandButton.disabled = state.experiment.frequencyBands.length >= 8 || Boolean(state.active); +} + +function renderLegend() { + elements.bandLegend.replaceChildren(); + if (!state.experiment) return; + for (const band of state.experiment.frequencyBands) { + const label = document.createElement("label"); + label.className = "legend-toggle"; + label.style.setProperty("--band-color", band.color); + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = state.visibleBandIds.has(band.id); + input.dataset.visibleBand = band.id; + const swatch = document.createElement("span"); + swatch.className = "legend-swatch"; + const text = document.createElement("span"); + text.textContent = `${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`; + label.append(input, swatch, text); + elements.bandLegend.append(label); + } +} + +function renderWorkload() { + if (!state.arrayDefinition || !state.scene || !state.experiment) return; + try { + const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment); + elements.workloadSummary.textContent = + `${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` + + `${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` + + `약 ${formatCount(workload.complexMacCount)} complex MAC · steering ${formatNumber(workload.distanceMiB, 1)} MiB`; + const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8; + elements.workloadSummary.classList.toggle("is-warning", heavyCpu || workload.pointCount >= 40000); + } catch (error) { + elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`; + elements.workloadSummary.classList.add("is-warning"); + } +} + +function bandsForVisualization(lastRun) { + if (!lastRun) return []; + const definitions = new Map(lastRun.snapshot.experiment.frequencyBands.map((band) => [band.id, band])); + const snapshotIds = [...definitions.keys()]; + const currentIds = state.experiment.frequencyBands.map((band) => band.id); + const sameBandSet = + snapshotIds.length === currentIds.length && snapshotIds.every((id) => currentIds.includes(id)); + const visibleBandIds = sameBandSet ? state.visibleBandIds : lastRun.visibleBandIds; + return lastRun.run.bands.map((band) => ({ + ...band, + color: band.color ?? definitions.get(band.id)?.color ?? "#65f2bf", + visible: visibleBandIds.has(band.id), + })); +} + +function renderSceneVisualization() { + if (!state.scene || !state.experiment) return; + const displayed = state.lastRun; + const snapshot = displayed?.snapshot ?? { scene: state.scene, experiment: state.experiment }; + sceneVisualization.render({ + scene: snapshot.scene, + experiment: snapshot.experiment, + image: displayed ? displayed.imageElement : assetRuntime.imageElement, + bands: bandsForVisualization(displayed), + evaluations: displayed?.evaluations, + dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, + opacity: Number(elements.overlayOpacity.value), + showTruth: elements.showTruth.checked, + }); + elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement)); + if (!displayed) { + elements.canvasBadge.textContent = "결과 없음"; + } else if (displayed.stale) { + elements.canvasBadge.textContent = `이전 설정 결과 · ${displayed.run.backend.toUpperCase()}`; + } else { + elements.canvasBadge.textContent = + `${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` + + `${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`; + } +} + +function appendMetric(label, value, detail, color) { + const card = document.createElement("article"); + card.className = "metric-card"; + if (color) card.style.borderTopColor = color; + const labelElement = document.createElement("span"); + labelElement.textContent = label; + const valueElement = document.createElement("strong"); + valueElement.textContent = value; + const detailElement = document.createElement("small"); + detailElement.textContent = detail; + card.append(labelElement, valueElement, detailElement); + elements.metricGrid.append(card); +} + +function renderResult() { + const displayed = state.lastRun; + elements.exportResultButton.disabled = Boolean(state.active) || !displayed; + elements.metricGrid.replaceChildren(); + elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed)); + elements.metricGrid.classList.toggle("is-hidden", !displayed); + if (!displayed) { + elements.resultBadge.textContent = "아직 실행하지 않음"; + return; + } + elements.resultBadge.textContent = displayed.stale + ? `이전 설정 · ${displayed.run.backend.toUpperCase()}` + : `${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`; + appendMetric( + "Backend", + displayed.run.backend.toUpperCase(), + displayed.run.fallback ? "WebGPU → CPU fallback" : "요청한 계산 경로", + ); + appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + DAS"); + appendMetric( + "신호 합성", + `${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`, + `${displayed.snapshot.arrayDefinition.microphones.length} channels`, + ); + appendMetric( + "Beamforming", + `${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`, + `${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`, + ); + for (const band of displayed.snapshot.experiment.frequencyBands) { + const evaluation = displayed.evaluations.get(band.id); + if (!evaluation) continue; + const matchedErrors = evaluation.sourceMatches + .filter((match) => match.withinTolerance && Number.isFinite(match.localizationErrorM)) + .map((match) => match.localizationErrorM); + const localization = matchedErrors.length ? Math.min(...matchedErrors) : null; + appendMetric( + `${band.id} · 위치 오차`, + localization === null ? "미검출" : `${formatNumber(localization * 100, 1)} cm`, + `${evaluation.counts.truePositive}/${evaluation.counts.truth} source`, + band.color, + ); + appendMetric( + `${band.id} · Truth energy`, + `${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`, + "ground-truth 반경 내", + band.color, + ); + appendMetric( + `${band.id} · Peak/sidelobe`, + `${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`, + "높을수록 분리 우수", + band.color, + ); + appendMetric( + `${band.id} · Precision / Recall`, + `${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`, + `${evaluation.counts.detected} peaks`, + band.color, + ); + appendMetric( + `${band.id} · Main lobe`, + `${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`, + "half-power equivalent diameter", + band.color, + ); + } +} + +function renderHistory() { + elements.historyList.replaceChildren(); + const signature = comparisonSignature(); + const items = state.history.filter((item) => item.comparisonSignature === signature); + if (items.length === 0) { + const empty = document.createElement("div"); + empty.className = "history-empty"; + empty.textContent = "현재 scene·설정의 비교 실행 기록이 없습니다."; + elements.historyList.append(empty); + return; + } + for (const item of items) { + const card = document.createElement("article"); + card.className = "history-card"; + const name = document.createElement("strong"); + name.textContent = item.arrayName; + const fields = [ + `${item.backend.toUpperCase()} · ${item.pointCount.toLocaleString("ko-KR")} pt`, + `${formatNumber(item.totalMs, 0)} ms`, + item.localizationErrorM === null ? "오차 —" : `오차 ${formatNumber(item.localizationErrorM * 100, 1)} cm`, + item.peakToSidelobeDb === null ? "PSR —" : `PSR ${formatNumber(item.peakToSidelobeDb, 1)} dB`, + ]; + card.append(name); + for (const field of fields) { + const value = document.createElement("span"); + value.textContent = field; + card.append(value); + } + elements.historyList.append(card); + } +} + +function validationWarnings() { + const warnings = []; + if (!state.scene || !state.experiment || !state.arrayDefinition) return warnings; + const zMismatch = state.scene.sources.some( + (source) => Math.abs(source.position[2] - state.experiment.scan.z) > 1e-6, + ); + if (zMismatch) { + warnings.push("일부 source z가 scan z와 달라 ground truth에는 평면 투영값을 사용합니다."); + } + if ( + state.scene.image?.plane && + Math.abs(state.scene.image.plane.z - state.experiment.scan.z) > 1e-6 + ) { + warnings.push("배경 image plane z와 scan z가 다릅니다. 이미지는 x/y 좌표만 투영해 표시합니다."); + } + 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를 권장합니다."); + } else if (workload.pointCount >= 40000) { + warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다."); + } + } catch { + // Contract validation reports the actionable error. + } + return warnings; +} + +async function validateCurrent() { + const sequence = ++state.validationSequence; + if (!state.arrayDefinition || !state.scene || !state.experiment) { + const validation = { + valid: false, + issues: [{ path: "$", message: "문서를 불러오는 중입니다." }], + warnings: [], + }; + if (sequence === state.validationSequence) applyValidation(validation); + return validation; + } + const revision = state.revision; + const snapshot = structuredClone({ + arrayDefinition: state.arrayDefinition, + scene: state.scene, + experiment: state.experiment, + }); + const [arrayIssues, sceneIssues, experimentIssues] = await Promise.all([ + validateArrayDefinition(snapshot.arrayDefinition), + validateScene(snapshot.scene), + validateExperiment(snapshot.experiment, { + arrayDefinition: snapshot.arrayDefinition, + scene: snapshot.scene, + }), + ]); + const issues = [ + ...arrayIssues.map((issue) => ({ ...issue, path: `array${issue.path}` })), + ...sceneIssues.map((issue) => ({ ...issue, path: `scene${issue.path}` })), + ...experimentIssues.map((issue) => ({ ...issue, path: `experiment${issue.path}` })), + ]; + const missingAssets = assetRuntime.missingAssetIds(snapshot.scene); + if (missingAssets.length > 0) { + issues.push({ + path: "scene.assets", + message: `원본 파일을 연결하세요: ${missingAssets.join(", ")}`, + }); + } + if (state.pendingInputOperations > 0) { + issues.push({ path: "$", message: "선택한 파일을 확인하고 있습니다. 잠시 기다리세요." }); + } + const validation = { + valid: issues.length === 0, + issues, + warnings: validationWarnings(), + revision, + }; + if (sequence === state.validationSequence) applyValidation(validation); + return validation; +} + +function applyValidation(validation) { + state.validation = validation; + elements.validationCard.classList.remove("is-valid", "is-invalid", "is-warning"); + if (!validation.valid) { + elements.validationCard.classList.add("is-invalid"); + elements.validationTitle.textContent = `${validation.issues.length}개 항목 확인 필요`; + elements.validationMessage.textContent = validation.issues.slice(0, 2).map(formatIssue).join(" · "); + } else if (validation.warnings.length > 0) { + elements.validationCard.classList.add("is-warning"); + elements.validationTitle.textContent = "실행 가능 · 주의"; + elements.validationMessage.textContent = validation.warnings.join(" · "); + } else { + elements.validationCard.classList.add("is-valid"); + elements.validationTitle.textContent = "실행 준비 완료"; + elements.validationMessage.textContent = "계약 검증과 asset 연결 상태가 정상입니다."; + } + document.documentElement.dataset.cameraValid = String(validation.valid); + if (!state.active) { + elements.runButton.disabled = !validation.valid; + if (validation.valid) { + elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ 기본 예제 실행"; + if (!state.lastRun && document.documentElement.dataset.runState === "idle") { + setRunStatus( + `${state.arrayDefinition.microphones.length} ch · ` + + `${state.experiment.scan.columns}×${state.experiment.scan.rows} · 실행 준비 완료`, + ); + } + } + } +} + +function scheduleValidation() { + if (validationQueued) return; + validationQueued = true; + queueMicrotask(async () => { + validationQueued = false; + await validateCurrent(); + }); +} + +function sourcesForBand(scene, band) { + return scene.sources.filter((source) => { + if (source.signal.type !== "tone") return true; + return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz; + }); +} + +function createAbortError() { + const error = new Error("시뮬레이션이 취소되었습니다."); + error.name = "AbortError"; + return error; +} + +function progressMessage(message) { + if (message.message) return message.message; + const progress = message.progress; + if (progress?.totalBins) { + return `DAS 계산 중 · ${progress.completedBins}/${progress.totalBins} bins`; + } + if (progress?.totalChunks) { + return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`; + } + return `계산 중 · ${message.stage ?? "worker"}`; +} + +function runWorker(activity, payload, transfer) { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" }); + activity.worker = worker; + activity.reject = reject; + worker.addEventListener("message", (event) => { + const message = event.data; + if (message?.jobId !== activity.jobId || state.active !== activity || activity.finished) return; + if (message.type === "progress") { + document.documentElement.dataset.runStage = message.stage ?? "worker"; + setRunStatus(progressMessage(message)); + } else if (message.type === "fallback") { + activity.fallback = { code: message.code, message: message.message }; + setRunStatus(`WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`); + } else if (message.type === "result") { + activity.finished = true; + resolve(message.run); + } else if (message.type === "error") { + activity.finished = true; + const error = new Error(message.error?.message ?? "Worker 실행에 실패했습니다."); + error.name = message.error?.name ?? "Error"; + error.code = message.error?.code ?? ""; + reject(error); + } + }); + worker.addEventListener("error", (event) => { + if (activity.finished) return; + activity.finished = true; + reject(new Error(event.message || "Simulation Worker를 시작하지 못했습니다.")); + }); + worker.postMessage(payload, transfer); + }); +} + +async function runSimulation() { + if (state.active) return; + const validation = await validateCurrent(); + if (!validation.valid) { + setTransientError( + new Error(validation.issues.slice(0, 2).map(formatIssue).join(" · ")), + "실행 전 검증 실패", + ); + return; + } + if (validation.revision !== state.revision || state.pendingInputOperations > 0) { + setRunStatus("입력 파일 또는 설정이 갱신되었습니다. 검증 완료 후 다시 실행하세요."); + await validateCurrent(); + return; + } + const snapshot = { + arrayDefinition: structuredClone(state.arrayDefinition), + scene: structuredClone(state.scene), + experiment: structuredClone(state.experiment), + }; + const activity = { + jobId: `camera-${Date.now()}-${++jobSequence}`, + revision: state.revision, + abortController: new AbortController(), + worker: null, + reject: null, + canceled: false, + finished: false, + }; + state.active = activity; + document.documentElement.dataset.runState = "running"; + document.documentElement.dataset.runStage = "assets"; + setConfigurationLocked(true); + elements.runButton.textContent = "계산 중…"; + setRunStatus("실행 입력을 고정하고 asset을 준비하고 있습니다…"); + try { + const inputHashPromise = hashSimulationInputs(snapshot); + const assetBuffers = await assetRuntime.decodeAudioAssets( + snapshot.scene, + activity.abortController.signal, + ({ completed, total, fileName }) => { + if (state.active !== activity) return; + setRunStatus( + total > 0 + ? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}` + : "Synthetic signal 준비 완료", + ); + }, + ); + if (activity.canceled || state.active !== activity) throw createAbortError(); + document.documentElement.dataset.runStage = "worker"; + const run = await runWorker( + activity, + { + type: "run", + jobId: activity.jobId, + scene: snapshot.scene, + arrayDefinition: snapshot.arrayDefinition, + experiment: snapshot.experiment, + assetBuffers, + }, + assetRuntime.transferListForAudio(assetBuffers), + ); + if (activity.canceled || state.active !== activity) throw createAbortError(); + setRunStatus("Peak 검출과 정량 지표를 계산하고 있습니다…"); + const evaluations = new Map(); + const exportedEvaluations = new Map(); + for (const band of run.bands) { + const definition = snapshot.experiment.frequencyBands.find( + (candidate) => candidate.id === band.id, + ); + const evaluation = evaluateDasBand({ + rawMap: band.rawMap, + scan: snapshot.experiment.scan, + sources: sourcesForBand(snapshot.scene, definition), + dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, + }); + evaluations.set(band.id, evaluation); + exportedEvaluations.set(band.id, exportBandEvaluation(evaluation)); + } + const inputHash = await inputHashPromise; + const resultDocument = createResultDocument({ + ...snapshot, + run, + evaluations: exportedEvaluations, + inputHash, + }); + const resultIssues = await validateResult(resultDocument, { + experiment: snapshot.experiment, + scene: snapshot.scene, + }); + if (resultIssues.length > 0) { + throw new Error( + `Result 계약 검증 실패 · ${resultIssues.slice(0, 3).map(formatIssue).join(" · ")}`, + ); + } + const completedRun = { + snapshot, + run, + evaluations, + exportedEvaluations, + inputHash, + resultDocument, + stale: state.revision !== activity.revision, + imageElement: assetRuntime.imageElement, + visibleBandIds: new Set(state.visibleBandIds), + }; + state.lastRun = completedRun; + const historyItem = createHistorySummary({ + ...snapshot, + run, + evaluations: exportedEvaluations, + inputHash, + }); + historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment); + state.history.unshift(historyItem); + state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS); + document.documentElement.dataset.runState = "done"; + document.documentElement.dataset.runBackend = run.backend; + document.documentElement.dataset.resultBandCount = String(run.bands.length); + setRunStatus( + `${run.backend.toUpperCase()} 완료 · ${formatNumber(run.timingMs.total, 0)} ms · ` + + `hash ${inputHash.slice(0, 10)}`, + ); + renderResult(); + renderHistory(); + renderSceneVisualization(); + } catch (error) { + if (error?.name === "AbortError" || activity.canceled) { + document.documentElement.dataset.runState = "canceled"; + setRunStatus("계산을 취소했습니다. 입력과 직전 결과는 그대로 유지됩니다."); + } else { + document.documentElement.dataset.runState = "error"; + setTransientError(error, "실행 실패"); + } + } finally { + activity.worker?.terminate(); + if (state.active === activity) state.active = null; + setConfigurationLocked(false); + elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ 기본 예제 실행"; + renderBandEditors(); + await validateCurrent(); + } +} + +function cancelSimulation() { + const activity = state.active; + if (!activity || activity.canceled) return; + activity.canceled = true; + activity.abortController.abort(createAbortError()); + activity.worker?.terminate(); + activity.reject?.(createAbortError()); + setRunStatus("계산을 취소하는 중…"); +} + +async function loadJson(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url} 응답 ${response.status}`); + return response.json(); +} + +async function importDocument(kind, input) { + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + const generation = ++state.importGenerations[kind]; + beginInputOperation(); + try { + const value = await readJsonFile(file); + if (generation !== state.importGenerations[kind]) return; + let issues; + if (kind === "array") { + issues = await validateArrayDefinition(value); + } else if (kind === "scene") { + issues = await validateScene(value); + } else { + issues = await validateExperiment(value, { + arrayDefinition: state.arrayDefinition, + scene: state.scene, + }); + } + if (generation !== state.importGenerations[kind]) return; + if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · ")); + if (kind === "array") { + state.arrayDefinition = value; + state.experiment.arrayId = value.id; + } else if (kind === "scene") { + state.scene = value; + state.experiment.sceneId = value.id; + assetRuntime.reset(); + } else { + state.experiment = value; + } + state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id)); + markChanged({ syncControls: true, renderBands: true }); + renderAssets(); + setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`); + } catch (error) { + setTransientError(error, `${file.name} 불러오기 실패`); + } finally { + endInputOperation(); + } +} + +function installDocumentInputListeners() { + elements.experimentName.addEventListener("input", () => { + state.experiment.name = elements.experimentName.value; + markChanged(); + }); + elements.experimentId.addEventListener("input", () => { + state.experiment.id = elements.experimentId.value; + markChanged(); + }); + elements.sceneDistance.addEventListener("change", () => { + const value = readFiniteInput(elements.sceneDistance); + if (value === null) return syncControls(); + shiftSceneDistance(state.scene, state.experiment, value); + markChanged(); + }); + elements.speedOfSound.addEventListener("change", () => { + const value = readFiniteInput(elements.speedOfSound); + if (value === null) return syncControls(); + state.scene.speedOfSound = value; + markChanged(); + }); + elements.sceneSeed.addEventListener("change", () => { + const value = readFiniteInput(elements.sceneSeed); + if (value === null) return syncControls(); + state.scene.seed = value; + markChanged(); + }); + elements.backend.addEventListener("change", () => { + state.experiment.beamforming.backend = elements.backend.value; + markChanged(); + }); + elements.gridPreset.addEventListener("change", () => { + if (elements.gridPreset.value === "custom") return; + const side = Number(elements.gridPreset.value); + state.experiment.scan.columns = side; + state.experiment.scan.rows = side; + markChanged({ syncControls: true }); + }); + const scanBindings = [ + [elements.scanColumns, "columns", true], + [elements.scanRows, "rows", true], + [elements.scanXMin, "xMin", false], + [elements.scanXMax, "xMax", false], + [elements.scanYMin, "yMin", false], + [elements.scanYMax, "yMax", false], + ]; + for (const [element, key, integer] of scanBindings) { + element.addEventListener("change", () => { + const value = readFiniteInput(element); + if (value === null) return syncControls(); + state.experiment.scan[key] = integer ? Math.trunc(value) : value; + markChanged({ syncControls: true }); + }); + } + elements.fftSize.addEventListener("change", () => { + state.experiment.stft.fftSize = Number(elements.fftSize.value); + if (state.experiment.stft.hopSize > state.experiment.stft.fftSize) { + state.experiment.stft.hopSize = state.experiment.stft.fftSize / 2; + } + markChanged({ syncControls: true }); + }); + elements.hopSize.addEventListener("change", () => { + const value = readFiniteInput(elements.hopSize); + if (value === null) return syncControls(); + state.experiment.stft.hopSize = Math.trunc(value); + markChanged({ syncControls: true }); + }); + elements.normalization.addEventListener("change", () => { + state.experiment.beamforming.normalization = elements.normalization.value; + markChanged(); + }); + elements.dynamicRange.addEventListener("change", () => { + const value = readFiniteInput(elements.dynamicRange); + if (value === null) return syncControls(); + state.experiment.beamforming.dynamicRangeDb = value; + markChanged(); + }); +} + +function installBandListeners() { + elements.bandList.addEventListener("change", (event) => { + const input = event.target.closest("input[data-band-index]"); + if (!input) return; + const band = state.experiment.frequencyBands[Number(input.dataset.bandIndex)]; + if (!band) return; + const key = input.dataset.bandKey; + if (key === "id") { + const previousId = band.id; + band.id = input.value; + state.visibleBandIds.delete(previousId); + state.visibleBandIds.add(band.id); + } else if (key === "color") { + band.color = input.value; + } else if (Number.isFinite(input.valueAsNumber)) { + band[key] = input.valueAsNumber; + } + markChanged(); + }); + elements.bandList.addEventListener("click", (event) => { + const button = event.target.closest("button[data-remove-band]"); + if (!button || state.experiment.frequencyBands.length === 1) return; + const [removed] = state.experiment.frequencyBands.splice(Number(button.dataset.removeBand), 1); + state.visibleBandIds.delete(removed.id); + markChanged({ renderBands: true }); + }); + elements.addBandButton.addEventListener("click", () => { + if (state.experiment.frequencyBands.length >= 8) return; + const used = new Set(state.experiment.frequencyBands.map((band) => band.id)); + let suffix = state.experiment.frequencyBands.length + 1; + while (used.has(`band-${suffix}`)) suffix += 1; + const previous = state.experiment.frequencyBands.at(-1); + const nyquist = state.scene.sampleRate / 2; + const minHz = Math.min(Math.max(0, previous?.maxHz ?? 900), nyquist - 101); + const band = { + id: `band-${suffix}`, + minHz, + maxHz: Math.min(nyquist - 1, minHz + 200), + color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length], + }; + state.experiment.frequencyBands.push(band); + state.visibleBandIds.add(band.id); + markChanged({ renderBands: true }); + }); + elements.bandLegend.addEventListener("change", (event) => { + const input = event.target.closest("input[data-visible-band]"); + if (!input) return; + if (input.checked) state.visibleBandIds.add(input.dataset.visibleBand); + else state.visibleBandIds.delete(input.dataset.visibleBand); + renderSceneVisualization(); + }); +} + +function installActionListeners() { + elements.runButton.addEventListener("click", runSimulation); + elements.cancelButton.addEventListener("click", cancelSimulation); + elements.sceneInput.addEventListener("change", () => importDocument("scene", elements.sceneInput)); + elements.arrayInput.addEventListener("change", () => importDocument("array", elements.arrayInput)); + elements.experimentInput.addEventListener( + "change", + () => importDocument("experiment", elements.experimentInput), + ); + elements.assetInput.addEventListener("change", async () => { + const files = [...(elements.assetInput.files ?? [])]; + elements.assetInput.value = ""; + if (files.length === 0) return; + beginInputOperation(); + try { + setRunStatus(`${files.length}개 asset의 SHA-256을 확인하고 있습니다…`); + const report = await assetRuntime.bindFiles(files, state.scene); + if (report.rejected.length > 0) { + setTransientError( + new Error(report.rejected.map((item) => `${item.fileName}: ${item.message}`).join(" · ")), + "Asset 연결 실패", + ); + } else { + setRunStatus(`${report.bound.length}개 asset descriptor를 연결했습니다.`); + } + } catch (error) { + setTransientError(error, "Asset 연결 실패"); + } finally { + renderAssets(); + endInputOperation(); + } + }); + elements.overlayOpacity.addEventListener("input", renderSceneVisualization); + elements.showTruth.addEventListener("change", renderSceneVisualization); + elements.exportExperimentButton.addEventListener("click", async () => { + const issues = await validateExperiment(state.experiment, { + arrayDefinition: state.arrayDefinition, + scene: state.scene, + }); + if (issues.length > 0) { + setTransientError( + new Error(issues.slice(0, 4).map(formatIssue).join(" · ")), + "Experiment 저장 실패", + ); + return; + } + downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`); + setRunStatus("Experiment JSON을 저장했습니다."); + }); + elements.exportResultButton.addEventListener("click", () => { + if (!state.lastRun) return; + downloadJson( + state.lastRun.resultDocument, + `${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`, + ); + setRunStatus("검증된 Result JSON을 저장했습니다."); + }); + elements.clearHistoryButton.addEventListener("click", () => { + const signature = comparisonSignature(); + state.history = state.history.filter((item) => item.comparisonSignature !== signature); + renderHistory(); + }); + window.addEventListener("beforeunload", () => { + cancelSimulation(); + sceneVisualization.destroy(); + arrayVisualization.destroy(); + assetRuntime.dispose(); + }); +} + +async function initialize() { + installDocumentInputListeners(); + installBandListeners(); + installActionListeners(); + setEditableControlsDisabled(true); + document.documentElement.dataset.runState = "idle"; + try { + const [arrayDefinition, scene, experiment] = await Promise.all([ + loadJson(DEFAULT_DOCUMENT_URLS.arrayDefinition), + loadJson(DEFAULT_DOCUMENT_URLS.scene), + loadJson(DEFAULT_DOCUMENT_URLS.experiment), + ]); + state.arrayDefinition = arrayDefinition; + state.scene = scene; + state.experiment = experiment; + state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id)); + syncControls(); + renderBandEditors(); + renderDocumentSummary(); + renderAssets(); + renderLegend(); + renderWorkload(); + renderResult(); + renderHistory(); + renderSceneVisualization(); + await validateCurrent(); + setEditableControlsDisabled(false); + renderBandEditors(); + elements.runButton.disabled = !state.validation.valid; + document.documentElement.dataset.appReady = "true"; + } catch (error) { + document.documentElement.dataset.appReady = "error"; + setTransientError(error, "기본 fixture 초기화 실패"); + } +} + +initialize(); diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js new file mode 100644 index 0000000..9fcd277 --- /dev/null +++ b/apps/acoustic-camera/model.js @@ -0,0 +1,177 @@ +import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js"; + +const RESULT_METRIC_KEYS = ["peakToSidelobeDb", "energyInsideTruthRatio", "precision", "recall"]; + +function sortForCanonicalJson(value) { + if (Array.isArray(value)) return value.map(sortForCanonicalJson); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort() + .map((key) => [key, sortForCanonicalJson(value[key])]), + ); + } + return value; +} + +export function canonicalStringify(value) { + return JSON.stringify(sortForCanonicalJson(value)); +} + +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)); + return [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +export function estimateExperimentWorkload(arrayDefinition, scene, experiment) { + const frameCount = getFrameCount( + Math.ceil(scene.duration * scene.sampleRate), + experiment.stft.fftSize, + experiment.stft.hopSize, + ); + const bandBins = experiment.frequencyBands.map((band) => + binsForBand(band, scene.sampleRate, experiment.stft.fftSize), + ); + 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; + return { + pointCount, + microphoneCount, + frameCount, + selectedBinCount, + bandBins, + complexMacCount, + scalingVs50: pointCount / 2500, + distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2, + }; +} + +export function shiftSceneDistance(scene, experiment, nextDistance) { + const previousDistance = experiment.scan.z; + const delta = nextDistance - previousDistance; + experiment.scan.z = nextDistance; + for (const source of scene.sources) source.position[2] += delta; + if (scene.image?.plane) scene.image.plane.z += delta; +} + +function finiteMetricSubset(metrics = {}) { + return Object.fromEntries( + RESULT_METRIC_KEYS + .filter((key) => Number.isFinite(metrics[key])) + .map((key) => [key, metrics[key]]), + ); +} + +function peakForResult(peak) { + const result = { + position: peak.position.map(Number), + value: Number(peak.value), + }; + if (peak.sourceId) result.sourceId = peak.sourceId; + if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM; + return result; +} + +function environmentForResult({ arrayDefinition, experiment, run }) { + const environment = { + userAgent: navigator.userAgent, + requestedBackend: experiment.beamforming.backend, + microphoneCount: arrayDefinition.microphones.length, + gridPoints: experiment.scan.rows * experiment.scan.columns, + }; + if (run.fallback?.code) environment.fallbackCode = run.fallback.code; + if (run.fallback?.message) environment.fallbackMessage = run.fallback.message; + const adapter = run.gpu?.adapterInfo; + if (adapter?.vendor) environment.gpuVendor = adapter.vendor; + if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture; + if (adapter?.device) environment.gpuDevice = adapter.device; + if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind; + return environment; +} + +export function createResultDocument(options) { + const { + arrayDefinition, + scene, + experiment, + run, + evaluations, + inputHash, + createdAt = new Date().toISOString(), + } = options; + return { + schemaVersion: "1.0.0", + kind: "micarrsim.result", + experimentId: experiment.id, + arrayId: arrayDefinition.id, + sceneId: scene.id, + createdAt, + backend: run.backend, + inputHash, + signalStats: { + preNormalizationPeak: run.signalStats.preNormalizationPeak, + appliedScale: run.signalStats.appliedScale, + clippedSampleCount: run.signalStats.clippedSampleCount, + }, + timingMs: { + total: run.timingMs.total, + signalGeneration: run.timingMs.signalGeneration, + beamforming: run.timingMs.beamforming, + }, + environment: environmentForResult({ arrayDefinition, experiment, run }), + bands: run.bands.map((band) => { + const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} }; + return { + id: band.id, + color: band.color, + rawMap: { + rows: experiment.scan.rows, + columns: experiment.scan.columns, + scale: "power", + values: Array.from(band.rawMap), + }, + displayMap: { + rows: experiment.scan.rows, + columns: experiment.scan.columns, + scale: band.displayScale, + values: Array.from(band.displayMap), + }, + peaks: evaluation.peaks.map(peakForResult), + metrics: finiteMetricSubset(evaluation.metrics), + }; + }), + }; +} + +export function createHistorySummary({ arrayDefinition, scene, experiment, run, evaluations, inputHash }) { + const firstBand = run.bands[0]; + const evaluation = evaluations.get(firstBand.id); + const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0]; + return { + id: `${Date.now()}-${inputHash.slice(0, 8)}`, + arrayId: arrayDefinition.id, + arrayName: arrayDefinition.name, + sceneId: scene.id, + experimentId: experiment.id, + backend: run.backend, + pointCount: experiment.scan.rows * experiment.scan.columns, + totalMs: run.timingMs.total, + localizationErrorM: firstMatchedPeak?.localizationErrorM ?? null, + peakToSidelobeDb: evaluation?.metrics?.peakToSidelobeDb ?? null, + inputHash, + }; +} + +export function safeFileStem(value, fallback = "micarrsim") { + return String(value || fallback) + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || fallback; +} diff --git a/apps/acoustic-camera/simulation-worker.js b/apps/acoustic-camera/simulation-worker.js new file mode 100644 index 0000000..932242d --- /dev/null +++ b/apps/acoustic-camera/simulation-worker.js @@ -0,0 +1,82 @@ +import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; +import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js"; + +function post(type, jobId, payload = {}, transfer = []) { + self.postMessage({ type, jobId, ...payload }, transfer); +} + +function serializeError(error) { + return { + name: error?.name ?? "Error", + code: error?.code ?? "", + message: error instanceof Error ? error.message : String(error), + stack: error?.stack ?? "", + causes: error instanceof AggregateError + ? error.errors.map((cause) => ({ name: cause?.name, code: cause?.code, message: cause?.message })) + : [], + }; +} + +self.addEventListener("message", async (event) => { + if (event.data?.type !== "run") return; + const { jobId, scene, arrayDefinition, experiment, assetBuffers } = event.data; + const totalStartedAt = performance.now(); + try { + post("progress", jobId, { stage: "signal", message: "192채널 자유음장 신호를 합성하고 있습니다…" }); + const signalStartedAt = performance.now(); + const synthesis = synthesizeMicrophoneSignals({ + scene, + arrayDefinition, + assetBuffers, + minimumDistance: experiment.beamforming.minimumDistance, + }); + const signalGeneration = performance.now() - signalStartedAt; + post("progress", jobId, { + stage: "signal-complete", + message: `마이크 신호 합성 완료 · ${signalGeneration.toFixed(0)} ms`, + }); + + const beamformingStartedAt = performance.now(); + const das = await computeDasWithBackend({ + channels: synthesis.channels, + sampleRate: synthesis.sampleRate, + arrayDefinition, + scene, + experiment, + onFallback: ({ error }) => + post("fallback", jobId, { + code: error.code, + message: error.message, + }), + onProgress: (progress) => post("progress", jobId, { stage: progress.stage, progress }), + }); + const beamforming = performance.now() - beamformingStartedAt; + const transfer = []; + for (const band of das.bands) { + transfer.push(band.rawMap.buffer, band.displayMap.buffer); + } + post( + "result", + jobId, + { + run: { + ...das, + signalStats: { + preNormalizationPeak: synthesis.preNormalizationPeak, + appliedScale: synthesis.appliedScale, + clippedSampleCount: synthesis.clippedSampleCount, + peak: synthesis.peak, + }, + timingMs: { + total: performance.now() - totalStartedAt, + signalGeneration, + beamforming, + }, + }, + }, + transfer, + ); + } catch (error) { + post("error", jobId, { error: serializeError(error) }); + } +}); diff --git a/apps/acoustic-camera/styles.css b/apps/acoustic-camera/styles.css new file mode 100644 index 0000000..a21b0d6 --- /dev/null +++ b/apps/acoustic-camera/styles.css @@ -0,0 +1,120 @@ +.camera-shell { min-width: 320px; } +.camera-header { position: sticky; z-index: 30; top: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 18px; } +.header-status, .header-actions, .workspace-tools, .overlay-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; min-width: 0; } +.header-status { justify-content: center; } +.run-status { overflow: hidden; max-width: 420px; color: var(--muted); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; } +.button { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 9px 14px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface-raised); cursor: pointer; font-size: .8rem; font-weight: 720; transition: border-color 140ms ease, background 140ms ease, transform 140ms ease; } +.button:hover, .button:focus-visible, .mini-button:hover, .mini-button:focus-visible { border-color: var(--mint); outline: none; transform: translateY(-1px); } +.button:disabled { cursor: wait; opacity: .48; transform: none; } +.button--primary { border-color: rgb(101 242 191 / 55%); color: #06130f; background: var(--mint); } +.button--ghost { background: rgb(255 255 255 / 3%); } +.button--full { width: 100%; } +.file-button input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); } +.camera-layout { display: grid; grid-template-columns: minmax(600px, 1fr) minmax(350px, 410px); min-height: calc(100vh - 65px); } +.scene-workspace { min-width: 0; padding: clamp(22px, 3vw, 42px); } +.workspace-heading, .section-heading, .array-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; } +.workspace-heading { margin-bottom: 20px; } +.workspace-heading .eyebrow, .section-heading .eyebrow, .array-heading .eyebrow { margin-bottom: 7px; } +.workspace-heading h1 { margin-bottom: 4px; font-size: clamp(1.9rem, 4vw, 3.2rem); letter-spacing: -.055em; } +.workspace-meta, .section-copy { margin: 0; color: var(--muted); font-size: .76rem; line-height: 1.55; } +.workspace-tools { justify-content: flex-end; } +.scene-canvas-card { position: relative; overflow: hidden; min-height: 520px; border: 1px solid var(--line); border-radius: 22px; background: #07110f; box-shadow: var(--shadow); } +#scene-canvas { display: block; width: 100%; height: clamp(520px, 64vh, 760px); } +.canvas-message { position: absolute; top: 32px; left: 50%; display: grid; width: min(440px, calc(100% - 48px)); gap: 6px; color: var(--muted); text-align: center; pointer-events: none; transform: translateX(-50%); } +.canvas-message strong { color: #cfe2db; font-size: .9rem; } +.canvas-message span { font-size: .74rem; } +.canvas-message.is-hidden { display: none; } +.canvas-badge { position: absolute; right: 14px; bottom: 14px; max-width: calc(100% - 28px); overflow: hidden; padding: 7px 10px; border: 1px solid rgb(255 255 255 / 10%); border-radius: 999px; color: #d7e8e2; background: rgb(8 17 15 / 80%); backdrop-filter: blur(10px); font: 650 .7rem ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; } +.overlay-toolbar { justify-content: space-between; margin-top: 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 14px; background: rgb(255 255 255 / 2%); } +.band-legend { display: flex; flex: 1 1 360px; flex-wrap: wrap; gap: 7px; } +.legend-toggle { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: #0b1714; cursor: pointer; font-size: .68rem; } +.legend-toggle input { accent-color: var(--band-color, var(--mint)); } +.legend-swatch { width: 9px; height: 9px; border-radius: 50%; background: var(--band-color, var(--mint)); } +.range-field, .check-field { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .7rem; font-weight: 650; } +.range-field input { width: 110px; accent-color: var(--mint); } +.check-field input { width: 17px; height: 17px; accent-color: var(--mint); } +.result-section, .history-section { margin-top: 28px; padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: rgb(255 255 255 / 2%); } +.section-heading h2 { margin: 0; font-size: 1.05rem; } +.result-chip { padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .68rem; } +.empty-result { margin-top: 14px; padding: 18px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); font-size: .78rem; line-height: 1.6; text-align: center; } +.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 10px; margin-top: 14px; } +.metric-card { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 13px; background: #0b1714; } +.metric-card span { display: block; margin-bottom: 7px; color: var(--muted); font-size: .66rem; } +.metric-card strong { display: block; overflow: hidden; font-size: 1.15rem; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; } +.metric-card small { display: block; margin-top: 5px; color: var(--muted); font-size: .62rem; } +.text-button, .mini-button { padding: 5px 7px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: .68rem; } +.history-list { display: grid; gap: 8px; margin-top: 14px; } +.history-empty { padding: 12px; color: var(--muted); font-size: .72rem; text-align: center; } +.history-card { display: grid; grid-template-columns: minmax(150px, 1.5fr) repeat(4, minmax(85px, .75fr)); gap: 10px; align-items: center; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; } +.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.history-card strong { font-size: .78rem; } +.history-card span { color: var(--muted); font-size: .67rem; } +.control-panel { position: sticky; top: 65px; align-self: start; overflow-y: auto; max-height: calc(100vh - 65px); padding: 16px; border-left: 1px solid var(--line); background: rgb(12 24 21 / 90%); backdrop-filter: blur(18px); } +.array-card, .asset-card, .control-section, .output-card, .validation-card { padding: 16px; border: 1px solid var(--line); border-radius: 16px; background: rgb(255 255 255 / 2%); } +.array-card + section, .control-panel section + section { margin-top: 11px; } +.array-heading { align-items: flex-start; } +.array-heading h2 { margin: 0; font-size: .95rem; } +.array-heading > strong { color: var(--mint); font: 750 .78rem ui-monospace, SFMono-Regular, Consolas, monospace; } +#array-canvas { display: block; width: 100%; height: 265px; margin-top: 10px; border: 1px solid var(--line); border-radius: 12px; background: #07110f; } +#array-meta { margin: 9px 0 0; color: var(--muted); font: 600 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; } +.section-title { display: flex; align-items: center; gap: 9px; margin-bottom: 13px; } +.section-title > span, .section-title > div > span { color: var(--mint); font: 750 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; } +.section-title h2 { margin: 0; font-size: .88rem; } +.section-title--spread { justify-content: space-between; } +.section-title--spread > div { display: flex; align-items: center; gap: 9px; } +.source-summary { display: grid; gap: 7px; } +.source-line { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .69rem; } +.source-line strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; } +.asset-list { display: grid; gap: 7px; margin-top: 10px; } +.asset-status { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); font-size: .66rem; } +.asset-status strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; } +.asset-status.is-ready span { color: var(--mint); } +.asset-status.is-error span { color: var(--danger); } +.asset-empty { color: var(--muted); font-size: .68rem; } +.builder-link { display: block; margin-top: 11px; color: var(--cyan); font-size: .68rem; text-decoration: none; } +.field { display: grid; min-width: 0; gap: 6px; } +.field > span { color: var(--muted); font-size: .67rem; font-weight: 650; } +.field input, .field select, .band-editor input { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; } +.field input:focus, .field select:focus, .band-editor input:focus { border-color: var(--mint); outline: 2px solid rgb(101 242 191 / 11%); } +.field--wide + .field--wide, .field--wide + .field-grid { margin-top: 9px; } +.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 9px; } +.workload-summary { margin: 11px 0 0; padding: 10px; border-radius: 9px; color: var(--muted); background: #0b1714; font: 600 .66rem/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; } +.workload-summary.is-warning { color: var(--amber); } +.band-editor-list { display: grid; gap: 9px; } +.band-editor { display: grid; grid-template-columns: minmax(70px, 1fr) minmax(62px, .8fr) minmax(62px, .8fr) 34px; gap: 6px; padding: 9px; border: 1px solid var(--line); border-radius: 11px; background: #0b1714; } +.band-editor input { min-width: 0; font-size: .68rem; } +.band-editor input[type="color"] { padding: 3px; } +.band-editor__remove { border: 0; border-radius: 8px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; } +.mini-button { border-color: var(--line); } +.validation-card { display: flex; align-items: flex-start; gap: 11px; } +.validation-dot { width: 10px; height: 10px; flex: 0 0 auto; margin-top: 4px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 5px rgb(145 170 162 / 8%); } +.validation-card strong { font-size: .8rem; } +.validation-card p { margin: 4px 0 0; color: var(--muted); font-size: .69rem; line-height: 1.5; } +.validation-card.is-valid .validation-dot { background: var(--mint); box-shadow: 0 0 0 5px rgb(101 242 191 / 10%); } +.validation-card.is-invalid .validation-dot { background: var(--danger); box-shadow: 0 0 0 5px rgb(255 123 115 / 10%); } +.validation-card.is-warning .validation-dot { background: var(--amber); box-shadow: 0 0 0 5px rgb(255 197 110 / 10%); } +.is-hidden { display: none !important; } +@media (max-width: 1120px) { + .camera-header { grid-template-columns: auto 1fr; } + .header-status { justify-content: flex-end; } + .header-actions { grid-column: 1 / -1; justify-content: flex-end; } + .camera-layout { grid-template-columns: 1fr; } + .control-panel { position: static; max-height: none; border-top: 1px solid var(--line); border-left: 0; } +} +@media (max-width: 760px) { + .camera-header, .workspace-heading { align-items: stretch; } + .camera-header { position: static; display: flex; flex-direction: column; } + .header-status, .header-actions, .workspace-tools { justify-content: flex-start; width: 100%; } + .header-actions .button, .workspace-tools .button { flex: 1 1 calc(50% - 5px); } + .scene-workspace { padding: 18px; } + .workspace-heading { flex-direction: column; } + .scene-canvas-card { min-height: 380px; } + #scene-canvas { height: 420px; } + .history-card { grid-template-columns: 1fr 1fr; } + .history-card strong { grid-column: 1 / -1; } +} +@media (max-width: 430px) { + .field-grid, .band-editor { grid-template-columns: 1fr; } + .band-editor__remove { min-height: 34px; } + .metric-grid { grid-template-columns: 1fr 1fr; } +} diff --git a/apps/acoustic-camera/visualization.js b/apps/acoustic-camera/visualization.js new file mode 100644 index 0000000..eae6630 --- /dev/null +++ b/apps/acoustic-camera/visualization.js @@ -0,0 +1,681 @@ +const DEFAULT_BACKGROUND = "#07110f"; +const DEFAULT_DYNAMIC_RANGE_DB = 48; +const MAX_PIXEL_RATIO = 3; + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function finiteNumber(value) { + return typeof value === "number" && Number.isFinite(value); +} + +function validScan(scan) { + return Boolean( + scan && + finiteNumber(scan.xMin) && + finiteNumber(scan.xMax) && + finiteNumber(scan.yMin) && + finiteNumber(scan.yMax) && + scan.xMin < scan.xMax && + scan.yMin < scan.yMax && + Number.isInteger(scan.columns) && + Number.isInteger(scan.rows) && + scan.columns > 0 && + scan.rows > 0, + ); +} + +function parseHexColor(value) { + const match = /^#([0-9a-f]{6})$/i.exec(value ?? ""); + if (!match) return [101, 242, 191]; + const packed = Number.parseInt(match[1], 16); + return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff]; +} + +function resolveCanvasSize(canvas) { + const rect = canvas.getBoundingClientRect?.(); + const width = Math.max(1, rect?.width || canvas.clientWidth || 1); + const height = Math.max(1, rect?.height || canvas.clientHeight || 1); + const pixelRatio = clamp(globalThis.devicePixelRatio || 1, 1, MAX_PIXEL_RATIO); + const backingWidth = Math.max(1, Math.round(width * pixelRatio)); + const backingHeight = Math.max(1, Math.round(height * pixelRatio)); + if (canvas.width !== backingWidth || canvas.height !== backingHeight) { + canvas.width = backingWidth; + canvas.height = backingHeight; + } + return { width, height, pixelRatio }; +} + +function prepareContext(canvas, context) { + const size = resolveCanvasSize(canvas); + context.setTransform(size.pixelRatio, 0, 0, size.pixelRatio, 0, 0); + context.globalAlpha = 1; + context.globalCompositeOperation = "source-over"; + context.imageSmoothingEnabled = true; + context.clearRect(0, 0, size.width, size.height); + return size; +} + +function createScheduledCanvas(canvas, draw) { + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("2D canvas context를 만들 수 없습니다."); + + let model = null; + let frameId = 0; + let destroyed = false; + const requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ?? + ((callback) => globalThis.setTimeout(callback, 16)); + const cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? globalThis.clearTimeout?.bind(globalThis); + + function drawCurrent() { + frameId = 0; + if (destroyed || model === null) return; + const size = prepareContext(canvas, context); + draw(context, size, model); + } + + function schedule() { + if (!destroyed && !frameId) frameId = requestFrame(drawCurrent); + } + + function render(nextModel) { + if (destroyed) return; + model = nextModel; + schedule(); + } + + function renderNow(nextModel) { + if (destroyed) return; + if (arguments.length > 0) model = nextModel; + if (frameId) { + cancelFrame?.(frameId); + frameId = 0; + } + drawCurrent(); + } + + let resizeObserver = null; + const onWindowResize = () => schedule(); + if (typeof globalThis.ResizeObserver === "function") { + resizeObserver = new globalThis.ResizeObserver(schedule); + resizeObserver.observe(canvas); + } else { + globalThis.addEventListener?.("resize", onWindowResize); + } + + return { + render, + renderNow, + destroy() { + destroyed = true; + if (frameId) cancelFrame?.(frameId); + frameId = 0; + resizeObserver?.disconnect(); + globalThis.removeEventListener?.("resize", onWindowResize); + model = null; + }, + }; +} + +function drawEmpty(context, width, height, message) { + context.fillStyle = DEFAULT_BACKGROUND; + context.fillRect(0, 0, width, height); + context.fillStyle = "rgba(205, 225, 218, .66)"; + context.font = "600 13px Inter, system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(message, width / 2, height / 2); + context.textAlign = "left"; + context.textBaseline = "alphabetic"; +} + +function fitPlot(width, height, xSpan, ySpan, margins) { + const availableWidth = Math.max(1, width - margins.left - margins.right); + const availableHeight = Math.max(1, height - margins.top - margins.bottom); + const dataAspect = xSpan / ySpan; + const availableAspect = availableWidth / availableHeight; + let plotWidth = availableWidth; + let plotHeight = availableHeight; + if (dataAspect > availableAspect) plotHeight = plotWidth / dataAspect; + else plotWidth = plotHeight * dataAspect; + return { + x: margins.left + (availableWidth - plotWidth) / 2, + y: margins.top + (availableHeight - plotHeight) / 2, + width: plotWidth, + height: plotHeight, + }; +} + +export function scanPositionToCanvas(position, scan, plot) { + return { + x: plot.x + ((position[0] - scan.xMin) / (scan.xMax - scan.xMin)) * plot.width, + y: plot.y + ((scan.yMax - position[1]) / (scan.yMax - scan.yMin)) * plot.height, + }; +} + +function niceStep(span, targetLines = 6) { + const raw = span / targetLines; + const magnitude = 10 ** Math.floor(Math.log10(raw)); + const normalized = raw / magnitude; + const multiplier = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1; + return multiplier * magnitude; +} + +function drawScanGrid(context, scan, plot) { + context.save(); + context.beginPath(); + context.rect(plot.x, plot.y, plot.width, plot.height); + context.clip(); + context.fillStyle = "#0b1714"; + context.fillRect(plot.x, plot.y, plot.width, plot.height); + + const xStep = niceStep(scan.xMax - scan.xMin); + const yStep = niceStep(scan.yMax - scan.yMin); + context.lineWidth = 1; + for (let x = Math.ceil(scan.xMin / xStep) * xStep; x <= scan.xMax + xStep * 1e-6; x += xStep) { + const pixel = scanPositionToCanvas([x, scan.yMin], scan, plot).x; + context.strokeStyle = Math.abs(x) < xStep * 1e-6 ? "rgba(101,242,191,.28)" : "rgba(166,196,186,.11)"; + context.beginPath(); + context.moveTo(pixel, plot.y); + context.lineTo(pixel, plot.y + plot.height); + context.stroke(); + } + for (let y = Math.ceil(scan.yMin / yStep) * yStep; y <= scan.yMax + yStep * 1e-6; y += yStep) { + const pixel = scanPositionToCanvas([scan.xMin, y], scan, plot).y; + context.strokeStyle = Math.abs(y) < yStep * 1e-6 ? "rgba(101,242,191,.28)" : "rgba(166,196,186,.11)"; + context.beginPath(); + context.moveTo(plot.x, pixel); + context.lineTo(plot.x + plot.width, pixel); + context.stroke(); + } + context.restore(); + + context.strokeStyle = "rgba(174, 211, 199, .32)"; + context.lineWidth = 1; + context.strokeRect(plot.x + 0.5, plot.y + 0.5, Math.max(0, plot.width - 1), Math.max(0, plot.height - 1)); + context.fillStyle = "rgba(205, 225, 218, .65)"; + context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace"; + context.textBaseline = "top"; + context.textAlign = "left"; + context.fillText(`${scan.xMin.toFixed(2)} m`, plot.x, plot.y + plot.height + 8); + context.textAlign = "right"; + context.fillText(`${scan.xMax.toFixed(2)} m`, plot.x + plot.width, plot.y + plot.height + 8); + context.save(); + context.translate(Math.max(8, plot.x - 12), plot.y + plot.height / 2); + context.rotate(-Math.PI / 2); + context.textAlign = "center"; + context.fillText(`y ${scan.yMin.toFixed(2)}…${scan.yMax.toFixed(2)} m`, 0, 0); + context.restore(); + context.textAlign = "left"; + context.textBaseline = "alphabetic"; +} + +function drawableImage(image) { + if (!image) return false; + if ("complete" in image && !image.complete) return false; + if ("naturalWidth" in image && image.naturalWidth === 0) return false; + return true; +} + +function validPlane(plane) { + return Boolean( + plane && + finiteNumber(plane.xMin) && + finiteNumber(plane.xMax) && + finiteNumber(plane.yMin) && + finiteNumber(plane.yMax) && + plane.xMin < plane.xMax && + plane.yMin < plane.yMax, + ); +} + +function drawSceneImage(context, image, imagePlane, scan, plot) { + if (!drawableImage(image)) return; + try { + const plane = validPlane(imagePlane) ? imagePlane : scan; + const topLeft = scanPositionToCanvas([plane.xMin, plane.yMax], scan, plot); + const bottomRight = scanPositionToCanvas([plane.xMax, plane.yMin], scan, plot); + context.save(); + context.beginPath(); + context.rect(plot.x, plot.y, plot.width, plot.height); + context.clip(); + context.globalAlpha = 0.72; + context.drawImage( + image, + topLeft.x, + topLeft.y, + bottomRight.x - topLeft.x, + bottomRight.y - topLeft.y, + ); + context.fillStyle = "rgba(2, 10, 8, .17)"; + context.fillRect( + topLeft.x, + topLeft.y, + bottomRight.x - topLeft.x, + bottomRight.y - topLeft.y, + ); + context.restore(); + } catch { + // A not-yet-decoded or detached CanvasImageSource is simply omitted. + } +} + +function resolveBandMap(band, scan) { + const map = band?.displayMap ?? band?.rawMap; + if (!map) return null; + const values = map.values ?? map; + if (!values || typeof values.length !== "number") return null; + const rows = map.rows ?? scan.rows; + const columns = map.columns ?? scan.columns; + if (rows !== scan.rows || columns !== scan.columns || values.length !== rows * columns) return null; + return { + values, + rows, + columns, + scale: map.scale ?? band.displayScale ?? "power", + }; +} + +function describeMap(map) { + let peak = -Infinity; + if (map.scale === "db") { + for (let index = 0; index < map.values.length; index += 1) { + const value = Number(map.values[index]); + if (Number.isFinite(value)) peak = Math.max(peak, value); + } + return Number.isFinite(peak) ? { peak, usable: true } : { peak: 0, usable: false }; + } + peak = 0; + for (let index = 0; index < map.values.length; index += 1) { + const value = Number(map.values[index]); + if (Number.isFinite(value) && value > peak) peak = value; + } + return { peak, usable: peak > 0 }; +} + +export function mapValueToDynamicRange(value, scale, peak, dynamicRangeDb) { + if (!Number.isFinite(value) || !Number.isFinite(peak)) return 0; + const range = clamp(Number(dynamicRangeDb) || DEFAULT_DYNAMIC_RANGE_DB, 1, 240); + let relativeDb; + if (scale === "db") relativeDb = value - peak; + else { + if (!(value > 0) || !(peak > 0)) return 0; + relativeDb = 10 * Math.log10(value / peak); + } + return clamp(1 + relativeDb / range, 0, 1); +} + +function createHeatmapCanvas(canvas, scan, bands, dynamicRangeDb) { + const ownerDocument = canvas.ownerDocument ?? globalThis.document; + if (!ownerDocument?.createElement) return null; + const heatCanvas = ownerDocument.createElement("canvas"); + heatCanvas.width = scan.columns; + heatCanvas.height = scan.rows; + const heatContext = heatCanvas.getContext("2d"); + if (!heatContext) return null; + const imageData = heatContext.createImageData(scan.columns, scan.rows); + const resolvedBands = bands + .filter((band) => band && band.visible !== false) + .map((band) => ({ band, map: resolveBandMap(band, scan), color: parseHexColor(band.color) })) + .filter(({ map }) => map) + .map((entry) => ({ ...entry, description: describeMap(entry.map) })) + .filter(({ description }) => description.usable); + + for (let index = 0; index < scan.rows * scan.columns; index += 1) { + let alphaSum = 0; + let red = 0; + let green = 0; + let blue = 0; + for (const { band, map, color, description } of resolvedBands) { + const intensity = mapValueToDynamicRange( + Number(map.values[index]), + map.scale, + description.peak, + dynamicRangeDb, + ) * clamp(Number(band.opacity ?? 1), 0, 1); + if (!(intensity > 0)) continue; + alphaSum += intensity; + red += color[0] * intensity; + green += color[1] * intensity; + blue += color[2] * intensity; + } + const alpha = Math.min(1, alphaSum); + if (!(alpha > 0)) continue; + const pixelOffset = index * 4; + // ImageData stores unassociated RGB. Dividing the additive premultiplied + // contributions by the final alpha preserves additive color mixing. + imageData.data[pixelOffset] = Math.round(clamp(red / alpha, 0, 255)); + imageData.data[pixelOffset + 1] = Math.round(clamp(green / alpha, 0, 255)); + imageData.data[pixelOffset + 2] = Math.round(clamp(blue / alpha, 0, 255)); + imageData.data[pixelOffset + 3] = Math.round(alpha * 255); + } + heatContext.putImageData(imageData, 0, 0); + return { canvas: heatCanvas, activeBands: resolvedBands.map(({ band }) => band) }; +} + +function drawTruthMarkers(context, sources, scan, plot) { + const validSources = Array.isArray(sources) ? sources : []; + validSources.forEach((source, index) => { + const position = source?.position; + if (!Array.isArray(position) || !finiteNumber(position[0]) || !finiteNumber(position[1])) return; + const point = scanPositionToCanvas(position, scan, plot); + if ( + point.x < plot.x - 16 || point.x > plot.x + plot.width + 16 || + point.y < plot.y - 16 || point.y > plot.y + plot.height + 16 + ) return; + context.save(); + context.strokeStyle = "rgba(255,255,255,.95)"; + context.fillStyle = "rgba(5,14,12,.82)"; + context.lineWidth = 2; + context.beginPath(); + context.arc(point.x, point.y, 7, 0, Math.PI * 2); + context.fill(); + context.stroke(); + context.beginPath(); + context.moveTo(point.x - 11, point.y); + context.lineTo(point.x + 11, point.y); + context.moveTo(point.x, point.y - 11); + context.lineTo(point.x, point.y + 11); + context.stroke(); + const label = source.name || source.id || `S${index + 1}`; + context.font = "700 11px Inter, system-ui, sans-serif"; + const labelWidth = context.measureText(label).width + 12; + const labelX = clamp(point.x + 12, plot.x + 4, plot.x + plot.width - labelWidth - 4); + const labelY = clamp(point.y - 20, plot.y + 4, plot.y + plot.height - 21); + context.fillStyle = "rgba(5,14,12,.82)"; + context.fillRect(labelX, labelY, labelWidth, 19); + context.fillStyle = "#f4fbf8"; + context.fillText(label, labelX + 6, labelY + 13); + context.restore(); + }); +} + +function evaluationForBand(evaluations, bandId) { + if (!evaluations) return null; + if (typeof evaluations.get === "function") return evaluations.get(bandId) ?? null; + if (Array.isArray(evaluations)) { + return evaluations.find((entry) => entry?.bandId === bandId || entry?.id === bandId) ?? null; + } + return evaluations[bandId] ?? null; +} + +function peaksForBand(band, evaluations) { + const evaluation = band.evaluation ?? evaluationForBand(evaluations, band.id); + const peaks = + band.peaks ?? + evaluation?.peaks ?? + evaluation?.detections ?? + (evaluation?.globalPeak ? [evaluation.globalPeak] : null) ?? + (band.peak ? [band.peak] : []); + return Array.isArray(peaks) ? peaks : []; +} + +function drawBandPeakMarkers(context, bands, evaluations, scan, plot) { + for (const band of bands) { + if (!band || band.visible === false) continue; + const color = /^#[0-9a-f]{6}$/i.test(band.color ?? "") ? band.color : "#65f2bf"; + for (const peak of peaksForBand(band, evaluations).slice(0, 128)) { + const position = peak?.position; + if (!position || !finiteNumber(position[0]) || !finiteNumber(position[1])) continue; + const point = scanPositionToCanvas(position, scan, plot); + if ( + point.x < plot.x || point.x > plot.x + plot.width || + point.y < plot.y || point.y > plot.y + plot.height + ) continue; + context.save(); + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = 1.75; + context.shadowColor = color; + context.shadowBlur = 5; + context.beginPath(); + context.arc(point.x, point.y, 5, 0, Math.PI * 2); + context.stroke(); + context.shadowBlur = 0; + context.globalAlpha = 0.9; + context.beginPath(); + context.arc(point.x, point.y, 1.4, 0, Math.PI * 2); + context.fill(); + context.restore(); + } + } +} + +function drawBandLegend(context, bands, plot) { + const visibleBands = bands.filter((band) => band && band.visible !== false).slice(0, 8); + if (visibleBands.length === 0) return; + context.save(); + context.font = "700 11px Inter, system-ui, sans-serif"; + const rows = visibleBands.map((band) => ({ + color: band.color ?? "#65f2bf", + label: band.label || band.name || band.id || "band", + })); + const width = Math.min( + plot.width - 12, + Math.max(...rows.map(({ label }) => context.measureText(label).width)) + 34, + ); + const height = rows.length * 19 + 10; + const x = plot.x + plot.width - width - 6; + const y = plot.y + 6; + context.fillStyle = "rgba(3, 11, 9, .72)"; + context.fillRect(x, y, width, height); + rows.forEach((row, index) => { + const rowY = y + 10 + index * 19; + context.fillStyle = row.color; + context.fillRect(x + 8, rowY + 1, 9, 9); + context.fillStyle = "rgba(239,249,245,.88)"; + context.fillText(row.label, x + 23, rowY + 10, Math.max(1, width - 29)); + }); + context.restore(); +} + +function normalizeSceneModel(model) { + const value = model ?? {}; + return { + scan: value.scan ?? value.experiment?.scan, + bands: value.bands ?? value.result?.bands ?? [], + sources: value.sources ?? value.scene?.sources ?? [], + image: value.image ?? value.imageElement ?? null, + imagePlane: value.imagePlane ?? value.scene?.image?.plane ?? null, + evaluations: value.evaluations ?? null, + dynamicRangeDb: + value.dynamicRangeDb ?? value.experiment?.beamforming?.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB, + opacity: value.opacity ?? 0.78, + showTruth: value.showTruth ?? true, + }; +} + +function drawScene(context, size, sourceModel, canvas) { + const model = normalizeSceneModel(sourceModel); + if (!validScan(model.scan)) { + drawEmpty(context, size.width, size.height, "scan 영역을 불러오면 acoustic map이 표시됩니다."); + return; + } + context.fillStyle = DEFAULT_BACKGROUND; + context.fillRect(0, 0, size.width, size.height); + const scan = model.scan; + const plot = fitPlot( + size.width, + size.height, + scan.xMax - scan.xMin, + scan.yMax - scan.yMin, + { left: 42, right: 16, top: 16, bottom: 34 }, + ); + drawScanGrid(context, scan, plot); + drawSceneImage(context, model.image, model.imagePlane, scan, plot); + + const bands = Array.isArray(model.bands) ? model.bands : []; + const heatmap = createHeatmapCanvas(canvas, scan, bands, model.dynamicRangeDb); + if (heatmap) { + context.save(); + context.beginPath(); + context.rect(plot.x, plot.y, plot.width, plot.height); + context.clip(); + const requestedOpacity = Number(model.opacity); + context.globalAlpha = finiteNumber(requestedOpacity) ? clamp(requestedOpacity, 0, 1) : 0.78; + context.imageSmoothingEnabled = true; + // Map storage is row-major from yMax to yMin, which is the Canvas top-to-bottom order. + context.drawImage(heatmap.canvas, plot.x, plot.y, plot.width, plot.height); + context.restore(); + drawBandPeakMarkers(context, bands, model.evaluations, scan, plot); + drawBandLegend(context, heatmap.activeBands, plot); + } + if (model.showTruth) drawTruthMarkers(context, model.sources, scan, plot); +} + +/** + * Create the scene-plane renderer. + * + * `render()` coalesces repeated updates into one animation frame. Use + * `renderNow()` only for deterministic capture/tests. Accepted model fields: + * `{ experiment|scan, result|bands, scene|sources, image, imagePlane, + * evaluations, dynamicRangeDb, opacity, showTruth }`. + */ +export function createSceneVisualization(canvas) { + if (!canvas) throw new TypeError("scene canvas가 필요합니다."); + return createScheduledCanvas(canvas, (context, size, model) => drawScene(context, size, model, canvas)); +} + +function resolveArrayDefinition(model) { + if (Array.isArray(model?.microphones)) return model; + return model?.arrayDefinition ?? null; +} + +function microphoneExtent(microphones, aperture) { + const positions = microphones + .map((microphone) => microphone?.position) + .filter((position) => Array.isArray(position) && finiteNumber(position[0]) && finiteNumber(position[1])); + if (positions.length === 0) return null; + let xMin = Math.min(...positions.map((position) => position[0])); + let xMax = Math.max(...positions.map((position) => position[0])); + let yMin = Math.min(...positions.map((position) => position[1])); + let yMax = Math.max(...positions.map((position) => position[1])); + if (finiteNumber(aperture?.width) && aperture.width > 0) { + xMin = Math.min(xMin, -aperture.width / 2); + xMax = Math.max(xMax, aperture.width / 2); + } + if (finiteNumber(aperture?.height) && aperture.height > 0) { + yMin = Math.min(yMin, -aperture.height / 2); + yMax = Math.max(yMax, aperture.height / 2); + } + const characteristicSize = Math.max(xMax - xMin, yMax - yMin, 0.01); + if (xMin === xMax) { + xMin -= characteristicSize * 0.5; + xMax += characteristicSize * 0.5; + } + if (yMin === yMax) { + yMin -= characteristicSize * 0.5; + yMax += characteristicSize * 0.5; + } + return { xMin, xMax, yMin, yMax, positions }; +} + +function arrayPositionToCanvas(position, extent, plot) { + return { + x: plot.x + ((position[0] - extent.xMin) / (extent.xMax - extent.xMin)) * plot.width, + y: plot.y + ((extent.yMax - position[1]) / (extent.yMax - extent.yMin)) * plot.height, + }; +} + +function drawArray(context, size, sourceModel) { + const arrayDefinition = resolveArrayDefinition(sourceModel); + const microphones = Array.isArray(arrayDefinition?.microphones) ? arrayDefinition.microphones : []; + const extent = microphoneExtent(microphones, arrayDefinition?.aperture); + if (!extent) { + drawEmpty(context, size.width, size.height, "array geometry를 불러오면 microphone 배치가 표시됩니다."); + return; + } + + context.fillStyle = DEFAULT_BACKGROUND; + context.fillRect(0, 0, size.width, size.height); + const xSpan = extent.xMax - extent.xMin; + const ySpan = extent.yMax - extent.yMin; + const padding = Math.max(xSpan, ySpan) * 0.08; + const view = { + ...extent, + xMin: extent.xMin - padding, + xMax: extent.xMax + padding, + yMin: extent.yMin - padding, + yMax: extent.yMax + padding, + }; + const plot = fitPlot( + size.width, + size.height, + view.xMax - view.xMin, + view.yMax - view.yMin, + { left: 24, right: 24, top: 52, bottom: 30 }, + ); + + context.save(); + context.beginPath(); + context.rect(plot.x, plot.y, plot.width, plot.height); + context.clip(); + context.fillStyle = "#0b1714"; + context.fillRect(plot.x, plot.y, plot.width, plot.height); + if (view.xMin <= 0 && view.xMax >= 0) { + const x = arrayPositionToCanvas([0, 0], view, plot).x; + context.strokeStyle = "rgba(101,242,191,.16)"; + context.beginPath(); + context.moveTo(x, plot.y); + context.lineTo(x, plot.y + plot.height); + context.stroke(); + } + if (view.yMin <= 0 && view.yMax >= 0) { + const y = arrayPositionToCanvas([0, 0], view, plot).y; + context.strokeStyle = "rgba(101,242,191,.16)"; + context.beginPath(); + context.moveTo(plot.x, y); + context.lineTo(plot.x + plot.width, y); + context.stroke(); + } + + const aperture = arrayDefinition.aperture; + if (finiteNumber(aperture?.width) && aperture.width > 0 && finiteNumber(aperture?.height) && aperture.height > 0) { + const topLeft = arrayPositionToCanvas([-aperture.width / 2, aperture.height / 2], view, plot); + const bottomRight = arrayPositionToCanvas([aperture.width / 2, -aperture.height / 2], view, plot); + context.setLineDash([5, 5]); + context.strokeStyle = "rgba(205,225,218,.28)"; + context.lineWidth = 1; + context.strokeRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y); + context.setLineDash([]); + } + + const radius = clamp(Math.min(plot.width, plot.height) / Math.max(40, Math.sqrt(microphones.length) * 8), 2, 5); + microphones.forEach((microphone) => { + const position = microphone?.position; + if (!Array.isArray(position) || !finiteNumber(position[0]) || !finiteNumber(position[1])) return; + const point = arrayPositionToCanvas(position, view, plot); + context.fillStyle = "rgba(101,242,191,.92)"; + context.beginPath(); + context.arc(point.x, point.y, radius, 0, Math.PI * 2); + context.fill(); + context.strokeStyle = "rgba(235,255,248,.5)"; + context.lineWidth = 0.75; + context.stroke(); + }); + context.restore(); + + context.strokeStyle = "rgba(174,211,199,.28)"; + context.strokeRect(plot.x + 0.5, plot.y + 0.5, Math.max(0, plot.width - 1), Math.max(0, plot.height - 1)); + context.fillStyle = "rgba(239,249,245,.94)"; + context.font = "700 13px Inter, system-ui, sans-serif"; + context.fillText(arrayDefinition.name || arrayDefinition.id || "Microphone array", 24, 23); + context.fillStyle = "rgba(205,225,218,.66)"; + context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace"; + const micXMin = Math.min(...extent.positions.map((position) => position[0])); + const micXMax = Math.max(...extent.positions.map((position) => position[0])); + const micYMin = Math.min(...extent.positions.map((position) => position[1])); + const micYMax = Math.max(...extent.positions.map((position) => position[1])); + context.fillText( + `${extent.positions.length} mic · ${(micXMax - micXMin).toFixed(3)} × ${(micYMax - micYMin).toFixed(3)} m`, + 24, + 41, + ); +} + +/** Create the microphone-array renderer. Accepts an array definition directly or `{ arrayDefinition }`. */ +export function createArrayVisualization(canvas) { + if (!canvas) throw new TypeError("array canvas가 필요합니다."); + return createScheduledCanvas(canvas, drawArray); +} diff --git a/packages/acoustics-core/evaluation.js b/packages/acoustics-core/evaluation.js new file mode 100644 index 0000000..f02bc44 --- /dev/null +++ b/packages/acoustics-core/evaluation.js @@ -0,0 +1,523 @@ +import { scanIndexToPosition } from "./math.js"; + +export const RESULT_METRIC_KEYS = Object.freeze([ + "peakToSidelobeDb", + "energyInsideTruthRatio", + "precision", + "recall", +]); + +const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS); +const DEFAULT_MAXIMUM_DETECTIONS = 128; +const DEFAULT_DYNAMIC_RANGE_DB = 120; + +function requireFinite(name, value) { + if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite.`); + return value; +} + +function requireNonNegative(name, value) { + requireFinite(name, value); + if (value < 0) throw new RangeError(`${name} must not be negative.`); + return value; +} + +function requireRatio(name, value, { positive = false } = {}) { + requireFinite(name, value); + if (value < (positive ? Number.EPSILON : 0) || value > 1) { + throw new RangeError(`${name} must be ${positive ? "greater than zero and " : ""}at most one.`); + } + return value; +} + +function validateScan(scan) { + if (!scan || typeof scan !== "object") throw new TypeError("scan is required."); + for (const key of ["xMin", "xMax", "yMin", "yMax", "z"]) requireFinite(`scan.${key}`, scan[key]); + for (const key of ["columns", "rows"]) { + if (!Number.isInteger(scan[key]) || scan[key] < 1) { + throw new RangeError(`scan.${key} must be a positive integer.`); + } + } + if (scan.xMin > scan.xMax || scan.yMin > scan.yMax) { + throw new RangeError("scan minimum bounds must not exceed maximum bounds."); + } +} + +function normalizeRawMap(rawMap, scan) { + const descriptor = rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap) ? rawMap : null; + const values = descriptor?.values ?? rawMap; + if (!Array.isArray(values) && !ArrayBuffer.isView(values)) { + throw new TypeError("rawMap must be an Array, a typed array, or a map descriptor with values."); + } + if (descriptor?.scale !== undefined && descriptor.scale !== "power") { + throw new RangeError("rawMap scale must be power."); + } + if (descriptor?.rows !== undefined && descriptor.rows !== scan.rows) { + throw new RangeError("rawMap rows do not match scan.rows."); + } + if (descriptor?.columns !== undefined && descriptor.columns !== scan.columns) { + throw new RangeError("rawMap columns do not match scan.columns."); + } + const expectedLength = scan.rows * scan.columns; + if (values.length !== expectedLength) { + throw new RangeError(`rawMap length must be scan.rows × scan.columns (${expectedLength}).`); + } + for (let index = 0; index < values.length; index += 1) { + requireNonNegative(`rawMap[${index}]`, values[index]); + } + return values; +} + +function normalizeSources(sources) { + if (!Array.isArray(sources)) throw new TypeError("sources must be an array."); + const ids = new Set(); + return sources.map((source, index) => { + if (!source || typeof source !== "object") throw new TypeError(`sources[${index}] must be an object.`); + if (typeof source.id !== "string" || source.id.length === 0) { + throw new TypeError(`sources[${index}].id must be a non-empty string.`); + } + if (ids.has(source.id)) throw new RangeError(`duplicate source id: ${source.id}`); + ids.add(source.id); + if (!Array.isArray(source.position) || source.position.length !== 3) { + throw new TypeError(`sources[${index}].position must contain three coordinates.`); + } + source.position.forEach((coordinate, coordinateIndex) => { + requireFinite(`sources[${index}].position[${coordinateIndex}]`, coordinate); + }); + return source; + }); +} + +function gridResolution(scan) { + const x = scan.columns > 1 ? Math.abs(scan.xMax - scan.xMin) / (scan.columns - 1) : 0; + const y = scan.rows > 1 ? Math.abs(scan.yMax - scan.yMin) / (scan.rows - 1) : 0; + return { x, y, diagonal: Math.hypot(x, y) }; +} + +function planarDistance(a, b) { + return Math.hypot(a[0] - b[0], a[1] - b[1]); +} + +function comparePeaks(a, b) { + return b.value - a.value || a.index - b.index; +} + +/** + * Return positive local maxima with each equal-valued 8-connected plateau + * represented by its lowest row-major index. A positive flat map falls back + * to the deterministic global peak at index zero. + */ +export function findLocalPeaks(rawMap, scan) { + validateScan(scan); + const values = normalizeRawMap(rawMap, scan); + let globalIndex = -1; + let globalValue = 0; + for (let index = 0; index < values.length; index += 1) { + if (values[index] > globalValue) { + globalValue = values[index]; + globalIndex = index; + } + } + if (!(globalValue > 0)) return []; + + const visited = new Uint8Array(values.length); + const queue = new Int32Array(values.length); + const peaks = []; + for (let startIndex = 0; startIndex < values.length; startIndex += 1) { + if (visited[startIndex] || !(values[startIndex] > 0)) continue; + const plateauValue = values[startIndex]; + let queueStart = 0; + let queueEnd = 1; + let representativeIndex = startIndex; + let hasHigherNeighbor = false; + let hasLowerNeighbor = false; + queue[0] = startIndex; + visited[startIndex] = 1; + + while (queueStart < queueEnd) { + const index = queue[queueStart]; + queueStart += 1; + representativeIndex = Math.min(representativeIndex, index); + const row = Math.floor(index / scan.columns); + const column = index % scan.columns; + const rowStart = Math.max(0, row - 1); + const rowEnd = Math.min(scan.rows - 1, row + 1); + const columnStart = Math.max(0, column - 1); + const columnEnd = Math.min(scan.columns - 1, column + 1); + for (let neighborRow = rowStart; neighborRow <= rowEnd; neighborRow += 1) { + for (let neighborColumn = columnStart; neighborColumn <= columnEnd; neighborColumn += 1) { + if (neighborRow === row && neighborColumn === column) continue; + const neighborIndex = neighborRow * scan.columns + neighborColumn; + const neighborValue = values[neighborIndex]; + if (neighborValue === plateauValue) { + if (!visited[neighborIndex]) { + visited[neighborIndex] = 1; + queue[queueEnd] = neighborIndex; + queueEnd += 1; + } + } else if (neighborValue > plateauValue) { + hasHigherNeighbor = true; + } else { + hasLowerNeighbor = true; + } + } + } + } + + if (!hasHigherNeighbor && hasLowerNeighbor) { + peaks.push({ + index: representativeIndex, + position: scanIndexToPosition(representativeIndex, scan), + value: plateauValue, + }); + } + } + + if (!peaks.some(({ index }) => index === globalIndex)) { + peaks.push({ index: globalIndex, position: scanIndexToPosition(globalIndex, scan), value: globalValue }); + } + peaks.sort(comparePeaks); + return peaks; +} + +function selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections) { + const detections = []; + for (const peak of localPeaks) { + if (peak.value < thresholdValue) continue; + if ( + minimumPeakDistanceM > 0 && + detections.some((selected) => planarDistance(selected.position, peak.position) <= minimumPeakDistanceM) + ) { + continue; + } + detections.push({ ...peak }); + if (detections.length === maximumDetections) break; + } + return detections; +} + +function matchTruths(truths, detections, matchToleranceM) { + const truthToDetection = new Int32Array(truths.length); + truthToDetection.fill(-1); + + // First maximize valid tolerance-based matches. Stronger detections are + // considered first; each one's candidate truths are ordered by distance. + const adjacency = detections.map((detection) => + truths + .map((truth, truthIndex) => ({ + truthIndex, + distance: planarDistance(detection.position, truth.position), + })) + .filter(({ distance }) => distance <= matchToleranceM) + .sort((a, b) => a.distance - b.distance || a.truthIndex - b.truthIndex), + ); + + function assign(detectionIndex, visitedTruths) { + for (const { truthIndex } of adjacency[detectionIndex]) { + if (visitedTruths[truthIndex]) continue; + visitedTruths[truthIndex] = 1; + const previousDetection = truthToDetection[truthIndex]; + if (previousDetection === -1 || assign(previousDetection, visitedTruths)) { + truthToDetection[truthIndex] = detectionIndex; + return true; + } + } + return false; + } + + detections.forEach((_, detectionIndex) => assign(detectionIndex, new Uint8Array(truths.length))); + const usedDetections = new Uint8Array(detections.length); + truthToDetection.forEach((detectionIndex) => { + if (detectionIndex >= 0) usedDetections[detectionIndex] = 1; + }); + + // Preserve a nearest-peak localization measurement for still-unmatched + // truths without counting a distant assignment as a true positive. + const remainingPairs = []; + truths.forEach((truth, truthIndex) => { + if (truthToDetection[truthIndex] >= 0) return; + detections.forEach((detection, detectionIndex) => { + if (!usedDetections[detectionIndex]) { + remainingPairs.push({ + truthIndex, + detectionIndex, + distance: planarDistance(detection.position, truth.position), + }); + } + }); + }); + remainingPairs.sort( + (a, b) => a.distance - b.distance || a.truthIndex - b.truthIndex || a.detectionIndex - b.detectionIndex, + ); + for (const pair of remainingPairs) { + if (truthToDetection[pair.truthIndex] >= 0 || usedDetections[pair.detectionIndex]) continue; + truthToDetection[pair.truthIndex] = pair.detectionIndex; + usedDetections[pair.detectionIndex] = 1; + } + + const sourceMatches = truths.map((truth, truthIndex) => { + const detectionIndex = truthToDetection[truthIndex]; + if (detectionIndex < 0) { + return { + sourceId: truth.sourceId, + truthPosition: [...truth.position], + peak: null, + localizationErrorM: null, + withinTolerance: false, + }; + } + const detection = detections[detectionIndex]; + const localizationErrorM = planarDistance(detection.position, truth.position); + return { + sourceId: truth.sourceId, + truthPosition: [...truth.position], + peak: { ...detection, position: [...detection.position] }, + localizationErrorM, + withinTolerance: localizationErrorM <= matchToleranceM, + }; + }); + + return { + sourceMatches, + truePositiveCount: sourceMatches.filter(({ withinTolerance }) => withinTolerance).length, + }; +} + +function energyInsideRatio(values, scan, truths, radiusM, globalValue) { + if (!(globalValue > 0) || truths.length === 0) return 0; + let total = 0; + let inside = 0; + for (let index = 0; index < values.length; index += 1) { + const scaledValue = values[index] / globalValue; + total += scaledValue; + if (truths.some((truth) => planarDistance(scanIndexToPosition(index, scan), truth.position) <= radiusM)) { + inside += scaledValue; + } + } + return total > 0 ? Math.min(1, Math.max(0, inside / total)) : 0; +} + +function peakToSidelobeDb(values, scan, truths, globalPeak, exclusionRadiusM, dynamicRangeDb) { + if (!globalPeak) return 0; + const exclusionCenters = truths.length > 0 ? truths.map(({ position }) => position) : [globalPeak.position]; + let mainPeak = truths.length > 0 ? 0 : globalPeak.value; + let sidelobePeak = 0; + for (let index = 0; index < values.length; index += 1) { + const position = scanIndexToPosition(index, scan); + const isMainRegion = exclusionCenters.some((center) => planarDistance(position, center) <= exclusionRadiusM); + if (isMainRegion) mainPeak = Math.max(mainPeak, values[index]); + else sidelobePeak = Math.max(sidelobePeak, values[index]); + } + if (!(mainPeak > 0)) return sidelobePeak > 0 ? -dynamicRangeDb : 0; + if (!(sidelobePeak > 0)) return dynamicRangeDb; + return Math.max( + -dynamicRangeDb, + Math.min(dynamicRangeDb, 10 * (Math.log10(mainPeak) - Math.log10(sidelobePeak))), + ); +} + +/** Measure the 8-connected half-power component containing a selected peak. */ +export function measureMainLobe(rawMap, scan, peakIndex, halfPowerRatio = 0.5) { + validateScan(scan); + const values = normalizeRawMap(rawMap, scan); + if (!Number.isInteger(peakIndex) || peakIndex < 0 || peakIndex >= values.length) { + throw new RangeError("peakIndex is outside rawMap."); + } + requireRatio("halfPowerRatio", halfPowerRatio, { positive: true }); + const peakValue = values[peakIndex]; + if (!(peakValue > 0)) { + return { + peakIndex, + thresholdValue: 0, + pixelCount: 0, + widthXM: 0, + widthYM: 0, + equivalentDiameterM: 0, + }; + } + + const thresholdValue = peakValue * halfPowerRatio; + const visited = new Uint8Array(values.length); + const queue = new Int32Array(values.length); + let queueStart = 0; + let queueEnd = 1; + let minimumRow = Math.floor(peakIndex / scan.columns); + let maximumRow = minimumRow; + let minimumColumn = peakIndex % scan.columns; + let maximumColumn = minimumColumn; + queue[0] = peakIndex; + visited[peakIndex] = 1; + + while (queueStart < queueEnd) { + const index = queue[queueStart]; + queueStart += 1; + const row = Math.floor(index / scan.columns); + const column = index % scan.columns; + minimumRow = Math.min(minimumRow, row); + maximumRow = Math.max(maximumRow, row); + minimumColumn = Math.min(minimumColumn, column); + maximumColumn = Math.max(maximumColumn, column); + for (let neighborRow = Math.max(0, row - 1); neighborRow <= Math.min(scan.rows - 1, row + 1); neighborRow += 1) { + for ( + let neighborColumn = Math.max(0, column - 1); + neighborColumn <= Math.min(scan.columns - 1, column + 1); + neighborColumn += 1 + ) { + const neighborIndex = neighborRow * scan.columns + neighborColumn; + if (!visited[neighborIndex] && values[neighborIndex] >= thresholdValue) { + visited[neighborIndex] = 1; + queue[queueEnd] = neighborIndex; + queueEnd += 1; + } + } + } + } + + const resolution = gridResolution(scan); + const widthXM = scan.columns > 1 ? (maximumColumn - minimumColumn + 1) * resolution.x : 0; + const widthYM = scan.rows > 1 ? (maximumRow - minimumRow + 1) * resolution.y : 0; + let equivalentDiameterM = 0; + if (resolution.x > 0 && resolution.y > 0) { + equivalentDiameterM = 2 * Math.sqrt((queueEnd * resolution.x * resolution.y) / Math.PI); + } else { + equivalentDiameterM = Math.max(widthXM, widthYM); + } + return { peakIndex, thresholdValue, pixelCount: queueEnd, widthXM, widthYM, equivalentDiameterM }; +} + +/** + * Evaluate one linear-power DAS band. Source z coordinates are projected onto + * scan.z, matching the image-plane ground-truth convention. + */ +export function evaluateDasBand(options) { + if (!options || typeof options !== "object") throw new TypeError("evaluation options are required."); + const { rawMap, scan } = options; + validateScan(scan); + const values = normalizeRawMap(rawMap, scan); + const sources = normalizeSources(options.sources ?? options.scene?.sources ?? []); + const truths = sources.map((source) => ({ + sourceId: source.id, + position: [source.position[0], source.position[1], scan.z], + })); + const resolution = gridResolution(scan); + const defaultRadius = resolution.diagonal > 0 ? resolution.diagonal * 1.5 : 0; + const truthRadiusM = requireNonNegative("truthRadiusM", options.truthRadiusM ?? defaultRadius); + const matchToleranceM = requireNonNegative( + "matchToleranceM", + options.matchToleranceM ?? Math.max(defaultRadius, truthRadiusM), + ); + const detectionThresholdRatio = requireRatio( + "detectionThresholdRatio", + options.detectionThresholdRatio ?? 0.5, + ); + const minimumPeakDistanceM = requireNonNegative( + "minimumPeakDistanceM", + options.minimumPeakDistanceM ?? resolution.diagonal, + ); + const sidelobeExclusionRadiusM = requireNonNegative( + "sidelobeExclusionRadiusM", + options.sidelobeExclusionRadiusM ?? truthRadiusM, + ); + const halfPowerRatio = requireRatio("halfPowerRatio", options.halfPowerRatio ?? 0.5, { positive: true }); + const dynamicRangeDb = requireNonNegative( + "dynamicRangeDb", + options.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB, + ); + const maximumDetections = options.maximumDetections ?? DEFAULT_MAXIMUM_DETECTIONS; + if (!Number.isInteger(maximumDetections) || maximumDetections < 1 || maximumDetections > 128) { + throw new RangeError("maximumDetections must be an integer from 1 through 128."); + } + + const localPeaks = findLocalPeaks(values, scan); + const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null; + const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio; + const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections); + const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM); + const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0; + const recall = truths.length > 0 ? truePositiveCount / truths.length : 1; + const mainLobe = globalPeak ? measureMainLobe(values, scan, globalPeak.index, halfPowerRatio) : null; + + return { + globalPeak, + localPeaks, + detections, + sourceMatches, + counts: { + truth: truths.length, + detected: detections.length, + truePositive: truePositiveCount, + falsePositive: detections.length - truePositiveCount, + falseNegative: truths.length - truePositiveCount, + }, + parameters: { + truthRadiusM, + matchToleranceM, + detectionThresholdRatio, + detectionThresholdValue: thresholdValue, + minimumPeakDistanceM, + sidelobeExclusionRadiusM, + halfPowerRatio, + }, + metrics: { + peakToSidelobeDb: peakToSidelobeDb( + values, + scan, + truths, + globalPeak, + sidelobeExclusionRadiusM, + dynamicRangeDb, + ), + energyInsideTruthRatio: energyInsideRatio(values, scan, truths, truthRadiusM, globalPeak?.value ?? 0), + precision, + recall, + }, + diagnostics: { + mainLobe, + mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0, + gridResolutionM: resolution, + }, + }; +} + +/** Select only metric names accepted by result.schema.json. */ +export function selectResultMetrics(evaluationOrMetrics, metricNames = RESULT_METRIC_KEYS) { + const metrics = evaluationOrMetrics?.metrics ?? evaluationOrMetrics; + if (!metrics || typeof metrics !== "object") throw new TypeError("evaluation metrics are required."); + if (!Array.isArray(metricNames)) throw new TypeError("metricNames must be an array."); + const selected = {}; + for (const metricName of metricNames) { + if (!RESULT_METRIC_KEY_SET.has(metricName)) { + throw new RangeError(`result.schema.json does not allow metric: ${metricName}`); + } + const value = metrics[metricName]; + requireFinite(`metrics.${metricName}`, value); + selected[metricName] = value; + } + return selected; +} + +/** Convert an evaluation to the schema-safe `peaks` and `metrics` band fields. */ +export function exportBandEvaluation(evaluation, options = {}) { + if (!evaluation || typeof evaluation !== "object") throw new TypeError("evaluation is required."); + const maximumPeaks = options.maximumPeaks ?? 128; + if (!Number.isInteger(maximumPeaks) || maximumPeaks < 0 || maximumPeaks > 128) { + throw new RangeError("maximumPeaks must be an integer from zero through 128."); + } + const matchByPeakIndex = new Map(); + for (const match of evaluation.sourceMatches ?? []) { + if (match.withinTolerance && match.peak) matchByPeakIndex.set(match.peak.index, match); + } + const peaks = (evaluation.detections ?? []).slice(0, maximumPeaks).map((peak) => { + const resultPeak = { position: [...peak.position], value: peak.value }; + const match = matchByPeakIndex.get(peak.index); + if (match) { + resultPeak.sourceId = match.sourceId; + resultPeak.localizationErrorM = match.localizationErrorM; + } + return resultPeak; + }); + return { + peaks, + metrics: selectResultMetrics(evaluation, options.metricNames ?? RESULT_METRIC_KEYS), + }; +} diff --git a/packages/acoustics-core/index.js b/packages/acoustics-core/index.js index 6be2685..1f0a9c1 100644 --- a/packages/acoustics-core/index.js +++ b/packages/acoustics-core/index.js @@ -1,4 +1,5 @@ export * from "./das.js"; +export * from "./evaluation.js"; export * from "./fft.js"; export * from "./fractional-delay.js"; export * from "./math.js"; diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js new file mode 100644 index 0000000..ac99182 --- /dev/null +++ b/tests/integration/camera-ui.test.js @@ -0,0 +1,147 @@ +import { validateResult } from "/packages/contracts/validation.js"; + +async function waitFor(predicate, label, timeoutMs = 30000) { + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + const value = predicate(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 40)); + } + throw new Error(`${label} 대기 시간이 초과되었습니다.`); +} + +function dispatchValue(windowObject, element, value, eventName = "change") { + element.value = String(value); + element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true })); +} + +async function loadJson(path) { + const response = await fetch(path); + assert(response.ok, `${path} load failed`); + return response.json(); +} + +async function nextPaint(windowObject) { + await new Promise((resolve) => windowObject.requestAnimationFrame(() => + windowObject.requestAnimationFrame(resolve))); +} + +test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름을 완주한다", async () => { + const frame = document.createElement("iframe"); + frame.src = "/apps/acoustic-camera/?integration-test=1"; + frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0"; + document.body.append(frame); + await new Promise((resolve, reject) => { + frame.addEventListener("load", resolve, { once: true }); + frame.addEventListener("error", () => reject(new Error("Acoustic Camera iframe load failed")), { + once: true, + }); + }); + + const windowObject = frame.contentWindow; + const documentObject = frame.contentDocument; + const downloads = []; + const objectUrls = []; + const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click; + const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL); + windowObject.HTMLAnchorElement.prototype.click = function captureDownload() { + downloads.push({ fileName: this.download, url: this.href }); + }; + windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url); + + try { + await waitFor( + () => documentObject.documentElement.dataset.appReady === "true", + "Acoustic Camera 초기화", + ); + await waitFor( + () => documentObject.documentElement.dataset.cameraValid === "true", + "기본 Camera 입력 검증", + ); + + const canvas = documentObject.querySelector("#scene-canvas"); + await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더"); + await nextPaint(windowObject); + const initialCanvas = canvas.toDataURL(); + + dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8); + dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8); + dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu"); + await waitFor( + () => documentObject.documentElement.dataset.cameraValid === "true" && + !documentObject.querySelector("#run-button").disabled, + "8×8 CPU 입력 검증", + ); + assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset"); + + documentObject.querySelector("#run-button").click(); + await waitFor( + () => documentObject.documentElement.dataset.runState === "done", + "8×8 CPU simulation", + 30000, + ); + assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend"); + assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count"); + assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered"); + assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible"); + assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered"); + await nextPaint(windowObject); + assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas"); + + documentObject.querySelector("#export-result-button").click(); + const resultDownload = await waitFor( + () => downloads.find((download) => download.fileName.endsWith(".result.json")), + "Result JSON download", + ); + const resultDocument = await fetch(resultDownload.url).then((response) => response.json()); + const [scene, experiment] = await Promise.all([ + loadJson("/assets/sample-scenes/single-tone.scene.json"), + loadJson("/assets/sample-scenes/single-tone.experiment.json"), + ]); + experiment.scan.columns = 8; + experiment.scan.rows = 8; + experiment.beamforming.backend = "cpu"; + const resultIssues = await validateResult(resultDocument, { scene, experiment }); + assertEqual(resultIssues.length, 0, `downloaded Result validation: ${resultIssues + .map((issue) => `${issue.path}: ${issue.message}`) + .join(" · ")}`); + assertEqual(resultDocument.backend, "cpu", "exported backend"); + 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"); + assert(/^[a-f0-9]{64}$/.test(resultDocument.inputHash), "exported input hash"); + + dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24); + await waitFor( + () => documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), + "changed input stale badge", + ); + assert(documentObject.querySelector("#canvas-badge").textContent.includes("이전 설정"), "canvas stale badge"); + + dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 30); + await waitFor( + () => documentObject.documentElement.dataset.cameraValid === "true" && + !documentObject.querySelector("#run-button").disabled, + "original comparison configuration restore", + ); + + documentObject.querySelector("#run-button").click(); + await waitFor( + () => documentObject.documentElement.dataset.runState === "running", + "rerun start", + ); + assert(!documentObject.querySelector("#cancel-button").classList.contains("is-hidden"), "cancel available"); + await waitFor( + () => documentObject.documentElement.dataset.runState === "done", + "8×8 CPU rerun", + 30000, + ); + assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge"); + assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history"); + } finally { + windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; + windowObject.URL.revokeObjectURL = originalRevoke; + for (const url of objectUrls) originalRevoke(url); + frame.remove(); + } +}); diff --git a/tests/test-runner.js b/tests/test-runner.js index 17d109e..a97c993 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -12,10 +12,13 @@ try { await import("/tests/unit/contracts.test.js"); await import("/tests/unit/acoustics.test.js"); await import("/tests/unit/builder.test.js"); + await import("/tests/unit/evaluation.test.js"); + await import("/tests/unit/camera.test.js"); await import("/tests/unit/webgpu.test.js"); await import("/tests/integration/cpu-baseline.test.js"); await import("/tests/integration/webgpu.test.js"); await import("/tests/integration/builder-ui.test.js"); + await import("/tests/integration/camera-ui.test.js"); } catch (error) { registeredTests.push({ name: "테스트 모듈을 불러온다", diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js new file mode 100644 index 0000000..fc9681a --- /dev/null +++ b/tests/unit/camera.test.js @@ -0,0 +1,182 @@ +import { + canonicalStringify, + createResultDocument, + estimateExperimentWorkload, + hashSimulationInputs, + shiftSceneDistance, +} from "/apps/acoustic-camera/model.js"; +import { validateResult } from "/packages/contracts/validation.js"; + +function assertNear(actual, expected, tolerance, label) { + assert( + Math.abs(actual - expected) <= tolerance, + `${label}: expected ${expected} ± ${tolerance}, received ${actual}`, + ); +} + +test("Camera input hash is canonical across object key order", async () => { + const first = { + arrayDefinition: { + id: "array", + microphones: [ + { id: "mic-1", position: [0, 0, 0] }, + { id: "mic-2", position: [0.1, 0, 0] }, + ], + }, + scene: { + id: "scene", + sampleRate: 8000, + sources: [{ id: "source", position: [0, 0, 2], signal: { type: "tone", frequency: 1000 } }], + }, + experiment: { + id: "experiment", + scan: { z: 2, columns: 8, rows: 8 }, + frequencyBands: [{ id: "band", minHz: 900, maxHz: 1100 }], + }, + }; + const reordered = { + experiment: { + frequencyBands: [{ maxHz: 1100, minHz: 900, id: "band" }], + scan: { rows: 8, columns: 8, z: 2 }, + id: "experiment", + }, + scene: { + sources: [{ signal: { frequency: 1000, type: "tone" }, position: [0, 0, 2], id: "source" }], + sampleRate: 8000, + id: "scene", + }, + arrayDefinition: { + microphones: [ + { position: [0, 0, 0], id: "mic-1" }, + { position: [0.1, 0, 0], id: "mic-2" }, + ], + id: "array", + }, + }; + + assertEqual(canonicalStringify({ z: undefined, b: 2, a: { d: 4, c: 3 } }), '{"a":{"c":3,"d":4},"b":2}', "canonical JSON"); + const firstHash = await hashSimulationInputs(first); + const reorderedHash = await hashSimulationInputs(reordered); + assertEqual(firstHash, reorderedHash, "key ordering does not alter hash"); + assert(/^[a-f0-9]{64}$/.test(firstHash), "hash is lowercase SHA-256"); + + reordered.scene.sources[0].position[0] = 0.01; + const changedHash = await hashSimulationInputs(reordered); + assert(changedHash !== firstHash, "semantic input change alters hash"); +}); + +test("Camera workload counts unique overlapping FFT bins", () => { + const arrayDefinition = { + microphones: Array.from({ length: 3 }, (_, index) => ({ id: `mic-${index}`, position: [index, 0, 0] })), + }; + const scene = { sampleRate: 8000, duration: 1 }; + const experiment = { + scan: { columns: 10, rows: 8 }, + stft: { fftSize: 256, hopSize: 128 }, + frequencyBands: [ + { id: "lower", minHz: 100, maxHz: 200 }, + { id: "upper", minHz: 187.5, maxHz: 250 }, + ], + }; + + const workload = estimateExperimentWorkload(arrayDefinition, scene, experiment); + assertEqual(workload.pointCount, 80, "scan point count"); + assertEqual(workload.microphoneCount, 3, "microphone 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(workload.selectedBinCount, 5, "overlapping bin is counted once"); + 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"); +}); + +test("Camera distance shift keeps the scene and scan on one z plane", () => { + const scene = { + sources: [ + { id: "one", position: [0.1, -0.2, 2] }, + { id: "two", position: [-0.3, 0.4, 2.5] }, + ], + image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } }, + }; + const experiment = { scan: { z: 2 } }; + + shiftSceneDistance(scene, experiment, 3.25); + + assertEqual(experiment.scan.z, 3.25, "scan z"); + assertEqual(scene.sources[0].position.join(","), "0.1,-0.2,3.25", "first source z shift"); + assertEqual(scene.sources[1].position.join(","), "-0.3,0.4,3.75", "relative source depth retained"); + assertEqual(scene.image.plane.z, 3.25, "background image plane z shift"); +}); + +test("Camera result document is schema-safe and linked to its snapshot", async () => { + const arrayDefinition = { + id: "test-array", + name: "Test array", + microphones: [ + { id: "mic-1", position: [0, 0, 0] }, + { id: "mic-2", position: [0.1, 0, 0] }, + ], + }; + const scene = { + id: "test-scene", + sources: [{ id: "source-1", position: [0, 0, 2] }], + }; + const experiment = { + id: "test-experiment", + arrayId: arrayDefinition.id, + sceneId: scene.id, + scan: { rows: 2, columns: 2 }, + beamforming: { backend: "auto" }, + frequencyBands: [{ id: "band-1", color: "#65f2bf" }], + }; + const run = { + backend: "cpu", + signalStats: { preNormalizationPeak: 0.75, appliedScale: 1, clippedSampleCount: 0 }, + timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 }, + fallback: { code: "webgpu-unavailable", message: "CPU fallback" }, + bands: [{ + id: "band-1", + color: "#65f2bf", + rawMap: Float32Array.of(0.1, 0.2, 0.3, 1), + displayMap: Float32Array.of(0.1, 0.2, 0.3, 1), + displayScale: "normalized", + }], + }; + const evaluations = new Map([["band-1", { + peaks: [{ index: 3, sourceId: "source-1", position: [1, -1, 2], value: 1, localizationErrorM: 0.05 }], + metrics: { + peakToSidelobeDb: 10, + energyInsideTruthRatio: 0.75, + precision: 1, + recall: 1, + mainLobeWidthM: 0.2, + ignoredNonFinite: Number.NaN, + }, + }]]); + + const result = createResultDocument({ + arrayDefinition, + scene, + experiment, + run, + evaluations, + inputHash: "a".repeat(64), + createdAt: "2026-08-28T00:00:00.000Z", + }); + + assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array"); + assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted"); + assertEqual( + Object.keys(result.bands[0].metrics).join(","), + "peakToSidelobeDb,energyInsideTruthRatio,precision,recall", + "only finite schema metrics are exported", + ); + assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded"); + + const issues = await validateResult(result, { experiment, scene }); + assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`); + + const wrongExperiment = { ...experiment, arrayId: "another-array" }; + const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene }); + assert(linkageIssues.some(({ path }) => path === "$.arrayId"), "validator catches snapshot linkage mismatch"); +}); diff --git a/tests/unit/evaluation.test.js b/tests/unit/evaluation.test.js new file mode 100644 index 0000000..70a7b5b --- /dev/null +++ b/tests/unit/evaluation.test.js @@ -0,0 +1,197 @@ +import { + RESULT_METRIC_KEYS, + evaluateDasBand, + exportBandEvaluation, + findLocalPeaks, + measureMainLobe, + selectResultMetrics, +} from "/packages/acoustics-core/evaluation.js"; + +function assertNear(actual, expected, tolerance, label) { + assert( + Math.abs(actual - expected) <= tolerance, + `${label}: expected ${expected} ± ${tolerance}, received ${actual}`, + ); +} + +function captureError(callback) { + try { + callback(); + } catch (error) { + return error; + } + return undefined; +} + +test("evaluation이 row-major 첫 행을 yMax로 해석한다", () => { + const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 3, rows: 3 }; + const rawMap = Float32Array.of( + 4, 0, 0, + 0, 0, 0, + 0, 0, 1, + ); + const evaluation = evaluateDasBand({ + rawMap, + scan, + sources: [{ id: "top-left", position: [-1, 1, 9] }], + truthRadiusM: 0.1, + matchToleranceM: 0.1, + sidelobeExclusionRadiusM: 0.1, + detectionThresholdRatio: 0.5, + minimumPeakDistanceM: 0, + }); + + assertEqual(evaluation.globalPeak.index, 0, "row-major global index"); + assertEqual(evaluation.globalPeak.position.join(","), "-1,1,2", "first row position"); + assertEqual(evaluation.sourceMatches[0].sourceId, "top-left", "source match id"); + assertNear(evaluation.sourceMatches[0].localizationErrorM, 0, 1e-12, "projected localization error"); + assertNear(evaluation.metrics.energyInsideTruthRatio, 0.8, 1e-12, "truth energy ratio"); + assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(4), 1e-12, "peak-to-sidelobe"); + assertEqual(evaluation.metrics.precision, 1, "single-source precision"); + assertEqual(evaluation.metrics.recall, 1, "single-source recall"); +}); + +test("복수 source와 threshold detection을 one-to-one matching한다", () => { + const scan = { xMin: -2, xMax: 2, yMin: -2, yMax: 2, z: 1, columns: 5, rows: 5 }; + const rawMap = new Float32Array(25); + rawMap[6] = 10; // (-1, +1) + rawMap[18] = 8; // (+1, -1) + rawMap[4] = 6; // unmatched (+2, +2) + const evaluation = evaluateDasBand({ + rawMap, + scan, + sources: [ + { id: "a", position: [-1, 1, 1] }, + { id: "b", position: [1, -1, 1] }, + ], + truthRadiusM: 0.25, + matchToleranceM: 0.25, + sidelobeExclusionRadiusM: 0.25, + detectionThresholdRatio: 0.5, + minimumPeakDistanceM: 0, + }); + + assertEqual(evaluation.detections.length, 3, "threshold detections"); + assertEqual(evaluation.counts.truePositive, 2, "true positives"); + assertEqual(evaluation.counts.falsePositive, 1, "false positives"); + assertEqual(evaluation.counts.falseNegative, 0, "false negatives"); + assertNear(evaluation.metrics.precision, 2 / 3, 1e-12, "multi-source precision"); + assertNear(evaluation.metrics.recall, 1, 1e-12, "multi-source recall"); + assertNear(evaluation.metrics.energyInsideTruthRatio, 18 / 24, 1e-12, "union truth energy"); + assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(10 / 6), 1e-12, "multi-source PSLR"); + assert( + evaluation.sourceMatches.every((match) => match.withinTolerance && match.localizationErrorM === 0), + "both sources matched exactly", + ); +}); + +test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => { + const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 }; + const evaluation = evaluateDasBand({ + rawMap: new Float32Array(4), + scan, + sources: [{ id: "silent", position: [0, 0, 1] }], + }); + + assertEqual(evaluation.globalPeak, null, "zero-map global peak"); + assertEqual(evaluation.localPeaks.length, 0, "zero-map local peaks"); + assertEqual(evaluation.detections.length, 0, "zero-map detections"); + assertEqual(evaluation.sourceMatches[0].peak, null, "zero-map source remains unmatched"); + assertEqual(evaluation.metrics.energyInsideTruthRatio, 0, "zero-map energy ratio"); + assertEqual(evaluation.metrics.peakToSidelobeDb, 0, "zero-map PSLR"); + assertEqual(evaluation.metrics.precision, 0, "zero-map precision"); + assertEqual(evaluation.metrics.recall, 0, "zero-map recall"); + assertEqual(evaluation.diagnostics.mainLobeWidthM, 0, "zero-map main-lobe width"); + Object.values(evaluation.metrics).forEach((value) => assert(Number.isFinite(value), "finite zero-map metric")); +}); + +test("flat plateau와 main-lobe width가 deterministic하다", () => { + const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 0, columns: 3, rows: 3 }; + const rawMap = Float32Array.from({ length: 9 }, () => 1); + const peaks = findLocalPeaks(rawMap, scan); + assertEqual(peaks.length, 1, "flat plateau represented once"); + assertEqual(peaks[0].index, 0, "flat plateau uses lowest row-major index"); + + const first = evaluateDasBand({ + rawMap, + scan, + sources: [{ id: "corner", position: [-1, 1, 0] }], + truthRadiusM: 0, + matchToleranceM: 0, + minimumPeakDistanceM: 0, + }); + const second = evaluateDasBand({ + rawMap, + scan, + sources: [{ id: "corner", position: [-1, 1, 0] }], + truthRadiusM: 0, + matchToleranceM: 0, + minimumPeakDistanceM: 0, + }); + assertEqual(JSON.stringify(first), JSON.stringify(second), "repeatable evaluation result"); + + const lobe = measureMainLobe(rawMap, scan, 0, 0.5); + assertEqual(lobe.pixelCount, 9, "flat half-power component size"); + assertEqual(lobe.widthXM, 3, "main-lobe x pixel footprint"); + assertEqual(lobe.widthYM, 3, "main-lobe y pixel footprint"); + assertNear(lobe.equivalentDiameterM, 6 / Math.sqrt(Math.PI), 1e-12, "equivalent main-lobe diameter"); +}); + +test("result exporter가 schema 허용 metric만 선택하고 내부 필드를 제거한다", () => { + const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 }; + const evaluation = evaluateDasBand({ + rawMap: Float32Array.of(5, 0, 0, 1), + scan, + sources: [{ id: "truth", position: [0, 1, 3] }], + truthRadiusM: 0, + matchToleranceM: 0, + sidelobeExclusionRadiusM: 0, + minimumPeakDistanceM: 0, + }); + const exported = exportBandEvaluation(evaluation, { metricNames: ["precision", "recall"] }); + + assertEqual(Object.keys(exported.metrics).join(","), "precision,recall", "selected metric keys"); + assertEqual(exported.peaks[0].sourceId, "truth", "schema peak source match"); + assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error"); + assert(!("index" in exported.peaks[0]), "internal peak index omitted"); + assert(!("mainLobeWidthM" in exported.metrics), "diagnostic omitted from schema metrics"); + assertEqual( + Object.keys(selectResultMetrics(evaluation)).join(","), + RESULT_METRIC_KEYS.join(","), + "default schema metric selection", + ); + + const unsupported = captureError(() => selectResultMetrics(evaluation, ["mainLobeWidthM"])); + assert(unsupported instanceof RangeError, "unsupported result metric is rejected"); +}); + +test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서 거부한다", () => { + const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 }; + for (const invalidValue of [Number.NaN, Number.POSITIVE_INFINITY, -1]) { + const rawMap = [0, 0, 0, invalidValue]; + assert( + captureError(() => evaluateDasBand({ rawMap, scan, sources: [] })) instanceof RangeError, + `invalid power rejected: ${invalidValue}`, + ); + } + assert( + captureError(() => evaluateDasBand({ rawMap: [0, 0, 0], scan, sources: [] })) instanceof RangeError, + "map shape mismatch rejected", + ); + assert( + captureError(() => evaluateDasBand({ + rawMap: [0, 0, 0, 0], + scan, + sources: [{ id: "bad", position: [0, Number.NaN, 1] }], + })) instanceof RangeError, + "non-finite source position rejected", + ); + assert( + captureError(() => evaluateDasBand({ + rawMap: { rows: 2, columns: 2, scale: "normalized", values: [0, 0, 0, 0] }, + scan, + sources: [], + })) instanceof RangeError, + "non-power map rejected", + ); +});