feat: complete acoustic camera workflow

This commit is contained in:
Codex
2026-08-28 12:11:10 +09:00
parent d5f7530620
commit d04a4d9f65
13 changed files with 3632 additions and 16 deletions
+147
View File
@@ -0,0 +1,147 @@
import { validateResult } from "/packages/contracts/validation.js";
async function waitFor(predicate, label, timeoutMs = 30000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const value = predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`${label} 대기 시간이 초과되었습니다.`);
}
function dispatchValue(windowObject, element, value, eventName = "change") {
element.value = String(value);
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
async function loadJson(path) {
const response = await fetch(path);
assert(response.ok, `${path} load failed`);
return response.json();
}
async function nextPaint(windowObject) {
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
windowObject.requestAnimationFrame(resolve)));
}
test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름을 완주한다", async () => {
const frame = document.createElement("iframe");
frame.src = "/apps/acoustic-camera/?integration-test=1";
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
document.body.append(frame);
await new Promise((resolve, reject) => {
frame.addEventListener("load", resolve, { once: true });
frame.addEventListener("error", () => reject(new Error("Acoustic Camera iframe load failed")), {
once: true,
});
});
const windowObject = frame.contentWindow;
const documentObject = frame.contentDocument;
const downloads = [];
const objectUrls = [];
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
downloads.push({ fileName: this.download, url: this.href });
};
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
try {
await waitFor(
() => documentObject.documentElement.dataset.appReady === "true",
"Acoustic Camera 초기화",
);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true",
"기본 Camera 입력 검증",
);
const canvas = documentObject.querySelector("#scene-canvas");
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
await nextPaint(windowObject);
const initialCanvas = canvas.toDataURL();
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"8×8 CPU 입력 검증",
);
assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset");
documentObject.querySelector("#run-button").click();
await waitFor(
() => documentObject.documentElement.dataset.runState === "done",
"8×8 CPU simulation",
30000,
);
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered");
assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible");
assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered");
await nextPaint(windowObject);
assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas");
documentObject.querySelector("#export-result-button").click();
const resultDownload = await waitFor(
() => downloads.find((download) => download.fileName.endsWith(".result.json")),
"Result JSON download",
);
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
const [scene, experiment] = await Promise.all([
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
experiment.scan.columns = 8;
experiment.scan.rows = 8;
experiment.beamforming.backend = "cpu";
const resultIssues = await validateResult(resultDocument, { scene, experiment });
assertEqual(resultIssues.length, 0, `downloaded Result validation: ${resultIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" · ")}`);
assertEqual(resultDocument.backend, "cpu", "exported backend");
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
assert(/^[a-f0-9]{64}$/.test(resultDocument.inputHash), "exported input hash");
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24);
await waitFor(
() => documentObject.querySelector("#result-badge").textContent.includes("이전 설정"),
"changed input stale badge",
);
assert(documentObject.querySelector("#canvas-badge").textContent.includes("이전 설정"), "canvas stale badge");
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 30);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"original comparison configuration restore",
);
documentObject.querySelector("#run-button").click();
await waitFor(
() => documentObject.documentElement.dataset.runState === "running",
"rerun start",
);
assert(!documentObject.querySelector("#cancel-button").classList.contains("is-hidden"), "cancel available");
await waitFor(
() => documentObject.documentElement.dataset.runState === "done",
"8×8 CPU rerun",
30000,
);
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
} finally {
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
windowObject.URL.revokeObjectURL = originalRevoke;
for (const url of objectUrls) originalRevoke(url);
frame.remove();
}
});
+3
View File
@@ -12,10 +12,13 @@ try {
await import("/tests/unit/contracts.test.js");
await import("/tests/unit/acoustics.test.js");
await import("/tests/unit/builder.test.js");
await import("/tests/unit/evaluation.test.js");
await import("/tests/unit/camera.test.js");
await import("/tests/unit/webgpu.test.js");
await import("/tests/integration/cpu-baseline.test.js");
await import("/tests/integration/webgpu.test.js");
await import("/tests/integration/builder-ui.test.js");
await import("/tests/integration/camera-ui.test.js");
} catch (error) {
registeredTests.push({
name: "테스트 모듈을 불러온다",
+182
View File
@@ -0,0 +1,182 @@
import {
canonicalStringify,
createResultDocument,
estimateExperimentWorkload,
hashSimulationInputs,
shiftSceneDistance,
} from "/apps/acoustic-camera/model.js";
import { validateResult } from "/packages/contracts/validation.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
);
}
test("Camera input hash is canonical across object key order", async () => {
const first = {
arrayDefinition: {
id: "array",
microphones: [
{ id: "mic-1", position: [0, 0, 0] },
{ id: "mic-2", position: [0.1, 0, 0] },
],
},
scene: {
id: "scene",
sampleRate: 8000,
sources: [{ id: "source", position: [0, 0, 2], signal: { type: "tone", frequency: 1000 } }],
},
experiment: {
id: "experiment",
scan: { z: 2, columns: 8, rows: 8 },
frequencyBands: [{ id: "band", minHz: 900, maxHz: 1100 }],
},
};
const reordered = {
experiment: {
frequencyBands: [{ maxHz: 1100, minHz: 900, id: "band" }],
scan: { rows: 8, columns: 8, z: 2 },
id: "experiment",
},
scene: {
sources: [{ signal: { frequency: 1000, type: "tone" }, position: [0, 0, 2], id: "source" }],
sampleRate: 8000,
id: "scene",
},
arrayDefinition: {
microphones: [
{ position: [0, 0, 0], id: "mic-1" },
{ position: [0.1, 0, 0], id: "mic-2" },
],
id: "array",
},
};
assertEqual(canonicalStringify({ z: undefined, b: 2, a: { d: 4, c: 3 } }), '{"a":{"c":3,"d":4},"b":2}', "canonical JSON");
const firstHash = await hashSimulationInputs(first);
const reorderedHash = await hashSimulationInputs(reordered);
assertEqual(firstHash, reorderedHash, "key ordering does not alter hash");
assert(/^[a-f0-9]{64}$/.test(firstHash), "hash is lowercase SHA-256");
reordered.scene.sources[0].position[0] = 0.01;
const changedHash = await hashSimulationInputs(reordered);
assert(changedHash !== firstHash, "semantic input change alters hash");
});
test("Camera workload counts unique overlapping FFT bins", () => {
const arrayDefinition = {
microphones: Array.from({ length: 3 }, (_, index) => ({ id: `mic-${index}`, position: [index, 0, 0] })),
};
const scene = { sampleRate: 8000, duration: 1 };
const experiment = {
scan: { columns: 10, rows: 8 },
stft: { fftSize: 256, hopSize: 128 },
frequencyBands: [
{ id: "lower", minHz: 100, maxHz: 200 },
{ id: "upper", minHz: 187.5, maxHz: 250 },
],
};
const workload = estimateExperimentWorkload(arrayDefinition, scene, experiment);
assertEqual(workload.pointCount, 80, "scan point count");
assertEqual(workload.microphoneCount, 3, "microphone count");
assertEqual(workload.frameCount, 62, "STFT frame count");
assertEqual(JSON.stringify(workload.bandBins), JSON.stringify([[4, 5, 6], [6, 7, 8]]), "bins per band");
assertEqual(workload.selectedBinCount, 5, "overlapping bin is counted once");
assertEqual(workload.complexMacCount, 74_400, "estimated complex MAC count");
assertNear(workload.scalingVs50, 80 / 2500, 1e-15, "50x50 scaling");
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
});
test("Camera distance shift keeps the scene and scan on one z plane", () => {
const scene = {
sources: [
{ id: "one", position: [0.1, -0.2, 2] },
{ id: "two", position: [-0.3, 0.4, 2.5] },
],
image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } },
};
const experiment = { scan: { z: 2 } };
shiftSceneDistance(scene, experiment, 3.25);
assertEqual(experiment.scan.z, 3.25, "scan z");
assertEqual(scene.sources[0].position.join(","), "0.1,-0.2,3.25", "first source z shift");
assertEqual(scene.sources[1].position.join(","), "-0.3,0.4,3.75", "relative source depth retained");
assertEqual(scene.image.plane.z, 3.25, "background image plane z shift");
});
test("Camera result document is schema-safe and linked to its snapshot", async () => {
const arrayDefinition = {
id: "test-array",
name: "Test array",
microphones: [
{ id: "mic-1", position: [0, 0, 0] },
{ id: "mic-2", position: [0.1, 0, 0] },
],
};
const scene = {
id: "test-scene",
sources: [{ id: "source-1", position: [0, 0, 2] }],
};
const experiment = {
id: "test-experiment",
arrayId: arrayDefinition.id,
sceneId: scene.id,
scan: { rows: 2, columns: 2 },
beamforming: { backend: "auto" },
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
};
const run = {
backend: "cpu",
signalStats: { preNormalizationPeak: 0.75, appliedScale: 1, clippedSampleCount: 0 },
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
bands: [{
id: "band-1",
color: "#65f2bf",
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayScale: "normalized",
}],
};
const evaluations = new Map([["band-1", {
peaks: [{ index: 3, sourceId: "source-1", position: [1, -1, 2], value: 1, localizationErrorM: 0.05 }],
metrics: {
peakToSidelobeDb: 10,
energyInsideTruthRatio: 0.75,
precision: 1,
recall: 1,
mainLobeWidthM: 0.2,
ignoredNonFinite: Number.NaN,
},
}]]);
const result = createResultDocument({
arrayDefinition,
scene,
experiment,
run,
evaluations,
inputHash: "a".repeat(64),
createdAt: "2026-08-28T00:00:00.000Z",
});
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted");
assertEqual(
Object.keys(result.bands[0].metrics).join(","),
"peakToSidelobeDb,energyInsideTruthRatio,precision,recall",
"only finite schema metrics are exported",
);
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
const issues = await validateResult(result, { experiment, scene });
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
const wrongExperiment = { ...experiment, arrayId: "another-array" };
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
assert(linkageIssues.some(({ path }) => path === "$.arrayId"), "validator catches snapshot linkage mismatch");
});
+197
View File
@@ -0,0 +1,197 @@
import {
RESULT_METRIC_KEYS,
evaluateDasBand,
exportBandEvaluation,
findLocalPeaks,
measureMainLobe,
selectResultMetrics,
} from "/packages/acoustics-core/evaluation.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
);
}
function captureError(callback) {
try {
callback();
} catch (error) {
return error;
}
return undefined;
}
test("evaluation이 row-major 첫 행을 yMax로 해석한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 3, rows: 3 };
const rawMap = Float32Array.of(
4, 0, 0,
0, 0, 0,
0, 0, 1,
);
const evaluation = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "top-left", position: [-1, 1, 9] }],
truthRadiusM: 0.1,
matchToleranceM: 0.1,
sidelobeExclusionRadiusM: 0.1,
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.globalPeak.index, 0, "row-major global index");
assertEqual(evaluation.globalPeak.position.join(","), "-1,1,2", "first row position");
assertEqual(evaluation.sourceMatches[0].sourceId, "top-left", "source match id");
assertNear(evaluation.sourceMatches[0].localizationErrorM, 0, 1e-12, "projected localization error");
assertNear(evaluation.metrics.energyInsideTruthRatio, 0.8, 1e-12, "truth energy ratio");
assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(4), 1e-12, "peak-to-sidelobe");
assertEqual(evaluation.metrics.precision, 1, "single-source precision");
assertEqual(evaluation.metrics.recall, 1, "single-source recall");
});
test("복수 source와 threshold detection을 one-to-one matching한다", () => {
const scan = { xMin: -2, xMax: 2, yMin: -2, yMax: 2, z: 1, columns: 5, rows: 5 };
const rawMap = new Float32Array(25);
rawMap[6] = 10; // (-1, +1)
rawMap[18] = 8; // (+1, -1)
rawMap[4] = 6; // unmatched (+2, +2)
const evaluation = evaluateDasBand({
rawMap,
scan,
sources: [
{ id: "a", position: [-1, 1, 1] },
{ id: "b", position: [1, -1, 1] },
],
truthRadiusM: 0.25,
matchToleranceM: 0.25,
sidelobeExclusionRadiusM: 0.25,
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.detections.length, 3, "threshold detections");
assertEqual(evaluation.counts.truePositive, 2, "true positives");
assertEqual(evaluation.counts.falsePositive, 1, "false positives");
assertEqual(evaluation.counts.falseNegative, 0, "false negatives");
assertNear(evaluation.metrics.precision, 2 / 3, 1e-12, "multi-source precision");
assertNear(evaluation.metrics.recall, 1, 1e-12, "multi-source recall");
assertNear(evaluation.metrics.energyInsideTruthRatio, 18 / 24, 1e-12, "union truth energy");
assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(10 / 6), 1e-12, "multi-source PSLR");
assert(
evaluation.sourceMatches.every((match) => match.withinTolerance && match.localizationErrorM === 0),
"both sources matched exactly",
);
});
test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
rawMap: new Float32Array(4),
scan,
sources: [{ id: "silent", position: [0, 0, 1] }],
});
assertEqual(evaluation.globalPeak, null, "zero-map global peak");
assertEqual(evaluation.localPeaks.length, 0, "zero-map local peaks");
assertEqual(evaluation.detections.length, 0, "zero-map detections");
assertEqual(evaluation.sourceMatches[0].peak, null, "zero-map source remains unmatched");
assertEqual(evaluation.metrics.energyInsideTruthRatio, 0, "zero-map energy ratio");
assertEqual(evaluation.metrics.peakToSidelobeDb, 0, "zero-map PSLR");
assertEqual(evaluation.metrics.precision, 0, "zero-map precision");
assertEqual(evaluation.metrics.recall, 0, "zero-map recall");
assertEqual(evaluation.diagnostics.mainLobeWidthM, 0, "zero-map main-lobe width");
Object.values(evaluation.metrics).forEach((value) => assert(Number.isFinite(value), "finite zero-map metric"));
});
test("flat plateau와 main-lobe width가 deterministic하다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 0, columns: 3, rows: 3 };
const rawMap = Float32Array.from({ length: 9 }, () => 1);
const peaks = findLocalPeaks(rawMap, scan);
assertEqual(peaks.length, 1, "flat plateau represented once");
assertEqual(peaks[0].index, 0, "flat plateau uses lowest row-major index");
const first = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "corner", position: [-1, 1, 0] }],
truthRadiusM: 0,
matchToleranceM: 0,
minimumPeakDistanceM: 0,
});
const second = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "corner", position: [-1, 1, 0] }],
truthRadiusM: 0,
matchToleranceM: 0,
minimumPeakDistanceM: 0,
});
assertEqual(JSON.stringify(first), JSON.stringify(second), "repeatable evaluation result");
const lobe = measureMainLobe(rawMap, scan, 0, 0.5);
assertEqual(lobe.pixelCount, 9, "flat half-power component size");
assertEqual(lobe.widthXM, 3, "main-lobe x pixel footprint");
assertEqual(lobe.widthYM, 3, "main-lobe y pixel footprint");
assertNear(lobe.equivalentDiameterM, 6 / Math.sqrt(Math.PI), 1e-12, "equivalent main-lobe diameter");
});
test("result exporter가 schema 허용 metric만 선택하고 내부 필드를 제거한다", () => {
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
rawMap: Float32Array.of(5, 0, 0, 1),
scan,
sources: [{ id: "truth", position: [0, 1, 3] }],
truthRadiusM: 0,
matchToleranceM: 0,
sidelobeExclusionRadiusM: 0,
minimumPeakDistanceM: 0,
});
const exported = exportBandEvaluation(evaluation, { metricNames: ["precision", "recall"] });
assertEqual(Object.keys(exported.metrics).join(","), "precision,recall", "selected metric keys");
assertEqual(exported.peaks[0].sourceId, "truth", "schema peak source match");
assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error");
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
assert(!("mainLobeWidthM" in exported.metrics), "diagnostic omitted from schema metrics");
assertEqual(
Object.keys(selectResultMetrics(evaluation)).join(","),
RESULT_METRIC_KEYS.join(","),
"default schema metric selection",
);
const unsupported = captureError(() => selectResultMetrics(evaluation, ["mainLobeWidthM"]));
assert(unsupported instanceof RangeError, "unsupported result metric is rejected");
});
test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서 거부한다", () => {
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
for (const invalidValue of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
const rawMap = [0, 0, 0, invalidValue];
assert(
captureError(() => evaluateDasBand({ rawMap, scan, sources: [] })) instanceof RangeError,
`invalid power rejected: ${invalidValue}`,
);
}
assert(
captureError(() => evaluateDasBand({ rawMap: [0, 0, 0], scan, sources: [] })) instanceof RangeError,
"map shape mismatch rejected",
);
assert(
captureError(() => evaluateDasBand({
rawMap: [0, 0, 0, 0],
scan,
sources: [{ id: "bad", position: [0, Number.NaN, 1] }],
})) instanceof RangeError,
"non-finite source position rejected",
);
assert(
captureError(() => evaluateDasBand({
rawMap: { rows: 2, columns: 2, scale: "normalized", values: [0, 0, 0, 0] },
scan,
sources: [],
})) instanceof RangeError,
"non-power map rejected",
);
});