fix: Python 대조 CRITICAL 10건 수정 (Method A/B 완전 동기화)

Method B:
- #1 Otsu: limit 제거 → 전체 신호로 계산
- #2 detectLowEcho: 2단계 fallback (Otsu→None이면 고정1250 재시도)
- #3 LOW_ECHO_AMP: 1150 → 1250
- #4 low mask: analysisLimit 제한 제거 → 전체 신호 마스킹
- #5 postH >= threshold 체크 제거 (Python에 없음)
- #6 merge gap < 3: 무조건 병합 (peak_check_min_gap=3)
- #7 TGC: raw fit → log 압축 후 fit (Python fit_attenuation_lines 동일)
- #8 Back reflection: 다채널 consensus → 채널별 개별 (Python pipeline.py 동일)

Method A:
- #9 sliding_scores_1d low quantile: floor index → np.quantile 선형 보간
- #10 score_thr: 중간값 근사 → np.quantile(0.5) 선형 보간
- npQuantile() 헬퍼 함수 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 14:33:58 +09:00
parent 5affcde3c4
commit e528bc6761
4 changed files with 49 additions and 33 deletions
@@ -42,7 +42,7 @@ object GreenZoneConstants {
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
* Python 원본: 1150 (low_echo_detection_method_b.py LOW_ECHO_AMP, 6ch 버전)
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
var lowEchoAmp: Float = 1150f
var lowEchoAmp: Float = 1250f
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
@@ -157,7 +157,7 @@ class PiezoEchoAnalyzer private constructor() {
}
}
/** 다채널 TGC 파이프라인: denoise → TGC → findBackReflection → suppress → detect */
/** 다채널 TGC 파이프라인: denoise → TGC → 채널별 backReflection → suppress → detect */
fun analyzeMultiChannelWithTgc(channelData: List<PiezoChannelData>): List<ChannelAnalysisResult> {
// 1) SG denoise all channels
val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } }
@@ -166,16 +166,14 @@ class PiezoEchoAnalyzer private constructor() {
// 2) TGC per channel
val tgcSignals = denoised.map { applyTgc(it) }
// 3) Multi-channel back reflection consensus
val brIdx = findBackReflectionMultichannel(tgcSignals, postMaxIdx)
// 4) Per-channel: suppress → detect
// 3) Per-channel: backReflection → suppress → detect (Python pipeline.py 동일)
return channelData.mapIndexed { idx, ch ->
try {
val raw = raws[idx]
val sg = denoised[idx]
val cleaned = suppressBackReflection(sg, brIdx)
val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = brIdx)
val br = findBackReflectionIdx(tgcSignals[idx], postMaxIdx)
val cleaned = suppressBackReflection(sg, br)
val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = br)
ChannelAnalysisResult(ch.channel, result, raw, cleaned)
} catch (_: Exception) {
ChannelAnalysisResult(ch.channel, null, raws[idx], denoised[idx])
@@ -361,14 +359,20 @@ class PiezoEchoAnalyzer private constructor() {
}
val otsuInput = otsuSignal ?: sg
val limit = min(otsuInput.size, effectivePostMax + 1)
val (thr, sep) = otsu1dWithSeparability(otsuInput, limit)
val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size)
val adjustedThr = thr * otsuRatio
lastOtsuSeparability = sep
if (sep >= minOtsuSeparability) {
lastOtsuThreshold = adjustedThr
return detectLowEchoCore(sg = sg, threshold = adjustedThr, effectivePostMax = effectivePostMax)
val result = detectLowEchoCore(sg = sg, threshold = adjustedThr, effectivePostMax = effectivePostMax)
if (result != null) return result
// Otsu 결과 None → 고정 threshold로 재시도 (|otsu - fixed| > 1.0일 때만)
if (abs(adjustedThr - lowEchoAmpDefault) > 1.0) {
lastOtsuThreshold = lowEchoAmpDefault
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
}
return null
}
// Separability 낮음 → 고정 threshold fallback
@@ -382,13 +386,14 @@ class PiezoEchoAnalyzer private constructor() {
val n = signal.size
if (n < 5) return signal.copyOf()
// Log 압축 후 linear fit (Python fit_attenuation_lines 동일)
val logSignal = DoubleArray(n) { kotlin.math.ln(max(signal[it], 1.0)) }
val x = DoubleArray(n) { it.toDouble() }
// Linear fit: slope, intercept
var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumX2 = 0.0
for (i in 0 until n) { sumX += x[i]; sumY += signal[i]; sumXY += x[i] * signal[i]; sumX2 += x[i] * x[i] }
for (i in 0 until n) { sumX += x[i]; sumY += logSignal[i]; sumXY += x[i] * logSignal[i]; sumX2 += x[i] * x[i] }
val slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
if (slope >= 0) return signal.copyOf() // 감쇠 없으면 보상 불필요
if (slope >= 0) return signal.copyOf()
val absSlope = abs(slope)
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
@@ -525,9 +530,8 @@ class PiezoEchoAnalyzer private constructor() {
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? {
if (sg.size < 10) return null
// 1) low-echo span 추출 (effectivePostMax까지)
val analysisLimit = min(sg.size, effectivePostMax + 1)
val lowMask = BooleanArray(sg.size) { it < analysisLimit && sg[it] <= threshold }
// 1) low-echo span 추출 (전체 신호에 마스킹, Python 동일)
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold }
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
// 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음
@@ -536,7 +540,7 @@ class PiezoEchoAnalyzer private constructor() {
val firstSpan = spans.firstOrNull() ?: return null
val s = max(0, firstSpan.first)
val e = min(analysisLimit - 1, firstSpan.second)
val e = min(sg.size - 1, firstSpan.second)
if (s > e) return null
val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
@@ -553,12 +557,12 @@ class PiezoEchoAnalyzer private constructor() {
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
// 4) post가 분석 범위를 넘으면 재탐색
if (post >= analysisLimit) {
// 4) post > effectivePostMax 재탐색
if (post > effectivePostMax) {
val backHalfEdge = (s + e) / 2
post = selectWallByProminence(
sg = sg, edge = backHalfEdge,
searchWin = analysisLimit - 1 - backHalfEdge,
searchWin = effectivePostMax - backHalfEdge,
peakMin = peakMin, side = WallSide.POST, otherEdge = null
) ?: return null
}
@@ -566,8 +570,6 @@ class PiezoEchoAnalyzer private constructor() {
val antH = sg[ant]
val postH = sg[post]
if (postH < threshold) return null
val lowDepth = ((antH + postH) / 2.0) - lowMean
val urineLen = post - ant - 1
@@ -766,9 +768,9 @@ class PiezoEchoAnalyzer private constructor() {
val prevEnd = merged[merged.size - 1].second
val gap = s - prevEnd - 1
if (gap <= maxGap) {
// Check: gap 내부에 벽 후보 peak이 있는지
// gap < 3은 무조건 병합 (Python peak_check_min_gap=3)
var hasWallPeak = false
if (signal != null && gapPeakThr > 0) {
if (gap >= 3 && signal != null && gapPeakThr > 0) {
val gapStart = prevEnd + 1
val gapEnd = s - 1
if (gapStart <= gapEnd) {
@@ -68,13 +68,9 @@ class PiezoEchoAnalyzerA private constructor() {
if (L < 10) return null
val y = DoubleArray(L) { sg[it] + alpha * abs(d1[it]) + beta * abs(d2[it]) }
// 2) Plateau score — threshold from full array (Python 동일)
// 2) Plateau score — threshold from full array (np.quantile 보간)
val platScore = slidingScores1d(sg, scoreWin)
val fullSorted = platScore.sorted().toDoubleArray()
val qIdx = (fullSorted.size * plateauQ).toInt().coerceIn(0, fullSorted.size - 1)
val scoreThr = if (qIdx > 0 && qIdx < fullSorted.size - 1) {
(fullSorted[qIdx] + fullSorted[qIdx + 1]) / 2.0
} else fullSorted[qIdx]
val scoreThr = npQuantile(platScore, plateauQ)
val limit = min(platScore.size, postMaxIdx + 1)
// 3) Plateau spans + merge (postMaxIdx 이내만)
@@ -186,8 +182,8 @@ class PiezoEchoAnalyzerA private constructor() {
val mean = sum / w
flat[i] = sqrt(max(0.0, sqSum / w - mean * mean))
val sorted = ww.sorted().toDoubleArray()
low[i] = sorted[(w * 0.2).toInt().coerceIn(0, w - 1)]
val sorted = ww.sorted()
low[i] = npQuantile(sorted, 0.2)
var slopeNum = 0.0
for (j in 0 until w) {
@@ -283,6 +279,23 @@ class PiezoEchoAnalyzerA private constructor() {
return corrected
}
// ── np.quantile 보간 (linear interpolation, numpy default) ──
private fun npQuantile(data: DoubleArray, q: Double): Double {
if (data.isEmpty()) return 0.0
val sorted = data.sorted().toDoubleArray()
return npQuantile(sorted.toList(), q)
}
private fun npQuantile(sorted: List<Double>, q: Double): Double {
if (sorted.isEmpty()) return 0.0
val n = sorted.size
val idx = q * (n - 1)
val lo = idx.toInt().coerceIn(0, n - 1)
val hi = (lo + 1).coerceAtMost(n - 1)
val frac = idx - lo
return sorted[lo] + frac * (sorted[hi] - sorted[lo])
}
// ── Peak / Valley helpers ──
private fun findPeaksInRange(sg: DoubleArray, from: Int, to: Int): List<Int> {