fix(methodd): merge_close_spans prominence semantic — Python 1:1

Python `merge_close_spans(gap_peak_min_prom=X)` 는 gap 내 peak 가 양쪽
endpoint valley (max(sg[prev_e], sg[s])) 대비 상대 높이가 X 초과일 때
병합을 스킵한다. Kotlin 은 대신 absolute (peak > low_amp + X) 기준을
써서, valley 가 low_amp 보다 훨씬 낮은 경우 병합 스킵 임계가 Python
보다 높아지는 잠재 divergence 있었음.

- SpanUtils.mergeCloseSpans 에 gapPeakMinProm 파라미터 추가, Python
  우선순위 (prom 이 thr 을 override) 그대로 이식.
- Legacy DetectLumenFirst 는 여전히 gapPeakThr (absolute) 사용 —
  peakCheckMinGap default 를 1 로 유지해 하위호환 (Python default 는 3).
- MethodDSpan 은 신규 gapPeakMinProm 파라미터로 호출 전환.
- SpanUtilsMergeCloseSpansTest 5 케이스 (prom split/merge, gap=0,
  absolute compat, 우선순위) 로 semantic 검증.

Golden test 33 케이스 회귀 없음 (기존 케이스에선 두 semantic 이
동일 결과였음 확인). Method D wall 검출 semantic 이 Python 1:1 도달.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 15:17:42 +09:00
parent 739d52e472
commit efbaab1d23
3 changed files with 150 additions and 12 deletions
@@ -29,26 +29,35 @@ object SpanUtils {
}
/**
* Stage 1 merge — width-bounded amplitude-aware merge. Closes gaps
* of width ≤ maxGap when the gap-peak amplitude is below gapPeakThr
* (typically T + 50 ADC). Compatible with V2 / Otsu / scalar paths.
* Stage 1 merge — width-bounded amplitude-aware merge.
*
* Note: gap-peak should be sampled from the ORIGINAL `sg` (not the
* median-filtered envelope) because the running median can clip a
* real wall peak to its plateau-median value, which on borderline
* cases drops below T + GAP_PEAK_MARGIN and would erroneously merge
* across the wall.
* Peak guard (병합 skip 조건) — Python `merge_close_spans` 와 동일한 우선순위:
* 1) [gapPeakMinProm] (prominence): `gap` 내 peak_amp 가 양쪽 valley
* (sg[prev_e], sg[s]) 중 큰 쪽보다 이만큼 높으면 병합 skip.
* `gap > 0` 인 모든 gap 에 적용. `sg` 필수. (Method D 표준)
* 2) [gapPeakThr] (absolute): gap 내 peak_amp 가 절대 임계 초과 시 skip
* (`gap >= peakCheckMinGap`). 둘 다 주면 (1) 만 사용 — Python 일치.
* legacy V4 / DetectLumenFirst 경로 호환.
*
* gap-peak 는 원본 `sg` 에서 샘플 — median-filtered envelope 은 wall peak 을
* plateau median 값으로 clip 해 borderline 케이스에서 잘못 merge 시킬 수 있음.
*/
fun mergeCloseSpans(
spans: List<Span>,
maxGap: Int,
sg: DoubleArray? = null,
gapPeakThr: Double? = null
gapPeakThr: Double? = null,
gapPeakMinProm: Double? = null,
// Python default = 3 (Method D). Kotlin legacy DetectLumenFirst 호출자는
// 기존 gap>=1 시맨틱에 의존 → 하위호환 위해 default = 1. Prom 모드는 항상
// gap > 0 이라 이 값 미사용.
peakCheckMinGap: Int = 1,
): List<Span> {
if (spans.isEmpty()) return emptyList()
val ordered = spans.sortedBy { it.start }
val merged = mutableListOf(ordered[0])
val usePeak = sg != null && gapPeakThr != null
val useProm = sg != null && gapPeakMinProm != null
val useThr = sg != null && gapPeakThr != null && !useProm
for (k in 1 until ordered.size) {
val (s, e) = ordered[k]
val last = merged.last()
@@ -56,7 +65,14 @@ object SpanUtils {
val gap = s - pe - 1
if (gap <= maxGap) {
var skip = false
if (usePeak && gap >= 1) {
if (useProm && gap > 0) {
var mx = Double.NEGATIVE_INFINITY
for (i in (pe + 1) until s) if (sg!![i] > mx) mx = sg[i]
if (mx > Double.NEGATIVE_INFINITY) {
val valley = maxOf(sg!![pe], sg[s])
if ((mx - valley) > gapPeakMinProm!!) skip = true
}
} else if (useThr && gap >= peakCheckMinGap) {
var mx = Double.NEGATIVE_INFINITY
for (i in (pe + 1) until s) if (sg!![i] > mx) mx = sg[i]
if (mx > gapPeakThr!!) skip = true
@@ -52,11 +52,14 @@ object MethodDSpan {
val mask = BooleanArray(sg.size) { sg[it] <= lowAmp }
val rawSpans = SpanUtils.contiguousTrueSpans(mask)
.filter { (it.end - it.start + 1) >= params.lowMinLen }
// Python 1:1: prominence 시맨틱 사용 (peak - max(sg[prev_e], sg[s]) > min_prom).
// 이전 코드는 absolute threshold (peak > low_amp + min_prom) 를 사용해
// valley 가 low_amp 보다 훨씬 낮은 경우 병합 스킵 임계가 Python 보다 높았음.
val spans = SpanUtils.mergeCloseSpans(
rawSpans,
maxGap = params.mergeGapMax,
sg = sg,
gapPeakThr = lowAmp + params.gapPeakMinProm,
gapPeakMinProm = params.gapPeakMinProm,
)
if (spans.isEmpty()) return null