feat: add standard peak grouping methods
This commit is contained in:
@@ -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>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="원시 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>
|
||||
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ const elements = {
|
||||
normalization: byId("normalization"),
|
||||
dynamicRange: byId("dynamic-range"),
|
||||
detectionThreshold: byId("detection-threshold"),
|
||||
detectionMethod: byId("detection-method"),
|
||||
addBandButton: byId("add-band-button"),
|
||||
bandList: byId("band-list"),
|
||||
exportSceneButton: byId("export-scene-button"),
|
||||
@@ -206,6 +207,9 @@ function applyExperimentDefaults(experiment) {
|
||||
if (experiment.evaluation.detectionThresholdRatio === undefined) {
|
||||
experiment.evaluation.detectionThresholdRatio = DEFAULT_DETECTION_THRESHOLD_RATIO;
|
||||
}
|
||||
if (experiment.evaluation.detectionMethod === undefined) {
|
||||
experiment.evaluation.detectionMethod = "local-max";
|
||||
}
|
||||
return experiment;
|
||||
}
|
||||
|
||||
@@ -426,6 +430,7 @@ function syncControls() {
|
||||
const detectionThresholdRatio =
|
||||
experiment.evaluation?.detectionThresholdRatio ?? DEFAULT_DETECTION_THRESHOLD_RATIO;
|
||||
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)
|
||||
? state.arrayDefinition.id
|
||||
: "custom";
|
||||
@@ -781,6 +786,11 @@ function renderResult() {
|
||||
`${formatNumber(detectionThreshold * 100, 0)}%`,
|
||||
"최강 map 값 기준 · 낮을수록 민감",
|
||||
);
|
||||
appendMetric(
|
||||
"Peak 묶기",
|
||||
displayed.snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
|
||||
"raw map 불변 · 검출 후보만 군집화",
|
||||
);
|
||||
for (const band of displayed.snapshot.experiment.frequencyBands) {
|
||||
const evaluation = displayed.evaluations.get(band.id);
|
||||
if (!evaluation) continue;
|
||||
@@ -1165,6 +1175,7 @@ async function runSimulation() {
|
||||
detectionThresholdRatio:
|
||||
snapshot.experiment.evaluation?.detectionThresholdRatio ??
|
||||
DEFAULT_DETECTION_THRESHOLD_RATIO,
|
||||
detectionMethod: snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
|
||||
});
|
||||
evaluations.set(band.id, evaluation);
|
||||
exportedEvaluations.set(band.id, exportBandEvaluation(evaluation));
|
||||
@@ -1300,6 +1311,7 @@ function evaluateLiveFrames(snapshot, plan, frames) {
|
||||
detectionThresholdRatio:
|
||||
snapshot.experiment.evaluation?.detectionThresholdRatio ??
|
||||
DEFAULT_DETECTION_THRESHOLD_RATIO,
|
||||
detectionMethod: snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1758,6 +1770,11 @@ function installDocumentInputListeners() {
|
||||
state.experiment.evaluation.detectionThresholdRatio = value / 100;
|
||||
markChanged();
|
||||
});
|
||||
elements.detectionMethod.addEventListener("change", () => {
|
||||
state.experiment.evaluation ??= {};
|
||||
state.experiment.evaluation.detectionMethod = elements.detectionMethod.value;
|
||||
markChanged();
|
||||
});
|
||||
}
|
||||
|
||||
function installBandListeners() {
|
||||
|
||||
@@ -7,6 +7,11 @@ export const RESULT_METRIC_KEYS = Object.freeze([
|
||||
"recall",
|
||||
"mainLobeWidthM",
|
||||
]);
|
||||
export const PEAK_DETECTION_METHODS = Object.freeze([
|
||||
"local-max",
|
||||
"connected-components",
|
||||
"watershed",
|
||||
]);
|
||||
|
||||
const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS);
|
||||
const DEFAULT_MAXIMUM_DETECTIONS = 128;
|
||||
@@ -114,6 +119,214 @@ function comparePeaks(a, b) {
|
||||
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
|
||||
* represented by its lowest row-major index. A positive flat map falls back
|
||||
@@ -557,6 +770,7 @@ export function evaluateLocalizationBand(options) {
|
||||
"detectionThresholdRatio",
|
||||
options.detectionThresholdRatio ?? 0.5,
|
||||
);
|
||||
const detectionMethod = requireDetectionMethod(options.detectionMethod ?? "local-max");
|
||||
const minimumPeakDistanceM = requireNonNegative(
|
||||
"minimumPeakDistanceM",
|
||||
options.minimumPeakDistanceM ?? resolution.diagonal,
|
||||
@@ -578,7 +792,14 @@ export function evaluateLocalizationBand(options) {
|
||||
const localPeaks = findLocalPeaks(values, scan);
|
||||
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
|
||||
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,
|
||||
beamSpread: measureBeamSpreadFromValues(
|
||||
@@ -612,6 +833,7 @@ export function evaluateLocalizationBand(options) {
|
||||
matchToleranceM,
|
||||
detectionThresholdRatio,
|
||||
detectionThresholdValue: thresholdValue,
|
||||
detectionMethod,
|
||||
minimumPeakDistanceM,
|
||||
sidelobeExclusionRadiusM,
|
||||
halfPowerRatio,
|
||||
@@ -634,6 +856,7 @@ export function evaluateLocalizationBand(options) {
|
||||
},
|
||||
diagnostics: {
|
||||
mapScale,
|
||||
detectionCandidateCount: candidates.length,
|
||||
mainLobe,
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
gridResolutionM: resolution,
|
||||
@@ -711,6 +934,7 @@ export function exportBandEvaluation(evaluation, options = {}) {
|
||||
matchToleranceM: evaluation.parameters.matchToleranceM,
|
||||
detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
|
||||
detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
|
||||
detectionMethod: evaluation.parameters.detectionMethod ?? "local-max",
|
||||
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
|
||||
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
|
||||
halfPowerRatio: evaluation.parameters.halfPowerRatio,
|
||||
|
||||
@@ -55,7 +55,10 @@
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["detectionThresholdRatio"],
|
||||
"properties": {
|
||||
"detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
||||
"detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
||||
"detectionMethod": {
|
||||
"enum": ["local-max", "connected-components", "watershed"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
"matchToleranceM": { "type": "number", "minimum": 0 },
|
||||
"detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"detectionThresholdValue": { "type": "number", "minimum": 0 },
|
||||
"detectionMethod": {
|
||||
"enum": ["local-max", "connected-components", "watershed"]
|
||||
},
|
||||
"minimumPeakDistanceM": { "type": "number", "minimum": 0 },
|
||||
"sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
|
||||
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
||||
|
||||
@@ -32,8 +32,8 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
|
||||
최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단
|
||||
- `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test,
|
||||
1/2/5 grid와 keyboard nudge
|
||||
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
||||
beam-spread covariance ellipse
|
||||
- `evaluation.test.js`: local maximum·8-connected component·marker watershed peak 묶기,
|
||||
검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적 beam-spread covariance ellipse
|
||||
- `camera.test.js`: canonical input hash, workload 추정, Scene 정렬의 분석 band 보존,
|
||||
이동 timeline 계획·재생 보간, Result schema 검증
|
||||
- `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
|
||||
|
||||
@@ -113,6 +113,52 @@ test("peak 검출 감도를 낮추면 약한 두 번째 음원을 선택한다",
|
||||
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을 반환한다", () => {
|
||||
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
|
||||
const evaluation = evaluateDasBand({
|
||||
@@ -338,6 +384,7 @@ test("result exporter가 schema 허용 metric만 선택하고 내부 필드를
|
||||
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
|
||||
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
|
||||
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(
|
||||
Object.keys(selectResultMetrics(evaluation)).join(","),
|
||||
|
||||
Reference in New Issue
Block a user