feat: tune multi-source peak sensitivity

This commit is contained in:
Codex
2026-08-28 16:30:01 +09:00
parent a54aa25cd9
commit 7cd737429a
9 changed files with 119 additions and 3 deletions
+1
View File
@@ -137,6 +137,7 @@
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label> <label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label> <label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label> <label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
<label class="field" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 검출 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label> <label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
</div> </div>
</section> </section>
+37 -3
View File
@@ -43,6 +43,7 @@ const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8f
const MAX_HISTORY_ITEMS = 24; const MAX_HISTORY_ITEMS = 24;
const MAX_LIVE_ANALYSIS_FRAMES = 48; const MAX_LIVE_ANALYSIS_FRAMES = 48;
const MAX_LIVE_SCAN_POINTS = 2500; const MAX_LIVE_SCAN_POINTS = 2500;
const DEFAULT_DETECTION_THRESHOLD_RATIO = 0.25;
const byId = (id) => document.getElementById(id); const byId = (id) => document.getElementById(id);
const elements = { const elements = {
@@ -102,6 +103,7 @@ const elements = {
hopSize: byId("hop-size"), hopSize: byId("hop-size"),
normalization: byId("normalization"), normalization: byId("normalization"),
dynamicRange: byId("dynamic-range"), dynamicRange: byId("dynamic-range"),
detectionThreshold: byId("detection-threshold"),
postprocess: byId("postprocess"), postprocess: byId("postprocess"),
addBandButton: byId("add-band-button"), addBandButton: byId("add-band-button"),
bandList: byId("band-list"), bandList: byId("band-list"),
@@ -168,6 +170,14 @@ function comparisonSignature(scene = state.scene, sourceExperiment = state.exper
return canonicalStringify({ scene, experiment }); 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) { function setRunStatus(message) {
elements.runStatus.textContent = message; elements.runStatus.textContent = message;
} }
@@ -359,6 +369,9 @@ function syncControls() {
elements.hopSize.value = experiment.stft.hopSize; elements.hopSize.value = experiment.stft.hopSize;
elements.normalization.value = experiment.beamforming.normalization; elements.normalization.value = experiment.beamforming.normalization;
elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb; 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) elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id)
? state.arrayDefinition.id ? state.arrayDefinition.id
: "custom"; : "custom";
@@ -638,6 +651,14 @@ function renderResult() {
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`, `${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`, `${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) { for (const band of displayed.snapshot.experiment.frequencyBands) {
const evaluation = displayed.evaluations.get(band.id); const evaluation = displayed.evaluations.get(band.id);
if (!evaluation) continue; if (!evaluation) continue;
@@ -1001,6 +1022,9 @@ async function runSimulation() {
scan: snapshot.experiment.scan, scan: snapshot.experiment.scan,
sources: sourcesForBand(snapshot.scene, definition), sources: sourcesForBand(snapshot.scene, definition),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
}); });
evaluations.set(band.id, evaluation); evaluations.set(band.id, evaluation);
exportedEvaluations.set(band.id, exportBandEvaluation(evaluation)); exportedEvaluations.set(band.id, exportBandEvaluation(evaluation));
@@ -1124,6 +1148,9 @@ function evaluateLiveFrames(snapshot, plan, frames) {
scan: snapshot.experiment.scan, scan: snapshot.experiment.scan,
sources: sourcesForBand(frameScene, definition, frame.sampleTime), sources: sourcesForBand(frameScene, definition, frame.sampleTime),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, 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(" · ")); if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = arrayDefinition; state.arrayDefinition = arrayDefinition;
state.scene = scene; state.scene = scene;
state.experiment = experiment; state.experiment = applyExperimentDefaults(experiment);
state.sceneDirty = false; state.sceneDirty = false;
state.experimentDirty = false; state.experimentDirty = false;
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id)); state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
@@ -1403,7 +1430,7 @@ async function importDocument(kind, input) {
state.sceneDirty = false; state.sceneDirty = false;
assetRuntime.reset(); assetRuntime.reset();
} else { } else {
state.experiment = value; state.experiment = applyExperimentDefaults(value);
state.experimentDirty = false; state.experimentDirty = false;
} }
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id)); state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
@@ -1498,6 +1525,13 @@ function installDocumentInputListeners() {
state.experiment.beamforming.dynamicRangeDb = value; state.experiment.beamforming.dynamicRangeDb = value;
markChanged(); 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() { function installBandListeners() {
@@ -1697,7 +1731,7 @@ async function initialize() {
]); ]);
state.arrayDefinition = arrayDefinition; state.arrayDefinition = arrayDefinition;
state.scene = scene; state.scene = scene;
state.experiment = experiment; state.experiment = applyExperimentDefaults(experiment);
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id)); state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
syncControls(); syncControls();
renderBandEditors(); renderBandEditors();
@@ -18,6 +18,7 @@
{ "id": "tone-2k", "minHz": 1850, "maxHz": 2150, "color": "#58c7ff" } { "id": "tone-2k", "minHz": 1850, "maxHz": 2150, "color": "#58c7ff" }
], ],
"stft": { "fftSize": 256, "hopSize": 128, "window": "hann" }, "stft": { "fftSize": 256, "hopSize": 128, "window": "hann" },
"evaluation": { "detectionThresholdRatio": 0.25 },
"beamforming": { "beamforming": {
"algorithm": "das", "algorithm": "das",
"backend": "auto", "backend": "auto",
@@ -18,6 +18,7 @@
{ "id": "tone-1k", "minHz": 900, "maxHz": 1100, "color": "#65f2bf" } { "id": "tone-1k", "minHz": 900, "maxHz": 1100, "color": "#65f2bf" }
], ],
"stft": { "fftSize": 512, "hopSize": 256, "window": "hann" }, "stft": { "fftSize": 512, "hopSize": 256, "window": "hann" },
"evaluation": { "detectionThresholdRatio": 0.25 },
"beamforming": { "beamforming": {
"algorithm": "das", "algorithm": "das",
"backend": "auto", "backend": "auto",
+10
View File
@@ -347,6 +347,16 @@ function validateExperimentRelationships(value, context = {}) {
exclusiveMin: true, 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) { if (context.arrayDefinition && context.scene) {
const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1; const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate); const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
+7
View File
@@ -47,6 +47,13 @@
"normalization": { "enum": ["peak", "none"] }, "normalization": { "enum": ["peak", "none"] },
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 } "dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }
} }
},
"evaluation": {
"type": "object", "additionalProperties": false,
"required": ["detectionThresholdRatio"],
"properties": {
"detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
}
} }
} }
} }
+7
View File
@@ -68,6 +68,8 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8); dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8); dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu"); 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( await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" && () => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled, !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"); assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
assert(/^[a-f0-9]{64}$/.test(resultDocument.inputHash), "exported input hash"); 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.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].evaluation.matchToleranceM), "match tolerance exported");
assert(Number.isFinite(resultDocument.bands[0].metrics.mainLobeWidthM), "main-lobe metric exported"); assert(Number.isFinite(resultDocument.bands[0].metrics.mainLobeWidthM), "main-lobe metric exported");
assertEqual(resultDocument.environment.scanXMin, experiment.scan.xMin, "scan bounds exported"); assertEqual(resultDocument.environment.scanXMin, experiment.scan.xMin, "scan bounds exported");
+29
View File
@@ -61,6 +61,35 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues"); 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 () => { test("이동 음원 trajectory의 keyframe 순서와 장면 시간 범위를 검증한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.sources[0].trajectory = [ scene.sources[0].trajectory = [
+26
View File
@@ -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을 반환한다", () => { test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 }; const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({ const evaluation = evaluateDasBand({