From c1c6dfc16a70aaeac856d70e07e71191f818bd53 Mon Sep 17 00:00:00 2001 From: dwjang Date: Tue, 21 Apr 2026 16:14:07 +0900 Subject: [PATCH] feat: port algorithm core from iOS (4 files) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../managers/GreenZoneConstants.kt | 128 ++++ .../managers/PiezoBVEstimator.kt | 526 +++++++++++++++ .../managers/PiezoEchoAnalyzer.kt | 636 ++++++++++++++++++ .../medilightv2android/managers/UrinAI.kt | 197 ++++++ 4 files changed, 1487 insertions(+) create mode 100644 app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt create mode 100644 app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt create mode 100644 app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt create mode 100644 app/src/main/java/com/example/medilightv2android/managers/UrinAI.kt diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt new file mode 100644 index 0000000..20c0dad --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -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 +} diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt new file mode 100644 index 0000000..e171032 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt @@ -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, + val dAntMm: DoubleArray, + val dPostMm: DoubleArray, + val dMm: DoubleArray, // 단면 직경 (cos 보정) + val sMm2: DoubleArray, // 단면적 + + // Sorted by y (bottom → top) + val sortedChannels: List, + 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 { + 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?>, // 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 = 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() + 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() + val dAnt = mutableListOf() + val dPost = mutableListOf() + + 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 { + 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, 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, 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) +} diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt new file mode 100644 index 0000000..66689ea --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt @@ -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 // (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, + 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, 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): 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 { + 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, 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() + 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() + 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 { + val n = x.size + if (n < 3) return emptyList() + + val peaks = mutableListOf() + 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> { + val spans = mutableListOf>() + 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>, maxGap: Int): List> { + return mergeCloseSpansWithWallCheck(spans, maxGap = maxGap, signal = null, gapPeakThr = 0.0) + } + + /** 병합 시 gap 내부에 벽 후보(gapPeakThr 초과 peak)가 있으면 병합하지 않음 */ + fun mergeCloseSpansWithWallCheck( + spans: List>, maxGap: Int, + signal: DoubleArray?, gapPeakThr: Double + ): List> { + 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 { + 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) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/managers/UrinAI.kt b/app/src/main/java/com/example/medilightv2android/managers/UrinAI.kt new file mode 100644 index 0000000..5ee990f --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/UrinAI.kt @@ -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, + /** 이진 마스크 (true=액체) */ + val binaryMask: List, + /** 순수 액체 구간들 */ + val urineZones: List, + /** anterior wall 평가 가능 여부 */ + val antWallAvailable: Boolean + ) + + // ── Detection ── + + /** 소변 존재 판정 */ + fun detect(rawAdc: List): 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() + + 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, 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, binary: List, + 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 + ) +}