feat: Python 알고리즘 대규모 동기화 — 후면반사 재작성 + detect_core + wall selection

BackReflection.kt (신규):
- 합의 기반 다채널 BR 시스템 (Python pipeline.py 1:1)
- buildCandidate: post 뒤 strongest peak 또는 post 자체
- consensusIdx: outlier 제거 후 median
- isReflectionOnlyMultichannel: 조건A(shared_idx) + 조건B(post 클러스터)
- isSuspiciousPostPeak: post>ant 또는 shared 근접
- selectIdx + resolveIndices: 최종 BR/suppress 위치 결정
- chooseResult: rerun vs initial fallback 로직
- suppress: valley early-stop 50.0 (over-erasure 방지)

PiezoEchoAnalyzer.kt:
- Otsu: separability/fallback 제거, OTSU_RATIO 0.85→0.9, Otsu-only 단일 시도
- detectLowEchoCore: 비대칭 score (ant_reliability × ant_depth + post_depth)
  - FP: postProm < minPeakMargin reject
  - FP: sg[ant/post] < threshold reject
  - wallLowMeanMinRatio 제거
- selectWallByProminence: ANT=nearest outer peak, POST=edge_score
- findRightValley/findLeftValley: descending-first logic (Python 동일)
- refineRightEdge: 병합 span 우측 트리밍
- POST 먼저 찾고 ANT는 반사 fallback (post-e 거리 대칭)
- analyzeMultiChannelWithTgc: Python pipeline.py 전체 흐름
  - TGC 전체 채널 통합 (applyTgcMultichannel)
  - initial detect → BR candidate → consensus → reflection_only
  - resolve → suppress → rerun → choose
- LOW_ECHO_AMP: 1250 (config_6ch 동일)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 16:34:18 +09:00
parent 0a8ae91409
commit 30c1d4d8db
2 changed files with 441 additions and 119 deletions
@@ -0,0 +1,264 @@
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<Int> = 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<Int> = PiezoEchoAnalyzer.shared.findPeaks1D(seg)
if (peaks.isEmpty()) return postMaxIdx
val globalPeaks: List<Int> = 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 postIdx
return findBackReflectionAfterPost(tgcSg, postIdx, postMaxIdx)
}
// ── consensus_back_reflection_idx ──
fun consensusIdx(candidates: List<Int?>, outlierTol: Int = OUTLIER_TOL): Int? {
val vals = candidates.filterNotNull()
if (vals.isEmpty()) return null
if (vals.size == 1) return vals[0]
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): 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]) 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<DoubleArray>,
wallPairs: List<Pair<Int?, Int?>>,
sharedIdx: Int? = null,
sharedTol: Int = SHARED_TOL,
minChannels: Int = MIN_CHANNELS
): 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
// 조건 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] }) 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] }) 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
}
}