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
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
// 1) SG denoise all channels
|
||||
val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } }
|
||||
val denoised = raws.map { denoise(it) }
|
||||
|
||||
// 2) TGC per channel
|
||||
val tgcSignals = denoised.map { applyTgc(it) }
|
||||
// TGC 전체 채널 통합 (Python preprocess_multichannel)
|
||||
val preprocessed = applyTgcMultichannel(denoised)
|
||||
|
||||
// 3) Per-channel: backReflection → suppress → detect (Python pipeline.py 동일)
|
||||
return channelData.mapIndexed { idx, ch ->
|
||||
// 1) Initial detect + BR candidate
|
||||
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 {
|
||||
val raw = raws[idx]
|
||||
val sg = denoised[idx]
|
||||
val br = findBackReflectionIdx(tgcSignals[idx], postMaxIdx)
|
||||
val cleaned = suppressBackReflection(sg, br)
|
||||
val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = br)
|
||||
ChannelAnalysisResult(ch.channel, result, raw, cleaned)
|
||||
val r = initialResults[i]
|
||||
var suppressIdx: Int? = null
|
||||
|
||||
if (r == null) {
|
||||
if (reflectionOnly && sharedBrIdx != null) suppressIdx = sharedBrIdx
|
||||
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) {
|
||||
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 {
|
||||
val results = channelData.map { ch ->
|
||||
@@ -339,45 +398,31 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
var lastOtsuThreshold: Double = 0.0; private set
|
||||
var lastOtsuSeparability: Double = 0.0; private set
|
||||
|
||||
// Otsu 관련 상수
|
||||
val otsuRatio: Double = 0.85
|
||||
val minOtsuSeparability: Double = 0.3
|
||||
val otsuRatio: Double = 0.9
|
||||
val minScore: Double = 3000.0
|
||||
val wallLowMeanMinRatio: Double = 1.15
|
||||
|
||||
/** 단일 1D 채널 → urine region 탐지 (otsuSignal: suppress 전 원본 SG로 Otsu 계산) */
|
||||
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null): LowEchoResult? {
|
||||
/** 단일 1D 채널 → urine region 탐지 (Otsu-only, Python 동기화) */
|
||||
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null, lowEchoAmpOverride: Double? = null): LowEchoResult? {
|
||||
val sg = denoised
|
||||
if (sg.size < 10) return null
|
||||
|
||||
val effectivePostMax = dynamicPostMax ?: postMaxIdx
|
||||
|
||||
if (lowEchoAmpOverride != null) {
|
||||
lastOtsuThreshold = lowEchoAmpOverride
|
||||
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpOverride, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
|
||||
if (!useAdaptiveThreshold) {
|
||||
lastOtsuThreshold = lowEchoAmpDefault
|
||||
lastOtsuSeparability = 0.0
|
||||
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
|
||||
// Otsu-only: 단일 시도, fallback 없음 (Python detect_low_echo_method_b 동일)
|
||||
val otsuInput = otsuSignal ?: sg
|
||||
val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size)
|
||||
val adjustedThr = thr * otsuRatio
|
||||
lastOtsuThreshold = thr * otsuRatio
|
||||
lastOtsuSeparability = sep
|
||||
|
||||
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)
|
||||
return detectLowEchoCore(sg = sg, threshold = lastOtsuThreshold, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
|
||||
// ── 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? {
|
||||
if (sg.size < 10) return null
|
||||
|
||||
// 1) low-echo span 추출 (전체 신호에 마스킹, Python 동일)
|
||||
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold }
|
||||
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
|
||||
|
||||
// 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음
|
||||
val gapPeakThr = threshold + 5.0
|
||||
val spans = mergeCloseSpansWithWallCheck(rawSpans, maxGap = mergeGapMax, signal = sg, gapPeakThr = gapPeakThr)
|
||||
val firstSpan = spans.firstOrNull() ?: return null
|
||||
|
||||
val s = max(0, firstSpan.first)
|
||||
val e = min(sg.size - 1, firstSpan.second)
|
||||
// refine_right_edge
|
||||
val (s0, e0) = refineRightEdge(rawSpans, firstSpan, lowMinLen)
|
||||
val s = max(0, s0)
|
||||
val e = min(sg.size - 1, e0)
|
||||
if (s > 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 peakMin = max(lowMean + minPeakMargin, threshold)
|
||||
|
||||
// 3) Prominence 기반 wall peak 선택
|
||||
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
|
||||
// POST 먼저, ANT 나중 (Python 순서)
|
||||
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
|
||||
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) {
|
||||
val backHalfEdge = (s + e) / 2
|
||||
post = selectWallByProminence(
|
||||
@@ -569,68 +616,79 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
|
||||
val antH = sg[ant]
|
||||
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
|
||||
|
||||
if (urineLen < minUrineLen) return null
|
||||
|
||||
// FP 필터: 벽/low 비율
|
||||
val wallMin = min(antH, postH)
|
||||
if (lowMean > 0 && (wallMin / lowMean) < wallLowMeanMinRatio) return null
|
||||
// FP 필터: post prominence 최소
|
||||
if (postProm < minPeakMargin) 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()
|
||||
|
||||
// FP 필터: score 최소
|
||||
if (score < minScore) return null
|
||||
|
||||
return LowEchoResult(
|
||||
ant = ant,
|
||||
post = post,
|
||||
lowStart = s,
|
||||
lowEnd = e,
|
||||
lowMean = lowMean,
|
||||
urineLen = urineLen,
|
||||
score = score,
|
||||
ant = ant, post = post,
|
||||
lowStart = s, lowEnd = e, lowMean = lowMean,
|
||||
urineLen = urineLen, score = score,
|
||||
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 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 {
|
||||
val n = sg.size
|
||||
var v = sg[peakIdx]
|
||||
for (i in (peakIdx + 1) until min(n, peakIdx + maxDist)) {
|
||||
if (sg[i] < v) {
|
||||
v = sg[i]
|
||||
} else if (sg[i] > v + valleyStopRise) {
|
||||
break
|
||||
}
|
||||
var descending = false
|
||||
for (i in (peakIdx + 1) until min(n, peakIdx + maxDist + 1)) {
|
||||
val cur = sg[i]
|
||||
if (cur <= v) { v = cur; descending = true; continue }
|
||||
if (descending) break
|
||||
if (cur > v + valleyStopRise) break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||||
var v = sg[peakIdx]
|
||||
var descending = false
|
||||
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
|
||||
if (sg[i] < v) {
|
||||
v = sg[i]
|
||||
} else if (sg[i] > v + valleyStopRise) {
|
||||
break
|
||||
}
|
||||
val cur = sg[i]
|
||||
if (cur <= v) { v = cur; descending = true; continue }
|
||||
if (descending) break
|
||||
if (cur > v + valleyStopRise) break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* edge 양쪽에서 prominence 최대인 wall peak 선택
|
||||
* 1:1 port of _select_wall_by_prominence()
|
||||
* Wall peak 선택 — Python _select_wall_by_prominence 1:1 포팅
|
||||
* ANT: 가장 가까운 outer peak (prominence는 tie-breaker)
|
||||
* POST: edge_score = prom / (1 + decay * dist)
|
||||
*/
|
||||
private fun selectWallByProminence(
|
||||
sg: DoubleArray, edge: Int, searchWin: Int,
|
||||
@@ -639,71 +697,71 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
val n = sg.size
|
||||
if (n <= 0 || edge < 0 || edge >= n) return null
|
||||
|
||||
// 검색 범위 결정
|
||||
val leftLo: Int
|
||||
val rightHi: Int
|
||||
|
||||
if (side == WallSide.ANT) {
|
||||
leftLo = max(0, edge - searchWin) // 바깥 (자유)
|
||||
var rh = min(n - 1, edge + searchWin) // 안쪽
|
||||
if (otherEdge != null) rh = min(rh, otherEdge) // e를 넘지 않음
|
||||
leftLo = max(0, edge - searchWin)
|
||||
var rh = min(n - 1, edge + searchWin)
|
||||
if (otherEdge != null) rh = min(rh, otherEdge)
|
||||
rightHi = rh
|
||||
} else {
|
||||
var ll = max(0, edge - searchWin) // 안쪽
|
||||
if (otherEdge != null) ll = max(ll, otherEdge) // s를 넘지 않음
|
||||
var ll = max(0, edge - searchWin)
|
||||
if (otherEdge != null) ll = max(ll, otherEdge)
|
||||
leftLo = ll
|
||||
rightHi = min(n - 1, edge + searchWin) // 바깥 (자유)
|
||||
rightHi = min(n - 1, edge + searchWin)
|
||||
}
|
||||
|
||||
// edge 왼쪽 peak 후보 — edge+1 포함 (inclusive boundary)
|
||||
var leftCandidates = listOf<Int>()
|
||||
val leftEnd = min(edge + 1, n)
|
||||
if (leftEnd > leftLo) {
|
||||
if (edge > leftLo) {
|
||||
val leftEnd = min(edge + 1, n)
|
||||
val seg = safeSlice(sg, from = leftLo, to = leftEnd - 1)
|
||||
if (seg != null) {
|
||||
val pks = findPeaks1D(seg)
|
||||
val global = pks.map { it + leftLo }
|
||||
.filter { it < edge && sg[it] >= peakMin }
|
||||
leftCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||||
leftCandidates = pks.map { it + leftLo }.filter { it < edge }.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
// edge 오른쪽 peak 후보 — edge-1부터 시작 (inclusive boundary)
|
||||
var rightCandidates = listOf<Int>()
|
||||
val rightStart = max(edge - 1, 0)
|
||||
if (rightHi >= rightStart) {
|
||||
if (rightHi >= edge) {
|
||||
val rightStart = max(edge - 1, 0)
|
||||
val seg = safeSlice(sg, from = rightStart, to = rightHi)
|
||||
if (seg != null) {
|
||||
val pks = findPeaks1D(seg)
|
||||
val global = pks.map { it + rightStart }
|
||||
.filter { it >= edge && sg[it] >= peakMin }
|
||||
rightCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||||
rightCandidates = pks.map { it + rightStart }.filter { it >= edge }.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
val candidates = leftCandidates + rightCandidates
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
// prominence + edge distance penalty (EDGE_DIST_DECAY=0.12)
|
||||
var bestPeak: Int? = null
|
||||
var bestScore = -1.0
|
||||
|
||||
for (p in candidates) {
|
||||
val valley: Double = if (side == WallSide.ANT) {
|
||||
findRightValley(sg, peakIdx = p)
|
||||
} else {
|
||||
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
|
||||
// Score candidates
|
||||
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> {
|
||||
return group.mapNotNull { p ->
|
||||
val valley = if (side == WallSide.ANT) findRightValley(sg, p) else findLeftValley(sg, p)
|
||||
val prom = sg[p] - valley
|
||||
if (sg[p] < peakMin) return@mapNotNull null
|
||||
val dist = abs(p - edge)
|
||||
val sidePref = if (groupName == "outer") 0 else 1
|
||||
val es = prom / (1.0 + edgeDistDecay * dist)
|
||||
Scored(p, prom, dist, sidePref, es)
|
||||
}
|
||||
}
|
||||
|
||||
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 ──
|
||||
|
||||
Reference in New Issue
Block a user