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 {