feat: complete acoustic camera workflow
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
+118
-15
@@ -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>
|
||||
<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
@@ -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;
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from "./das.js";
|
||||
export * from "./evaluation.js";
|
||||
export * from "./fft.js";
|
||||
export * from "./fractional-delay.js";
|
||||
export * from "./math.js";
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { validateResult } from "/packages/contracts/validation.js";
|
||||
|
||||
async function waitFor(predicate, label, timeoutMs = 30000) {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (performance.now() < deadline) {
|
||||
const value = predicate();
|
||||
if (value) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
throw new Error(`${label} 대기 시간이 초과되었습니다.`);
|
||||
}
|
||||
|
||||
function dispatchValue(windowObject, element, value, eventName = "change") {
|
||||
element.value = String(value);
|
||||
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
||||
}
|
||||
|
||||
async function loadJson(path) {
|
||||
const response = await fetch(path);
|
||||
assert(response.ok, `${path} load failed`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function nextPaint(windowObject) {
|
||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
|
||||
windowObject.requestAnimationFrame(resolve)));
|
||||
}
|
||||
|
||||
test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름을 완주한다", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = "/apps/acoustic-camera/?integration-test=1";
|
||||
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||||
document.body.append(frame);
|
||||
await new Promise((resolve, reject) => {
|
||||
frame.addEventListener("load", resolve, { once: true });
|
||||
frame.addEventListener("error", () => reject(new Error("Acoustic Camera iframe load failed")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
const windowObject = frame.contentWindow;
|
||||
const documentObject = frame.contentDocument;
|
||||
const downloads = [];
|
||||
const objectUrls = [];
|
||||
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
|
||||
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
|
||||
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
|
||||
downloads.push({ fileName: this.download, url: this.href });
|
||||
};
|
||||
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
|
||||
|
||||
try {
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.appReady === "true",
|
||||
"Acoustic Camera 초기화",
|
||||
);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.cameraValid === "true",
|
||||
"기본 Camera 입력 검증",
|
||||
);
|
||||
|
||||
const canvas = documentObject.querySelector("#scene-canvas");
|
||||
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
|
||||
await nextPaint(windowObject);
|
||||
const initialCanvas = canvas.toDataURL();
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"8×8 CPU 입력 검증",
|
||||
);
|
||||
assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset");
|
||||
|
||||
documentObject.querySelector("#run-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.runState === "done",
|
||||
"8×8 CPU simulation",
|
||||
30000,
|
||||
);
|
||||
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
|
||||
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
|
||||
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered");
|
||||
assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible");
|
||||
assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered");
|
||||
await nextPaint(windowObject);
|
||||
assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas");
|
||||
|
||||
documentObject.querySelector("#export-result-button").click();
|
||||
const resultDownload = await waitFor(
|
||||
() => downloads.find((download) => download.fileName.endsWith(".result.json")),
|
||||
"Result JSON download",
|
||||
);
|
||||
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
|
||||
const [scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
experiment.scan.columns = 8;
|
||||
experiment.scan.rows = 8;
|
||||
experiment.beamforming.backend = "cpu";
|
||||
const resultIssues = await validateResult(resultDocument, { scene, experiment });
|
||||
assertEqual(resultIssues.length, 0, `downloaded Result validation: ${resultIssues
|
||||
.map((issue) => `${issue.path}: ${issue.message}`)
|
||||
.join(" · ")}`);
|
||||
assertEqual(resultDocument.backend, "cpu", "exported backend");
|
||||
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
|
||||
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
|
||||
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
|
||||
assert(/^[a-f0-9]{64}$/.test(resultDocument.inputHash), "exported input hash");
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24);
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#result-badge").textContent.includes("이전 설정"),
|
||||
"changed input stale badge",
|
||||
);
|
||||
assert(documentObject.querySelector("#canvas-badge").textContent.includes("이전 설정"), "canvas stale badge");
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 30);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"original comparison configuration restore",
|
||||
);
|
||||
|
||||
documentObject.querySelector("#run-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.runState === "running",
|
||||
"rerun start",
|
||||
);
|
||||
assert(!documentObject.querySelector("#cancel-button").classList.contains("is-hidden"), "cancel available");
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.runState === "done",
|
||||
"8×8 CPU rerun",
|
||||
30000,
|
||||
);
|
||||
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
|
||||
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
|
||||
} finally {
|
||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||
for (const url of objectUrls) originalRevoke(url);
|
||||
frame.remove();
|
||||
}
|
||||
});
|
||||
@@ -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: "테스트 모듈을 불러온다",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user