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:
@@ -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
|
||||
|
||||
|
||||
+119
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user