3370fd8087
BackReflection.kt (Python 동기화): - consensusIdx: min_votes=3 (후보 3개 미만이면 None) - buildCandidate: post≈global_br이면 None 반환 (post를 BR로 판단 안함) - isSuspiciousPostPeak: post>ant → post>ant×1.5 (amp_ratio=1.5) - isReflectionOnlyMultichannel: post>ant → post>ant×1.5 BV Estimation: - 1채널 구(sphere) 계산 추가 (Python _single_channel_bv) - V = (4/3)πR³, R = D/2 (cos 보정) - centerWallCount >= 1이면 BV 계산 (기존 3 → 1) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
269 lines
11 KiB
Kotlin
269 lines
11 KiB
Kotlin
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 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<Int?>, 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<DoubleArray>,
|
|
wallPairs: List<Pair<Int?, Int?>>,
|
|
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
|
|
}
|
|
}
|