package com.example.medilightv2android.managers import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt /** * 후면반사 탐지+제거 시스템 — Python span_utils.py + pipeline.py 1:1 포팅 * * 흐름: build_candidate → consensus → is_reflection_only → resolve → suppress → rerun → choose */ object BackReflection { private const val MIN_BACK_REFLECTION_PROM = 50.0 private const val POST_MATCH_TOL = 3 private const val OUTLIER_TOL = 4 private const val SHARED_TOL = 3 private const val MIN_CHANNELS = 3 private const val RADIUS = 6 private const val EXTEND = 20 // ── Valley helpers (50.0 rise early-stop) ── private fun findLeftValleyValue(x: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double { var v = x[peakIdx] for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) { val cur = x[i] if (cur < v) v = cur else if (cur > v + 50.0) break } return v } private fun findRightValleyValue(x: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double { var v = x[peakIdx] for (i in (peakIdx + 1) until min(x.size, peakIdx + maxDist + 1)) { val cur = x[i] if (cur < v) v = cur else if (cur > v + 50.0) break } return v } // ── Strongest significant peak ── private fun strongestSignificantPeak(x: DoubleArray, lo: Int, hi: Int, minProm: Double = MIN_BACK_REFLECTION_PROM): Int? { if (lo >= hi || lo < 0 || hi > x.size) return null val seg: DoubleArray = x.sliceArray(lo until hi) val peaks: List = PiezoEchoAnalyzer.shared.findPeaks1D(seg) if (peaks.isEmpty()) return null var bestIdx: Int? = null var bestAmp = Double.NEGATIVE_INFINITY for (p: Int in peaks) { val gp = p + lo val leftV = findLeftValleyValue(x, gp) val rightV = findRightValleyValue(x, gp) val prom = x[gp] - min(leftV, rightV) if (prom < minProm) continue if (x[gp] > bestAmp) { bestAmp = x[gp]; bestIdx = gp } } return bestIdx } // ── find_back_reflection_idx (legacy, 전체 범위) ── fun findBackReflectionIdx(tgcSg: DoubleArray, postMaxIdx: Int, minSearchIdx: Int = 40, extend: Int = EXTEND): Int { val n = tgcSg.size val searchEnd = min(n, postMaxIdx + extend) if (minSearchIdx >= searchEnd) return postMaxIdx val seg: DoubleArray = tgcSg.sliceArray(minSearchIdx until searchEnd) val peaks: List = PiezoEchoAnalyzer.shared.findPeaks1D(seg) if (peaks.isEmpty()) return postMaxIdx val globalPeaks: List = peaks.map { p -> p + minSearchIdx } return globalPeaks.maxByOrNull { idx -> tgcSg[idx] } ?: postMaxIdx } // ── find_back_reflection_after_post ── fun findBackReflectionAfterPost(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, extend: Int = EXTEND): Int? { val searchStart = postIdx + 1 val searchEnd = min(tgcSg.size, postMaxIdx + extend) if (searchStart >= searchEnd) return null return strongestSignificantPeak(tgcSg, searchStart, searchEnd) } // ── find_back_reflection_near_idx ── fun findBackReflectionNearIdx(tgcSg: DoubleArray, centerIdx: Int, postIdx: Int, postMaxIdx: Int, radius: Int = RADIUS, extend: Int = EXTEND): Int? { val n = tgcSg.size val lo = max(postIdx + 1, centerIdx - radius) val hi = min(n, min(postMaxIdx + extend, centerIdx + radius + 1)) if (lo >= hi) return null return strongestSignificantPeak(tgcSg, lo, hi) } // ── build_back_reflection_candidate ── fun buildCandidate(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, postMatchTol: Int = POST_MATCH_TOL): Int? { val globalBr = findBackReflectionIdx(tgcSg, postMaxIdx) if (abs(globalBr - postIdx) <= postMatchTol) return null // 후위벽≈전체BR → 별도 BR 없음 return findBackReflectionAfterPost(tgcSg, postIdx, postMaxIdx) } // ── consensus_back_reflection_idx ── private const val MIN_VOTES = 3 private const val AMP_RATIO = 1.5 fun consensusIdx(candidates: List, outlierTol: Int = OUTLIER_TOL, minVotes: Int = MIN_VOTES): Int? { val vals = candidates.filterNotNull() if (vals.size < minVotes) return null val sorted = vals.sorted() val med = sorted[sorted.size / 2].toDouble() val inliers = vals.filter { abs(it - med) <= outlierTol } val finalVals = inliers.ifEmpty { vals } val finalSorted = finalVals.sorted() return finalSorted[finalSorted.size / 2] } // ── is_suspicious_post_peak ── fun isSuspiciousPostPeak(tgcSg: DoubleArray, postIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, sharedTol: Int = 2, ampRatio: Double = AMP_RATIO): Boolean { val n = tgcSg.size if (postIdx < 0 || postIdx >= n) return false var suspicious = false if (antIdx != null && antIdx in 0 until n && tgcSg[postIdx] > tgcSg[antIdx] * ampRatio) suspicious = true if (sharedIdx != null && abs(postIdx - sharedIdx) <= sharedTol) suspicious = true return suspicious } // ── select_back_reflection_idx ── fun selectIdx(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, channelCandidate: Int? = null): Int? { val suspiciousPost = isSuspiciousPostPeak(tgcSg, postIdx, antIdx = antIdx, sharedIdx = sharedIdx) var local: Int? = null if (sharedIdx != null) { local = findBackReflectionNearIdx(tgcSg, sharedIdx, postIdx, postMaxIdx) } if (suspiciousPost && sharedIdx != null) { val postDist = abs(postIdx - sharedIdx) val localDist = local?.let { abs(it - sharedIdx) } val candDist = channelCandidate?.let { abs(it - sharedIdx) } var nearestOther: Int? = localDist if (candDist != null) nearestOther = if (nearestOther == null) candDist else min(nearestOther, candDist) if (nearestOther == null || postDist < nearestOther) return postIdx } if (local != null) return local if (channelCandidate != null) return channelCandidate if (suspiciousPost) return postIdx return null } // ── resolve_back_reflection_indices ── data class ResolveResult(val brIdx: Int?, val suppressIdx: Int?) fun resolveIndices(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, channelCandidate: Int? = null): ResolveResult { val br = selectIdx(tgcSg, postIdx, postMaxIdx, antIdx = antIdx, sharedIdx = sharedIdx, channelCandidate = channelCandidate) ?: return ResolveResult(null, null) val suppressIdx = sharedIdx ?: br return ResolveResult(br, suppressIdx) } // ── is_reflection_only_multichannel ── fun isReflectionOnlyMultichannel( tgcSignals: List, wallPairs: List>, sharedIdx: Int? = null, sharedTol: Int = SHARED_TOL, minChannels: Int = MIN_CHANNELS, ampRatio: Double = AMP_RATIO ): Boolean { data class Valid(val i: Int, val ant: Int, val post: Int) val valid = wallPairs.mapIndexedNotNull { i, (ant, post) -> if (ant != null && post != null) Valid(i, ant, post) else null } if (valid.size < minChannels) return false val ratio = ampRatio // 조건 A: shared_idx 기반 var effectiveShared = sharedIdx if (effectiveShared == null) { val postVals = valid.map { it.post }.sorted() val med = postVals[postVals.size / 2].toDouble() val inliers = valid.filter { abs(it.post - med) <= sharedTol } if (inliers.size >= minChannels) effectiveShared = med.roundToInt() } if (effectiveShared != null) { if (valid.all { abs(it.post - effectiveShared!!) <= sharedTol && tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] * ratio }) return true } // 조건 B: post 클러스터 val postVals = valid.map { it.post }.sorted() val medPost = postVals[postVals.size / 2].toDouble() val inlierTriples = valid.filter { abs(it.post - medPost) <= sharedTol } if (inlierTriples.size >= minChannels) { if (inlierTriples.all { tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] * ratio }) return true } return false } // ── choose_rerun_or_initial_result ── fun chooseResult( initial: LowEchoResult?, rerun: LowEchoResult?, brIdx: Int?, suppressIdx: Int?, minGap: Int, minBrSep: Int = 0, minInitialUrineLen: Int = 0 ): LowEchoResult? { if (rerun != null) return rerun if (canFallbackToInitial(initial, brIdx, suppressIdx, minGap, minBrSep, minInitialUrineLen)) return initial return null } private fun canFallbackToInitial(initial: LowEchoResult?, brIdx: Int?, suppressIdx: Int?, minGap: Int, minBrSep: Int, minInitialUrineLen: Int): Boolean { if (initial == null || brIdx == null || suppressIdx == null) return false if (abs(brIdx - initial.post) < minBrSep) return false if ((suppressIdx - initial.post) < minGap) return false if (initial.urineLen < minInitialUrineLen) return false return true } // ── suppress_back_reflection (valley early-stop 50.0) ── fun suppress(sg: DoubleArray, backRefIdx: Int, searchMargin: Int = 10): DoubleArray { val cleaned = sg.copyOf() val n = sg.size if (backRefIdx >= n) return cleaned val lo = max(0, backRefIdx - min(searchMargin, 3)) val hi = min(n, backRefIdx + searchMargin + 1) var actualPeak = lo for (i in lo until hi) if (sg[i] > sg[actualPeak]) actualPeak = i // Left valley with 50.0 rise early-stop var leftValley = actualPeak var leftBest = sg[actualPeak] for (i in (actualPeak - 1) downTo max(0, actualPeak - searchMargin)) { val cur = sg[i] if (cur < leftBest) { leftBest = cur; leftValley = i } else if (cur > leftBest + 50.0) break } // Right valley with 50.0 rise early-stop var rightValley = actualPeak var rightBest = sg[actualPeak] for (i in (actualPeak + 1) until min(n, actualPeak + searchMargin + 1)) { val cur = sg[i] if (cur < rightBest) { rightBest = cur; rightValley = i } else if (cur > rightBest + 50.0) break } if (rightValley > leftValley) { val leftVal = sg[leftValley] val rightVal = sg[rightValley] val length = rightValley - leftValley for (i in 0..length) { cleaned[leftValley + i] = leftVal + (rightVal - leftVal) * i.toDouble() / length } } return cleaned } }