diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index d633748..9aeaede 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -94,6 +94,7 @@ +

연산량 계산 중…

diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index 9ae2b44..897cd51 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -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() { diff --git a/packages/acoustics-core/evaluation.js b/packages/acoustics-core/evaluation.js index 49045e4..ecdc5a7 100644 --- a/packages/acoustics-core/evaluation.js +++ b/packages/acoustics-core/evaluation.js @@ -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, diff --git a/schemas/experiment.schema.json b/schemas/experiment.schema.json index 4e79a00..78e076b 100644 --- a/schemas/experiment.schema.json +++ b/schemas/experiment.schema.json @@ -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"] + } } } } diff --git a/schemas/result.schema.json b/schemas/result.schema.json index 32b318c..800ab84 100644 --- a/schemas/result.schema.json +++ b/schemas/result.schema.json @@ -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 }, diff --git a/tests/unit/README.md b/tests/unit/README.md index 3024569..ae64be2 100644 --- a/tests/unit/README.md +++ b/tests/unit/README.md @@ -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, diff --git a/tests/unit/evaluation.test.js b/tests/unit/evaluation.test.js index 71444f6..8aa3fac 100644 --- a/tests/unit/evaluation.test.js +++ b/tests/unit/evaluation.test.js @@ -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(","),