feat: Otsu adaptive threshold, Method A 포팅, BV ellipse cap, UI 정리

Algorithm:
- Otsu 1D adaptive threshold 포팅 (span_utils.py otsu_1d, 기본 ON)
- Method A 벽 검출 포팅 (PiezoEchoAnalyzerA.kt: plateau score + prominence)
- Method A cross-validation (채널간 중앙값/gradient 보정)
- BV cap 높이: parabolic → ellipse 피팅 (8점 축정렬 타원)
- Bottom cap: sphere, Top cap: cone (Python bv_estimation.py 동기화)
- 설정에 Method A/B 토글 (왼쪽=A, 오른쪽=B)

Logging:
- BLE 로그 + CSV에 otsu threshold 값 기록
- 측정 로그에 method label (A/B) 표시

UI:
- 설정 패널: floating overlay로 변경 + MaxVolume 슬라이더 추가
- Onboarding: 1페이지로 단순화 (Pager/indicator 제거)
- Registration: 한 페이지에 전체 입력 + Skip 버튼
- Placement: 로딩 인디케이터/스캔 텍스트 삭제

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 18:43:56 +09:00
parent c6e84bd6c0
commit c849943b4e
10 changed files with 762 additions and 525 deletions
@@ -12,8 +12,12 @@ package com.example.medilightv2android.managers
* ⚠ 미세 조정 시 이 파일만 수정하면 전체 파이프라인에 반영됨. * ⚠ 미세 조정 시 이 파일만 수정하면 전체 파이프라인에 반영됨.
* 각 상수의 의미와 영향 범위를 아래 주석 참고. * 각 상수의 의미와 영향 범위를 아래 주석 참고.
*/ */
enum class DetectionMethod { METHOD_A, METHOD_B }
object GreenZoneConstants { object GreenZoneConstants {
var detectionMethod: DetectionMethod = DetectionMethod.METHOD_B
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 신호 범위 // 신호 범위
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -266,130 +266,115 @@ private fun segmentToDistancesMm(
return Pair(min(d1, d2), max(d1, d2)) return Pair(min(d1, d2), max(d1, d2))
} }
// ── Parabolic Cap Fitting ── // ── Ellipse Cap Height Fitting ──
// bv_estimation.py _ellipse_cap_heights() 1:1 포팅
// 8개 경계점(4ch × ant/post)으로 축 정렬 타원 피팅 → cap 높이
private data class ParabolicCapResult( private data class EllipseCapResult(
val s: DoubleArray, val hBot: Double,
val aCap: DoubleArray, val hTop: Double,
val bEff: Double?, val botKind: String,
val capMode: String, val topKind: String
val outlierIdx: Int?
) )
/** private fun ellipseCapHeights(
* S(y) 포물선 피팅 → outlier 보정 + b_eff 추출 dAnt: List<Double>, dPost: List<Double>,
*/ validChannels: List<Int>,
private fun parabolicCap( sensorZMm: DoubleArray, degreeDeg: DoubleArray,
sS: DoubleArray, yS: DoubleArray, aCapS: DoubleArray, aCapBot: Double, aCapTop: Double,
outlierSigma: Double = 1.5, r2Min: Double = 0.5 yS: DoubleArray
): ParabolicCapResult { ): EllipseCapResult {
val n = sS.size val ptsX = mutableListOf<Double>()
val sWork = sS.copyOf() val ptsY = mutableListOf<Double>()
val aWork = aCapS.copyOf()
var outlierIdx: Int? = null
if (n < 3) { for ((i, ch) in validChannels.withIndex()) {
return ParabolicCapResult(sWork, aWork, null, "fallback", null) val theta = degreeDeg[ch] * Math.PI / 180.0
ptsX.add(dAnt[i] * cos(theta))
ptsY.add(sensorZMm[ch] + dAnt[i] * sin(theta))
ptsX.add(dPost[i] * cos(theta))
ptsY.add(sensorZMm[ch] + dPost[i] * sin(theta))
} }
// ── Phase 0: Edge-peak 보정 ── if (ptsX.size < 5) {
val peakIdx = sWork.indices.maxByOrNull { sWork[it] } ?: 0 return EllipseCapResult(aCapBot, aCapTop, "fallback", "fallback")
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σ) ── // 축 정렬 타원: Ax² + Cy² + Dx + Ey = 1 (F=-1 정규화)
val coeffs1 = polyfit2(yS, sWork) // least squares: M @ [A,C,D,E]^T = 1
val sFitted1 = DoubleArray(n) { polyval2(coeffs1, yS[it]) } val nPts = ptsX.size
val residuals = DoubleArray(n) { sWork[it] - sFitted1[it] } val sol = solveEllipseLSQ(ptsX.toDoubleArray(), ptsY.toDoubleArray(), nPts)
val absRes = DoubleArray(n) { abs(residuals[it]) } ?: return EllipseCapResult(aCapBot, aCapTop, "fallback", "fallback")
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 (aa, cc, dd, ee) = sol
val sCorr = max(sFitted1[worst], 1.0) if (aa <= 0 || cc <= 0) {
sWork[worst] = sCorr return EllipseCapResult(aCapBot, aCapTop, "fallback", "fallback")
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 xc = -dd / (2 * aa)
val coeffs2 = polyfit2(yS, sWork) val yc = -ee / (2 * cc)
val c2 = coeffs2.first // highest degree coeff val rhs = dd * dd / (4 * aa) + ee * ee / (4 * cc) + 1.0
val sFitted2 = DoubleArray(n) { polyval2(coeffs2, yS[it]) } if (rhs <= 0) {
val ssRes = (0 until n).sumOf { (sWork[it] - sFitted2[it]).let { d -> d * d } } return EllipseCapResult(aCapBot, aCapTop, "fallback", "fallback")
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) val cSemi = sqrt(rhs / cc) // SI 반축
val bladderBot = yc - cSemi
val bladderTop = yc + cSemi
var hBot = yS[0] - bladderBot
var hTop = bladderTop - yS[yS.size - 1]
hBot = hBot.coerceIn(0.0, aCapBot)
hTop = hTop.coerceIn(0.0, aCapTop)
return EllipseCapResult(hBot, hTop, "ellipse", "ellipse")
} }
// ── Cap Volume ── /** 4×4 least squares: M^T M x = M^T 1 */
private fun solveEllipseLSQ(px: DoubleArray, py: DoubleArray, n: Int): DoubleArray? {
// Build 4×4 normal equations: (M^T M) params = M^T ones
// M columns: [x², y², x, y]
val mtm = Array(4) { DoubleArray(4) }
val mtb = DoubleArray(4)
private data class CapResult(val h: Double, val volume: Double, val kind: String) for (k in 0 until n) {
val x = px[k]; val y = py[k]
/** val row = doubleArrayOf(x * x, y * y, x, y)
* 단일 cap (bottom 또는 top) 높이 + 부피 계산 for (i in 0 until 4) {
* shape: "sphere" = spherical cap, "cone" = 원뿔 for (j in 0 until 4) mtm[i][j] += row[i] * row[j]
*/ mtb[i] += row[i] // RHS = 1
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"
} }
return solve4x4(mtm, mtb)
}
val volume: Double private fun solve4x4(A: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
if (shape == "cone") { val a = Array(4) { A[it].copyOf() }
volume = sEdge * h / 3.0 val bb = b.copyOf()
kind += " cone" for (col in 0 until 4) {
} else { var maxRow = col; var maxVal = abs(a[col][col])
volume = sEdge * h / 2.0 + Math.PI * h * h * h / 6.0 for (row in (col + 1) until 4) {
kind += " sphere" 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
}
for (row in (col + 1) until 4) {
val factor = a[row][col] / a[col][col]
for (j in col until 4) a[row][j] -= factor * a[col][j]
bb[row] -= factor * bb[col]
}
} }
val x = DoubleArray(4)
return CapResult(h, volume, kind) for (i in 3 downTo 0) {
var sum = bb[i]
for (j in (i + 1) until 4) sum -= a[i][j] * x[j]
if (abs(a[i][i]) < 1e-12) return null
x[i] = sum / a[i][i]
}
return x
} }
// ── Core BV Computation ── // ── Core BV Computation ──
@@ -488,12 +473,12 @@ fun estimateBladderVolume(
var aCapS = DoubleArray(order.size) { aCap[order[it]] } var aCapS = DoubleArray(order.size) { aCap[order[it]] }
val sortedCh = order.map { validChannels[it] } val sortedCh = order.map { validChannels[it] }
// 7) 포물선 피팅 → outlier 보정 + b_eff // 7) 타원 피팅 → cap 높이
val paraResult = parabolicCap(sS = sS, yS = yS, aCapS = aCapS) val ellCap = ellipseCapHeights(
sS = paraResult.s dAnt, dPost, validChannels,
aCapS = paraResult.aCap sensorZMm, degreeDeg,
val bEff = paraResult.bEff aCapS[0], aCapS[n - 1], yS
val capMode = paraResult.capMode )
// 8) Core frustum (traditional: h = dy) // 8) Core frustum (traditional: h = dy)
val dy = DoubleArray(n - 1) { yS[it + 1] - yS[it] } val dy = DoubleArray(n - 1) { yS[it + 1] - yS[it] }
@@ -502,27 +487,12 @@ fun estimateBladderVolume(
} }
val vCore = vFrustum.sum() val vCore = vFrustum.sum()
// 9) Caps // 9) Caps — Bottom: sphere, Top: cone
val sMax = sS.max() val vBottom = sS[0] * ellCap.hBot / 2.0 + Math.PI * ellCap.hBot * ellCap.hBot * ellCap.hBot / 6.0
val vTop = sS[n - 1] * ellCap.hTop / 3.0
// 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 (edgeIsShort) {
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) 합산 // 10) 합산
val bvMm3 = vCore + botCap.volume + topCap.volume val bvMm3 = vCore + vBottom + vTop
return BVResult( return BVResult(
volumeMl = bvMm3 / 1000.0, volumeMl = bvMm3 / 1000.0,
@@ -537,12 +507,12 @@ fun estimateBladderVolume(
sortedSMm2 = sS, sortedSMm2 = sS,
vFrustumMm3 = vFrustum, vFrustumMm3 = vFrustum,
vCoreMm3 = vCore, vCoreMm3 = vCore,
vBottomMm3 = botCap.volume, vBottomMm3 = vBottom,
vTopMm3 = topCap.volume, vTopMm3 = vTop,
bottomHMm = botCap.h, bottomHMm = ellCap.hBot,
topHMm = topCap.h, topHMm = ellCap.hTop,
bottomKind = botCap.kind, bottomKind = ellCap.botKind,
topKind = topCap.kind, topKind = ellCap.topKind,
lrRatio = lrRatio, lrRatio = lrRatio,
distancePerSample = distancePerSample, distancePerSample = distancePerSample,
delayOffsetMm = delayOffsetMm delayOffsetMm = delayOffsetMm
@@ -311,59 +311,78 @@ class PiezoEchoAnalyzer private constructor() {
// ── Low Echo Detection ── // ── Low Echo Detection ──
/** 적응형 low-echo 임계값: 신호 통계 기반 */ var useAdaptiveThreshold: Boolean = true
fun adaptiveThreshold(signal: DoubleArray): Double { var lastOtsuThreshold: Double = 0.0; private set
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)
}
var useAdaptiveThreshold: Boolean = false
/** 단일 1D 채널 → urine region 탐지 */ /** 단일 1D 채널 → urine region 탐지 */
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray): LowEchoResult? { fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray): LowEchoResult? {
val sg = denoised val sg = denoised
if (sg.size < 10) return null if (sg.size < 10) return null
if (!useAdaptiveThreshold) { val thr = if (useAdaptiveThreshold) {
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault) val limit = min(sg.size, postMaxIdx + 1)
otsu1d(sg, limit)
} else {
lowEchoAmpDefault
} }
lastOtsuThreshold = thr
// Adaptive threshold: percentile + depth 보상 조합
val thr = computeAdaptiveThreshold(sg)
return detectLowEchoCore(sg = sg, threshold = thr) return detectLowEchoCore(sg = sg, threshold = thr)
} }
/** /**
* Adaptive threshold: 신호 통계 + depth attenuation 보상 * 1D Otsu threshold — 원은지 연구원 span_utils.py otsu_1d() 1:1 포팅
* *
* 1) Percentile 기반 베이스라인: 전체 신호의 q25~median 사이에서 결정 * 히스토그램에서 between-class variance를 최대화하는 임계값 반환.
* 2) 초반 peak(피부 반사) 제외: ringSkip(3) 이후 사용 * 저진폭(urine/조직)과 고진폭(벽 echo) 두 모집단을 분리.
* 3) 고정 threshold와의 가중 평균으로 급격한 변동 방지
*/ */
fun computeAdaptiveThreshold(sg: DoubleArray): Double { fun otsu1d(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): Double {
val skip = GreenZoneConstants.ringSkip val n = min(values.size, limit)
val usable = if (sg.size > skip + 10) sg.sliceArray(skip until sg.size) else sg if (n == 0) return 0.0
val sorted = usable.sorted() var vMin = values[0]; var vMax = values[0]
val n = sorted.size for (i in 1 until n) {
val q25 = sorted[n / 4] if (values[i] < vMin) vMin = values[i]
val q50 = sorted[n / 2] if (values[i] > vMax) vMax = values[i]
val q75 = sorted[3 * n / 4] }
val iqr = q75 - q25 if (vMax == vMin) return vMin
// Percentile 기반: 소변 영역은 보통 하위 25~50% val binWidth = (vMax - vMin) / nBins
val percThr = max(q25 + iqr * 0.3, q50 - iqr * 0.3) val hist = IntArray(nBins)
for (i in 0 until n) {
val bin = ((values[i] - vMin) / binWidth).toInt().coerceIn(0, nBins - 1)
hist[bin]++
}
// 고정값과 adaptive의 가중 평균 (급격한 변동 방지) val centers = DoubleArray(nBins) { vMin + (it + 0.5) * binWidth }
val adaptive = percThr.coerceIn(lowEchoAmpDefault * 0.7, lowEchoAmpDefault * 1.5) val total = n.toDouble()
val blended = lowEchoAmpDefault * 0.4 + adaptive * 0.6
return blended // cumulative probability & mean
val cumP = DoubleArray(nBins)
val cumMP = DoubleArray(nBins)
cumP[0] = hist[0] / total
cumMP[0] = cumP[0] * centers[0]
for (i in 1 until nBins) {
cumP[i] = cumP[i - 1] + hist[i] / total
cumMP[i] = cumMP[i - 1] + (hist[i] / total) * centers[i]
}
val totalM = cumMP[nBins - 1]
// between-class variance 최대화
var bestSigma = -1.0
var bestIdx = 0
for (t in 0 until nBins - 1) {
val w0 = cumP[t]
val w1 = 1.0 - w0
if (w0 < 1e-6 || w1 < 1e-6) continue
val m0 = cumMP[t] / w0
val m1 = (totalM - cumMP[t]) / w1
val sigmaB = w0 * w1 * (m0 - m1) * (m0 - m1)
if (sigmaB > bestSigma) {
bestSigma = sigmaB
bestIdx = t
}
}
return centers[bestIdx]
} }
/** /**
@@ -0,0 +1,304 @@
package com.example.medilightv2android.managers
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
/**
* Method A — TVD → SG → Derivative + Plateau-first wall expansion.
* 1:1 port of low_echo_detection_method_a.py (원은지 연구원)
*
* Pipeline:
* 1. Condat(2013) 1D TVD
* 2. SG smooth
* 3. Composite signal y = sg + α|d1| + β|d2|
* 4. Sliding plateau score
* 5. Plateau span detection + merge
* 6. Prominence-based wall selection from plateau edges
*/
class PiezoEchoAnalyzerA private constructor() {
companion object {
val shared = PiezoEchoAnalyzerA()
}
// ── Parameters (notebook 실측 튜닝값) ──
val tvLambda: Double = 3.0
val alpha: Double = 1.0
val beta: Double = 1.0
val postMaxIdx: Int = 80
val plateauMinLen: Int = 3
val plateauMergeGap: Int = 5
val scoreWin: Int = 7
val plateauQ: Double = 0.5
val edgeDistDecay: Double = 0.15
val peakSearchWin: Int = 20
val minWallProminence: Double = 3.0
val minWallContrastRatio: Double = 1.2
val minUrineLen: Int = 3
val minEffectiveDist: Int = 3
val valleyStopRise: Double = 5.0
var lastOtsuThreshold: Double = 0.0; private set
// ── Public API ──
fun analyzeChannel(rawADC: List<UShort>, channel: Int = 0): ChannelAnalysisResult {
val raw = DoubleArray(rawADC.size) { rawADC[it].toDouble() }
if (raw.size < 10) {
return ChannelAnalysisResult(channel, null, raw, raw.copyOf())
}
return try {
val sg = PiezoEchoAnalyzer.shared.denoise(raw)
val result = detectMethodA(raw, sg)
ChannelAnalysisResult(channel, result, raw, sg)
} catch (_: Exception) {
ChannelAnalysisResult(channel, null, raw, raw.copyOf())
}
}
fun detectMethodA(raw: DoubleArray, sg: DoubleArray): LowEchoResult? {
if (sg.size < 10) return null
// 1) Composite signal y = sg + α|d1| + β|d2|
val d1 = DoubleArray(sg.size - 1) { sg[it + 1] - sg[it] }
val d2 = DoubleArray(sg.size - 2) { sg[it + 2] - 2 * sg[it + 1] + sg[it] }
val L = d2.size
if (L < 10) return null
val y = DoubleArray(L) { sg[it] + alpha * abs(d1[it]) + beta * abs(d2[it]) }
// 2) Plateau score
val platScore = slidingScores1d(sg, scoreWin)
val limit = min(platScore.size, postMaxIdx + 1)
val scoreSorted = platScore.sliceArray(0 until limit).sorted().toDoubleArray()
val scoreThr = scoreSorted[(scoreSorted.size * plateauQ).toInt().coerceIn(0, scoreSorted.size - 1)]
// 3) Plateau spans + merge
var plateauSpans = findPlateauSpans(platScore, limit, scoreThr, plateauMinLen)
if (plateauSpans.size > 1) {
val merged = mutableListOf(plateauSpans[0])
for (i in 1 until plateauSpans.size) {
val (s, e) = plateauSpans[i]
val (ps, pe) = merged.last()
if (s - pe <= plateauMergeGap) {
merged[merged.size - 1] = Pair(ps, e)
} else {
merged.add(Pair(s, e))
}
}
plateauSpans = merged
}
// 4) Plateau-first matching
for ((spanStart, spanEnd) in plateauSpans) {
val leftLo = max(0, spanStart - peakSearchWin)
val rightHi = min(sg.size, spanEnd + peakSearchWin + 1)
val platMean = if (spanEnd >= spanStart) {
var sum = 0.0
for (i in spanStart..spanEnd) sum += sg[i]
sum / (spanEnd - spanStart + 1)
} else continue
if (platMean <= 0) continue
// Contrast check
var leftMax = 0.0
for (i in leftLo..min(spanStart, sg.size - 1)) if (sg[i] > leftMax) leftMax = sg[i]
var rightMax = 0.0
for (i in spanEnd until rightHi) if (sg[i] > rightMax) rightMax = sg[i]
if (leftMax / platMean < minWallContrastRatio || rightMax / platMean < minWallContrastRatio) continue
// Find peaks
val leftPks = findPeaksInRange(sg, leftLo, spanStart).filter { it < spanStart }
val rightPks = findPeaksInRange(sg, spanEnd, rightHi - 1).filter { it > spanEnd && it <= postMaxIdx }
if (leftPks.isEmpty() || rightPks.isEmpty()) continue
// Score left candidates
var bestLeftScore = -1.0; var bestLeft = -1
for (p in leftPks) {
val prom = sg[p] - findRightValley(sg, p)
if (prom < minWallProminence) continue
val dist = spanStart - p
val effDist = max(dist, minEffectiveDist)
val score = prom / (1.0 + edgeDistDecay * effDist)
if (score > bestLeftScore) { bestLeftScore = score; bestLeft = p }
}
// Score right candidates
var bestRightScore = -1.0; var bestRight = -1
for (p in rightPks) {
val prom = sg[p] - findLeftValley(sg, p)
if (prom < minWallProminence) continue
val dist = p - spanEnd
val effDist = max(dist, minEffectiveDist)
val score = prom / (1.0 + edgeDistDecay * effDist)
if (score > bestRightScore) { bestRightScore = score; bestRight = p }
}
if (bestLeft < 0 || bestRight < 0 || bestRight <= bestLeft) continue
val urineLen = bestRight - bestLeft
if (urineLen < minUrineLen) continue
val lowSlice = sg.sliceArray(spanStart..spanEnd)
val lowMean = lowSlice.average()
val wallScore = (sg[bestLeft] + sg[bestRight]) / 2.0 - lowMean
return LowEchoResult(
ant = bestLeft, post = bestRight,
lowStart = spanStart, lowEnd = spanEnd,
lowMean = lowMean, urineLen = urineLen,
score = wallScore * urineLen,
innerPeaks = PiezoEchoAnalyzer.shared.findInnerPeaks(sg, bestLeft, bestRight)
)
}
return null
}
// ── Sliding plateau score ──
private fun slidingScores1d(x: DoubleArray, win: Int): DoubleArray {
val w = if (win % 2 == 0) win + 1 else win
val half = w / 2
val T = x.size
val flat = DoubleArray(T)
val slope = DoubleArray(T)
val low = DoubleArray(T)
val tt = DoubleArray(w) { (it - half).toDouble() }
var ttSqSum = 0.0
for (t in tt) ttSqSum += t * t
ttSqSum += 1e-12
for (i in 0 until T) {
val wStart = max(0, i - half)
val wEnd = min(T - 1, i + half)
val wLen = wEnd - wStart + 1
var sum = 0.0; var sqSum = 0.0
val vals = mutableListOf<Double>()
for (j in wStart..wEnd) { sum += x[j]; sqSum += x[j] * x[j]; vals.add(x[j]) }
val mean = sum / wLen
flat[i] = sqrt(max(0.0, sqSum / wLen - mean * mean))
vals.sort()
low[i] = vals[(wLen * 0.2).toInt().coerceIn(0, wLen - 1)]
var slopeNum = 0.0
for (j in wStart..wEnd) {
slopeNum += (j - i).toDouble() * (x[j] - mean)
}
slope[i] = abs(slopeNum / ttSqSum)
}
return DoubleArray(T) { robustZ(low)[it] + robustZ(flat)[it] + robustZ(slope)[it] }
}
private fun robustZ(a: DoubleArray): DoubleArray {
val sorted = a.sorted().toDoubleArray()
val med = sorted[sorted.size / 2]
val deviations = DoubleArray(a.size) { abs(a[it] - med) }
val devSorted = deviations.sorted().toDoubleArray()
val mad = devSorted[devSorted.size / 2] + 1e-12
return DoubleArray(a.size) { (a[it] - med) / (1.4826 * mad) }
}
// ── Plateau span detection ──
private fun findPlateauSpans(score: DoubleArray, limit: Int, thr: Double, minLen: Int): MutableList<Pair<Int, Int>> {
val spans = mutableListOf<Pair<Int, Int>>()
var runStart: Int? = null
for (i in 0 until limit) {
if (score[i] < thr) {
if (runStart == null) runStart = i
} else {
if (runStart != null && (i - runStart) >= minLen) {
spans.add(Pair(runStart, i - 1))
}
runStart = null
}
}
if (runStart != null && (limit - runStart) >= minLen) {
spans.add(Pair(runStart, limit - 1))
}
return spans
}
// ── Cross-validation (채널간 벽 보정) ──
// 1:1 port of _cross_validate_walls()
private val crossValMaxDev = 5
private val crossValMaxGradient = 3
fun crossValidateWalls(
walls: List<Pair<Int, Int>?>,
centerCh: List<Int> = listOf(0, 1, 2, 3)
): List<Pair<Int, Int>?> {
val corrected = walls.toMutableList()
// 1단계: 글로벌 중앙값 기반
val ants = corrected.mapNotNull { it?.first }
val posts = corrected.mapNotNull { it?.second }
if (ants.size >= 3) {
val medAnt = ants.sorted()[ants.size / 2]
val medPost = posts.sorted()[posts.size / 2]
for (i in corrected.indices) {
val w = corrected[i] ?: continue
val a = if (abs(w.first - medAnt) >= crossValMaxDev) medAnt else w.first
val p = if (abs(w.second - medPost) >= crossValMaxDev) medPost else w.second
corrected[i] = Pair(a, p)
}
}
// 2단계: 인접 center 채널 smoothness
val validCenter = centerCh.filter { it < corrected.size && corrected[it] != null }
if (validCenter.size < 3) return corrected
for (field in listOf("ant", "post")) {
val vals = validCenter.map { if (field == "ant") corrected[it]!!.first else corrected[it]!!.second }.toMutableList()
for (j in vals.indices) {
val neighbors = mutableListOf<Int>()
if (j > 0) neighbors.add(vals[j - 1])
if (j < vals.size - 1) neighbors.add(vals[j + 1])
if (neighbors.isEmpty()) continue
val neighborMean = neighbors.average()
if (abs(vals[j] - neighborMean) > crossValMaxGradient) {
val newVal = neighborMean.toInt()
val chIdx = validCenter[j]
val w = corrected[chIdx]!!
corrected[chIdx] = if (field == "ant") Pair(newVal, w.second) else Pair(w.first, newVal)
vals[j] = newVal
}
}
}
return corrected
}
// ── Peak / Valley helpers ──
private fun findPeaksInRange(sg: DoubleArray, from: Int, to: Int): List<Int> {
val lo = max(0, from); val hi = min(sg.size - 1, to)
if (hi - lo < 2) return emptyList()
val seg = sg.sliceArray(lo..hi)
return PiezoEchoAnalyzer.shared.findPeaks1D(seg).map { it + lo }
}
private fun findRightValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
var v = sg[peakIdx]
for (i in (peakIdx + 1) until min(sg.size, peakIdx + maxDist)) {
if (sg[i] < v) v = sg[i]
else if (sg[i] > v + valleyStopRise) break
}
return v
}
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 + valleyStopRise) break
}
return v
}
}
@@ -54,7 +54,9 @@ object AdcCsvLogger {
val timestamp = timeFmt.format(Date()) val timestamp = timeFmt.format(Date())
val volStr = volumeMl?.let { "%.1f".format(it) } ?: "" val volStr = volumeMl?.let { "%.1f".format(it) } ?: ""
val lrStr = "%.2f".format(lrRatio) val lrStr = "%.2f".format(lrRatio)
val thrStr = "%.0f".format(com.example.medilightv2android.managers.GreenZoneConstants.lowEchoAmp) val analyzer = com.example.medilightv2android.managers.PiezoEchoAnalyzer.shared
val thrStr = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0)
"%.0f".format(analyzer.lastOtsuThreshold) else "%.0f".format(com.example.medilightv2android.managers.GreenZoneConstants.lowEchoAmp)
val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample) val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample)
val sgStr = if (com.example.medilightv2android.managers.PiezoEchoAnalyzer.shared.useSgFilter) "ON" else "OFF" val sgStr = if (com.example.medilightv2android.managers.PiezoEchoAnalyzer.shared.useSgFilter) "ON" else "OFF"
@@ -155,7 +155,7 @@ class BleDebugLogger private constructor(private val context: Context) {
info("ENDIAN_DETECT ${if (isBigEndian) "BIG" else "LITTLE"} (LE=$firstSampleLE, BE=$firstSampleBE)") info("ENDIAN_DETECT ${if (isBigEndian) "BIG" else "LITTLE"} (LE=$firstSampleLE, BE=$firstSampleBE)")
} }
fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List<Int>) { fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List<Int>, otsuThreshold: Double = 0.0) {
val scanId = AdcCsvLogger.lastScanId val scanId = AdcCsvLogger.lastScanId
val msg = buildString { val msg = buildString {
append("MEASUREMENT scan=$scanId") append("MEASUREMENT scan=$scanId")
@@ -163,6 +163,7 @@ class BleDebugLogger private constructor(private val context: Context) {
append(" lr=${"%.2f".format(lrRatio)}") append(" lr=${"%.2f".format(lrRatio)}")
append(" valid=$validChannels/6") append(" valid=$validChannels/6")
append(" urine=[${urineLines.joinToString(",")}]") append(" urine=[${urineLines.joinToString(",")}]")
if (otsuThreshold > 0) append(" otsu=${"%.0f".format(otsuThreshold)}")
} }
add(Entry(System.currentTimeMillis(), Level.INFO, msg)) add(Entry(System.currentTimeMillis(), Level.INFO, msg))
} }
@@ -112,23 +112,38 @@ fun PiezoMonitoringView(appState: AppState) {
val validChannels = channels.filter { it.isValid } val validChannels = channels.filter { it.isValid }
if (validChannels.isNotEmpty()) { if (validChannels.isNotEmpty()) {
val analysisResult = analyzer.analyzeMultiChannel(validChannels) val useMethodA = com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod == com.example.medilightv2android.managers.DetectionMethod.METHOD_A
val analyzerA = com.example.medilightv2android.managers.PiezoEchoAnalyzerA.shared
// Build walls list for 6ch BV estimation // Method A or B로 채널별 분석
val channelResults = if (useMethodA) {
validChannels.map { analyzerA.analyzeChannel(it.buffer, it.channel) }
} else {
validChannels.map { analyzer.analyzeChannel(it.buffer, it.channel) }
}
// Cross-validation (Method A only)
val allWalls: MutableList<Pair<Int, Int>?> = MutableList(6) { null } val allWalls: MutableList<Pair<Int, Int>?> = MutableList(6) { null }
for (ch in analysisResult.channels) { for (ch in channelResults) {
if (ch.channel < 6 && ch.result != null) { if (ch.channel < 6 && ch.result != null) {
allWalls[ch.channel] = Pair(ch.result.ant, ch.result.post) allWalls[ch.channel] = Pair(ch.result.ant, ch.result.post)
} }
} }
if (useMethodA) {
val validated = analyzerA.crossValidateWalls(allWalls)
for (i in validated.indices) allWalls[i] = validated[i]
}
val analysisResult = analyzer.analyzeMultiChannel(validChannels)
// Count valid center channels (CH0~CH3) // Count valid center channels (CH0~CH3)
val centerWallCount = (0..3).count { allWalls[it] != null } val centerWallCount = (0..3).count { allWalls[it] != null }
val totalWallCount = allWalls.count { it != null } val totalWallCount = allWalls.count { it != null }
val methodLabel = if (useMethodA) "A" else "B"
for (ch in analysisResult.channels) { for (ch in channelResults) {
if (ch.isValid && ch.result != null) { if (ch.isValid && ch.result != null) {
Log.d("PiezoMonitor", " CH${ch.channel}: ant=${ch.result.ant} post=${ch.result.post} urine=${ch.result.urineLen} score=${"%.1f".format(ch.result.score)}") Log.d("PiezoMonitor", " CH${ch.channel}[$methodLabel]: ant=${ch.result.ant} post=${ch.result.post} urine=${ch.result.urineLen} score=${"%.1f".format(ch.result.score)}")
} }
} }
@@ -152,7 +167,8 @@ fun PiezoMonitoringView(appState: AppState) {
maxVolumeMl = maxOf(maxVolumeMl, bvResult.volumeMl) maxVolumeMl = maxOf(maxVolumeMl, bvResult.volumeMl)
Log.d("PiezoMonitor", "BV: ${"%.1f".format(bvResult.volumeMl)}ml (6ch, lr=${"%.2f".format(bvResult.lrRatio)}, center=${centerWallCount}/4)") Log.d("PiezoMonitor", "BV: ${"%.1f".format(bvResult.volumeMl)}ml (6ch, lr=${"%.2f".format(bvResult.lrRatio)}, center=${centerWallCount}/4)")
bleManager.debugLogger.measurementResult(bvResult.volumeMl, bvResult.lrRatio, totalWallCount, bleManager.debugLogger.measurementResult(bvResult.volumeMl, bvResult.lrRatio, totalWallCount,
(0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }) (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 },
otsuThreshold = analyzer.lastOtsuThreshold)
logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC) logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC)
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio) com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio)
} }
@@ -586,150 +602,16 @@ fun PiezoMonitoringView(appState: AppState) {
if (showSettings) scrollState.animateScrollTo(0) if (showSettings) scrollState.animateScrollTo(0)
} }
Box(modifier = Modifier.weight(1f)) {
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxSize()
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(horizontal = hPad) .padding(horizontal = hPad)
.padding(top = if (isCompact) 8.dp else 16.dp, bottom = if (isCompact) 16.dp else 32.dp), .padding(top = if (isCompact) 8.dp else 16.dp, bottom = if (isCompact) 16.dp else 32.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp) verticalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp)
) { ) {
// Settings panel
if (showSettings) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.shadow(8.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.06f))
) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Catheterization Threshold
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Catheterization Threshold", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
Text("Level ${appState.bladderLevel.catheterizationThreshold}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(
value = appState.bladderLevel.catheterizationThreshold.toFloat(),
onValueChange = { appState.updateCatheterizationThreshold(it.toInt()) },
valueRange = 1f..10f,
steps = 8,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
HorizontalDivider()
// Low Echo Threshold
var currentThreshold by remember { mutableStateOf(GreenZoneConstants.lowEchoAmp) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Low Echo Threshold", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
Text("${"%.0f".format(currentThreshold)}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFFFF9500))
}
Slider(
value = currentThreshold,
onValueChange = {
currentThreshold = it
GreenZoneConstants.lowEchoAmp = it
},
valueRange = 800f..2000f,
colors = SliderDefaults.colors(thumbColor = Color(0xFFFF9500), activeTrackColor = Color(0xFFFF9500))
)
HorizontalDivider()
// Distance Per Sample (직접 입력)
var dpsText by remember { mutableStateOf("%.3f".format(PiezoHW.distancePerSample)) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Distance/Sample", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
OutlinedTextField(
value = dpsText,
onValueChange = { input ->
dpsText = input
input.toDoubleOrNull()?.let { v ->
if (v in 0.5..5.0) PiezoHW.distancePerSample = v
}
},
suffix = { Text("mm", fontSize = 12.sp, color = MlSecondaryText) },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal
),
modifier = Modifier.width(120.dp),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF4CAF50)
)
)
}
HorizontalDivider()
// SG Filter toggle
var sgEnabled by remember { mutableStateOf(analyzer.useSgFilter) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("SG Filter", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
Switch(
checked = sgEnabled,
onCheckedChange = {
sgEnabled = it
analyzer.useSgFilter = it
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
HorizontalDivider()
Row {
Text("Max Volume", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
Text("${appState.piezoSettings.maxVolume} ml", fontSize = 14.sp, color = MlSecondaryText)
}
HorizontalDivider()
// Disconnect device
var showDisconnectConfirm by remember { mutableStateOf(false) }
Button(
onClick = { showDisconnectConfirm = true },
modifier = Modifier.fillMaxWidth().height(44.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f))
) {
Icon(Icons.Default.LinkOff, null, tint = Color.Red, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Disconnect Device", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.Red)
}
if (showDisconnectConfirm) {
AlertDialog(
onDismissRequest = { showDisconnectConfirm = false },
title = { Text("Disconnect Device") },
text = { Text("기기 연결을 해제하고 페어링 정보를 삭제합니다. 다시 연결하려면 기기 검색이 필요합니다.") },
confirmButton = {
TextButton(onClick = {
showDisconnectConfirm = false
showSettings = false
// Disconnect + remove bond + clear saved device
bleManager.disconnectAndUnbond()
appState.goHome()
}) { Text("Disconnect", color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") }
}
)
}
}
}
}
// Status badge // Status badge
Text( Text(
@@ -865,7 +747,164 @@ fun PiezoMonitoringView(appState: AppState) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BleDebugPanel(bleManager = bleManager) BleDebugPanel(bleManager = bleManager)
} }
} // end scrollable Column
// ── Floating Settings Overlay ──
if (showSettings) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
.clickable { showSettings = false }
)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground.copy(alpha = 0.97f)),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.align(Alignment.TopCenter)
.shadow(12.dp, RoundedCornerShape(16.dp))
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
// Max Volume
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Max Volume", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
Text("${appState.piezoSettings.maxVolume} mL", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(
value = appState.piezoSettings.maxVolume.toFloat(),
onValueChange = { appState.piezoSettings = appState.piezoSettings.copy(maxVolume = it.toInt()) },
valueRange = 200f..800f,
steps = 11,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
HorizontalDivider()
// Catheterization Threshold
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Catheter Threshold", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
Text("Level ${appState.bladderLevel.catheterizationThreshold}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(
value = appState.bladderLevel.catheterizationThreshold.toFloat(),
onValueChange = { appState.updateCatheterizationThreshold(it.toInt()) },
valueRange = 1f..10f,
steps = 8,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
HorizontalDivider()
// Low Echo Threshold (Otsu / manual)
var otsuEnabled by remember { mutableStateOf(analyzer.useAdaptiveThreshold) }
var currentThreshold by remember { mutableStateOf(GreenZoneConstants.lowEchoAmp) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Threshold", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(8.dp))
Text(
if (otsuEnabled) "Otsu: ${"%.0f".format(analyzer.lastOtsuThreshold)}" else "${"%.0f".format(currentThreshold)}",
fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = if (otsuEnabled) Color(0xFF4CAF50) else Color(0xFFFF9500)
)
Spacer(modifier = Modifier.weight(1f))
Text("Otsu", fontSize = 12.sp, color = MlSecondaryText)
Switch(
checked = otsuEnabled,
onCheckedChange = { otsuEnabled = it; analyzer.useAdaptiveThreshold = it },
colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFF4CAF50))
)
}
if (!otsuEnabled) {
Slider(
value = currentThreshold,
onValueChange = { currentThreshold = it; GreenZoneConstants.lowEchoAmp = it },
valueRange = 800f..2000f,
colors = SliderDefaults.colors(thumbColor = Color(0xFFFF9500), activeTrackColor = Color(0xFFFF9500))
)
}
HorizontalDivider()
// DPS
var dpsText by remember { mutableStateOf("%.3f".format(PiezoHW.distancePerSample)) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("DPS", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
OutlinedTextField(
value = dpsText,
onValueChange = { input -> dpsText = input; input.toDoubleOrNull()?.let { v -> if (v in 0.5..5.0) PiezoHW.distancePerSample = v } },
suffix = { Text("mm", fontSize = 12.sp, color = MlSecondaryText) },
singleLine = true,
modifier = Modifier.width(110.dp),
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF4CAF50))
)
}
HorizontalDivider()
// Detection Method A/B (왼쪽=A, 오른쪽=B)
var useMethodB by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod == com.example.medilightv2android.managers.DetectionMethod.METHOD_B) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Detection", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(8.dp))
Text(if (useMethodB) "Method B" else "Method A", fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = if (useMethodB) MlPrimary else Color(0xFF9C27B0))
Spacer(modifier = Modifier.weight(1f))
Switch(checked = useMethodB, onCheckedChange = {
useMethodB = it
com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod =
if (it) com.example.medilightv2android.managers.DetectionMethod.METHOD_B
else com.example.medilightv2android.managers.DetectionMethod.METHOD_A
}, colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary, uncheckedTrackColor = Color(0xFF9C27B0).copy(alpha = 0.5f)))
}
HorizontalDivider()
// SG Filter
var sgEnabled by remember { mutableStateOf(analyzer.useSgFilter) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("SG Filter", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
Switch(checked = sgEnabled, onCheckedChange = { sgEnabled = it; analyzer.useSgFilter = it },
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary))
}
HorizontalDivider()
// Disconnect
var showDisconnectConfirm by remember { mutableStateOf(false) }
Button(
onClick = { showDisconnectConfirm = true },
modifier = Modifier.fillMaxWidth().height(40.dp),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f))
) {
Icon(Icons.Default.LinkOff, null, tint = Color.Red, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Disconnect", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.Red)
}
if (showDisconnectConfirm) {
AlertDialog(
onDismissRequest = { showDisconnectConfirm = false },
title = { Text("Disconnect Device") },
text = { Text("기기 연결을 해제하고 페어링 정보를 삭제합니다.") },
confirmButton = {
TextButton(onClick = {
showDisconnectConfirm = false; showSettings = false
bleManager.disconnectAndUnbond(); appState.goHome()
}) { Text("Disconnect", color = Color.Red) }
},
dismissButton = { TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") } }
)
}
}
}
} }
} // end Box
} }
} }
@@ -1055,7 +1094,7 @@ private fun WaveformChart(
val denoised = analyzer.denoise(raw) val denoised = analyzer.denoise(raw)
val maxVal = 2500f val maxVal = 2500f
val threshold = if (analyzer.useAdaptiveThreshold) val threshold = if (analyzer.useAdaptiveThreshold)
analyzer.computeAdaptiveThreshold(denoised).toFloat() analyzer.otsu1d(denoised, kotlin.math.min(denoised.size, analyzer.postMaxIdx + 1)).toFloat()
else analyzer.lowEchoAmpDefault.toFloat() else analyzer.lowEchoAmpDefault.toFloat()
val distPerSample = 1.771f // mm per sample val distPerSample = 1.771f // mm per sample
@@ -312,6 +312,7 @@ fun PlacementGuideView(appState: AppState) {
for (ch in channels) { for (ch in channels) {
if (ch.buffer.isNotEmpty() && ch.channel < 6) { if (ch.buffer.isNotEmpty() && ch.channel < 6) {
val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel) val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel)
Log.d("PlacementGuide", "CH${ch.channel} otsu_thr=${"%.0f".format(analyzer.lastOtsuThreshold)} valid=${analysis.isValid} urine=${analysis.result?.urineLen ?: 0}")
if (analysis.isValid && analysis.result != null && analysis.result.urineLen >= MIN_URINE_LEN_PLACEMENT) { if (analysis.isValid && analysis.result != null && analysis.result.urineLen >= MIN_URINE_LEN_PLACEMENT) {
detected[ch.channel] = true detected[ch.channel] = true
urineLens[ch.channel] = analysis.result.urineLen urineLens[ch.channel] = analysis.result.urineLen
@@ -359,11 +360,11 @@ fun PlacementGuideView(appState: AppState) {
if (guideResult.isPass) { if (guideResult.isPass) {
isLocked = true isLocked = true
bleManager.sendLedMode(6) // ALIGN_COMPLETE bleManager.sendLedMode(6) // ALIGN_COMPLETE
bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score}") bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)}")
} else { } else {
isLocked = false isLocked = false
bleManager.sendLedMode(5) // ALIGN_SEARCHING bleManager.sendLedMode(5) // ALIGN_SEARCHING
bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} hint=${guideResult.hint}") bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)} hint=${guideResult.hint}")
} }
} }
@@ -419,14 +420,6 @@ fun PlacementGuideView(appState: AppState) {
containerColor = if (isContinuousMode) Color.Red else MlPrimary containerColor = if (isContinuousMode) Color.Red else MlPrimary
) )
) { ) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text( Text(
text = when { text = when {
isContinuousMode -> "Stop Scanning" isContinuousMode -> "Stop Scanning"
@@ -439,15 +432,6 @@ fun PlacementGuideView(appState: AppState) {
) )
} }
if (isContinuousMode) {
Text(
"Scanning every 3s — move sensor and watch the score",
fontSize = 11.sp, color = MlSecondaryText,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
// Start Measurement button (when 4+ channels detected) // Start Measurement button (when 4+ channels detected)
if (isLocked || detectedCount >= 4) { if (isLocked || detectedCount >= 4) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -1,20 +1,14 @@
package com.example.medilightv2android.ui.views.onboarding package com.example.medilightv2android.ui.views.onboarding
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -24,91 +18,41 @@ import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
import com.example.medilightv2android.ui.components.BladdyIdleRiveView import com.example.medilightv2android.ui.components.BladdyIdleRiveView
import com.example.medilightv2android.ui.theme.* import com.example.medilightv2android.ui.theme.*
import kotlinx.coroutines.launch
private val pages = listOf(
"Welcome to VesiScan-Basic" to "Smart bladder monitoring\nfor better quality of life",
"Real-time Monitoring" to "Track your bladder level\nwith instant visual feedback"
)
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun OnboardingView(appState: AppState) { fun OnboardingView(appState: AppState) {
val pagerState = rememberPagerState(pageCount = { pages.size })
val scope = rememberCoroutineScope()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MlBackground) .background(MlBackground),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
HorizontalPager( Spacer(modifier = Modifier.weight(1f))
state = pagerState,
modifier = Modifier.weight(1f)
) { page ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.weight(1f))
BladdyIdleRiveView(size = 180.dp) BladdyIdleRiveView(size = 180.dp)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
text = pages[page].first, text = "Welcome to VesiScan-Basic",
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = pages[page].second, text = "Smart bladder monitoring\nfor better quality of life",
fontSize = 16.sp, fontSize = 16.sp,
color = MlSecondaryText, color = MlSecondaryText,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.weight(2f)) Spacer(modifier = Modifier.weight(2f))
}
}
// Page indicators
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.Center
) {
repeat(pages.size) { index ->
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(if (index == pagerState.currentPage) 10.dp else 8.dp)
.clip(CircleShape)
.background(
if (index == pagerState.currentPage) MlPrimary
else MlPrimary.copy(alpha = 0.3f)
)
)
}
}
// Button
Button( Button(
onClick = { onClick = { appState.completeOnboarding() },
if (pagerState.currentPage < pages.size - 1) {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
} else {
appState.completeOnboarding()
}
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 32.dp) .padding(horizontal = 32.dp)
@@ -127,12 +71,7 @@ fun OnboardingView(appState: AppState) {
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text("Get Started", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
text = if (pagerState.currentPage < pages.size - 1) "Next" else "Get Started",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
} }
} }
} }
@@ -3,16 +3,14 @@ package com.example.medilightv2android.ui.views.registration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -26,131 +24,104 @@ import com.example.medilightv2android.ui.theme.*
@Composable @Composable
fun RegistrationView(appState: AppState) { fun RegistrationView(appState: AppState) {
var step by remember { mutableIntStateOf(1) }
var firstName by remember { mutableStateOf("") } var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") } var lastName by remember { mutableStateOf("") }
var ageText by remember { mutableStateOf("") } var ageText by remember { mutableStateOf("") }
var selectedSex by remember { mutableStateOf<Sex?>(null) } var selectedSex by remember { mutableStateOf<Sex?>(null) }
var heightText by remember { mutableStateOf("") } var heightText by remember { mutableStateOf("") }
var weightText by remember { mutableStateOf("") } var weightText by remember { mutableStateOf("") }
val totalSteps = 3
val canProceed = when (step) {
1 -> firstName.trim().isNotEmpty() && lastName.trim().isNotEmpty()
2 -> {
val age = ageText.toIntOrNull() ?: 0
age in 1..120 && selectedSex != null
}
3 -> {
val h = heightText.toDoubleOrNull() ?: 0.0
val w = weightText.toDoubleOrNull() ?: 0.0
h in 50.0..250.0 && w in 10.0..300.0
}
else -> false
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MlBackground) .background(MlBackground)
) { ) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (step > 1) {
IconButton(onClick = { step-- }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MlPrimary)
}
}
Spacer(modifier = Modifier.weight(1f))
Text("$step/$totalSteps", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = MlSecondaryText)
}
BladdyIdleRiveView(
size = 120.dp,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.weight(1f))
// Content
Column( Column(
modifier = Modifier.padding(horizontal = 32.dp), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(20.dp) .weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
when (step) { Spacer(modifier = Modifier.height(32.dp))
1 -> {
Text("What's your name?", fontSize = 24.sp, fontWeight = FontWeight.Bold) BladdyIdleRiveView(size = 100.dp)
Spacer(modifier = Modifier.height(16.dp))
Text("User Registration", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(4.dp))
Text("Enter your information", fontSize = 14.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(24.dp))
// Name
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("First Name", firstName, { firstName = it }) RegistrationTextField("First Name", firstName, { firstName = it })
}
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Last Name", lastName, { lastName = it }) RegistrationTextField("Last Name", lastName, { lastName = it })
} }
2 -> { }
Text("About you", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(12.dp))
// Age + Sex
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number) RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number)
Spacer(modifier = Modifier.height(4.dp))
Text("Sex", fontSize = 14.sp, color = MlSecondaryText)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
listOf(Sex.MALE to "남성", Sex.FEMALE to "여성").forEach { (sex, label) ->
val selected = selectedSex == sex
OutlinedButton(
onClick = { selectedSex = sex },
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MlPrimary.copy(alpha = 0.1f) else Color.Transparent
),
border = androidx.compose.foundation.BorderStroke(
1.dp, if (selected) MlPrimary else MlInputBorder.copy(alpha = 0.3f)
)
) {
Text(label, color = if (selected) MlPrimary else MlSecondaryText, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
} }
3 -> { listOf(Sex.MALE to "남성", Sex.FEMALE to "여성").forEach { (sex, label) ->
Text("Body measurements", fontSize = 24.sp, fontWeight = FontWeight.Bold) val selected = selectedSex == sex
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedButton(
Box(modifier = Modifier.weight(1f)) { onClick = { selectedSex = sex },
RegistrationTextField("Height", heightText, { heightText = it }, KeyboardType.Decimal, "cm") modifier = Modifier.height(52.dp),
} shape = RoundedCornerShape(12.dp),
Box(modifier = Modifier.weight(1f)) { colors = ButtonDefaults.outlinedButtonColors(
RegistrationTextField("Weight", weightText, { weightText = it }, KeyboardType.Decimal, "kg") containerColor = if (selected) MlPrimary.copy(alpha = 0.1f) else Color.Transparent
} ),
border = androidx.compose.foundation.BorderStroke(
1.dp, if (selected) MlPrimary else MlInputBorder.copy(alpha = 0.3f)
)
) {
Text(label, color = if (selected) MlPrimary else MlSecondaryText, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
} }
} }
} }
Spacer(modifier = Modifier.height(12.dp))
// Height + Weight
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Height", heightText, { heightText = it }, KeyboardType.Decimal, "cm")
}
Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Weight", weightText, { weightText = it }, KeyboardType.Decimal, "kg")
}
}
Spacer(modifier = Modifier.height(24.dp))
} }
Spacer(modifier = Modifier.weight(1f)) // Complete button
// Next button
Button( Button(
onClick = { onClick = {
if (step < totalSteps) { appState.user = appState.user.copy(
step++ firstName = firstName,
} else { lastName = lastName,
appState.user = appState.user.copy( age = ageText.toIntOrNull() ?: 0,
firstName = firstName, sex = selectedSex,
lastName = lastName, heightCm = heightText.toDoubleOrNull() ?: 0.0,
age = ageText.toIntOrNull() ?: 0, weightKg = weightText.toDoubleOrNull() ?: 0.0
sex = selectedSex, )
heightCm = heightText.toDoubleOrNull() ?: 0.0, appState.completeRegistration()
weightKg = weightText.toDoubleOrNull() ?: 0.0
)
appState.completeRegistration()
}
}, },
enabled = canProceed,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 32.dp) .padding(horizontal = 32.dp)
.padding(bottom = 48.dp) .height(52.dp),
.height(56.dp),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues() contentPadding = PaddingValues()
@@ -159,20 +130,24 @@ fun RegistrationView(appState: AppState) {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
brush = if (canProceed) Brush.horizontalGradient(listOf(MlTeal, MlPrimary)) brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
else Brush.horizontalGradient(listOf(Color.Gray.copy(alpha = 0.4f), Color.Gray.copy(alpha = 0.4f))),
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp)
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text("Complete", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
text = if (step < totalSteps) "Next" else "Complete",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
} }
} }
// Skip button
TextButton(
onClick = { appState.completeRegistration() },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
) {
Text("Skip", fontSize = 14.sp, color = MlSecondaryText)
}
} }
} }