feat: add standard peak grouping methods

This commit is contained in:
2026-08-29 09:00:40 +09:00
parent 447f77dfa7
commit e048ccbadc
7 changed files with 299 additions and 4 deletions
+1
View File
@@ -94,6 +94,7 @@
<label class="field"><span>Grid preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label> <label class="field"><span>Grid preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label> <label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.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" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
<label class="field" title="원시 map은 바꾸지 않고, 임계값 위 영역을 peak로 묶는 표준 방식을 선택합니다."><span>Peak 묶기</span><select id="detection-method"><option value="local-max">Local maxima</option><option value="connected-components">8-connected components</option><option value="watershed">Marker watershed</option></select></label>
</div> </div>
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p> <p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
+17
View File
@@ -126,6 +126,7 @@ const elements = {
normalization: byId("normalization"), normalization: byId("normalization"),
dynamicRange: byId("dynamic-range"), dynamicRange: byId("dynamic-range"),
detectionThreshold: byId("detection-threshold"), detectionThreshold: byId("detection-threshold"),
detectionMethod: byId("detection-method"),
addBandButton: byId("add-band-button"), addBandButton: byId("add-band-button"),
bandList: byId("band-list"), bandList: byId("band-list"),
exportSceneButton: byId("export-scene-button"), exportSceneButton: byId("export-scene-button"),
@@ -206,6 +207,9 @@ function applyExperimentDefaults(experiment) {
if (experiment.evaluation.detectionThresholdRatio === undefined) { if (experiment.evaluation.detectionThresholdRatio === undefined) {
experiment.evaluation.detectionThresholdRatio = DEFAULT_DETECTION_THRESHOLD_RATIO; experiment.evaluation.detectionThresholdRatio = DEFAULT_DETECTION_THRESHOLD_RATIO;
} }
if (experiment.evaluation.detectionMethod === undefined) {
experiment.evaluation.detectionMethod = "local-max";
}
return experiment; return experiment;
} }
@@ -426,6 +430,7 @@ function syncControls() {
const detectionThresholdRatio = const detectionThresholdRatio =
experiment.evaluation?.detectionThresholdRatio ?? DEFAULT_DETECTION_THRESHOLD_RATIO; experiment.evaluation?.detectionThresholdRatio ?? DEFAULT_DETECTION_THRESHOLD_RATIO;
elements.detectionThreshold.value = String(Number((detectionThresholdRatio * 100).toFixed(4))); elements.detectionThreshold.value = String(Number((detectionThresholdRatio * 100).toFixed(4)));
elements.detectionMethod.value = experiment.evaluation?.detectionMethod ?? "local-max";
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";
@@ -781,6 +786,11 @@ function renderResult() {
`${formatNumber(detectionThreshold * 100, 0)}%`, `${formatNumber(detectionThreshold * 100, 0)}%`,
"최강 map 값 기준 · 낮을수록 민감", "최강 map 값 기준 · 낮을수록 민감",
); );
appendMetric(
"Peak 묶기",
displayed.snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
"raw map 불변 · 검출 후보만 군집화",
);
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;
@@ -1165,6 +1175,7 @@ async function runSimulation() {
detectionThresholdRatio: detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ?? snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO, DEFAULT_DETECTION_THRESHOLD_RATIO,
detectionMethod: snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
}); });
evaluations.set(band.id, evaluation); evaluations.set(band.id, evaluation);
exportedEvaluations.set(band.id, exportBandEvaluation(evaluation)); exportedEvaluations.set(band.id, exportBandEvaluation(evaluation));
@@ -1300,6 +1311,7 @@ function evaluateLiveFrames(snapshot, plan, frames) {
detectionThresholdRatio: detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ?? snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO, DEFAULT_DETECTION_THRESHOLD_RATIO,
detectionMethod: snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
}), }),
); );
} }
@@ -1758,6 +1770,11 @@ function installDocumentInputListeners() {
state.experiment.evaluation.detectionThresholdRatio = value / 100; state.experiment.evaluation.detectionThresholdRatio = value / 100;
markChanged(); markChanged();
}); });
elements.detectionMethod.addEventListener("change", () => {
state.experiment.evaluation ??= {};
state.experiment.evaluation.detectionMethod = elements.detectionMethod.value;
markChanged();
});
} }
function installBandListeners() { function installBandListeners() {
+225 -1
View File
@@ -7,6 +7,11 @@ export const RESULT_METRIC_KEYS = Object.freeze([
"recall", "recall",
"mainLobeWidthM", "mainLobeWidthM",
]); ]);
export const PEAK_DETECTION_METHODS = Object.freeze([
"local-max",
"connected-components",
"watershed",
]);
const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS); const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS);
const DEFAULT_MAXIMUM_DETECTIONS = 128; const DEFAULT_MAXIMUM_DETECTIONS = 128;
@@ -114,6 +119,214 @@ function comparePeaks(a, b) {
return b.value - a.value || a.index - b.index; return b.value - a.value || a.index - b.index;
} }
function requireDetectionMethod(value = "local-max") {
if (!PEAK_DETECTION_METHODS.includes(value)) {
throw new RangeError(
"detectionMethod must be local-max, connected-components, or watershed.",
);
}
return value;
}
function forEachNeighbor(index, scan, callback) {
const row = Math.floor(index / scan.columns);
const column = index % scan.columns;
for (
let neighborRow = Math.max(0, row - 1);
neighborRow <= Math.min(scan.rows - 1, row + 1);
neighborRow += 1
) {
for (
let neighborColumn = Math.max(0, column - 1);
neighborColumn <= Math.min(scan.columns - 1, column + 1);
neighborColumn += 1
) {
if (neighborRow === row && neighborColumn === column) continue;
callback(neighborRow * scan.columns + neighborColumn);
}
}
}
function centroidCandidate(values, scan, indices, peakIndex, extra = {}) {
let totalWeight = 0;
let weightedX = 0;
let weightedY = 0;
for (const index of indices) {
const weight = values[index];
const position = scanIndexToPosition(index, scan);
totalWeight += weight;
weightedX += position[0] * weight;
weightedY += position[1] * weight;
}
const fallback = scanIndexToPosition(peakIndex, scan);
return {
index: peakIndex,
position: totalWeight > 0
? [weightedX / totalWeight, weightedY / totalWeight, scan.z]
: fallback,
value: values[peakIndex],
clusterPixelCount: indices.length,
...extra,
};
}
function findConnectedComponentCandidates(values, scan, thresholdValue) {
const visited = new Uint8Array(values.length);
const queue = new Int32Array(values.length);
const candidates = [];
const active = (index) => values[index] > 0 && values[index] >= thresholdValue;
for (let startIndex = 0; startIndex < values.length; startIndex += 1) {
if (visited[startIndex] || !active(startIndex)) continue;
let queueStart = 0;
let queueEnd = 1;
let peakIndex = startIndex;
queue[0] = startIndex;
visited[startIndex] = 1;
while (queueStart < queueEnd) {
const index = queue[queueStart];
queueStart += 1;
if (
values[index] > values[peakIndex] ||
(values[index] === values[peakIndex] && index < peakIndex)
) {
peakIndex = index;
}
forEachNeighbor(index, scan, (neighborIndex) => {
if (visited[neighborIndex] || !active(neighborIndex)) return;
visited[neighborIndex] = 1;
queue[queueEnd] = neighborIndex;
queueEnd += 1;
});
}
candidates.push(
centroidCandidate(
values,
scan,
queue.subarray(0, queueEnd),
peakIndex,
{ clusterMethod: "connected-components" },
),
);
}
candidates.sort(comparePeaks);
return candidates;
}
function watershedEntryHigher(left, right) {
return (
left.priority > right.priority ||
(
left.priority === right.priority &&
(
left.seedOrder < right.seedOrder ||
(left.seedOrder === right.seedOrder && left.index < right.index)
)
)
);
}
function pushWatershedEntry(heap, entry) {
heap.push(entry);
let index = heap.length - 1;
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (!watershedEntryHigher(heap[index], heap[parent])) break;
[heap[index], heap[parent]] = [heap[parent], heap[index]];
index = parent;
}
}
function popWatershedEntry(heap) {
const first = heap[0];
const last = heap.pop();
if (heap.length === 0) return first;
heap[0] = last;
let index = 0;
while (true) {
const left = index * 2 + 1;
const right = left + 1;
let best = index;
if (left < heap.length && watershedEntryHigher(heap[left], heap[best])) best = left;
if (right < heap.length && watershedEntryHigher(heap[right], heap[best])) best = right;
if (best === index) break;
[heap[index], heap[best]] = [heap[best], heap[index]];
index = best;
}
return first;
}
function findWatershedCandidates(values, scan, thresholdValue, localPeaks) {
const seeds = localPeaks.filter((peak) => peak.value >= thresholdValue);
if (seeds.length === 0) return [];
const active = (index) => values[index] > 0 && values[index] >= thresholdValue;
const assignments = new Int32Array(values.length);
assignments.fill(-1);
const priorities = new Float64Array(values.length);
priorities.fill(Number.NEGATIVE_INFINITY);
const heap = [];
seeds.forEach((seed, seedOrder) => {
assignments[seed.index] = seedOrder;
priorities[seed.index] = seed.value;
pushWatershedEntry(heap, {
index: seed.index,
priority: seed.value,
seedOrder,
});
});
while (heap.length > 0) {
const current = popWatershedEntry(heap);
if (
priorities[current.index] !== current.priority ||
assignments[current.index] !== current.seedOrder
) {
continue;
}
forEachNeighbor(current.index, scan, (neighborIndex) => {
if (!active(neighborIndex)) return;
const nextPriority = Math.min(current.priority, values[neighborIndex]);
const previousPriority = priorities[neighborIndex];
const previousSeed = assignments[neighborIndex];
if (
nextPriority < previousPriority ||
(nextPriority === previousPriority && previousSeed >= 0 && previousSeed <= current.seedOrder)
) {
return;
}
priorities[neighborIndex] = nextPriority;
assignments[neighborIndex] = current.seedOrder;
pushWatershedEntry(heap, {
index: neighborIndex,
priority: nextPriority,
seedOrder: current.seedOrder,
});
});
}
const basinIndices = seeds.map(() => []);
assignments.forEach((seedOrder, index) => {
if (seedOrder >= 0) basinIndices[seedOrder].push(index);
});
const candidates = seeds.map((seed, seedOrder) =>
centroidCandidate(
values,
scan,
basinIndices[seedOrder],
seed.index,
{ clusterMethod: "watershed", basin: seedOrder + 1 },
));
candidates.sort(comparePeaks);
return candidates;
}
function detectionCandidates(values, scan, localPeaks, thresholdValue, detectionMethod) {
if (detectionMethod === "local-max") return localPeaks;
if (detectionMethod === "connected-components") {
return findConnectedComponentCandidates(values, scan, thresholdValue);
}
return findWatershedCandidates(values, scan, thresholdValue, localPeaks);
}
/** /**
* Return positive local maxima with each equal-valued 8-connected plateau * Return positive local maxima with each equal-valued 8-connected plateau
* represented by its lowest row-major index. A positive flat map falls back * represented by its lowest row-major index. A positive flat map falls back
@@ -557,6 +770,7 @@ export function evaluateLocalizationBand(options) {
"detectionThresholdRatio", "detectionThresholdRatio",
options.detectionThresholdRatio ?? 0.5, options.detectionThresholdRatio ?? 0.5,
); );
const detectionMethod = requireDetectionMethod(options.detectionMethod ?? "local-max");
const minimumPeakDistanceM = requireNonNegative( const minimumPeakDistanceM = requireNonNegative(
"minimumPeakDistanceM", "minimumPeakDistanceM",
options.minimumPeakDistanceM ?? resolution.diagonal, options.minimumPeakDistanceM ?? resolution.diagonal,
@@ -578,7 +792,14 @@ export function evaluateLocalizationBand(options) {
const localPeaks = findLocalPeaks(values, scan); const localPeaks = findLocalPeaks(values, scan);
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null; const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio; const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio;
const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections).map( const candidates = detectionCandidates(
values,
scan,
localPeaks,
thresholdValue,
detectionMethod,
);
const detections = selectDetections(candidates, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
(detection) => ({ (detection) => ({
...detection, ...detection,
beamSpread: measureBeamSpreadFromValues( beamSpread: measureBeamSpreadFromValues(
@@ -612,6 +833,7 @@ export function evaluateLocalizationBand(options) {
matchToleranceM, matchToleranceM,
detectionThresholdRatio, detectionThresholdRatio,
detectionThresholdValue: thresholdValue, detectionThresholdValue: thresholdValue,
detectionMethod,
minimumPeakDistanceM, minimumPeakDistanceM,
sidelobeExclusionRadiusM, sidelobeExclusionRadiusM,
halfPowerRatio, halfPowerRatio,
@@ -634,6 +856,7 @@ export function evaluateLocalizationBand(options) {
}, },
diagnostics: { diagnostics: {
mapScale, mapScale,
detectionCandidateCount: candidates.length,
mainLobe, mainLobe,
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0, mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
gridResolutionM: resolution, gridResolutionM: resolution,
@@ -711,6 +934,7 @@ export function exportBandEvaluation(evaluation, options = {}) {
matchToleranceM: evaluation.parameters.matchToleranceM, matchToleranceM: evaluation.parameters.matchToleranceM,
detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio, detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
detectionThresholdValue: evaluation.parameters.detectionThresholdValue, detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
detectionMethod: evaluation.parameters.detectionMethod ?? "local-max",
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM, minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM, sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
halfPowerRatio: evaluation.parameters.halfPowerRatio, halfPowerRatio: evaluation.parameters.halfPowerRatio,
+4 -1
View File
@@ -55,7 +55,10 @@
"type": "object", "additionalProperties": false, "type": "object", "additionalProperties": false,
"required": ["detectionThresholdRatio"], "required": ["detectionThresholdRatio"],
"properties": { "properties": {
"detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 } "detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
"detectionMethod": {
"enum": ["local-max", "connected-components", "watershed"]
}
} }
} }
} }
+3
View File
@@ -140,6 +140,9 @@
"matchToleranceM": { "type": "number", "minimum": 0 }, "matchToleranceM": { "type": "number", "minimum": 0 },
"detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 }, "detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 },
"detectionThresholdValue": { "type": "number", "minimum": 0 }, "detectionThresholdValue": { "type": "number", "minimum": 0 },
"detectionMethod": {
"enum": ["local-max", "connected-components", "watershed"]
},
"minimumPeakDistanceM": { "type": "number", "minimum": 0 }, "minimumPeakDistanceM": { "type": "number", "minimum": 0 },
"sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 }, "sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
+2 -2
View File
@@ -32,8 +32,8 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단 최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단
- `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test, - `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test,
1/2/5 grid와 keyboard nudge 1/2/5 grid와 keyboard nudge
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적 - `evaluation.test.js`: local maximum·8-connected component·marker watershed peak 묶기,
beam-spread covariance ellipse 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적 beam-spread covariance ellipse
- `camera.test.js`: canonical input hash, workload 추정, Scene 정렬의 분석 band 보존, - `camera.test.js`: canonical input hash, workload 추정, Scene 정렬의 분석 band 보존,
이동 timeline 계획·재생 보간, Result schema 검증 이동 timeline 계획·재생 보간, Result schema 검증
- `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching, - `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
+47
View File
@@ -113,6 +113,52 @@ test("peak 검출 감도를 낮추면 약한 두 번째 음원을 선택한다",
assertEqual(recommended.parameters.detectionThresholdRatio, 0.25, "selected ratio is recorded"); assertEqual(recommended.parameters.detectionThresholdRatio, 0.25, "selected ratio is recorded");
}); });
test("connected component와 watershed가 연결된 복수 lobe를 서로 다르게 군집화한다", () => {
const scan = { xMin: 0, xMax: 6, yMin: 0, yMax: 2, z: 1, columns: 7, rows: 3 };
const rawMap = Float32Array.of(
0, 0, 0, 0, 0, 0, 0,
0, 10, 8, 6, 8, 9, 0,
0, 0, 0, 0, 0, 0, 0,
);
const common = {
rawMap,
scan,
sources: [],
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
};
const local = evaluateDasBand({ ...common, detectionMethod: "local-max" });
const connected = evaluateDasBand({ ...common, detectionMethod: "connected-components" });
const watershed = evaluateDasBand({ ...common, detectionMethod: "watershed" });
assertEqual(local.detections.length, 2, "local maxima keeps both summits");
assertEqual(connected.detections.length, 1, "connected lobe becomes one component");
assertNear(connected.detections[0].position[0], 121 / 41, 1e-12, "component weighted centroid x");
assertNear(connected.detections[0].position[1], 1, 1e-12, "component weighted centroid y");
assertEqual(connected.detections[0].clusterPixelCount, 5, "component pixel count");
assertEqual(watershed.detections.length, 2, "watershed splits the connected lobe at its saddle");
assertNear(watershed.detections[0].position[0], 44 / 24, 1e-12, "strong basin centroid");
assertNear(watershed.detections[1].position[0], 77 / 17, 1e-12, "weak basin centroid");
assertEqual(connected.parameters.detectionMethod, "connected-components", "component method recorded");
assertEqual(watershed.parameters.detectionMethod, "watershed", "watershed method recorded");
assertEqual(
JSON.stringify(evaluateDasBand({ ...common, detectionMethod: "watershed" })),
JSON.stringify(watershed),
"watershed assignment is deterministic",
);
const suppressed = evaluateDasBand({
...common,
detectionMethod: "watershed",
minimumPeakDistanceM: 10,
});
assertEqual(suppressed.detections.length, 1, "common minimum-distance suppression still applies");
assert(
captureError(() => evaluateDasBand({ ...common, detectionMethod: "unknown" })) instanceof RangeError,
"unsupported detection method is rejected",
);
});
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({
@@ -338,6 +384,7 @@ test("result exporter가 schema 허용 metric만 선택하고 내부 필드를
assert(!("index" in exported.peaks[0]), "internal peak index omitted"); assert(!("index" in exported.peaks[0]), "internal peak index omitted");
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected"); assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported"); assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");
assertEqual(exported.evaluation.detectionMethod, "local-max", "detection method exported");
assertEqual(exported.evaluation.matchToleranceM, 0, "match tolerance exported"); assertEqual(exported.evaluation.matchToleranceM, 0, "match tolerance exported");
assertEqual( assertEqual(
Object.keys(selectResultMetrics(evaluation)).join(","), Object.keys(selectResultMetrics(evaluation)).join(","),