From 7cd737429a440a42cb83ef41b8488a824592d38a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 16:30:01 +0900 Subject: [PATCH] feat: tune multi-source peak sensitivity --- apps/acoustic-camera/index.html | 1 + apps/acoustic-camera/main.js | 40 +++++++++++++++++-- .../sample-scenes/moving-tone.experiment.json | 1 + .../sample-scenes/single-tone.experiment.json | 1 + packages/contracts/validation.js | 10 +++++ schemas/experiment.schema.json | 7 ++++ tests/integration/camera-ui.test.js | 7 ++++ tests/unit/contracts.test.js | 29 ++++++++++++++ tests/unit/evaluation.test.js | 26 ++++++++++++ 9 files changed, 119 insertions(+), 3 deletions(-) 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({