diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js
index 47eb33a..7224d07 100644
--- a/apps/acoustic-camera/main.js
+++ b/apps/acoustic-camera/main.js
@@ -8,6 +8,7 @@ import {
} from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.js";
import {
+ alignExperimentToScene,
canonicalStringify,
createHistorySummary,
createResultDocument,
@@ -919,6 +920,7 @@ async function importDocument(kind, input) {
} else if (kind === "scene") {
state.scene = value;
state.experiment.sceneId = value.id;
+ alignExperimentToScene(state.experiment, value);
assetRuntime.reset();
} else {
state.experiment = value;
diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js
index 9fcd277..ef3bde1 100644
--- a/apps/acoustic-camera/model.js
+++ b/apps/acoustic-camera/model.js
@@ -1,6 +1,12 @@
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
-const RESULT_METRIC_KEYS = ["peakToSidelobeDb", "energyInsideTruthRatio", "precision", "recall"];
+const RESULT_METRIC_KEYS = [
+ "peakToSidelobeDb",
+ "energyInsideTruthRatio",
+ "precision",
+ "recall",
+ "mainLobeWidthM",
+];
function sortForCanonicalJson(value) {
if (Array.isArray(value)) return value.map(sortForCanonicalJson);
@@ -60,6 +66,53 @@ export function shiftSceneDistance(scene, experiment, nextDistance) {
if (scene.image?.plane) scene.image.plane.z += delta;
}
+export function alignExperimentToScene(experiment, scene) {
+ const plane = scene.image?.plane;
+ if (plane) {
+ Object.assign(experiment.scan, {
+ z: plane.z,
+ xMin: plane.xMin,
+ xMax: plane.xMax,
+ yMin: plane.yMin,
+ yMax: plane.yMax,
+ });
+ return "image-plane";
+ }
+
+ const positions = (scene.sources ?? [])
+ .map((source) => source.position)
+ .filter((position) =>
+ Array.isArray(position) &&
+ position.length === 3 &&
+ position.every(Number.isFinite),
+ );
+ if (positions.length === 0) return "unchanged";
+
+ const paddedBounds = (values) => {
+ const minimum = Math.min(...values);
+ const maximum = Math.max(...values);
+ const padding = Math.max(0.25, (maximum - minimum) * 0.15);
+ return [minimum - padding, maximum + padding];
+ };
+ const median = (values) => {
+ const sorted = [...values].sort((left, right) => left - right);
+ const middle = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0
+ ? (sorted[middle - 1] + sorted[middle]) / 2
+ : sorted[middle];
+ };
+ const [xMin, xMax] = paddedBounds(positions.map((position) => position[0]));
+ const [yMin, yMax] = paddedBounds(positions.map((position) => position[1]));
+ Object.assign(experiment.scan, {
+ z: median(positions.map((position) => position[2])),
+ xMin,
+ xMax,
+ yMin,
+ yMax,
+ });
+ return "source-bounds";
+}
+
function finiteMetricSubset(metrics = {}) {
return Object.fromEntries(
RESULT_METRIC_KEYS
@@ -75,6 +128,9 @@ function peakForResult(peak) {
};
if (peak.sourceId) result.sourceId = peak.sourceId;
if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM;
+ if (Number.isFinite(peak.localizationErrorPixels)) {
+ result.localizationErrorPixels = peak.localizationErrorPixels;
+ }
return result;
}
@@ -85,6 +141,17 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
microphoneCount: arrayDefinition.microphones.length,
gridPoints: experiment.scan.rows * experiment.scan.columns,
};
+ for (const [key, value] of Object.entries({
+ gridRows: experiment.scan.rows,
+ gridColumns: experiment.scan.columns,
+ scanXMin: experiment.scan.xMin,
+ scanXMax: experiment.scan.xMax,
+ scanYMin: experiment.scan.yMin,
+ scanYMax: experiment.scan.yMax,
+ scanZ: experiment.scan.z,
+ })) {
+ if (Number.isFinite(value)) environment[key] = value;
+ }
if (run.fallback?.code) environment.fallbackCode = run.fallback.code;
if (run.fallback?.message) environment.fallbackMessage = run.fallback.message;
const adapter = run.gpu?.adapterInfo;
@@ -127,7 +194,7 @@ export function createResultDocument(options) {
environment: environmentForResult({ arrayDefinition, experiment, run }),
bands: run.bands.map((band) => {
const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} };
- return {
+ const bandResult = {
id: band.id,
color: band.color,
rawMap: {
@@ -145,6 +212,8 @@ export function createResultDocument(options) {
peaks: evaluation.peaks.map(peakForResult),
metrics: finiteMetricSubset(evaluation.metrics),
};
+ if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation };
+ return bandResult;
}),
};
}
diff --git a/apps/dataset-builder/index.html b/apps/dataset-builder/index.html
index 9dc7a23..945c04c 100644
--- a/apps/dataset-builder/index.html
+++ b/apps/dataset-builder/index.html
@@ -19,6 +19,7 @@
+ Scene 저장 후 Camera 열기 →
diff --git a/apps/dataset-builder/styles.css b/apps/dataset-builder/styles.css
index 183dd99..36a5a9d 100644
--- a/apps/dataset-builder/styles.css
+++ b/apps/dataset-builder/styles.css
@@ -32,6 +32,7 @@
cursor: pointer;
font-size: 0.82rem;
font-weight: 720;
+ text-decoration: none;
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease;
}
diff --git a/packages/acoustics-core/evaluation.js b/packages/acoustics-core/evaluation.js
index f02bc44..ef7beca 100644
--- a/packages/acoustics-core/evaluation.js
+++ b/packages/acoustics-core/evaluation.js
@@ -5,6 +5,7 @@ export const RESULT_METRIC_KEYS = Object.freeze([
"energyInsideTruthRatio",
"precision",
"recall",
+ "mainLobeWidthM",
]);
const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS);
@@ -457,6 +458,8 @@ export function evaluateDasBand(options) {
minimumPeakDistanceM,
sidelobeExclusionRadiusM,
halfPowerRatio,
+ dynamicRangeDb,
+ maximumDetections,
},
metrics: {
peakToSidelobeDb: peakToSidelobeDb(
@@ -470,6 +473,7 @@ export function evaluateDasBand(options) {
energyInsideTruthRatio: energyInsideRatio(values, scan, truths, truthRadiusM, globalPeak?.value ?? 0),
precision,
recall,
+ mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
},
diagnostics: {
mainLobe,
@@ -513,11 +517,30 @@ export function exportBandEvaluation(evaluation, options = {}) {
if (match) {
resultPeak.sourceId = match.sourceId;
resultPeak.localizationErrorM = match.localizationErrorM;
+ const resolution = evaluation.diagnostics?.gridResolutionM;
+ if (resolution?.x > 0 && resolution?.y > 0) {
+ resultPeak.localizationErrorPixels = Math.hypot(
+ (match.peak.position[0] - match.truthPosition[0]) / resolution.x,
+ (match.peak.position[1] - match.truthPosition[1]) / resolution.y,
+ );
+ }
}
return resultPeak;
});
return {
peaks,
metrics: selectResultMetrics(evaluation, options.metricNames ?? RESULT_METRIC_KEYS),
+ evaluation: {
+ version: "1.0.0",
+ truthRadiusM: evaluation.parameters.truthRadiusM,
+ matchToleranceM: evaluation.parameters.matchToleranceM,
+ detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
+ detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
+ minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
+ sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
+ halfPowerRatio: evaluation.parameters.halfPowerRatio,
+ dynamicRangeDb: evaluation.parameters.dynamicRangeDb,
+ maximumDetections: evaluation.parameters.maximumDetections,
+ },
};
}
diff --git a/packages/ui/landing.js b/packages/ui/landing.js
index 105710f..6a30876 100644
--- a/packages/ui/landing.js
+++ b/packages/ui/landing.js
@@ -1,12 +1,14 @@
+import { detectWebGpuCapability } from "./webgpu-status.js";
+
const statusElement = document.querySelector("#webgpu-status");
if (!(statusElement instanceof HTMLElement)) {
throw new Error("WebGPU status element is missing.");
}
-if ("gpu" in navigator) {
- statusElement.textContent = "WebGPU 사용 가능";
-} else {
- statusElement.textContent = "CPU fallback 사용 예정";
- statusElement.classList.add("status-chip--warning");
-}
+detectWebGpuCapability().then((capability) => {
+ statusElement.textContent = capability.label;
+ statusElement.title = capability.detail;
+ statusElement.classList.toggle("status-chip--warning", !capability.available);
+ statusElement.dataset.backendAvailable = capability.available ? "webgpu" : "cpu";
+});
diff --git a/packages/ui/runtime-status.js b/packages/ui/runtime-status.js
index f1d1a25..f5fd7da 100644
--- a/packages/ui/runtime-status.js
+++ b/packages/ui/runtime-status.js
@@ -1,8 +1,13 @@
-const statusElements = document.querySelectorAll("[data-runtime-status]");
-const hasWebGpu = "gpu" in navigator;
+import { detectWebGpuCapability } from "./webgpu-status.js";
-for (const statusElement of statusElements) {
- statusElement.textContent = hasWebGpu ? "WebGPU 준비됨" : "CPU fallback";
- statusElement.classList.toggle("status-chip--warning", !hasWebGpu);
- statusElement.classList.toggle("status-chip--neutral", hasWebGpu);
-}
+const statusElements = document.querySelectorAll("[data-runtime-status]");
+
+detectWebGpuCapability().then((capability) => {
+ for (const statusElement of statusElements) {
+ statusElement.textContent = capability.label;
+ statusElement.title = capability.detail;
+ statusElement.classList.toggle("status-chip--warning", !capability.available);
+ statusElement.classList.toggle("status-chip--neutral", capability.available);
+ statusElement.dataset.backendAvailable = capability.available ? "webgpu" : "cpu";
+ }
+});
diff --git a/packages/ui/webgpu-status.js b/packages/ui/webgpu-status.js
new file mode 100644
index 0000000..d2170e0
--- /dev/null
+++ b/packages/ui/webgpu-status.js
@@ -0,0 +1,33 @@
+let capabilityPromise;
+
+export function detectWebGpuCapability() {
+ capabilityPromise ??= detect();
+ return capabilityPromise;
+}
+
+async function detect() {
+ if (!navigator.gpu) {
+ return { available: false, label: "CPU fallback", detail: "WebGPU API 없음" };
+ }
+ try {
+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
+ if (!adapter) {
+ return { available: false, label: "CPU fallback", detail: "WebGPU adapter 없음" };
+ }
+ const info = adapter.info;
+ const adapterName = [info?.vendor, info?.architecture || info?.device]
+ .filter(Boolean)
+ .join(" · ");
+ return {
+ available: true,
+ label: adapterName ? `WebGPU · ${adapterName}` : "WebGPU adapter 준비됨",
+ detail: adapterName || "WebGPU adapter 준비됨",
+ };
+ } catch (error) {
+ return {
+ available: false,
+ label: "CPU fallback",
+ detail: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
diff --git a/schemas/result.schema.json b/schemas/result.schema.json
index b71cebb..71c469c 100644
--- a/schemas/result.schema.json
+++ b/schemas/result.schema.json
@@ -66,7 +66,28 @@
"sourceId": { "type": "string", "minLength": 1 },
"position": { "$ref": "#/$defs/position" },
"value": { "type": "number", "minimum": 0 },
- "localizationErrorM": { "type": "number", "minimum": 0 }
+ "localizationErrorM": { "type": "number", "minimum": 0 },
+ "localizationErrorPixels": { "type": "number", "minimum": 0 }
+ }
+ },
+ "evaluation": {
+ "type": "object", "additionalProperties": false,
+ "required": [
+ "version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio",
+ "detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM",
+ "halfPowerRatio", "dynamicRangeDb", "maximumDetections"
+ ],
+ "properties": {
+ "version": { "const": "1.0.0" },
+ "truthRadiusM": { "type": "number", "minimum": 0 },
+ "matchToleranceM": { "type": "number", "minimum": 0 },
+ "detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 },
+ "detectionThresholdValue": { "type": "number", "minimum": 0 },
+ "minimumPeakDistanceM": { "type": "number", "minimum": 0 },
+ "sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
+ "halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
+ "dynamicRangeDb": { "type": "number", "minimum": 0 },
+ "maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 }
}
},
"processedMap": {
@@ -84,6 +105,7 @@
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"rawMap": { "$ref": "#/$defs/map" },
"displayMap": { "$ref": "#/$defs/map" },
+ "evaluation": { "$ref": "#/$defs/evaluation" },
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
"metrics": {
@@ -92,7 +114,8 @@
"peakToSidelobeDb": { "type": "number" },
"energyInsideTruthRatio": { "type": "number", "minimum": 0, "maximum": 1 },
"precision": { "type": "number", "minimum": 0, "maximum": 1 },
- "recall": { "type": "number", "minimum": 0, "maximum": 1 }
+ "recall": { "type": "number", "minimum": 0, "maximum": 1 },
+ "mainLobeWidthM": { "type": "number", "minimum": 0 }
}
}
}
diff --git a/scripts/serve.ps1 b/scripts/serve.ps1
index d9d5b13..2e50eb6 100644
--- a/scripts/serve.ps1
+++ b/scripts/serve.ps1
@@ -42,7 +42,15 @@ function Send-TextResponse {
}
try {
- $listener.Start()
+ try {
+ $listener.Start()
+ }
+ catch [System.Net.HttpListenerException] {
+ throw [System.InvalidOperationException]::new(
+ "Port $Port is unavailable. Close the running micarrsim window or choose another port, for example: start.cmd -Port 4174",
+ $_.Exception
+ )
+ }
Write-Host "micarrsim is running at $serverUrl" -ForegroundColor Green
Write-Host 'Press Ctrl+C to stop.' -ForegroundColor DarkGray
@@ -54,11 +62,26 @@ try {
$context = $listener.GetContext()
try {
+ $context.Response.Headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; worker-src 'self'; connect-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'none'"
+ $context.Response.Headers['Referrer-Policy'] = 'no-referrer'
+ $context.Response.Headers['X-Content-Type-Options'] = 'nosniff'
+
+ if ($context.Request.HttpMethod -notin @('GET', 'HEAD')) {
+ $context.Response.Headers['Allow'] = 'GET, HEAD'
+ Send-TextResponse -Response $context.Response -StatusCode 405 -Message 'Method not allowed'
+ continue
+ }
+
$relativePath = [System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
$relativePath = $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
if ([string]::IsNullOrWhiteSpace($relativePath)) {
$relativePath = 'index.html'
}
+ $pathSegments = $relativePath -split '[\\/]'
+ if ($pathSegments | Where-Object { $_.StartsWith('.') }) {
+ Send-TextResponse -Response $context.Response -StatusCode 403 -Message 'Forbidden'
+ continue
+ }
$candidatePath = [System.IO.Path]::GetFullPath((Join-Path $projectRoot $relativePath))
$isWithinRoot = $candidatePath.Equals($projectRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
diff --git a/start.cmd b/start.cmd
index cb15566..fe4de47 100644
--- a/start.cmd
+++ b/start.cmd
@@ -1,5 +1,5 @@
@echo off
setlocal
cd /d "%~dp0"
-powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\serve.ps1"
+powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\serve.ps1" %*
if errorlevel 1 pause
diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js
index 971fb70..a67eedf 100644
--- a/tests/integration/camera-ui.test.js
+++ b/tests/integration/camera-ui.test.js
@@ -110,6 +110,10 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
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");
+ assertEqual(resultDocument.bands[0].evaluation.version, "1.0.0", "evaluation version");
+ assert(Number.isFinite(resultDocument.bands[0].evaluation.matchToleranceM), "match tolerance exported");
+ assert(Number.isFinite(resultDocument.bands[0].metrics.mainLobeWidthM), "main-lobe metric exported");
+ assertEqual(resultDocument.environment.scanXMin, experiment.scan.xMin, "scan bounds exported");
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24);
await waitFor(
diff --git a/tests/integration/server.test.js b/tests/integration/server.test.js
new file mode 100644
index 0000000..38b1c8a
--- /dev/null
+++ b/tests/integration/server.test.js
@@ -0,0 +1,14 @@
+test("로컬 서버가 숨김 경로와 쓰기 method를 차단하고 보안 header를 보낸다", async () => {
+ const hiddenResponse = await fetch("/.git/config");
+ assertEqual(hiddenResponse.status, 403, "hidden repository path");
+
+ const postResponse = await fetch("/", { method: "POST" });
+ assertEqual(postResponse.status, 405, "unsupported method");
+ assertEqual(postResponse.headers.get("allow"), "GET, HEAD", "allowed methods");
+
+ const rootResponse = await fetch("/");
+ assertEqual(rootResponse.status, 200, "root remains available");
+ assertEqual(rootResponse.headers.get("x-content-type-options"), "nosniff", "nosniff header");
+ assertEqual(rootResponse.headers.get("referrer-policy"), "no-referrer", "referrer policy");
+ assert(rootResponse.headers.get("content-security-policy")?.includes("worker-src 'self'"), "worker CSP");
+});
diff --git a/tests/integration/workflow-ui.test.js b/tests/integration/workflow-ui.test.js
new file mode 100644
index 0000000..51f06d9
--- /dev/null
+++ b/tests/integration/workflow-ui.test.js
@@ -0,0 +1,285 @@
+import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
+import { validateExperiment, validateResult, validateScene } 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} timed out.`);
+}
+
+function dispatchValue(windowObject, element, value, eventName = "input") {
+ element.value = String(value);
+ element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
+}
+
+function dispatchChecked(windowObject, element, checked) {
+ element.checked = checked;
+ element.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
+}
+
+function dispatchFiles(windowObject, input, files) {
+ const transfer = new windowObject.DataTransfer();
+ files.forEach((file) => transfer.items.add(file));
+ input.files = transfer.files;
+ input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
+}
+
+async function mountApp(path, label) {
+ const frame = document.createElement("iframe");
+ frame.src = `${path}?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(`${label} iframe load failed`)), { once: true });
+ });
+ await waitFor(
+ () => frame.contentDocument.documentElement.dataset.appReady === "true",
+ `${label} initialization`,
+ );
+ return frame;
+}
+
+function captureDownloads(windowObject) {
+ 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);
+ return {
+ downloads,
+ restore() {
+ windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
+ windowObject.URL.revokeObjectURL = originalRevoke;
+ for (const url of objectUrls) originalRevoke(url);
+ },
+ };
+}
+
+function makeExperiment(scene) {
+ const plane = scene.image.plane;
+ return {
+ schemaVersion: "1.0.0",
+ kind: "micarrsim.experiment",
+ id: "builder-camera-workflow",
+ name: "Builder to Camera asset workflow",
+ arrayId: "rectangular-16x12",
+ sceneId: scene.id,
+ scan: {
+ z: plane.z,
+ xMin: plane.xMin,
+ xMax: plane.xMax,
+ yMin: plane.yMin,
+ yMax: plane.yMax,
+ columns: 8,
+ rows: 8,
+ },
+ frequencyBands: [
+ { id: "asset-500", minHz: 450, maxHz: 550, color: "#65f2bf" },
+ { id: "tone-1k", minHz: 950, maxHz: 1050, color: "#58c7e8" },
+ ],
+ stft: { fftSize: 128, hopSize: 64, window: "hann" },
+ beamforming: {
+ algorithm: "das",
+ backend: "cpu",
+ minimumDistance: 0.01,
+ normalization: "peak",
+ dynamicRangeDb: 30,
+ },
+ };
+}
+
+test("Dataset Builder exports asset Scene that Acoustic Camera rebinds and runs", async () => {
+ const sampleRate = 8000;
+ const duration = 0.05;
+ const samples = new Float32Array(sampleRate * duration);
+ for (let index = 0; index < samples.length; index += 1) {
+ samples[index] = Math.sin((2 * Math.PI * 500 * index) / sampleRate) * 0.2;
+ }
+ const audioBytes = await encodeMonoPcm16Wav(samples, sampleRate).arrayBuffer();
+ const imageBytes = new TextEncoder().encode(
+ '',
+ );
+
+ let builderFrame = null;
+ let builderCapture = null;
+ let cameraFrame = null;
+ let cameraCapture = null;
+ try {
+ builderFrame = await mountApp("/apps/dataset-builder/", "Dataset Builder");
+ const builderWindow = builderFrame.contentWindow;
+ const builderDocument = builderFrame.contentDocument;
+ builderCapture = captureDownloads(builderWindow);
+
+ dispatchValue(builderWindow, builderDocument.querySelector("#scene-id"), "workflow-asset-scene");
+ dispatchValue(builderWindow, builderDocument.querySelector("#scene-name"), "Workflow asset scene");
+ dispatchValue(builderWindow, builderDocument.querySelector("#scene-duration"), duration);
+ dispatchValue(builderWindow, builderDocument.querySelector("#source-duration"), duration);
+ dispatchValue(builderWindow, builderDocument.querySelector("#source-z"), 1.6);
+ dispatchValue(builderWindow, builderDocument.querySelector("#plane-x-min"), -0.3);
+ dispatchValue(builderWindow, builderDocument.querySelector("#plane-x-max"), 0.5);
+ dispatchValue(builderWindow, builderDocument.querySelector("#plane-y-min"), -0.25);
+ dispatchValue(builderWindow, builderDocument.querySelector("#plane-y-max"), 0.35);
+ dispatchValue(builderWindow, builderDocument.querySelector("#sample-rate"), sampleRate, "change");
+
+ const builderAudioFile = new builderWindow.File([audioBytes.slice(0)], "workflow-tone.wav", {
+ type: "audio/wav",
+ });
+ const builderImageFile = new builderWindow.File([imageBytes.slice(0)], "workflow-layout.svg", {
+ type: "image/svg+xml",
+ });
+ dispatchFiles(builderWindow, builderDocument.querySelector("#audio-input"), [builderAudioFile]);
+ await waitFor(
+ () => builderDocument.querySelectorAll(".asset-chip .asset-chip__state").length === 1 &&
+ builderDocument.querySelector(".asset-chip .asset-chip__state").textContent.includes("연결"),
+ "Builder audio SHA attachment",
+ );
+ dispatchFiles(builderWindow, builderDocument.querySelector("#image-input"), [builderImageFile]);
+ await waitFor(
+ () => builderDocument.querySelectorAll(".asset-chip .asset-chip__state").length === 2 &&
+ [...builderDocument.querySelectorAll(".asset-chip .asset-chip__state")]
+ .every((element) => element.textContent.includes("연결")),
+ "Builder image SHA attachment",
+ );
+
+ builderDocument.querySelector("#add-source-button").click();
+ assertEqual(builderDocument.querySelectorAll(".source-card").length, 2, "two Builder sources");
+ dispatchValue(builderWindow, builderDocument.querySelector("#source-name"), "Asset source");
+ dispatchValue(builderWindow, builderDocument.querySelector("#source-x"), -0.1);
+ dispatchValue(builderWindow, builderDocument.querySelector("#source-y"), -0.08);
+ dispatchValue(builderWindow, builderDocument.querySelector("#source-type"), "asset", "change");
+ dispatchChecked(builderWindow, builderDocument.querySelector("#source-loop"), true);
+ await waitFor(
+ () => builderDocument.documentElement.dataset.sceneValid === "true" &&
+ builderDocument.documentElement.dataset.assetsReady === "true" &&
+ !builderDocument.querySelector("#export-button").disabled,
+ "Builder asset Scene validation",
+ );
+
+ builderDocument.querySelector("#export-button").click();
+ const sceneDownload = await waitFor(
+ () => builderCapture.downloads.find((download) => download.fileName.endsWith(".scene.json")),
+ "Builder Scene JSON export",
+ );
+ const sceneJson = await fetch(sceneDownload.url).then((response) => response.text());
+ const scene = JSON.parse(sceneJson);
+ const sceneIssues = await validateScene(scene);
+ assertEqual(sceneIssues.length, 0, `Builder Scene validation: ${sceneIssues
+ .map((issue) => `${issue.path}: ${issue.message}`)
+ .join(" | ")}`);
+ assertEqual(scene.sampleRate, sampleRate, "exported Scene sample rate");
+ assertEqual(scene.duration, duration, "exported Scene duration");
+ assertEqual(scene.sources.length, 2, "exported source count");
+ assert(scene.sources.some((source) => source.signal.type === "tone"), "exported tone source");
+ assert(scene.sources.some((source) => source.signal.type === "asset"), "exported asset source");
+ assertEqual(scene.assets.length, 2, "exported audio and image descriptors");
+ assertEqual(scene.image.plane.z, 1.6, "exported image plane z");
+ assertEqual(scene.image.plane.xMin, -0.3, "exported image plane xMin");
+ assertEqual(scene.image.plane.xMax, 0.5, "exported image plane xMax");
+ assert(scene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "exported SHA manifests");
+
+ builderCapture.restore();
+ builderCapture = null;
+ builderFrame.remove();
+ builderFrame = null;
+
+ cameraFrame = await mountApp("/apps/acoustic-camera/", "Acoustic Camera");
+ const cameraWindow = cameraFrame.contentWindow;
+ const cameraDocument = cameraFrame.contentDocument;
+ cameraCapture = captureDownloads(cameraWindow);
+
+ const sceneFile = new cameraWindow.File([sceneJson], "workflow.scene.json", {
+ type: "application/json",
+ });
+ dispatchFiles(cameraWindow, cameraDocument.querySelector("#scene-input"), [sceneFile]);
+ await waitFor(
+ () => cameraDocument.documentElement.dataset.inputBusy === "false" &&
+ cameraDocument.querySelector("#workspace-title").textContent === scene.name &&
+ cameraDocument.querySelectorAll("#asset-list .asset-status").length === 2,
+ "Camera Scene import",
+ );
+ assertEqual(cameraDocument.documentElement.dataset.cameraValid, "false", "assets required after import");
+ assertEqual(cameraDocument.querySelector("#scene-distance").valueAsNumber, scene.image.plane.z, "scan z follows Scene");
+ assertEqual(cameraDocument.querySelector("#scan-x-min").valueAsNumber, scene.image.plane.xMin, "scan xMin follows Scene");
+ assertEqual(cameraDocument.querySelector("#scan-x-max").valueAsNumber, scene.image.plane.xMax, "scan xMax follows Scene");
+ assertEqual(cameraDocument.querySelector("#scan-y-min").valueAsNumber, scene.image.plane.yMin, "scan yMin follows Scene");
+ assertEqual(cameraDocument.querySelector("#scan-y-max").valueAsNumber, scene.image.plane.yMax, "scan yMax follows Scene");
+
+ const experiment = makeExperiment(scene);
+ const arrayDefinition = await fetch("/assets/array-geometries/rectangular-16x12.array.json")
+ .then((response) => response.json());
+ const experimentIssues = await validateExperiment(experiment, { scene, arrayDefinition });
+ assertEqual(experimentIssues.length, 0, `workflow Experiment validation: ${experimentIssues
+ .map((issue) => `${issue.path}: ${issue.message}`)
+ .join(" | ")}`);
+ const experimentFile = new cameraWindow.File(
+ [JSON.stringify(experiment)],
+ "workflow.experiment.json",
+ { type: "application/json" },
+ );
+ dispatchFiles(cameraWindow, cameraDocument.querySelector("#experiment-input"), [experimentFile]);
+ await waitFor(
+ () => cameraDocument.documentElement.dataset.inputBusy === "false" &&
+ cameraDocument.querySelector("#experiment-id").value === experiment.id &&
+ cameraDocument.querySelector("#scan-columns").value === "8" &&
+ cameraDocument.querySelector("#backend").value === "cpu",
+ "Camera Experiment import",
+ );
+
+ const cameraAudioFile = new cameraWindow.File([audioBytes.slice(0)], "workflow-tone.wav", {
+ type: "audio/wav",
+ });
+ const cameraImageFile = new cameraWindow.File([imageBytes.slice(0)], "workflow-layout.svg", {
+ type: "image/svg+xml",
+ });
+ dispatchFiles(cameraWindow, cameraDocument.querySelector("#asset-input"), [cameraAudioFile, cameraImageFile]);
+ await waitFor(
+ () => cameraDocument.documentElement.dataset.inputBusy === "false" &&
+ cameraDocument.documentElement.dataset.cameraValid === "true" &&
+ cameraDocument.querySelectorAll("#asset-list .asset-status.is-ready").length === 2 &&
+ !cameraDocument.querySelector("#run-button").disabled,
+ "Camera SHA asset rebind",
+ );
+
+ cameraDocument.querySelector("#run-button").click();
+ await waitFor(
+ () => cameraDocument.documentElement.dataset.runState === "done",
+ "Camera CPU Worker run",
+ 30000,
+ );
+ assertEqual(cameraDocument.documentElement.dataset.runBackend, "cpu", "workflow backend");
+ assertEqual(cameraDocument.documentElement.dataset.resultBandCount, "2", "workflow result bands");
+
+ cameraDocument.querySelector("#export-result-button").click();
+ const resultDownload = await waitFor(
+ () => cameraCapture.downloads.find((download) => download.fileName.endsWith(".result.json")),
+ "Camera Result JSON export",
+ );
+ const result = await fetch(resultDownload.url).then((response) => response.json());
+ const resultIssues = await validateResult(result, { scene, experiment });
+ assertEqual(resultIssues.length, 0, `workflow Result validation: ${resultIssues
+ .map((issue) => `${issue.path}: ${issue.message}`)
+ .join(" | ")}`);
+ assertEqual(result.sceneId, scene.id, "Result references Builder Scene");
+ assertEqual(result.experimentId, experiment.id, "Result references imported Experiment");
+ assertEqual(result.backend, "cpu", "Result records CPU backend");
+ assertEqual(result.bands.length, 2, "Result records both bands");
+ assert(result.bands.every((band) => band.rawMap.rows === 8), "Result map rows");
+ assert(result.bands.every((band) => band.rawMap.columns === 8), "Result map columns");
+ assert(result.bands.every((band) => band.rawMap.values.length === 64), "Result map values");
+ } finally {
+ cameraCapture?.restore();
+ builderCapture?.restore();
+ cameraFrame?.remove();
+ builderFrame?.remove();
+ }
+});
diff --git a/tests/test-runner.js b/tests/test-runner.js
index a97c993..47fc81f 100644
--- a/tests/test-runner.js
+++ b/tests/test-runner.js
@@ -17,8 +17,10 @@ try {
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/server.test.js");
await import("/tests/integration/builder-ui.test.js");
await import("/tests/integration/camera-ui.test.js");
+ await import("/tests/integration/workflow-ui.test.js");
} catch (error) {
registeredTests.push({
name: "테스트 모듈을 불러온다",
diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js
index fc9681a..ba25aee 100644
--- a/tests/unit/camera.test.js
+++ b/tests/unit/camera.test.js
@@ -1,4 +1,5 @@
import {
+ alignExperimentToScene,
canonicalStringify,
createResultDocument,
estimateExperimentWorkload,
@@ -108,6 +109,46 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => {
assertEqual(scene.image.plane.z, 3.25, "background image plane z shift");
});
+test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
+ const experiment = {
+ scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 },
+ };
+ const imageScene = {
+ sources: [{ id: "one", position: [0.2, -0.1, 4.5] }],
+ image: { plane: { z: 4.5, xMin: -2, xMax: 3, yMin: -1.5, yMax: 2.5 } },
+ };
+
+ assertEqual(alignExperimentToScene(experiment, imageScene), "image-plane", "alignment source");
+ assertEqual(
+ JSON.stringify(experiment.scan),
+ JSON.stringify({
+ z: 4.5,
+ xMin: -2,
+ xMax: 3,
+ yMin: -1.5,
+ yMax: 2.5,
+ columns: 32,
+ rows: 24,
+ }),
+ "image plane becomes scan plane while rows and columns stay unchanged",
+ );
+
+ const sourceScene = {
+ sources: [
+ { id: "one", position: [-0.5, 0.1, 3] },
+ { id: "two", position: [0.5, 0.2, 5] },
+ ],
+ };
+ assertEqual(alignExperimentToScene(experiment, sourceScene), "source-bounds", "source fallback");
+ assertEqual(experiment.scan.z, 4, "median source depth");
+ assertNear(experiment.scan.xMin, -0.75, 1e-12, "source x minimum with padding");
+ assertNear(experiment.scan.xMax, 0.75, 1e-12, "source x maximum with padding");
+ assertNear(experiment.scan.yMin, -0.15, 1e-12, "source y minimum with padding");
+ assertNear(experiment.scan.yMax, 0.45, 1e-12, "source y maximum with padding");
+ assertEqual(experiment.scan.columns, 32, "columns retained after source fallback");
+ assertEqual(experiment.scan.rows, 24, "rows retained after source fallback");
+});
+
test("Camera result document is schema-safe and linked to its snapshot", async () => {
const arrayDefinition = {
id: "test-array",
@@ -125,7 +166,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
id: "test-experiment",
arrayId: arrayDefinition.id,
sceneId: scene.id,
- scan: { rows: 2, columns: 2 },
+ scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
beamforming: { backend: "auto" },
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
};
@@ -143,7 +184,14 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
}],
};
const evaluations = new Map([["band-1", {
- peaks: [{ index: 3, sourceId: "source-1", position: [1, -1, 2], value: 1, localizationErrorM: 0.05 }],
+ peaks: [{
+ index: 3,
+ sourceId: "source-1",
+ position: [1, -1, 2],
+ value: 1,
+ localizationErrorM: 0.05,
+ localizationErrorPixels: 0.1,
+ }],
metrics: {
peakToSidelobeDb: 10,
energyInsideTruthRatio: 0.75,
@@ -152,6 +200,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
mainLobeWidthM: 0.2,
ignoredNonFinite: Number.NaN,
},
+ evaluation: {
+ version: "1.0.0",
+ truthRadiusM: 0.1,
+ matchToleranceM: 0.2,
+ detectionThresholdRatio: 0.5,
+ detectionThresholdValue: 0.25,
+ minimumPeakDistanceM: 0.1,
+ sidelobeExclusionRadiusM: 0.1,
+ halfPowerRatio: 0.5,
+ dynamicRangeDb: 30,
+ maximumDetections: 128,
+ },
}]]);
const result = createResultDocument({
@@ -168,9 +228,12 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
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",
+ "peakToSidelobeDb,energyInsideTruthRatio,precision,recall,mainLobeWidthM",
"only finite schema metrics are exported",
);
+ assertEqual(result.bands[0].peaks[0].localizationErrorPixels, 0.1, "pixel error is exported");
+ assertEqual(result.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported");
+ assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
const issues = await validateResult(result, { experiment, scene });
diff --git a/tests/unit/evaluation.test.js b/tests/unit/evaluation.test.js
index 70a7b5b..3d415f2 100644
--- a/tests/unit/evaluation.test.js
+++ b/tests/unit/evaluation.test.js
@@ -153,15 +153,18 @@ test("result exporter가 schema 허용 metric만 선택하고 내부 필드를
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");
+ assertEqual(exported.peaks[0].localizationErrorPixels, 0, "schema peak pixel error");
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
- assert(!("mainLobeWidthM" in exported.metrics), "diagnostic omitted from schema metrics");
+ assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
+ assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");
+ assertEqual(exported.evaluation.matchToleranceM, 0, "match tolerance exported");
assertEqual(
Object.keys(selectResultMetrics(evaluation)).join(","),
RESULT_METRIC_KEYS.join(","),
"default schema metric selection",
);
- const unsupported = captureError(() => selectResultMetrics(evaluation, ["mainLobeWidthM"]));
+ const unsupported = captureError(() => selectResultMetrics(evaluation, ["gridResolutionM"]));
assert(unsupported instanceof RangeError, "unsupported result metric is rejected");
});