feat: harden end-to-end simulation workflow
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<button class="button button--ghost" id="preview-button" type="button">▶ 미리 듣기</button>
|
||||
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
|
||||
<button class="button button--primary" id="export-button" type="button">Scene JSON 저장</button>
|
||||
<a class="button button--ghost" href="/apps/acoustic-camera/">Scene 저장 후 Camera 열기 →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,15 @@ function Send-TextResponse {
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">' +
|
||||
'<rect width="320" height="240" fill="#102924"/>' +
|
||||
'<circle cx="210" cy="95" r="35" fill="#65f2bf"/></svg>',
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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: "테스트 모듈을 불러온다",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user