feat: port algorithm core from iOS (4 files)

GreenZoneConstants.kt — all 16 constants (thresholds, physical)
UrinAI.kt — urine gatekeeper (binary threshold detection)
PiezoEchoAnalyzer.kt — low-echo detection with prominence-based wall selection
PiezoBVEstimator.kt — Frustum BV estimation with device presets

1:1 port from Swift, same logic, same constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dwjang
2026-04-21 16:14:07 +09:00
parent b83cb16f35
commit c1c6dfc16a
4 changed files with 1487 additions and 0 deletions
@@ -0,0 +1,128 @@
package com.example.medilightv2android.managers
/**
* Green Zone 전체 파이프라인의 공유 상수 — Single Source of Truth.
*
* UrinAI, PeakDetection, Scoring 모두 이 상수를 참조.
* 파일 간 임계값 불일치 방지.
*
* 원본: xBench/GreenZoneConstants.kt (Charles KWON)
* 포팅일: 2026-04-17
*
* ⚠ 미세 조정 시 이 파일만 수정하면 전체 파이프라인에 반영됨.
* 각 상수의 의미와 영향 범위를 아래 주석 참고.
*/
object GreenZoneConstants {
// ═══════════════════════════════════════════════════════════
// 신호 범위
// ═══════════════════════════════════════════════════════════
/** raw ADC 중 사용할 샘플 수.
* Kotlin 원본: 90. VesiScan maa 응답은 100 samples 전송하지만 끝부분 10개는
* CRC/noise 가능성 → 앞 90개만 사용.
* 영향: UrinAI 탐색 범위, PeakDetection 탐색 범위 */
const val useSamples: Int = 90
/** 초기 ringdown 스킵 (센서 근접 반사 무시).
* idx 0~2는 피에조 소자 직접 반사 → 항상 높은 값 → 액체로 오판 방지.
* 영향: UrinAI.fw 탐색 시작점, PeakDetection.fw 탐색 시작점 */
const val ringSkip: Int = 3
// ═══════════════════════════════════════════════════════════
// 액체(소변) 판정 임계 — 12-bit ADC (0~4095)
// ═══════════════════════════════════════════════════════════
/** Low-echo detection 임계값 (denoised 신호 기준).
* denoised ≤ 이 값 → low-echo (소변 후보).
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
* Python 원본: 1600 (low_echo_detection_method_b.py LOW_ECHO_AMP)
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
const val lowEchoAmp: Float = 1600f
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
* Kotlin 원본: 1400.
* 영향: UrinAI.binarize → fw/bw 탐색의 기반 */
const val liquidThrLoose: Float = 1400f
/** 순수 액체 확인용 엄격 임계값: raw < 이 값 → "확실한 소변".
* liquidThrLoose보다 낮아야 함.
* 이 기준으로 연속 액체 구간(liquidRun) 측정.
* Kotlin 원본: 1100. VesiScan 실측에서 팬텀 low-echo가 1100~1400 범위.
* 1100→run=0, 1300→run=2 (미달). 1400으로 상향.
* liquidThrLoose(1400)와 동일하게 설정 — 팬텀에서는 loose/strict 구분 불필요.
* ⚠ 인체 측정 시 loose > strict 으로 재분리 필요할 수 있음
* 영향: UrinAI.maxRun 계산 → 최종 판정의 핵심 */
const val liquidThrStrict: Float = 1400f
/** 최소 연속 순수 액체 길이.
* maxRun ≥ 이 값이어야 "소변 있음" 판정.
* 소방광(50mL) 대응: chord≈24 중 순수 액체 5+ 필요.
* Kotlin 원본: 5.
* 영향: UrinAI 최종 판정 (detected 조건 1/3) */
const val minLiquidRun: Int = 5
// ═══════════════════════════════════════════════════════════
// 벽 검출 범위
// ═══════════════════════════════════════════════════════════
/** bw - fw 최소 (chord 최소 길이).
* 소방광 한계: 15 → 30mL(chord≈20) 이상 검출 가능.
* 50mL(chord≈24): margin 9 ✓
* 30mL(chord≈20): margin 5 ⚠
* 25mL 이하: 검출 불가 (임상적으로 PVR 50mL+ 의미 있는 범위)
* Kotlin 원본: 15.
* 영향: UrinAI 최종 판정 (detected 조건 2/3), PeakDetection bw 탐색 하한 */
const val minChord: Int = 15
/** bw - fw 최대 (이론적 상한).
* USE_SAMPLES(90)가 실질 제한.
* Kotlin 원본: 80.
* 영향: PeakDetection bw 탐색 상한 */
const val maxChord: Int = 80
// ═══════════════════════════════════════════════════════════
// 품질
// ═══════════════════════════════════════════════════════════
/** (wallPeak - lumenFloor) / wallPeak 최소.
* 벽과 소변 사이 대비가 이 값 이상이어야 유효.
* 높일수록 엄격 (선명한 벽 요구), 낮출수록 관대.
* Kotlin 원본: 0.15.
* 영향: UrinAI 최종 판정 (detected 조건 3/3) */
const val contrastMin: Float = 0.15f
// ═══════════════════════════════════════════════════════════
// Green Zone Finder
// ═══════════════════════════════════════════════════════════
/** score ≥ 이 값 → center lock (Green Zone 확정).
* Scoring.THR_EXCELLENT(75)보다 낮아 빠른 UX 확보.
* Kotlin 원본: 70.
* 영향: Placement Guide에서 Good 판정 기준 */
const val lockThreshold: Int = 70
/** 최대 기억 측정 수 (히스토리).
* Kotlin 원본: 20. */
const val maxHistory: Int = 20
// ═══════════════════════════════════════════════════════════
// 물리 상수 (교정용)
// ═══════════════════════════════════════════════════════════
/** ADC 샘플링 레이트 (Hz). VesiScan HW 사양. */
const val fs: Double = 400_000.0
/** 기준 조직 음속 (m/s). */
const val cRef: Double = 1540.0
/** 교정용 팬텀 직경 (mm). V=530mL 구. */
const val dPhantomMm: Double = 100.406
/** 팬텀 용적 (mL). */
const val vPhantomMl: Double = 530.0
/** 팬텀 반지름 (mm). */
const val rPhantomMm: Double = 50.203
}
@@ -0,0 +1,526 @@
package com.example.medilightv2android.managers
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.max
import kotlin.math.sin
import kotlin.math.sqrt
// ==========================================================================
// Bladder Volume Estimation — Multi-Channel Frustum + Cap (5ch vertical)
//
// 1:1 port of PiezoBVEstimator.swift / bv_estimation.py (algo-test branch)
// Only "traditional" mode is used (coord/hybrid disabled in Python too)
// ==========================================================================
// ── Hardware Parameters (5ch vertical array) ──
/**
* Probe geometry — probe model별 고정
*/
object PiezoHW {
const val nCh = 5 // BV estimation에 사용할 수직 채널 수
// ═══════════════════════════════════════════════════════════
// Device Presets — 기기별 센서 배치
// ═══════════════════════════════════════════════════════════
enum class DevicePreset {
/** 기존 5ch 수직 배열 (placeholder 각도) */
LEGACY_5CH,
/** 전채널 0° (BLE 테스트용, 각도 보정 없음) */
FLAT,
/** 새 기기 Case A: 0° / 10° / 20° / 30° + 좌우 ±5° */
NEW_CASE1,
/** 새 기기 Case B: -10° / 0° / 10° / 20° + 좌우 ±5° */
NEW_CASE2
}
/** ★ 여기만 바꾸면 전체 적용 ★ */
val activePreset: DevicePreset = DevicePreset.FLAT
// BV용 수직 채널 (CH0~CH3)
val sensorZMm: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0)
DevicePreset.FLAT -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
DevicePreset.NEW_CASE1 -> doubleArrayOf(22.0, 16.5, 11.0, 5.5)
DevicePreset.NEW_CASE2 -> doubleArrayOf(22.0, 16.5, 11.0, 5.5)
}
val sensorXMm: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
}
/** SI beam angle (degrees) — BV 계산용 */
val degree: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8)
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 10.0, 20.0, 30.0)
DevicePreset.NEW_CASE2 -> doubleArrayOf(-10.0, 0.0, 10.0, 20.0)
}
val degreeLR: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
}
/** 좌우 날개 채널 각도 (Placement Guide + lr_ratio 계산용)
* CH4: 왼쪽, CH5: 오른쪽 */
const val lateralDegreeSI: Double = 10.0 // SI 방향 (아래쪽을 봄)
const val lateralDegreeLR: Double = 5.0 // LR 방향 (바깥쪽)
/** Acoustic calibration */
const val distancePerSample: Double = 1.974 // mm/sample (390 ksps, 1540 m/s)
const val delayOffsetMm: Double = 6.85 // mm (first ADC sample 이전 고정 전파 지연)
/** Volume model */
val areaK: Double = Math.PI / 4.0 // S = areaK · D² · lr_ratio
const val defaultLrRatio: Double = 1.0 // 원형 단면 가정
/** Phantom presets */
fun lrRatio(phantom: String): Double {
if (phantom.contains("bp1_70")) return 1.56
if (phantom.contains("bp2_500")) return 1.0
return defaultLrRatio
}
/** 현재 preset 이름 (로그용) */
val presetName: String
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> "Legacy 5ch"
DevicePreset.FLAT -> "Flat (all 0°)"
DevicePreset.NEW_CASE1 -> "Case1 (0/10/20/30)"
DevicePreset.NEW_CASE2 -> "Case2 (-10/0/10/20)"
}
}
// ── Result ──
data class BVResult(
val volumeMl: Double,
val volumeMm3: Double,
// Per-channel (입력 순서, valid만)
val validChannels: List<Int>,
val dAntMm: DoubleArray,
val dPostMm: DoubleArray,
val dMm: DoubleArray, // 단면 직경 (cos 보정)
val sMm2: DoubleArray, // 단면적
// Sorted by y (bottom → top)
val sortedChannels: List<Int>,
val sortedYMm: DoubleArray,
val sortedSMm2: DoubleArray,
// Frustum / caps
val vFrustumMm3: DoubleArray,
val vCoreMm3: Double,
val vBottomMm3: Double,
val vTopMm3: Double,
val bottomHMm: Double,
val topHMm: Double,
val bottomKind: String,
val topKind: String,
// Parameters used
val lrRatio: Double,
val distancePerSample: Double,
val delayOffsetMm: Double
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is BVResult) return false
return volumeMl == other.volumeMl && volumeMm3 == other.volumeMm3
&& validChannels == other.validChannels
}
override fun hashCode(): Int = 31 * volumeMl.hashCode() + volumeMm3.hashCode()
}
// ── Helpers ──
/** Sample index → distance (mm) */
private fun sampleToMm(
idx: Double,
dps: Double = PiezoHW.distancePerSample,
offset: Double = PiezoHW.delayOffsetMm
): Double = offset + idx * dps
/** (ant, post) sample indices → (d_near, d_far) mm */
private fun segmentToDistancesMm(
ant: Int, post: Int,
dps: Double = PiezoHW.distancePerSample,
offset: Double = PiezoHW.delayOffsetMm
): Pair<Double, Double> {
val d1 = sampleToMm(ant.toDouble(), dps, offset)
val d2 = sampleToMm(post.toDouble(), dps, offset)
return Pair(min(d1, d2), max(d1, d2))
}
// ── Parabolic Cap Fitting ──
private data class ParabolicCapResult(
val s: DoubleArray,
val aCap: DoubleArray,
val bEff: Double?,
val capMode: String,
val outlierIdx: Int?
)
/**
* S(y) 포물선 피팅 → outlier 보정 + b_eff 추출
*/
private fun parabolicCap(
sS: DoubleArray, yS: DoubleArray, aCapS: DoubleArray,
outlierSigma: Double = 1.5, r2Min: Double = 0.5
): ParabolicCapResult {
val n = sS.size
val sWork = sS.copyOf()
val aWork = aCapS.copyOf()
var outlierIdx: Int? = null
if (n < 3) {
return ParabolicCapResult(sWork, aWork, null, "fallback", null)
}
// ── Phase 0: Edge-peak 보정 ──
val peakIdx = sWork.indices.maxByOrNull { sWork[it] } ?: 0
if (peakIdx == 0 && n >= 3) {
val slope: Double = if (abs(yS[2] - yS[1]) > 1e-6)
(sWork[2] - sWork[1]) / (yS[2] - yS[1]) else 0.0
val sExtrap = max(sWork[1] + slope * (yS[0] - yS[1]), 1.0)
if (sExtrap < sWork[0]) {
sWork[0] = sExtrap
val ratio = if (sS[0] > 0) sWork[0] / sS[0] else 1.0
aWork[0] = aCapS[0] * sqrt(ratio)
outlierIdx = 0
}
} else if (peakIdx == n - 1 && n >= 3) {
val slope: Double = if (abs(yS[n - 2] - yS[n - 3]) > 1e-6)
(sWork[n - 2] - sWork[n - 3]) / (yS[n - 2] - yS[n - 3]) else 0.0
val sExtrap = max(sWork[n - 2] + slope * (yS[n - 1] - yS[n - 2]), 1.0)
if (sExtrap < sWork[n - 1]) {
sWork[n - 1] = sExtrap
val ratio = if (sS[n - 1] > 0) sWork[n - 1] / sS[n - 1] else 1.0
aWork[n - 1] = aCapS[n - 1] * sqrt(ratio)
outlierIdx = n - 1
}
}
// ── Phase 1: 잔차 기반 outlier 보정 (1.5σ) ──
val coeffs1 = polyfit2(yS, sWork)
val sFitted1 = DoubleArray(n) { polyval2(coeffs1, yS[it]) }
val residuals = DoubleArray(n) { sWork[it] - sFitted1[it] }
val absRes = DoubleArray(n) { abs(residuals[it]) }
val worst = absRes.indices.maxByOrNull { absRes[it] } ?: 0
val resStd = std(residuals)
val threshold = if (resStd > 1e-6) outlierSigma * resStd else Double.MAX_VALUE
if (worst != outlierIdx && absRes[worst] > threshold) {
val sCorr = max(sFitted1[worst], 1.0)
sWork[worst] = sCorr
val ratio = if (sS[worst] > 0) sWork[worst] / sS[worst] else 1.0
aWork[worst] = aCapS[worst] * sqrt(ratio)
outlierIdx = worst
}
// ── Phase 2: 재피팅 → b_eff ──
val coeffs2 = polyfit2(yS, sWork)
val c2 = coeffs2.first // highest degree coeff
val sFitted2 = DoubleArray(n) { polyval2(coeffs2, yS[it]) }
val ssRes = (0 until n).sumOf { (sWork[it] - sFitted2[it]).let { d -> d * d } }
val meanS = sWork.sum() / n.toDouble()
val ssTot = sWork.sumOf { (it - meanS).let { d -> d * d } }
val rSq = if (ssTot > 1e-12) 1.0 - ssRes / ssTot else 0.0
if (c2 < -1e-3 && rSq > r2Min) {
val sPeakFit = coeffs2.third - coeffs2.second * coeffs2.second / (4.0 * c2)
if (sPeakFit > 0) {
val bEff = sqrt(sPeakFit / abs(c2))
return ParabolicCapResult(sWork, aWork, bEff, "parabolic", outlierIdx)
}
}
return ParabolicCapResult(sWork, aWork, null, "fallback", outlierIdx)
}
// ── Cap Volume ──
private data class CapResult(val h: Double, val volume: Double, val kind: String)
/**
* 단일 cap (bottom 또는 top) 높이 + 부피 계산
* shape: "sphere" = spherical cap, "cone" = 원뿔
*/
private fun capVolume(
sEdge: Double, aCapEdge: Double, bEff: Double?,
sMax: Double, capMode: String, shape: String = "sphere"
): CapResult {
val h: Double
var kind: String
if (capMode == "parabolic" && bEff != null && sMax > 0) {
val sRatio = min(sEdge / sMax, 0.999)
var hCalc = bEff * (1.0 - sqrt(1.0 - sRatio))
hCalc = min(hCalc, aCapEdge) // hemisphere/cone(h=R) 초과 방지
h = hCalc
kind = "parabolic"
} else {
h = aCapEdge
kind = "fallback"
}
val volume: Double
if (shape == "cone") {
volume = sEdge * h / 3.0
kind += " cone"
} else {
volume = sEdge * h / 2.0 + Math.PI * h * h * h / 6.0
kind += " sphere"
}
return CapResult(h, volume, kind)
}
// ── Core BV Computation ──
/**
* Traditional frustum BV 추정 (cos 보정 + SI 축 기반)
* 1:1 port of _bv_core(mode="traditional")
*/
fun estimateBladderVolume(
walls: List<Pair<Int, Int>?>, // 5채널 (ant, post), null = invalid
distancePerSample: Double = PiezoHW.distancePerSample,
delayOffsetMm: Double = PiezoHW.delayOffsetMm,
sensorZMm: DoubleArray = PiezoHW.sensorZMm,
degreeDeg: DoubleArray = PiezoHW.degree,
areaK: Double = PiezoHW.areaK,
lrRatio: Double = PiezoHW.defaultLrRatio,
applyAngleCorrection: Boolean = true,
ch4MinRatio: Double? = 0.9,
ch4RefChannels: List<Int> = listOf(2, 3)
): BVResult? {
// 1) CH4 filter — cap 방식 결정용 플래그
var ch4IsShort = false
if (ch4MinRatio != null && walls.size >= 5) {
val w4 = walls[4]
if (w4 != null) {
val l4 = w4.second - w4.first
val refLens = mutableListOf<Int>()
for (ci in ch4RefChannels) {
if (ci < walls.size) {
val w = walls[ci]
if (w != null) {
refLens.add(w.second - w.first)
}
}
}
val minRef = refLens.minOrNull()
if (minRef != null && l4.toDouble() < ch4MinRatio * minRef.toDouble()) {
ch4IsShort = true
}
}
}
// 2) 유효 채널 추출 + sample → mm
val validChannels = mutableListOf<Int>()
val dAnt = mutableListOf<Double>()
val dPost = mutableListOf<Double>()
for ((i, w) in walls.withIndex()) {
if (w == null) continue
validChannels.add(i)
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second, distancePerSample, delayOffsetMm)
dAnt.add(dNear)
dPost.add(dFar)
}
if (validChannels.size < 2) return null
val n = validChannels.size
// theta, sensor_z for valid channels
val theta = DoubleArray(n) { degreeDeg[validChannels[it]] * Math.PI / 180.0 }
val sensZ = DoubleArray(n) { sensorZMm[validChannels[it]] }
// 3) 단면 직경 (traditional: cos 보정)
val lRaw = DoubleArray(n) { dPost[it] - dAnt[it] }
val D = DoubleArray(n) { if (applyAngleCorrection) lRaw[it] * cos(theta[it]) else lRaw[it] }
// 4) 단면적 + AP 반지름
val S = DoubleArray(n) { areaK * D[it] * D[it] * lrRatio }
val aCap = DoubleArray(n) { D[it] / 2.0 }
// 5) Midpoint 좌표
val dMid = DoubleArray(n) { (dAnt[it] + dPost[it]) / 2.0 }
val y = DoubleArray(n) { sensZ[it] + dMid[it] * sin(theta[it]) }
// 6) y 오름차순 정렬
val order = (0 until n).sortedBy { y[it] }
val yS = DoubleArray(order.size) { y[order[it]] }
var sS = DoubleArray(order.size) { S[order[it]] }
var aCapS = DoubleArray(order.size) { aCap[order[it]] }
val sortedCh = order.map { validChannels[it] }
// 7) 포물선 피팅 → outlier 보정 + b_eff
val paraResult = parabolicCap(sS = sS, yS = yS, aCapS = aCapS)
sS = paraResult.s
aCapS = paraResult.aCap
val bEff = paraResult.bEff
val capMode = paraResult.capMode
// 8) Core frustum (traditional: h = dy)
val dy = DoubleArray(n - 1) { yS[it + 1] - yS[it] }
val vFrustum = DoubleArray(n - 1) {
(dy[it] / 3.0) * (sS[it] + sS[it + 1] + sqrt(sS[it] * sS[it + 1]))
}
val vCore = vFrustum.sum()
// 9) Caps
val sMax = sS.max()
// Bottom: 항상 sphere
val botCap = capVolume(
sEdge = sS[0], aCapEdge = aCapS[0], bEff = bEff, sMax = sMax,
capMode = capMode, shape = "sphere")
// Top: cone (정상) / sphere (CH4 short)
val topCap = if (ch4IsShort) {
capVolume(
sEdge = sS[n - 1], aCapEdge = aCapS[n - 1], bEff = bEff, sMax = sMax,
capMode = "fallback", shape = "sphere")
} else {
capVolume(
sEdge = sS[n - 1], aCapEdge = aCapS[n - 1], bEff = bEff, sMax = sMax,
capMode = capMode, shape = "cone")
}
// 10) 합산
val bvMm3 = vCore + botCap.volume + topCap.volume
return BVResult(
volumeMl = bvMm3 / 1000.0,
volumeMm3 = bvMm3,
validChannels = validChannels,
dAntMm = dAnt.toDoubleArray(),
dPostMm = dPost.toDoubleArray(),
dMm = D,
sMm2 = S,
sortedChannels = sortedCh,
sortedYMm = yS,
sortedSMm2 = sS,
vFrustumMm3 = vFrustum,
vCoreMm3 = vCore,
vBottomMm3 = botCap.volume,
vTopMm3 = topCap.volume,
bottomHMm = botCap.h,
topHMm = topCap.h,
bottomKind = botCap.kind,
topKind = topCap.kind,
lrRatio = lrRatio,
distancePerSample = distancePerSample,
delayOffsetMm = delayOffsetMm
)
}
// ── Math Utilities ──
/**
* Degree 2 polynomial fit: returns Triple(c2, c1, c0) where f(x) = c2*x² + c1*x + c0
* Least squares via normal equations (Vandermonde)
*/
private fun polyfit2(x: DoubleArray, y: DoubleArray): Triple<Double, Double, Double> {
val n = x.size
if (n < 3) return Triple(0.0, 0.0, y.firstOrNull() ?: 0.0)
// Build normal equations for Ax = b where A is Vandermonde [x^0, x^1, x^2]
val sx = DoubleArray(5)
val sy = DoubleArray(3)
for (i in 0 until n) {
val xi = x[i]
val yi = y[i]
var xp = 1.0
for (k in 0 until 5) {
sx[k] += xp
xp *= xi
}
sy[0] += yi
sy[1] += yi * xi
sy[2] += yi * xi * xi
}
// 3x3 system
val a = arrayOf(
doubleArrayOf(sx[0], sx[1], sx[2]),
doubleArrayOf(sx[1], sx[2], sx[3]),
doubleArrayOf(sx[2], sx[3], sx[4])
)
val b = doubleArrayOf(sy[0], sy[1], sy[2])
val sol = solve3x3(a, b) ?: return Triple(0.0, 0.0, y.firstOrNull() ?: 0.0)
return Triple(sol[2], sol[1], sol[0]) // (c2, c1, c0)
}
/** Evaluate degree 2 polynomial: c2*x² + c1*x + c0 */
private fun polyval2(c: Triple<Double, Double, Double>, x: Double): Double =
c.first * x * x + c.second * x + c.third
/** Solve 3x3 linear system via Gaussian elimination with partial pivoting */
private fun solve3x3(A: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
val a = Array(3) { A[it].copyOf() }
val bb = b.copyOf()
for (col in 0 until 3) {
// Partial pivoting
var maxRow = col
var maxVal = abs(a[col][col])
for (row in (col + 1) until 3) {
if (abs(a[row][col]) > maxVal) {
maxVal = abs(a[row][col])
maxRow = row
}
}
if (maxVal < 1e-12) return null
if (maxRow != col) {
val tmpA = a[col]; a[col] = a[maxRow]; a[maxRow] = tmpA
val tmpB = bb[col]; bb[col] = bb[maxRow]; bb[maxRow] = tmpB
}
// Eliminate
for (row in (col + 1) until 3) {
val factor = a[row][col] / a[col][col]
for (j in col until 3) a[row][j] -= factor * a[col][j]
bb[row] -= factor * bb[col]
}
}
// Back substitution
val x = DoubleArray(3)
for (i in 2 downTo 0) {
var sum = bb[i]
for (j in (i + 1) until 3) sum -= a[i][j] * x[j]
if (abs(a[i][i]) < 1e-12) return null
x[i] = sum / a[i][i]
}
return x
}
/** Standard deviation */
private fun std(arr: DoubleArray): Double {
val n = arr.size.toDouble()
if (n <= 0) return 0.0
val mean = arr.sum() / n
val variance = arr.sumOf { (it - mean).let { d -> d * d } } / n
return sqrt(variance)
}
@@ -0,0 +1,636 @@
package com.example.medilightv2android.managers
import com.example.medilightv2android.ble.PiezoChannelData
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
// ── Result Types ──
/**
* Low-echo 기반 urine region 탐지 결과 (원은지 B방식 포팅)
*/
data class LowEchoResult(
val ant: Int, // 전벽 index
val post: Int, // 후벽 index
val lowStart: Int, // low-echo span 시작
val lowEnd: Int, // low-echo span 끝 (inclusive)
val lowMean: Double, // low-echo 구간 평균 진폭
val urineLen: Int, // post - ant - 1
val score: Double, // low_depth * urine_len
val innerPeaks: List<Int> // (ant, post) 내부 local peak indices
) {
/** 방광 직경 (mm) = urine_len × distancePerSample */
val diameterMm: Double
get() = urineLen.toDouble() * PiezoConstants.distancePerSample
/** 방광 용적 (ml) = K × (D_cm)³ (DrBench 공식, 팬텀 캘리브레이션) */
val volumeMl: Double
get() = PiezoConstants.volumeMl(diameterMm)
/** 전벽 깊이 (mm) */
val antDepthMm: Double
get() = PiezoConstants.distanceMm(ant)
/** 후벽 깊이 (mm) */
val postDepthMm: Double
get() = PiezoConstants.distanceMm(post)
}
/**
* 채널별 분석 결과
*/
data class ChannelAnalysisResult(
val channel: Int,
val result: LowEchoResult?,
val rawSignal: DoubleArray,
val denoisedSignal: DoubleArray
) {
val isValid: Boolean get() = result != null
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ChannelAnalysisResult) return false
return channel == other.channel && result == other.result
}
override fun hashCode(): Int = 31 * channel + (result?.hashCode() ?: 0)
}
/**
* 전체 측정 분석 결과
*/
data class PiezoAnalysisResult(
val channels: List<ChannelAnalysisResult>,
val volumeMl: Double,
val confidence: Double, // 0~100
val validChannelCount: Int,
val method: String // "single", "multi_avg", "multi_weighted"
) {
val bestChannel: ChannelAnalysisResult?
get() = channels.filter { it.isValid }
.maxByOrNull { it.result?.score ?: 0.0 }
}
// ── PiezoConstants (referenced by LowEchoResult) ──
/**
* Physical constants for Piezo ultrasound echo measurement.
* Mirrors iOS PiezoConstants, delegates distancePerSample/delayOffsetMm to PiezoHW.
*/
object PiezoConstants {
const val adcMaxValue: Int = 4095
const val adcVrefMv: Double = 3300.0
const val sampleRateMhz: Double = 0.5375
const val sampleIntervalUs: Double = 1.86
const val soundSpeedMmUs: Double = 1.54
val distancePerSample: Double get() = PiezoHW.distancePerSample
val delayOffsetMm: Double get() = PiezoHW.delayOffsetMm
const val volumeK: Double = 1.76
fun distanceMm(index: Int): Double =
index.toDouble() * distancePerSample + delayOffsetMm
fun voltageMv(adcValue: Int): Double =
adcValue.toDouble() / adcMaxValue.toDouble() * adcVrefMv
fun volumeMl(diameterMm: Double): Double {
val dCm = diameterMm / 10.0
return volumeK * dCm * dCm * dCm
}
}
// ── Analyzer ──
/**
* Piezo 초음파 에코 분석기 — 원은지 연구원 B방식 알고리즘 포팅
* Pipeline: Raw ADC → TVD Denoise → SG Smooth → Low-Echo Detection → Volume
*/
class PiezoEchoAnalyzer private constructor() {
companion object {
val shared = PiezoEchoAnalyzer()
}
// ── Parameters (노트북 검증값) ──
// TVD
val tvdWeight: Double = 0.06
val tvdMaxIter: Int = 200
val tvdTol: Double = 2e-4
val tvdTau: Double = 0.25
// Savitzky-Golay (window=5, poly=2 → 고정 계수)
val sgCoeffs: DoubleArray = doubleArrayOf(-3.0/35.0, 12.0/35.0, 17.0/35.0, 12.0/35.0, -3.0/35.0)
// Low Echo Detection
val lowEchoAmpDefault: Double get() = GreenZoneConstants.lowEchoAmp.toDouble()
val lowMinLen: Int = 3
val mergeGapMax: Int = 3
val peakSearchWin: Int = 20
val postMaxIdx: Int = 80
val minPeakMargin: Double = 30.0
val minUrineLen: Int = 3
val postRefineTolRatio: Double = 0.05
val maxRealisticVolumeMl: Double = 1500.0
// ── Public API ──
/** 단일 채널 raw ADC → 용적 분석 (crash-safe) */
fun analyzeChannel(rawADC: List<UShort>, channel: Int = 0): ChannelAnalysisResult {
val raw = DoubleArray(rawADC.size) { rawADC[it].toDouble() }
if (raw.size < 10) {
return ChannelAnalysisResult(channel = channel, result = null, rawSignal = raw, denoisedSignal = raw.copyOf())
}
return try {
val denoised = denoise(raw)
val result = detectLowEcho(raw = raw, denoised = denoised)
ChannelAnalysisResult(channel = channel, result = result, rawSignal = raw, denoisedSignal = denoised)
} catch (_: Exception) {
ChannelAnalysisResult(channel = channel, result = null, rawSignal = raw, denoisedSignal = raw.copyOf())
}
}
/** 다채널 분석 → 최종 용적 */
fun analyzeMultiChannel(channelData: List<PiezoChannelData>): PiezoAnalysisResult {
val results = channelData.map { ch ->
analyzeChannel(ch.buffer, ch.channel)
}
// Step 1: Get valid results (algorithm found urine region)
val validResults = results.filter { it.isValid }
// Step 2: Filter out unrealistic volumes (container wall artifacts)
val realisticResults = validResults.filter { (it.result?.volumeMl ?: 0.0) <= maxRealisticVolumeMl }
// Step 3: Outlier removal — if 3+ results, remove > 2× median
var filtered = realisticResults
if (filtered.size >= 3) {
val vols = filtered.mapNotNull { it.result?.volumeMl }.sorted()
val median = vols[vols.size / 2]
filtered = filtered.filter { cr ->
val v = cr.result?.volumeMl ?: return@filter false
v <= median * 2.5 && v >= median * 0.3
}
}
val usedCount = filtered.size
val volumeMl: Double
val method: String
val confidence: Double
if (usedCount == 0) {
volumeMl = 0.0
method = "none"
confidence = 0.0
} else if (usedCount == 1) {
volumeMl = filtered[0].result!!.volumeMl
method = "single"
confidence = min(100.0, filtered[0].result!!.score / 100.0)
} else {
// Median-based: use median volume (robust against remaining outliers)
val vols = filtered.mapNotNull { it.result?.volumeMl }.sorted()
val median = vols[vols.size / 2]
volumeMl = median
method = "median_${usedCount}ch"
confidence = min(100.0, usedCount.toDouble() / results.size.toDouble() * 100.0)
}
return PiezoAnalysisResult(
channels = results,
volumeMl = volumeMl,
confidence = confidence,
validChannelCount = usedCount,
method = method
)
}
// ── Denoising Pipeline ──
/** TVD → SG 디노이징 */
fun denoise(signal: DoubleArray): DoubleArray {
val tvd = tvDenoiseChambolle1D(signal)
return sgSmooth(tvd)
}
/** 1D Total Variation Denoising — Chambolle (2004) dual projected gradient */
fun tvDenoiseChambolle1D(signal: DoubleArray): DoubleArray {
val n = signal.size
if (n < 2 || tvdWeight <= 0) return signal.copyOf()
// Skip if signal is flat (all same values)
val mn = signal.min()
val mx = signal.max()
if (mx - mn < 1.0) return signal.copyOf()
val p = DoubleArray(n - 1)
var uPrev = signal.copyOf()
val tauW = tvdTau / tvdWeight
for (iter in 0 until tvdMaxIter) {
// u = x + weight * div(p)
val divP = DoubleArray(n)
divP[0] = p[0]
for (i in 1 until (n - 1)) {
divP[i] = p[i] - p[i - 1]
}
divP[n - 1] = -p[n - 2]
val u = DoubleArray(n) { signal[it] + tvdWeight * divP[it] }
// grad(u) = diff(u) and dual update
var maxDiff = 0.0
for (i in 0 until (n - 1)) {
val gradU = u[i + 1] - u[i]
p[i] = (p[i] + tauW * gradU) / (1.0 + tauW * abs(gradU))
maxDiff = max(maxDiff, abs(u[i] - uPrev[i]))
}
maxDiff = max(maxDiff, abs(u[n - 1] - uPrev[n - 1]))
if (maxDiff < tvdTol) break
uPrev = u
}
// Final reconstruction
val divP = DoubleArray(n)
divP[0] = p[0]
for (i in 1 until (n - 1)) {
divP[i] = p[i] - p[i - 1]
}
divP[n - 1] = -p[n - 2]
return DoubleArray(n) { signal[it] + tvdWeight * divP[it] }
}
/** Savitzky-Golay 5-tap FIR smoothing (window=5, poly=2) */
fun sgSmooth(signal: DoubleArray): DoubleArray {
val n = signal.size
if (n < 5) return signal.copyOf()
val out = DoubleArray(n)
// Inner: convolution with fixed coefficients [-3, 12, 17, 12, -3]/35
for (i in 2 until (n - 2)) {
out[i] = sgCoeffs[0] * signal[i - 2] +
sgCoeffs[1] * signal[i - 1] +
sgCoeffs[2] * signal[i] +
sgCoeffs[3] * signal[i + 1] +
sgCoeffs[4] * signal[i + 2]
}
// Edge: polynomial fit for first/last 2 samples
// Left edge: fit poly2 to signal[0..4], evaluate at 0,1
val leftCoeffs = polyFit2(signal.sliceArray(0 until 5))
out[0] = evalPoly2(leftCoeffs, -2.0)
out[1] = evalPoly2(leftCoeffs, -1.0)
// Right edge: fit poly2 to signal[n-5..n-1], evaluate at n-2,n-1
val rightCoeffs = polyFit2(signal.sliceArray((n - 5) until n))
out[n - 2] = evalPoly2(rightCoeffs, 1.0)
out[n - 1] = evalPoly2(rightCoeffs, 2.0)
return out
}
// Quadratic LS fit to 5 points centered at 0: x = [-2,-1,0,1,2]
// Returns Triple(c0, c1, c2) where f(t) = c0 + c1*t + c2*t²
private fun polyFit2(y: DoubleArray): Triple<Double, Double, Double> {
val c0 = (-3*y[0] + 12*y[1] + 17*y[2] + 12*y[3] - 3*y[4]) / 35.0
val c1 = (-2*y[0] - y[1] + y[3] + 2*y[4]) / 10.0
val c2 = (2*y[0] - y[1] - 2*y[2] - y[3] + 2*y[4]) / 14.0
return Triple(c0, c1, c2)
}
private fun evalPoly2(c: Triple<Double, Double, Double>, t: Double): Double =
c.first + c.second * t + c.third * t * t
// ── Low Echo Detection ──
/** 적응형 low-echo 임계값: 신호 통계 기반 */
fun adaptiveThreshold(signal: DoubleArray): Double {
if (signal.size < 10) return lowEchoAmpDefault
val sorted = signal.sorted().toDoubleArray()
val q25 = sorted[sorted.size / 4]
val median = sorted[sorted.size / 2]
val q75 = sorted[3 * sorted.size / 4]
val iqr = q75 - q25
return max(q25, median - 0.5 * iqr)
}
/** 단일 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)
}
/**
* Low-echo 탐지 핵심 — prominence 기반 wall peak 선택
* 1:1 port of low_echo_detection_method_b.py (2026-04-20 update)
*/
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double): LowEchoResult? {
if (sg.size < 10) return null
// 1) low-echo span 추출
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)
if (s > e) return null
val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
if (lowSlice.isEmpty()) return null
val lowMean = lowSlice.sum() / lowSlice.size.toDouble()
val peakMin = lowMean + minPeakMargin
// 3) Prominence 기반 wall peak 선택
val ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.ANT, otherEdge = e) ?: return null
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
// 4) post > POST_MAX_IDX: prominence 기반 재탐색
if (post > postMaxIdx) {
val backHalfEdge = (s + e) / 2
post = selectWallByProminence(
sg = sg, edge = backHalfEdge,
searchWin = postMaxIdx - backHalfEdge,
peakMin = peakMin, side = WallSide.POST, otherEdge = null
) ?: return null
}
val antH = sg[ant]
val postH = sg[post]
val lowDepth = ((antH + postH) / 2.0) - lowMean
val urineLen = post - ant - 1
if (urineLen < minUrineLen) return null
return LowEchoResult(
ant = ant,
post = post,
lowStart = s,
lowEnd = e,
lowMean = lowMean,
urineLen = urineLen,
score = lowDepth * urineLen.toDouble(),
innerPeaks = findInnerPeaks(sg, left = ant, right = post)
)
}
// ── Prominence-based Wall Selection ──
private val maxPeakCandidates = 3
private enum class WallSide { ANT, POST }
/** peak 오른쪽에서 가장 가까운 valley의 sg 값 */
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 + 10) {
break
}
}
return v
}
/** peak 왼쪽에서 가장 가까운 valley의 sg 값 */
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
var v = sg[peakIdx]
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
if (sg[i] < v) {
v = sg[i]
} else if (sg[i] > v + 10) {
break
}
}
return v
}
/**
* edge 양쪽에서 prominence 최대인 wall peak 선택
* 1:1 port of _select_wall_by_prominence()
*/
private fun selectWallByProminence(
sg: DoubleArray, edge: Int, searchWin: Int,
peakMin: Double, side: WallSide, otherEdge: Int?
): Int? {
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를 넘지 않음
rightHi = rh
} else {
var ll = max(0, edge - searchWin) // 안쪽
if (otherEdge != null) ll = max(ll, otherEdge) // s를 넘지 않음
leftLo = ll
rightHi = min(n - 1, edge + searchWin) // 바깥 (자유)
}
// edge 왼쪽 peak 후보 (가까운 순)
var leftCandidates = listOf<Int>()
if (edge > leftLo) {
val seg = safeSlice(sg, from = leftLo, to = edge - 1)
if (seg != null) {
val pks = findPeaks1D(seg)
val global = pks.map { it + leftLo }.filter { sg[it] >= peakMin }
leftCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
}
}
// edge 오른쪽 peak 후보 (가까운 순)
var rightCandidates = listOf<Int>()
if (rightHi >= edge) {
val seg = safeSlice(sg, from = edge, to = rightHi)
if (seg != null) {
val pks = findPeaks1D(seg)
val global = pks.map { it + edge }.filter { sg[it] >= peakMin }
rightCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
}
}
val candidates = leftCandidates + rightCandidates
if (candidates.isEmpty()) return null
// prominence 계산 (urine 방향 valley 기준)
var bestPeak: Int? = null
var bestProm = -1.0
for (p in candidates) {
val valley: Double = if (side == WallSide.ANT) {
findRightValley(sg, peakIdx = p) // urine 방향 = 오른쪽
} else {
findLeftValley(sg, peakIdx = p) // urine 방향 = 왼쪽
}
val prom = sg[p] - valley
if (prom > bestProm) {
bestProm = prom
bestPeak = p
}
}
return bestPeak
}
// ── Span Utilities ──
fun findPeaks1D(x: DoubleArray, height: Double? = null): List<Int> {
val n = x.size
if (n < 3) return emptyList()
val peaks = mutableListOf<Int>()
var i = 1
while (i < n - 1) {
if (x[i - 1] < x[i]) {
var j = i
while (j < n - 1 && x[j + 1] == x[j]) j++
if (j < n - 1 && x[j + 1] < x[j]) {
peaks.add((i + j) / 2)
}
i = j + 1
} else {
i++
}
}
return if (height != null) {
peaks.filter { x[it] >= height }
} else {
peaks
}
}
fun contiguousTrueSpans(mask: BooleanArray): List<Pair<Int, Int>> {
val spans = mutableListOf<Pair<Int, Int>>()
var start: Int? = null
for ((i, m) in mask.withIndex()) {
if (m && start == null) {
start = i
} else if (!m && start != null) {
spans.add(Pair(start, i - 1))
start = null
}
}
if (start != null) {
spans.add(Pair(start, mask.size - 1))
}
return spans
}
fun mergeCloseSpans(spans: List<Pair<Int, Int>>, maxGap: Int): List<Pair<Int, Int>> {
return mergeCloseSpansWithWallCheck(spans, maxGap = maxGap, signal = null, gapPeakThr = 0.0)
}
/** 병합 시 gap 내부에 벽 후보(gapPeakThr 초과 peak)가 있으면 병합하지 않음 */
fun mergeCloseSpansWithWallCheck(
spans: List<Pair<Int, Int>>, maxGap: Int,
signal: DoubleArray?, gapPeakThr: Double
): List<Pair<Int, Int>> {
if (spans.isEmpty()) return emptyList()
val ordered = spans.sortedBy { it.first }
val merged = mutableListOf(ordered[0])
for (i in 1 until ordered.size) {
val (s, e) = ordered[i]
val prevEnd = merged[merged.size - 1].second
val gap = s - prevEnd - 1
if (gap <= maxGap) {
// Check: gap 내부에 벽 후보 peak이 있는지
var hasWallPeak = false
if (signal != null && gapPeakThr > 0) {
val gapStart = prevEnd + 1
val gapEnd = s - 1
if (gapStart <= gapEnd) {
val gapSlice = safeSlice(signal, from = gapStart, to = gapEnd)
if (gapSlice != null) {
val peaks = findPeaks1D(gapSlice, height = gapPeakThr)
if (peaks.isNotEmpty()) hasWallPeak = true
// Also check endpoints
if (!hasWallPeak) {
if ((gapSlice.firstOrNull() ?: 0.0) > gapPeakThr) hasWallPeak = true
if ((gapSlice.lastOrNull() ?: 0.0) > gapPeakThr) hasWallPeak = true
}
}
}
}
if (hasWallPeak) {
// Wall candidate in gap — don't merge
merged.add(Pair(s, e))
} else {
merged[merged.size - 1] = Pair(merged[merged.size - 1].first, max(prevEnd, e))
}
} else {
merged.add(Pair(s, e))
}
}
return merged
}
fun nearestPeak(signal: DoubleArray, center: Int, left: Int, right: Int, minHeight: Double): Int? {
if (signal.isEmpty()) return null
val l = max(0, left)
val r = min(signal.size - 1, right)
val local = safeSlice(signal, from = l, to = r) ?: return null
val pkLocal = findPeaks1D(local, height = minHeight)
val candidates = (pkLocal.map { it + l }).toMutableSet()
// Endpoint correction
if (l == r) {
if (signal[l] >= minHeight) candidates.add(l)
} else {
if (signal[l] >= minHeight && signal[l] >= signal[l + 1]) candidates.add(l)
if (signal[r] >= minHeight && signal[r] >= signal[r - 1]) candidates.add(r)
}
if (candidates.isEmpty()) return null
val sorted = candidates.sorted()
// Find nearest to center, tie-break by amplitude
var bestIdx = sorted[0]
var bestDist = abs(sorted[0] - center)
for (c in sorted) {
val dist = abs(c - center)
if (dist < bestDist || (dist == bestDist && signal[c] > signal[bestIdx])) {
bestIdx = c
bestDist = dist
}
}
return bestIdx
}
fun findInnerPeaks(signal: DoubleArray, left: Int, right: Int): List<Int> {
val l = left + 1
val r = right - 1
val local = safeSlice(signal, from = l, to = r) ?: return emptyList()
return findPeaks1D(local).map { it + l }
}
// ── Safe Array Slicing ──
/** Safe inclusive range slice — returns null if bounds are invalid */
private fun safeSlice(arr: DoubleArray, from: Int, to: Int): DoubleArray? {
val l = max(0, from)
val r = min(arr.size - 1, to)
if (l > r || arr.isEmpty()) return null
return arr.sliceArray(l..r)
}
}
@@ -0,0 +1,197 @@
package com.example.medilightv2android.managers
/**
* UrinAI (UrinGateKeeper) — 소변(액체) 존재 판정 엔진.
*
* 역할: raw ADC 신호에서 "방광(액체)이 있는가?" → true/false 반환.
* true일 때만 벽 검출 엔진(PiezoEchoAnalyzer)이 실행됨.
*
* 원리: 이진 임계 기법
* raw < LIQUID_THR_LOOSE → 액체 / raw ≥ LIQUID_THR_LOOSE → 조직
* 연속 액체 구간 + contrast 검증 → 생물학적 판정
*
* 1:1 port of UrinAI.swift / UrinAI.kt (Charles KWON)
*/
object UrinAI {
// ── Result ──
data class Result(
/** 핵심: 소변 존재 여부 */
val detected: Boolean,
/** lumen 시작점 (액체 진입) */
val fw: Int,
/** posterior wall peak */
val bw: Int,
/** 연속 순수 액체 최대 길이 */
val liquidRun: Int,
/** wall-lumen 대비 (0~1) */
val contrast: Float,
/** 원본 raw (그래프 표시용) */
val rawSignal: List<UShort>,
/** 이진 마스크 (true=액체) */
val binaryMask: List<Boolean>,
/** 순수 액체 구간들 */
val urineZones: List<IntRange>,
/** anterior wall 평가 가능 여부 */
val antWallAvailable: Boolean
)
// ── Detection ──
/** 소변 존재 판정 */
fun detect(rawAdc: List<UShort>): Result {
if (rawAdc.isEmpty()) {
return emptyResult(rawCopy = emptyList(), binary = emptyList())
}
val n = minOf(rawAdc.size, GreenZoneConstants.useSamples)
val raw = FloatArray(n) { rawAdc[it].toFloat() }
val rawCopy = rawAdc.take(n)
val binarizeThr = GreenZoneConstants.liquidThrLoose
val inUrineThr = GreenZoneConstants.liquidThrStrict
val minLiquidRun = GreenZoneConstants.minLiquidRun
val minDepth = GreenZoneConstants.minChord
val contrastMin = GreenZoneConstants.contrastMin
val ringSkip = GreenZoneConstants.ringSkip
// 1. Binarize (LIQUID_THR_LOOSE 기준)
val binary = List(n) { raw[it] < binarizeThr }
// 2. fw = 첫 번째 연속 액체 (3샘플 이상, ringdown skip)
var fw = -1
for (i in ringSkip until (n - 2)) {
if (binary[i] && binary[i + 1] && binary[i + 2]) {
fw = i
break
}
}
if (fw < 0) {
return emptyResult(rawCopy = rawCopy, binary = binary)
}
// 3. bw = fw 이후 가장 강한 조직 그룹의 peak
// Two-pass: 엄격(3샘플) → 실패 시 완화(2샘플, 소방광 대응)
var bw = findBw(fw = fw, binary = binary, raw = raw, minGroupLen = 3)
if (bw < 0 || bw - fw < minDepth) {
val bw2 = findBw(fw = fw, binary = binary, raw = raw, minGroupLen = 2)
if (bw2 >= 0 && bw2 - fw >= minDepth) {
bw = bw2
}
}
if (bw < 0) {
return emptyResult(rawCopy = rawCopy, binary = binary, fw = fw)
}
if (bw - fw < minDepth) {
return emptyResult(rawCopy = rawCopy, binary = binary, fw = fw, bw = bw)
}
// 4. 순수 액체 연속 구간 (strict threshold)
var maxRun = 0
var run = 0
var zoneStart = -1
val zones = mutableListOf<IntRange>()
for (j in fw until bw) {
if (raw[j] < inUrineThr) {
if (zoneStart < 0) zoneStart = j
run += 1
} else {
if (run > 0) {
if (run > maxRun) maxRun = run
zones.add(zoneStart until j)
}
run = 0
zoneStart = -1
}
}
if (run > 0 && zoneStart >= 0) {
if (run > maxRun) maxRun = run
zones.add(zoneStart until bw)
}
// 5. Contrast
val antWallAvailable = fw > ringSkip + 2
var antWallPeak = raw[fw]
for (j in maxOf(ringSkip, fw - 5) until fw) {
if (raw[j] > antWallPeak) antWallPeak = raw[j]
}
val wallPeak = maxOf(antWallPeak, raw[bw])
var lumenFloor = raw[fw + 1]
for (j in (fw + 2) until bw) {
if (raw[j] < lumenFloor) lumenFloor = raw[j]
}
val contrast: Float = if (wallPeak > 0) (wallPeak - lumenFloor) / wallPeak else 0f
// 6. 판정
val detected = maxRun >= minLiquidRun
&& (bw - fw) >= minDepth
&& contrast >= contrastMin
return Result(
detected = detected,
fw = fw, bw = bw,
liquidRun = maxRun, contrast = contrast,
rawSignal = rawCopy, binaryMask = binary,
urineZones = zones,
antWallAvailable = antWallAvailable
)
}
// ── Private ──
/** fw 이후 가장 강한 조직 그룹의 peak index */
private fun findBw(fw: Int, binary: List<Boolean>, raw: FloatArray, minGroupLen: Int): Int {
val n = raw.size
var bw = -1
var maxGroupSum = 0f
var i = fw + 1
// fw 이후 liquid 구간 건너뛰기
while (i < n && binary[i]) i++
while (i < n) {
if (!binary[i]) {
val gs = i
var gSum = 0f
while (i < n && !binary[i]) {
gSum += raw[i]
i++
}
val ge = i - 1
if (ge - gs + 1 >= minGroupLen && gSum > maxGroupSum) {
maxGroupSum = gSum
// peak index within group
var peakIdx = gs
for (k in (gs + 1)..ge) {
if (raw[k] > raw[peakIdx]) peakIdx = k
}
bw = peakIdx
}
} else {
i++
}
}
return bw
}
/** 실패 시 빈 결과 */
private fun emptyResult(
rawCopy: List<UShort>, binary: List<Boolean>,
fw: Int = 0, bw: Int = 0
): Result = Result(
detected = false,
fw = fw, bw = bw,
liquidRun = 0, contrast = 0f,
rawSignal = rawCopy, binaryMask = binary,
urineZones = emptyList(),
antWallAvailable = false
)
}