feat: adaptive threshold 구현 (percentile + 고정값 blended)

detectLowEcho: 고정 1150 → adaptive threshold (기본 활성화)
  1. ringSkip(3) 이후 신호에서 q25/q50/q75 계산
  2. percentile 기반: q25 + iqr*0.3 ~ q50 - iqr*0.3
  3. 고정값(1150) 범위 제한: ×0.7 ~ ×1.5 (805~1725)
  4. 가중 평균: 고정 40% + adaptive 60%
  → 신호 레벨에 자동 적응하되 급격한 변동 방지

WaveformChart: threshold 점선도 adaptive 값으로 표시
useAdaptiveThreshold 플래그로 on/off 전환 가능

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 14:58:28 +09:00
parent a02562eb31
commit 6889136783
2 changed files with 42 additions and 3 deletions
@@ -321,11 +321,48 @@ class PiezoEchoAnalyzer private constructor() {
return max(q25, median - 0.5 * iqr)
}
/** 단일 1D 채널 → urine region 탐지 (고정 임계값만 사용) */
var useAdaptiveThreshold: Boolean = true
/** 단일 1D 채널 → urine region 탐지 */
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray): LowEchoResult? {
val sg = denoised
if (sg.size < 10) return null
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault)
if (!useAdaptiveThreshold) {
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault)
}
// Adaptive threshold: percentile + depth 보상 조합
val thr = computeAdaptiveThreshold(sg)
return detectLowEchoCore(sg = sg, threshold = thr)
}
/**
* Adaptive threshold: 신호 통계 + depth attenuation 보상
*
* 1) Percentile 기반 베이스라인: 전체 신호의 q25~median 사이에서 결정
* 2) 초반 peak(피부 반사) 제외: ringSkip(3) 이후 사용
* 3) 고정 threshold와의 가중 평균으로 급격한 변동 방지
*/
fun computeAdaptiveThreshold(sg: DoubleArray): Double {
val skip = GreenZoneConstants.ringSkip
val usable = if (sg.size > skip + 10) sg.sliceArray(skip until sg.size) else sg
val sorted = usable.sorted()
val n = sorted.size
val q25 = sorted[n / 4]
val q50 = sorted[n / 2]
val q75 = sorted[3 * n / 4]
val iqr = q75 - q25
// Percentile 기반: 소변 영역은 보통 하위 25~50%
val percThr = max(q25 + iqr * 0.3, q50 - iqr * 0.3)
// 고정값과 adaptive의 가중 평균 (급격한 변동 방지)
val adaptive = percThr.coerceIn(lowEchoAmpDefault * 0.7, lowEchoAmpDefault * 1.5)
val blended = lowEchoAmpDefault * 0.4 + adaptive * 0.6
return blended
}
/**
@@ -985,7 +985,9 @@ private fun WaveformChart(
val raw = buffer.map { it.toDouble() }.toDoubleArray()
val denoised = analyzer.denoise(raw)
val maxVal = 2500f
val threshold = analyzer.lowEchoAmpDefault.toFloat()
val threshold = if (analyzer.useAdaptiveThreshold)
analyzer.computeAdaptiveThreshold(denoised).toFloat()
else analyzer.lowEchoAmpDefault.toFloat()
val distPerSample = 1.771f // mm per sample
val yLabels = listOf(0, 500, 1000, 1500, 2000, 2500)