From e528bc67619ffd70d341c4d9d8964c62b4c9fbda Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 30 Apr 2026 14:33:58 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20Python=20=EB=8C=80=EC=A1=B0=20CRITICAL?= =?UTF-8?q?=2010=EA=B1=B4=20=EC=88=98=EC=A0=95=20(Method=20A/B=20=EC=99=84?= =?UTF-8?q?=EC=A0=84=20=EB=8F=99=EA=B8=B0=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 1 + .../managers/GreenZoneConstants.kt | 2 +- .../managers/PiezoEchoAnalyzer.kt | 50 ++++++++++--------- .../managers/PiezoEchoAnalyzerA.kt | 29 ++++++++--- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 4c943ba..494f81b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /local.properties /.idea/ .DS_Store +/docs/ /build /captures .externalNativeBuild diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index 1709650..6539df0 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -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). * 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대. diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt index fba5f2e..063c49a 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt @@ -157,7 +157,7 @@ class PiezoEchoAnalyzer private constructor() { } } - /** 다채널 TGC 파이프라인: denoise → TGC → findBackReflection → suppress → detect */ + /** 다채널 TGC 파이프라인: denoise → TGC → 채널별 backReflection → suppress → detect */ fun analyzeMultiChannelWithTgc(channelData: List): List { // 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) { diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzerA.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzerA.kt index 3a99eaa..d2209fd 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzerA.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzerA.kt @@ -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, 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 {