feat: complete acoustic camera workflow

This commit is contained in:
Codex
2026-08-28 12:11:10 +09:00
parent d5f7530620
commit d04a4d9f65
13 changed files with 3632 additions and 16 deletions
+261
View File
@@ -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();
},
};
}
+119 -16
View File
@@ -3,30 +3,133 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="micarrsim WebGPU acoustic camera simulator" />
<meta name="description" content="192채널 WebGPU acoustic camera simulator" />
<title>Acoustic Camera · micarrsim</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<link rel="stylesheet" href="/apps/acoustic-camera/styles.css" />
</head>
<body>
<div class="app-shell">
<header class="app-header">
<a class="app-brand" href="/">
<strong>micarrsim</strong>
<span>Acoustic Camera</span>
</a>
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
<div class="app-shell camera-shell">
<header class="app-header camera-header">
<a class="app-brand" href="/"><strong>micarrsim</strong><span>Acoustic Camera</span></a>
<div class="header-status">
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
<span class="run-status" id="run-status">기본 fixture 준비 중…</span>
</div>
<div class="header-actions">
<button class="button button--ghost is-hidden" id="cancel-button" type="button">계산 취소</button>
<button class="button button--ghost" id="export-result-button" type="button" disabled>Result JSON</button>
<button class="button button--primary" id="run-button" type="button" disabled>▶ 기본 예제 실행</button>
</div>
</header>
<main class="app-main">
<section class="empty-state">
<p class="eyebrow">Beamforming laboratory</p>
<h1>배열 성능 비교하기</h1>
<p>
이 화면에서 scene과 192채널 배열을 불러와 자유음장 전달 신호를 만들고 주파수 대역별
DAS acoustic image를 ground truth와 비교하게 됩니다.
</p>
<main class="camera-layout">
<section class="scene-workspace" aria-label="Acoustic image workspace">
<div class="workspace-heading">
<div>
<p class="eyebrow">Ground truth + acoustic overlay</p>
<h1 id="workspace-title">음향 장면</h1>
<p class="workspace-meta" id="scene-meta">장면을 불러오고 있습니다.</p>
</div>
<div class="workspace-tools" aria-label="입력 파일">
<label class="button button--ghost file-button">Scene<input id="scene-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button">Array<input id="array-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button">Experiment<input id="experiment-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button">Assets<input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
</div>
</div>
<div class="scene-canvas-card">
<canvas id="scene-canvas" aria-label="Ground truth와 주파수별 acoustic image"></canvas>
<div class="canvas-message" id="canvas-message"><strong>기본 synthetic scene이 준비됩니다.</strong><span>실행하면 주파수별 acoustic image가 이 평면에 겹쳐집니다.</span></div>
<div class="canvas-badge" id="canvas-badge">결과 없음</div>
</div>
<div class="overlay-toolbar">
<div class="band-legend" id="band-legend" aria-label="표시 주파수 대역"></div>
<label class="range-field"><span>Overlay</span><input id="overlay-opacity" type="range" min="0" max="1" step="0.05" value="0.72" /></label>
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
</div>
<section class="result-section" aria-live="polite">
<div class="section-heading"><div><p class="eyebrow">Evaluation</p><h2>실행 결과</h2></div><span class="result-chip" id="result-badge">아직 실행하지 않음</span></div>
<div class="empty-result" id="empty-result">시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
<div class="metric-grid is-hidden" id="metric-grid"></div>
</section>
<section class="history-section">
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
<p class="section-copy">Array JSON을 바꿔 다시 실행하면 같은 scene의 결과를 이 세션에서 나란히 비교합니다.</p>
<div class="history-list" id="history-list"></div>
</section>
</section>
<aside class="control-panel" aria-label="Simulation parameters">
<section class="array-card">
<div class="array-heading"><div><p class="eyebrow">Microphone geometry</p><h2 id="array-title">배열</h2></div><strong id="microphone-count">— ch</strong></div>
<canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas>
<p id="array-meta">배열을 불러오고 있습니다.</p>
</section>
<section class="asset-card">
<div class="section-title"><span>INPUT</span><h2>장면과 assets</h2></div>
<div class="source-summary" id="source-summary"></div>
<div class="asset-list" id="asset-list"></div>
<a class="builder-link" href="/apps/dataset-builder/">음원 위치·ambient 편집은 Dataset Builder에서 →</a>
</section>
<section class="control-section">
<div class="section-title"><span>01</span><h2>실험</h2></div>
<label class="field field--wide"><span>이름</span><input id="experiment-name" type="text" maxlength="120" /></label>
<label class="field field--wide"><span>Experiment ID</span><input id="experiment-id" type="text" maxlength="120" /></label>
<div class="field-grid">
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
<label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label>
<label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label>
<label class="field"><span>Backend</span><select id="backend"><option value="auto">Auto (GPU→CPU)</option><option value="webgpu">WebGPU 강제</option><option value="cpu">CPU 강제</option></select></label>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>02</span><h2>Scan grid</h2></div>
<label class="field field--wide"><span>Preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
<div class="field-grid">
<label class="field"><span>Columns</span><input id="scan-columns" type="number" min="8" max="512" step="1" /></label>
<label class="field"><span>Rows</span><input id="scan-rows" type="number" min="8" max="512" step="1" /></label>
<label class="field"><span>x min (m)</span><input id="scan-x-min" type="number" step="0.05" /></label>
<label class="field"><span>x max (m)</span><input id="scan-x-max" type="number" step="0.05" /></label>
<label class="field"><span>y min (m)</span><input id="scan-y-min" type="number" step="0.05" /></label>
<label class="field"><span>y max (m)</span><input id="scan-y-max" type="number" step="0.05" /></label>
</div>
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
</section>
<section class="control-section">
<div class="section-title"><span>03</span><h2>STFT와 표시</h2></div>
<div class="field-grid">
<label class="field"><span>FFT size</span><select id="fft-size"><option>128</option><option>256</option><option selected>512</option><option>1024</option><option>2048</option><option>4096</option></select></label>
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
</div>
</section>
<section class="control-section">
<div class="section-title section-title--spread"><div><span>04</span><h2>주파수 대역</h2></div><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
<div class="band-editor-list" id="band-list"></div>
</section>
<section class="output-card"><button class="button button--ghost button--full" id="export-experiment-button" type="button">Experiment JSON 저장</button></section>
<section class="validation-card" aria-live="polite">
<div class="validation-dot" id="validation-dot"></div>
<div><strong id="validation-title">검증 대기</strong><p id="validation-message">입력을 준비하고 있습니다.</p></div>
</section>
</aside>
</main>
</div>
<script type="module" src="/packages/ui/runtime-status.js"></script>
<script type="module" src="/apps/acoustic-camera/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+177
View File
@@ -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;
}
+82
View File
@@ -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) });
}
});
+120
View File
@@ -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; }
}
+681
View File
@@ -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);
}