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
}
}
@@ -157,30 +157,89 @@ class PiezoEchoAnalyzer private constructor() {
} }
} }
/** 다채널 TGC 파이프라인: denoise → TGC → 채널별 backReflection → suppress → detect */ /** 다채널 파이프라인 — Python pipeline.py detect_walls_multichannel 1:1 포팅
* denoise → TGC(전체 채널 통합) → initial detect → BR consensus → resolve → suppress → rerun → choose
*/
fun analyzeMultiChannelWithTgc(channelData: List<PiezoChannelData>): List<ChannelAnalysisResult> { fun analyzeMultiChannelWithTgc(channelData: List<PiezoChannelData>): List<ChannelAnalysisResult> {
// 1) SG denoise all channels
val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } } val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } }
val denoised = raws.map { denoise(it) } val denoised = raws.map { denoise(it) }
// 2) TGC per channel // TGC 전체 채널 통합 (Python preprocess_multichannel)
val tgcSignals = denoised.map { applyTgc(it) } val preprocessed = applyTgcMultichannel(denoised)
// 3) Per-channel: backReflection → suppress → detect (Python pipeline.py 동일) // 1) Initial detect + BR candidate
return channelData.mapIndexed { idx, ch -> val initialResults = mutableListOf<LowEchoResult?>()
val brCandidates = mutableListOf<Int?>()
for (i in channelData.indices) {
val r = detectLowEcho(raw = raws[i], denoised = preprocessed[i])
initialResults.add(r)
brCandidates.add(if (r != null) BackReflection.buildCandidate(preprocessed[i], r.post, postMaxIdx) else null)
}
// 2) Consensus + reflection_only
val sharedBrIdx = BackReflection.consensusIdx(brCandidates)
val wallPairs = initialResults.map { r -> if (r != null) Pair<Int?, Int?>(r.ant, r.post) else Pair(null, null) }
val reflectionOnly = BackReflection.isReflectionOnlyMultichannel(preprocessed, wallPairs, sharedIdx = sharedBrIdx)
// 3) Per-channel: resolve → suppress → rerun → choose
return channelData.mapIndexed { i, ch ->
try { try {
val raw = raws[idx] val r = initialResults[i]
val sg = denoised[idx] var suppressIdx: Int? = null
val br = findBackReflectionIdx(tgcSignals[idx], postMaxIdx)
val cleaned = suppressBackReflection(sg, br) if (r == null) {
val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = br) if (reflectionOnly && sharedBrIdx != null) suppressIdx = sharedBrIdx
ChannelAnalysisResult(ch.channel, result, raw, cleaned) else return@mapIndexed ChannelAnalysisResult(ch.channel, null, raws[i], preprocessed[i])
} else {
val resolved = BackReflection.resolveIndices(preprocessed[i], r.post, postMaxIdx, antIdx = r.ant, sharedIdx = sharedBrIdx, channelCandidate = brCandidates[i])
suppressIdx = resolved.suppressIdx
if (suppressIdx == null && reflectionOnly && sharedBrIdx != null) suppressIdx = sharedBrIdx
}
if (suppressIdx == null) {
return@mapIndexed ChannelAnalysisResult(ch.channel, r, raws[i], preprocessed[i])
}
val cleaned = BackReflection.suppress(preprocessed[i], suppressIdx)
val r2 = detectLowEcho(raw = raws[i], denoised = cleaned, otsuSignal = preprocessed[i], dynamicPostMax = suppressIdx)
val chosen = BackReflection.chooseResult(r, r2, brIdx = BackReflection.selectIdx(preprocessed[i], r?.post ?: 0, postMaxIdx, antIdx = r?.ant, sharedIdx = sharedBrIdx, channelCandidate = brCandidates[i]), suppressIdx = suppressIdx, minGap = 8)
ChannelAnalysisResult(ch.channel, chosen, raws[i], cleaned)
} catch (_: Exception) { } catch (_: Exception) {
ChannelAnalysisResult(ch.channel, null, raws[idx], denoised[idx]) ChannelAnalysisResult(ch.channel, null, raws[i], preprocessed[i])
} }
} }
} }
/** TGC 전체 채널 통합 처리 (Python preprocess_multichannel) */
fun applyTgcMultichannel(denoisedChannels: List<DoubleArray>): List<DoubleArray> {
if (denoisedChannels.isEmpty()) return denoisedChannels
val n = denoisedChannels[0].size
val nCh = denoisedChannels.size
// Log 압축 → 채널별 slope 계산
val slopes = DoubleArray(nCh)
for (ch in 0 until nCh) {
val logSig = DoubleArray(n) { kotlin.math.ln(max(denoisedChannels[ch][it], 1.0)) }
val x = DoubleArray(n) { it.toDouble() }
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 += logSig[i]; sumXY += x[i] * logSig[i]; sumX2 += x[i] * x[i] }
slopes[ch] = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
}
// 채널별 보상 적용
return denoisedChannels.mapIndexed { ch, signal ->
val slope = slopes[ch]
if (slope >= 0) return@mapIndexed signal.copyOf()
val absSlope = abs(slope)
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
val ratio = if (absSlope < slopeThresh) 1.0
else max(ratioMin, 1.0 - (1.0 - ratioMin) * (absSlope - slopeThresh) / (slopeMax - slopeThresh))
val targetSlope = slope * ratio
val x = DoubleArray(n) { it.toDouble() }
DoubleArray(n) { signal[it] + (targetSlope - slope) * x[it] }
}
}
/** 다채널 분석 → 최종 용적 */ /** 다채널 분석 → 최종 용적 */
fun analyzeMultiChannel(channelData: List<PiezoChannelData>): PiezoAnalysisResult { fun analyzeMultiChannel(channelData: List<PiezoChannelData>): PiezoAnalysisResult {
val results = channelData.map { ch -> val results = channelData.map { ch ->
@@ -339,45 +398,31 @@ class PiezoEchoAnalyzer private constructor() {
var lastOtsuThreshold: Double = 0.0; private set var lastOtsuThreshold: Double = 0.0; private set
var lastOtsuSeparability: Double = 0.0; private set var lastOtsuSeparability: Double = 0.0; private set
// Otsu 관련 상수 val otsuRatio: Double = 0.9
val otsuRatio: Double = 0.85
val minOtsuSeparability: Double = 0.3
val minScore: Double = 3000.0 val minScore: Double = 3000.0
val wallLowMeanMinRatio: Double = 1.15
/** 단일 1D 채널 → urine region 탐지 (otsuSignal: suppress 전 원본 SG로 Otsu 계산) */ /** 단일 1D 채널 → urine region 탐지 (Otsu-only, Python 동기화) */
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null): LowEchoResult? { fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null, lowEchoAmpOverride: Double? = null): LowEchoResult? {
val sg = denoised val sg = denoised
if (sg.size < 10) return null if (sg.size < 10) return null
val effectivePostMax = dynamicPostMax ?: postMaxIdx val effectivePostMax = dynamicPostMax ?: postMaxIdx
if (lowEchoAmpOverride != null) {
lastOtsuThreshold = lowEchoAmpOverride
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpOverride, effectivePostMax = effectivePostMax)
}
if (!useAdaptiveThreshold) { if (!useAdaptiveThreshold) {
lastOtsuThreshold = lowEchoAmpDefault lastOtsuThreshold = lowEchoAmpDefault
lastOtsuSeparability = 0.0
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax) return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
} }
// Otsu-only: 단일 시도, fallback 없음 (Python detect_low_echo_method_b 동일)
val otsuInput = otsuSignal ?: sg val otsuInput = otsuSignal ?: sg
val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size) val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size)
val adjustedThr = thr * otsuRatio lastOtsuThreshold = thr * otsuRatio
lastOtsuSeparability = sep lastOtsuSeparability = sep
return detectLowEchoCore(sg = sg, threshold = lastOtsuThreshold, effectivePostMax = effectivePostMax)
if (sep >= minOtsuSeparability) {
lastOtsuThreshold = adjustedThr
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
lastOtsuThreshold = lowEchoAmpDefault
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
} }
// ── TGC (Time Gain Compensation) ── // ── TGC (Time Gain Compensation) ──
@@ -525,22 +570,22 @@ class PiezoEchoAnalyzer private constructor() {
} }
/** /**
* Low-echo 탐지 핵심 — prominence 기반 wall peak 선택 + FP 필터 * Low-echo 탐지 핵심 — Python _detect_core 1:1 포팅 (비대칭 score + FP 필터)
*/ */
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? { private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? {
if (sg.size < 10) return null if (sg.size < 10) return null
// 1) low-echo span 추출 (전체 신호에 마스킹, Python 동일)
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold } val lowMask = BooleanArray(sg.size) { sg[it] <= threshold }
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen } val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
// 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음
val gapPeakThr = threshold + 5.0 val gapPeakThr = threshold + 5.0
val spans = mergeCloseSpansWithWallCheck(rawSpans, maxGap = mergeGapMax, signal = sg, gapPeakThr = gapPeakThr) val spans = mergeCloseSpansWithWallCheck(rawSpans, maxGap = mergeGapMax, signal = sg, gapPeakThr = gapPeakThr)
val firstSpan = spans.firstOrNull() ?: return null val firstSpan = spans.firstOrNull() ?: return null
val s = max(0, firstSpan.first) // refine_right_edge
val e = min(sg.size - 1, firstSpan.second) val (s0, e0) = refineRightEdge(rawSpans, firstSpan, lowMinLen)
val s = max(0, s0)
val e = min(sg.size - 1, e0)
if (s > e) return null if (s > e) return null
val lowSlice = safeSlice(sg, from = s, to = e) ?: return null val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
@@ -548,16 +593,18 @@ class PiezoEchoAnalyzer private constructor() {
val lowMean = lowSlice.sum() / lowSlice.size.toDouble() val lowMean = lowSlice.sum() / lowSlice.size.toDouble()
val peakMin = max(lowMean + minPeakMargin, threshold) val peakMin = max(lowMean + minPeakMargin, threshold)
// 3) Prominence 기반 wall peak 선택 // POST 먼저, ANT 나중 (Python 순서)
var ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.ANT, otherEdge = e)
// ant 없으면 span 시작 직전 (단조 감소 대응)
if (ant == null && s > 0) ant = s - 1
if (ant == null) return null
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin, var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
var ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.ANT, otherEdge = e)
// ant fallback: low-echo 오른쪽 경계와 post 사이 거리만큼 왼쪽 반사
if (ant == null && post != null) ant = max(0, s - (post - e))
if (ant == null && s > 0) ant = s - 1
if (ant == null) return null
// sg[ant] < threshold 또는 sg[post] < threshold → reject
if (sg[ant] < threshold || sg[post] < threshold) return null
// 4) post > effectivePostMax 재탐색
if (post > effectivePostMax) { if (post > effectivePostMax) {
val backHalfEdge = (s + e) / 2 val backHalfEdge = (s + e) / 2
post = selectWallByProminence( post = selectWallByProminence(
@@ -569,68 +616,79 @@ class PiezoEchoAnalyzer private constructor() {
val antH = sg[ant] val antH = sg[ant]
val postH = sg[post] val postH = sg[post]
val antProm = antH - findRightValley(sg, ant)
val postProm = postH - findLeftValley(sg, post)
val lowDepth = ((antH + postH) / 2.0) - lowMean
val urineLen = post - ant - 1 val urineLen = post - ant - 1
if (urineLen < minUrineLen) return null if (urineLen < minUrineLen) return null
// FP 필터: 벽/low 비율 // FP 필터: post prominence 최소
val wallMin = min(antH, postH) if (postProm < minPeakMargin) return null
if (lowMean > 0 && (wallMin / lowMean) < wallLowMeanMinRatio) return null
// 비대칭 score (ant_reliability)
val antDepth = max(0.0, antH - lowMean)
val postDepth = max(0.0, postH - lowMean)
val antReliability = (antProm / max(postProm, 1e-6)).coerceIn(0.25, 1.0)
val lowDepth = ((antReliability * antDepth) + postDepth) / (1.0 + antReliability)
val score = lowDepth * urineLen.toDouble() val score = lowDepth * urineLen.toDouble()
// FP 필터: score 최소
if (score < minScore) return null if (score < minScore) return null
return LowEchoResult( return LowEchoResult(
ant = ant, ant = ant, post = post,
post = post, lowStart = s, lowEnd = e, lowMean = lowMean,
lowStart = s, urineLen = urineLen, score = score,
lowEnd = e,
lowMean = lowMean,
urineLen = urineLen,
score = score,
innerPeaks = findInnerPeaks(sg, left = ant, right = post) innerPeaks = findInnerPeaks(sg, left = ant, right = post)
) )
} }
// ── Prominence-based Wall Selection ── /** 병합 span 우측 트리밍 (Python _refine_right_edge_from_merged_components) */
private fun refineRightEdge(rawSpans: List<Pair<Int, Int>>, mergedSpan: Pair<Int, Int>, lowMinLen: Int): Pair<Int, Int> {
val (s, e) = mergedSpan
val components = rawSpans.filter { it.first >= s && it.second <= e }
if (components.size <= 1) return mergedSpan
val (firstS, firstE) = components[0]
val (secondS, _) = components[1]
val spanLen = e - s + 1
if ((secondS - s) >= (spanLen / 2) && (firstE - s + 1) >= lowMinLen) return Pair(firstS, firstE)
return mergedSpan
}
// ── Prominence-based Wall Selection (Python 동기화: ANT=nearest, POST=edge_score) ──
private val maxPeakCandidates = 3 private val maxPeakCandidates = 3
private enum class WallSide { ANT, POST } private enum class WallSide { ANT, POST }
/** peak 오른쪽에서 가장 가까운 valley의 sg 값 */ /** valley with descending-first logic (Python _find_right_valley) */
private fun findRightValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double { private fun findRightValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
val n = sg.size val n = sg.size
var v = sg[peakIdx] var v = sg[peakIdx]
for (i in (peakIdx + 1) until min(n, peakIdx + maxDist)) { var descending = false
if (sg[i] < v) { for (i in (peakIdx + 1) until min(n, peakIdx + maxDist + 1)) {
v = sg[i] val cur = sg[i]
} else if (sg[i] > v + valleyStopRise) { if (cur <= v) { v = cur; descending = true; continue }
break if (descending) break
} if (cur > v + valleyStopRise) break
} }
return v return v
} }
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double { private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
var v = sg[peakIdx] var v = sg[peakIdx]
var descending = false
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) { for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
if (sg[i] < v) { val cur = sg[i]
v = sg[i] if (cur <= v) { v = cur; descending = true; continue }
} else if (sg[i] > v + valleyStopRise) { if (descending) break
break if (cur > v + valleyStopRise) break
}
} }
return v return v
} }
/** /**
* edge 양쪽에서 prominence 최대인 wall peak 선택 * Wall peak 선택 — Python _select_wall_by_prominence 1:1 포팅
* 1:1 port of _select_wall_by_prominence() * ANT: 가장 가까운 outer peak (prominence는 tie-breaker)
* POST: edge_score = prom / (1 + decay * dist)
*/ */
private fun selectWallByProminence( private fun selectWallByProminence(
sg: DoubleArray, edge: Int, searchWin: Int, sg: DoubleArray, edge: Int, searchWin: Int,
@@ -639,71 +697,71 @@ class PiezoEchoAnalyzer private constructor() {
val n = sg.size val n = sg.size
if (n <= 0 || edge < 0 || edge >= n) return null if (n <= 0 || edge < 0 || edge >= n) return null
// 검색 범위 결정
val leftLo: Int val leftLo: Int
val rightHi: Int val rightHi: Int
if (side == WallSide.ANT) { if (side == WallSide.ANT) {
leftLo = max(0, edge - searchWin) // 바깥 (자유) leftLo = max(0, edge - searchWin)
var rh = min(n - 1, edge + searchWin) // 안쪽 var rh = min(n - 1, edge + searchWin)
if (otherEdge != null) rh = min(rh, otherEdge) // e를 넘지 않음 if (otherEdge != null) rh = min(rh, otherEdge)
rightHi = rh rightHi = rh
} else { } else {
var ll = max(0, edge - searchWin) // 안쪽 var ll = max(0, edge - searchWin)
if (otherEdge != null) ll = max(ll, otherEdge) // s를 넘지 않음 if (otherEdge != null) ll = max(ll, otherEdge)
leftLo = ll leftLo = ll
rightHi = min(n - 1, edge + searchWin) // 바깥 (자유) rightHi = min(n - 1, edge + searchWin)
} }
// edge 왼쪽 peak 후보 — edge+1 포함 (inclusive boundary)
var leftCandidates = listOf<Int>() var leftCandidates = listOf<Int>()
val leftEnd = min(edge + 1, n) if (edge > leftLo) {
if (leftEnd > leftLo) { val leftEnd = min(edge + 1, n)
val seg = safeSlice(sg, from = leftLo, to = leftEnd - 1) val seg = safeSlice(sg, from = leftLo, to = leftEnd - 1)
if (seg != null) { if (seg != null) {
val pks = findPeaks1D(seg) val pks = findPeaks1D(seg)
val global = pks.map { it + leftLo } leftCandidates = pks.map { it + leftLo }.filter { it < edge }.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
.filter { it < edge && sg[it] >= peakMin }
leftCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
} }
} }
// edge 오른쪽 peak 후보 — edge-1부터 시작 (inclusive boundary)
var rightCandidates = listOf<Int>() var rightCandidates = listOf<Int>()
val rightStart = max(edge - 1, 0) if (rightHi >= edge) {
if (rightHi >= rightStart) { val rightStart = max(edge - 1, 0)
val seg = safeSlice(sg, from = rightStart, to = rightHi) val seg = safeSlice(sg, from = rightStart, to = rightHi)
if (seg != null) { if (seg != null) {
val pks = findPeaks1D(seg) val pks = findPeaks1D(seg)
val global = pks.map { it + rightStart } rightCandidates = pks.map { it + rightStart }.filter { it >= edge }.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
.filter { it >= edge && sg[it] >= peakMin }
rightCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
} }
} }
val candidates = leftCandidates + rightCandidates // Score candidates
if (candidates.isEmpty()) return null data class Scored(val p: Int, val prom: Double, val dist: Int, val sidePref: Int, val edgeScore: Double)
fun scoreGroup(group: List<Int>, groupName: String): List<Scored> {
// prominence + edge distance penalty (EDGE_DIST_DECAY=0.12) return group.mapNotNull { p ->
var bestPeak: Int? = null val valley = if (side == WallSide.ANT) findRightValley(sg, p) else findLeftValley(sg, p)
var bestScore = -1.0 val prom = sg[p] - valley
if (sg[p] < peakMin) return@mapNotNull null
for (p in candidates) { val dist = abs(p - edge)
val valley: Double = if (side == WallSide.ANT) { val sidePref = if (groupName == "outer") 0 else 1
findRightValley(sg, peakIdx = p) val es = prom / (1.0 + edgeDistDecay * dist)
} else { Scored(p, prom, dist, sidePref, es)
findLeftValley(sg, peakIdx = p)
}
val prom = sg[p] - valley
val dist = abs(p - edge)
val score = prom / (1.0 + edgeDistDecay * dist)
if (score > bestScore) {
bestScore = score
bestPeak = p
} }
} }
return bestPeak val (outerCands, innerCands) = if (side == WallSide.ANT)
Pair(leftCandidates, rightCandidates) else Pair(rightCandidates, leftCandidates)
val outerScored = scoreGroup(outerCands, "outer")
val innerScored = scoreGroup(innerCands, "inner")
// ANT: 가장 가까운 outer peak (distance 우선, prominence tie-break)
if (side == WallSide.ANT) {
if (outerScored.isNotEmpty()) {
return outerScored.sortedWith(compareBy<Scored> { it.dist }.thenByDescending { it.prom }.thenBy { it.p }).first().p
}
return null
}
// POST: edge_score 최대
val allScored = outerScored + innerScored
if (allScored.isEmpty()) return null
return allScored.sortedWith(compareByDescending<Scored> { it.edgeScore }.thenByDescending { it.prom }.thenBy { it.dist }.thenBy { it.sidePref }.thenBy { it.p }).first().p
} }
// ── Span Utilities ── // ── Span Utilities ──