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>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>
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(","),
|
||||||
|
|||||||
Reference in New Issue
Block a user