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,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)
}
}