feat: tune multi-source peak sensitivity
This commit is contained in:
@@ -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>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" 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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user