diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html
index 0a76d88..bd42ab8 100644
--- a/apps/acoustic-camera/index.html
+++ b/apps/acoustic-camera/index.html
@@ -137,6 +137,7 @@
+
diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js
index ba900fd..c91d4b4 100644
--- a/apps/acoustic-camera/main.js
+++ b/apps/acoustic-camera/main.js
@@ -43,6 +43,7 @@ const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8f
const MAX_HISTORY_ITEMS = 24;
const MAX_LIVE_ANALYSIS_FRAMES = 48;
const MAX_LIVE_SCAN_POINTS = 2500;
+const DEFAULT_DETECTION_THRESHOLD_RATIO = 0.25;
const byId = (id) => document.getElementById(id);
const elements = {
@@ -102,6 +103,7 @@ const elements = {
hopSize: byId("hop-size"),
normalization: byId("normalization"),
dynamicRange: byId("dynamic-range"),
+ detectionThreshold: byId("detection-threshold"),
postprocess: byId("postprocess"),
addBandButton: byId("add-band-button"),
bandList: byId("band-list"),
@@ -168,6 +170,14 @@ function comparisonSignature(scene = state.scene, sourceExperiment = state.exper
return canonicalStringify({ scene, experiment });
}
+function applyExperimentDefaults(experiment) {
+ if (!experiment.evaluation) experiment.evaluation = {};
+ if (experiment.evaluation.detectionThresholdRatio === undefined) {
+ experiment.evaluation.detectionThresholdRatio = DEFAULT_DETECTION_THRESHOLD_RATIO;
+ }
+ return experiment;
+}
+
function setRunStatus(message) {
elements.runStatus.textContent = message;
}
@@ -359,6 +369,9 @@ function syncControls() {
elements.hopSize.value = experiment.stft.hopSize;
elements.normalization.value = experiment.beamforming.normalization;
elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb;
+ const detectionThresholdRatio =
+ experiment.evaluation?.detectionThresholdRatio ?? DEFAULT_DETECTION_THRESHOLD_RATIO;
+ elements.detectionThreshold.value = String(Number((detectionThresholdRatio * 100).toFixed(4)));
elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id)
? state.arrayDefinition.id
: "custom";
@@ -638,6 +651,14 @@ function renderResult() {
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`,
);
+ const detectionThreshold =
+ displayed.snapshot.experiment.evaluation?.detectionThresholdRatio ??
+ DEFAULT_DETECTION_THRESHOLD_RATIO;
+ appendMetric(
+ "Peak 검출 임계값",
+ `${formatNumber(detectionThreshold * 100, 0)}%`,
+ "최강 peak power 기준 · 낮을수록 민감",
+ );
for (const band of displayed.snapshot.experiment.frequencyBands) {
const evaluation = displayed.evaluations.get(band.id);
if (!evaluation) continue;
@@ -1001,6 +1022,9 @@ async function runSimulation() {
scan: snapshot.experiment.scan,
sources: sourcesForBand(snapshot.scene, definition),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
+ detectionThresholdRatio:
+ snapshot.experiment.evaluation?.detectionThresholdRatio ??
+ DEFAULT_DETECTION_THRESHOLD_RATIO,
});
evaluations.set(band.id, evaluation);
exportedEvaluations.set(band.id, exportBandEvaluation(evaluation));
@@ -1124,6 +1148,9 @@ function evaluateLiveFrames(snapshot, plan, frames) {
scan: snapshot.experiment.scan,
sources: sourcesForBand(frameScene, definition, frame.sampleTime),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
+ detectionThresholdRatio:
+ snapshot.experiment.evaluation?.detectionThresholdRatio ??
+ DEFAULT_DETECTION_THRESHOLD_RATIO,
}),
);
}
@@ -1312,7 +1339,7 @@ async function loadMovingExample() {
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = arrayDefinition;
state.scene = scene;
- state.experiment = experiment;
+ state.experiment = applyExperimentDefaults(experiment);
state.sceneDirty = false;
state.experimentDirty = false;
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
@@ -1403,7 +1430,7 @@ async function importDocument(kind, input) {
state.sceneDirty = false;
assetRuntime.reset();
} else {
- state.experiment = value;
+ state.experiment = applyExperimentDefaults(value);
state.experimentDirty = false;
}
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
@@ -1498,6 +1525,13 @@ function installDocumentInputListeners() {
state.experiment.beamforming.dynamicRangeDb = value;
markChanged();
});
+ elements.detectionThreshold.addEventListener("change", () => {
+ const value = readFiniteInput(elements.detectionThreshold);
+ if (value === null) return syncControls();
+ state.experiment.evaluation ??= {};
+ state.experiment.evaluation.detectionThresholdRatio = value / 100;
+ markChanged();
+ });
}
function installBandListeners() {
@@ -1697,7 +1731,7 @@ async function initialize() {
]);
state.arrayDefinition = arrayDefinition;
state.scene = scene;
- state.experiment = experiment;
+ state.experiment = applyExperimentDefaults(experiment);
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
syncControls();
renderBandEditors();
diff --git a/assets/sample-scenes/moving-tone.experiment.json b/assets/sample-scenes/moving-tone.experiment.json
index e3602b4..dad2f9a 100644
--- a/assets/sample-scenes/moving-tone.experiment.json
+++ b/assets/sample-scenes/moving-tone.experiment.json
@@ -18,6 +18,7 @@
{ "id": "tone-2k", "minHz": 1850, "maxHz": 2150, "color": "#58c7ff" }
],
"stft": { "fftSize": 256, "hopSize": 128, "window": "hann" },
+ "evaluation": { "detectionThresholdRatio": 0.25 },
"beamforming": {
"algorithm": "das",
"backend": "auto",
diff --git a/assets/sample-scenes/single-tone.experiment.json b/assets/sample-scenes/single-tone.experiment.json
index 9b9a166..aabe28e 100644
--- a/assets/sample-scenes/single-tone.experiment.json
+++ b/assets/sample-scenes/single-tone.experiment.json
@@ -18,6 +18,7 @@
{ "id": "tone-1k", "minHz": 900, "maxHz": 1100, "color": "#65f2bf" }
],
"stft": { "fftSize": 512, "hopSize": 256, "window": "hann" },
+ "evaluation": { "detectionThresholdRatio": 0.25 },
"beamforming": {
"algorithm": "das",
"backend": "auto",
diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js
index a69bf2c..1fad070 100644
--- a/packages/contracts/validation.js
+++ b/packages/contracts/validation.js
@@ -347,6 +347,16 @@ function validateExperimentRelationships(value, context = {}) {
exclusiveMin: true,
});
}
+ if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
+ const threshold = value.evaluation.detectionThresholdRatio;
+ if (!isFiniteNumber(threshold) || threshold <= 0 || threshold > 1) {
+ addIssue(
+ issues,
+ "$.evaluation.detectionThresholdRatio",
+ "피크 검출 임계값 비율은 0보다 크고 1 이하여야 합니다. 더 약한 피크를 찾으려면 값을 낮추세요.",
+ );
+ }
+ }
if (context.arrayDefinition && context.scene) {
const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
diff --git a/schemas/experiment.schema.json b/schemas/experiment.schema.json
index 1b688f0..4026898 100644
--- a/schemas/experiment.schema.json
+++ b/schemas/experiment.schema.json
@@ -47,6 +47,13 @@
"normalization": { "enum": ["peak", "none"] },
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }
}
+ },
+ "evaluation": {
+ "type": "object", "additionalProperties": false,
+ "required": ["detectionThresholdRatio"],
+ "properties": {
+ "detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
+ }
}
}
}
diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js
index bd502c7..36a8c48 100644
--- a/tests/integration/camera-ui.test.js
+++ b/tests/integration/camera-ui.test.js
@@ -68,6 +68,8 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
+ assertEqual(documentObject.querySelector("#detection-threshold").value, "25", "default peak sensitivity");
+ dispatchValue(windowObject, documentObject.querySelector("#detection-threshold"), 10);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
@@ -134,6 +136,11 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
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");
+ assertEqual(
+ resultDocument.bands[0].evaluation.detectionThresholdRatio,
+ 0.1,
+ "selected peak sensitivity is exported",
+ );
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");
diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js
index 9d0e81d..6a68b19 100644
--- a/tests/unit/contracts.test.js
+++ b/tests/unit/contracts.test.js
@@ -61,6 +61,35 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
});
+test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => {
+ const [arrayDefinition, scene, experiment] = await Promise.all([
+ loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
+ loadJson("/assets/sample-scenes/single-tone.scene.json"),
+ loadJson("/assets/sample-scenes/single-tone.experiment.json"),
+ ]);
+ assertEqual(experiment.evaluation.detectionThresholdRatio, 0.25, "sample threshold ratio");
+ assertEqual(
+ (await validateExperiment(experiment, { arrayDefinition, scene })).length,
+ 0,
+ "0.25 threshold issues",
+ );
+
+ experiment.evaluation.detectionThresholdRatio = 0;
+ let issues = await validateExperiment(experiment, { arrayDefinition, scene });
+ assert(
+ issues.some(({ path, message }) =>
+ path === "$.evaluation.detectionThresholdRatio" && message.includes("0보다 크고 1 이하")),
+ "zero threshold is rejected with actionable guidance",
+ );
+
+ experiment.evaluation.detectionThresholdRatio = 1.01;
+ issues = await validateExperiment(experiment, { arrayDefinition, scene });
+ assert(
+ issues.some(({ path }) => path === "$.evaluation.detectionThresholdRatio"),
+ "threshold above one is rejected",
+ );
+});
+
test("이동 음원 trajectory의 keyframe 순서와 장면 시간 범위를 검증한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.sources[0].trajectory = [
diff --git a/tests/unit/evaluation.test.js b/tests/unit/evaluation.test.js
index fab76ea..eb65636 100644
--- a/tests/unit/evaluation.test.js
+++ b/tests/unit/evaluation.test.js
@@ -86,6 +86,32 @@ test("복수 source와 threshold detection을 one-to-one matching한다", () =>
);
});
+test("peak 검출 감도를 낮추면 약한 두 번째 음원을 선택한다", () => {
+ const scan = { xMin: -2, xMax: 2, yMin: -2, yMax: 2, z: 1, columns: 5, rows: 5 };
+ const rawMap = new Float32Array(25);
+ rawMap[6] = 10;
+ rawMap[18] = 2.5;
+ const options = {
+ rawMap,
+ scan,
+ sources: [
+ { id: "strong", position: [-1, 1, 1] },
+ { id: "weak", position: [1, -1, 1] },
+ ],
+ matchToleranceM: 0.25,
+ minimumPeakDistanceM: 0,
+ };
+
+ const conservative = evaluateDasBand({ ...options, detectionThresholdRatio: 0.5 });
+ const recommended = evaluateDasBand({ ...options, detectionThresholdRatio: 0.25 });
+
+ assertEqual(conservative.detections.length, 1, "50% threshold omits the weak peak");
+ assertEqual(conservative.counts.falseNegative, 1, "one source remains undetected");
+ assertEqual(recommended.detections.length, 2, "25% threshold retains the weak peak");
+ assertEqual(recommended.counts.truePositive, 2, "both sources match at recommended sensitivity");
+ assertEqual(recommended.parameters.detectionThresholdRatio, 0.25, "selected ratio is recorded");
+});
+
test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({