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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user