From 30c1d4d8db0fd0d089e0427c0984ab590c3da692 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 30 Apr 2026 16:34:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Python=20=EC=95=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=EC=A6=98=20=EB=8C=80=EA=B7=9C=EB=AA=A8=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20=ED=9B=84=EB=A9=B4=EB=B0=98=EC=82=AC=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=91=EC=84=B1=20+=20detect=5Fcore=20+=20wall=20se?= =?UTF-8?q?lection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../managers/BackReflection.kt | 264 ++++++++++++++++ .../managers/PiezoEchoAnalyzer.kt | 296 +++++++++++------- 2 files changed, 441 insertions(+), 119 deletions(-) create mode 100644 app/src/main/java/com/example/medilightv2android/managers/BackReflection.kt diff --git a/app/src/main/java/com/example/medilightv2android/managers/BackReflection.kt b/app/src/main/java/com/example/medilightv2android/managers/BackReflection.kt new file mode 100644 index 0000000..4fc0a00 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/BackReflection.kt @@ -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 = 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 = PiezoEchoAnalyzer.shared.findPeaks1D(seg) + if (peaks.isEmpty()) return postMaxIdx + val globalPeaks: List = 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, 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, + wallPairs: List>, + 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 + } +} diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt index 063c49a..1b3d020 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt @@ -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): List { - // 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() + val brCandidates = mutableListOf() + 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(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): List { + 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): 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>, mergedSpan: Pair, lowMinLen: Int): Pair { + 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() - 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() - 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, groupName: String): List { + 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 { 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 { it.edgeScore }.thenByDescending { it.prom }.thenBy { it.dist }.thenBy { it.sidePref }.thenBy { it.p }).first().p } // ── Span Utilities ──