diff --git a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/SpanUtils.kt b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/SpanUtils.kt index 18e8a59..476b331 100644 --- a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/SpanUtils.kt +++ b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/SpanUtils.kt @@ -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, 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 { 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 diff --git a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDSpan.kt b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDSpan.kt index a581d3e..1312c06 100644 --- a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDSpan.kt +++ b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDSpan.kt @@ -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 diff --git a/app/src/test/java/com/medithings/vesiscan/walldetect/algo/SpanUtilsMergeCloseSpansTest.kt b/app/src/test/java/com/medithings/vesiscan/walldetect/algo/SpanUtilsMergeCloseSpansTest.kt new file mode 100644 index 0000000..5cac865 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/walldetect/algo/SpanUtilsMergeCloseSpansTest.kt @@ -0,0 +1,119 @@ +package com.medithings.vesiscan.walldetect.algo + +import com.medithings.vesiscan.walldetect.algo.SpanUtils.Span +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * D1 fix — mergeCloseSpans prominence 시맨틱 검증. + * + * Python `merge_close_spans(gap_peak_min_prom=X)` 시맨틱: + * skip_merge = (gap 내 peak_amp − max(sg[prev_e], sg[s])) > gap_peak_min_prom + * → valley (양쪽 endpoint 중 큰 값) 대비 relative 로 판정. + * + * 이전 Kotlin absolute 시맨틱 (버그): + * skip = gap 내 peak_amp > (low_amp + gap_peak_min_prom) + * → valley 무관, low_amp 기준. valley 가 low_amp 보다 훨씬 낮으면 발동 임계 + * Python 보다 높아짐 → Python 은 병합 skip 하는데 Kotlin 은 병합. + */ +class SpanUtilsMergeCloseSpansTest { + + /** + * 케이스: valley 가 low_amp 보다 훨씬 낮은 경우 두 시맨틱이 갈림. + * + * spans = [(0..4), (10..14)], gap = [5..9] (5 samples) + * sg[4] = 20 (valley left) + * sg[9] = 20 (valley right) → valley max = 20 + * sg[5..8] max = 90 → peak_amp = 90 + * low_amp = 50 (Otsu × ratio) + * gap_peak_min_prom = 50 + * + * prominence: (90 − 20) = 70 > 50 → skip merge (Python) ✓ + * absolute : 90 > (50 + 50) = 100 → false → merge (이전 Kotlin) ✗ + * + * D1 fix 로 이제 두 span 이 분리 유지되어야 함. + */ + @Test + fun `prominence mode splits on relative peak height above endpoint valleys`() { + val sg = DoubleArray(15).also { + for (i in 0..4) it[i] = 20.0 // span 1 저평탄 + it[5] = 80.0; it[6] = 90.0; it[7] = 85.0; it[8] = 60.0 + for (i in 9..14) it[i] = 20.0 // span 2 저평탄 + } + val spans = listOf(Span(0, 4), Span(10, 14)) + val merged = SpanUtils.mergeCloseSpans( + spans, maxGap = 5, sg = sg, gapPeakMinProm = 50.0, + ) + assertEquals("prominence mode 는 두 span 을 분리 유지해야 함", 2, merged.size) + assertEquals(0, merged[0].start); assertEquals(4, merged[0].end) + assertEquals(10, merged[1].start); assertEquals(14, merged[1].end) + } + + /** + * 케이스: gap 내 peak 가 valley 보다 겨우 조금만 높음 → prominence < threshold → 병합. + * sg[5..8] max = 60, valley = 20 → prom = 40 < 50 → merge. + */ + @Test + fun `prominence mode merges when relative peak height is below threshold`() { + val sg = DoubleArray(15).also { + for (i in 0..4) it[i] = 20.0 + it[5] = 55.0; it[6] = 60.0; it[7] = 55.0; it[8] = 40.0 + for (i in 9..14) it[i] = 20.0 + } + val spans = listOf(Span(0, 4), Span(10, 14)) + val merged = SpanUtils.mergeCloseSpans( + spans, maxGap = 5, sg = sg, gapPeakMinProm = 50.0, + ) + assertEquals("prom < threshold 면 병합되어야 함", 1, merged.size) + assertEquals(0, merged[0].start); assertEquals(14, merged[0].end) + } + + /** gap=0 (인접 span) 은 prom 체크 스킵 → 무조건 병합 (Python `gap > 0` 조건). */ + @Test + fun `prominence mode — gap zero always merges without check`() { + val sg = DoubleArray(10) { 0.0 } + val spans = listOf(Span(0, 4), Span(5, 9)) + val merged = SpanUtils.mergeCloseSpans( + spans, maxGap = 5, sg = sg, gapPeakMinProm = 50.0, + ) + assertEquals(1, merged.size) + assertEquals(0, merged[0].start); assertEquals(9, merged[0].end) + } + + /** + * Backward compat — `gapPeakThr` 모드는 유지 (DetectLumenFirst 등 legacy). + * spans = [(0..4), (7..11)], gap=[5..6], sg[5]=80, sg[6]=70 → mx=80 > 60 → skip merge. + * gap=2 이지만 default peakCheckMinGap=1 이라 발동. + */ + @Test + fun `absolute mode preserved for backward compatibility (default peakCheckMinGap=1)`() { + val sg = DoubleArray(12).also { + for (i in 0..4) it[i] = 20.0 + it[5] = 80.0; it[6] = 70.0 + for (i in 7..11) it[i] = 20.0 + } + val spans = listOf(Span(0, 4), Span(7, 11)) + val merged = SpanUtils.mergeCloseSpans( + spans, maxGap = 3, sg = sg, gapPeakThr = 60.0, + ) + assertEquals("absolute mode 유지 확인", 2, merged.size) + } + + /** 둘 다 주면 prom 이 승 (Python `use_prom = ... and not use_prom` 시맨틱). */ + @Test + fun `both params — prominence takes precedence`() { + val sg = DoubleArray(15).also { + for (i in 0..4) it[i] = 20.0 + it[5] = 55.0; it[6] = 60.0; it[7] = 55.0; it[8] = 40.0 + for (i in 9..14) it[i] = 20.0 + } + val spans = listOf(Span(0, 4), Span(10, 14)) + // prom threshold 는 낮게 (병합 유지 예상) — absolute 도 함께 주지만 무시되어야 함 + val merged = SpanUtils.mergeCloseSpans( + spans, maxGap = 5, sg = sg, + gapPeakThr = 30.0, // absolute 이면 mx=60 > 30 → skip 이 되었을 것 + gapPeakMinProm = 50.0, // prom 이면 (60-20)=40 < 50 → merge + ) + assertEquals("prom 이 우선 → 병합 결과", 1, merged.size) + } +}