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);
}
+523
View File
@@ -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),
};
}
+1
View File
@@ -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";
+147
View File
@@ -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();
}
});
+3
View File
@@ -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: "테스트 모듈을 불러온다",
+182
View File
@@ -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");
});
+197
View File
@@ -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",
);
});