diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index 5cc8e1a..c48bf89 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -12,8 +12,12 @@ package com.example.medilightv2android.managers * ⚠ 미세 조정 시 이 파일만 수정하면 전체 파이프라인에 반영됨. * 각 상수의 의미와 영향 범위를 아래 주석 참고. */ +enum class DetectionMethod { METHOD_A, METHOD_B } + object GreenZoneConstants { + var detectionMethod: DetectionMethod = DetectionMethod.METHOD_B + // ═══════════════════════════════════════════════════════════ // 신호 범위 // ═══════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt index caf4033..5ef88e5 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt @@ -266,130 +266,115 @@ private fun segmentToDistancesMm( 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( - val s: DoubleArray, - val aCap: DoubleArray, - val bEff: Double?, - val capMode: String, - val outlierIdx: Int? +private data class EllipseCapResult( + val hBot: Double, + val hTop: Double, + val botKind: String, + val topKind: String ) -/** - * 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 +private fun ellipseCapHeights( + dAnt: List, dPost: List, + validChannels: List, + sensorZMm: DoubleArray, degreeDeg: DoubleArray, + aCapBot: Double, aCapTop: Double, + yS: DoubleArray +): EllipseCapResult { + val ptsX = mutableListOf() + val ptsY = mutableListOf() - if (n < 3) { - return ParabolicCapResult(sWork, aWork, null, "fallback", null) + for ((i, ch) in validChannels.withIndex()) { + 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 보정 ── - 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 - } + if (ptsX.size < 5) { + return EllipseCapResult(aCapBot, aCapTop, "fallback", "fallback") } - // ── 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 + // 축 정렬 타원: Ax² + Cy² + Dx + Ey = 1 (F=-1 정규화) + // least squares: M @ [A,C,D,E]^T = 1 + val nPts = ptsX.size + val sol = solveEllipseLSQ(ptsX.toDoubleArray(), ptsY.toDoubleArray(), nPts) + ?: return EllipseCapResult(aCapBot, aCapTop, "fallback", "fallback") - 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 + val (aa, cc, dd, ee) = sol + if (aa <= 0 || cc <= 0) { + return EllipseCapResult(aCapBot, aCapTop, "fallback", "fallback") } - // ── 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) - } + val xc = -dd / (2 * aa) + val yc = -ee / (2 * cc) + val rhs = dd * dd / (4 * aa) + ee * ee / (4 * cc) + 1.0 + if (rhs <= 0) { + return EllipseCapResult(aCapBot, aCapTop, "fallback", "fallback") } - 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) - -/** - * 단일 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" + for (k in 0 until n) { + val x = px[k]; val y = py[k] + val row = doubleArrayOf(x * x, y * y, x, y) + for (i in 0 until 4) { + for (j in 0 until 4) mtm[i][j] += row[i] * row[j] + mtb[i] += row[i] // RHS = 1 + } } + return solve4x4(mtm, mtb) +} - 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" +private fun solve4x4(A: Array, b: DoubleArray): DoubleArray? { + val a = Array(4) { A[it].copyOf() } + val bb = b.copyOf() + for (col in 0 until 4) { + var maxRow = col; var maxVal = abs(a[col][col]) + for (row in (col + 1) until 4) { + 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] + } } - - return CapResult(h, volume, kind) + val x = DoubleArray(4) + 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 ── @@ -488,12 +473,12 @@ fun estimateBladderVolume( 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 + // 7) 타원 피팅 → cap 높이 + val ellCap = ellipseCapHeights( + dAnt, dPost, validChannels, + sensorZMm, degreeDeg, + aCapS[0], aCapS[n - 1], yS + ) // 8) Core frustum (traditional: h = dy) val dy = DoubleArray(n - 1) { yS[it + 1] - yS[it] } @@ -502,27 +487,12 @@ fun estimateBladderVolume( } 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 (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") - } + // 9) Caps — Bottom: sphere, Top: cone + 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 // 10) 합산 - val bvMm3 = vCore + botCap.volume + topCap.volume + val bvMm3 = vCore + vBottom + vTop return BVResult( volumeMl = bvMm3 / 1000.0, @@ -537,12 +507,12 @@ fun estimateBladderVolume( sortedSMm2 = sS, vFrustumMm3 = vFrustum, vCoreMm3 = vCore, - vBottomMm3 = botCap.volume, - vTopMm3 = topCap.volume, - bottomHMm = botCap.h, - topHMm = topCap.h, - bottomKind = botCap.kind, - topKind = topCap.kind, + vBottomMm3 = vBottom, + vTopMm3 = vTop, + bottomHMm = ellCap.hBot, + topHMm = ellCap.hTop, + bottomKind = ellCap.botKind, + topKind = ellCap.topKind, lrRatio = lrRatio, distancePerSample = distancePerSample, delayOffsetMm = delayOffsetMm diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt index f797576..505e123 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt @@ -311,59 +311,78 @@ class PiezoEchoAnalyzer private constructor() { // ── 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) - } - - var useAdaptiveThreshold: Boolean = false + var useAdaptiveThreshold: Boolean = true + var lastOtsuThreshold: Double = 0.0; private set /** 단일 1D 채널 → urine region 탐지 */ fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray): LowEchoResult? { val sg = denoised if (sg.size < 10) return null - if (!useAdaptiveThreshold) { - return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault) + val thr = if (useAdaptiveThreshold) { + val limit = min(sg.size, postMaxIdx + 1) + otsu1d(sg, limit) + } else { + lowEchoAmpDefault } - - // Adaptive threshold: percentile + depth 보상 조합 - val thr = computeAdaptiveThreshold(sg) + lastOtsuThreshold = 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 사이에서 결정 - * 2) 초반 peak(피부 반사) 제외: ringSkip(3) 이후 사용 - * 3) 고정 threshold와의 가중 평균으로 급격한 변동 방지 + * 히스토그램에서 between-class variance를 최대화하는 임계값 반환. + * 저진폭(urine/조직)과 고진폭(벽 echo) 두 모집단을 분리. */ - fun computeAdaptiveThreshold(sg: DoubleArray): Double { - val skip = GreenZoneConstants.ringSkip - val usable = if (sg.size > skip + 10) sg.sliceArray(skip until sg.size) else sg + fun otsu1d(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): Double { + val n = min(values.size, limit) + if (n == 0) return 0.0 - val sorted = usable.sorted() - val n = sorted.size - val q25 = sorted[n / 4] - val q50 = sorted[n / 2] - val q75 = sorted[3 * n / 4] - val iqr = q75 - q25 + var vMin = values[0]; var vMax = values[0] + for (i in 1 until n) { + if (values[i] < vMin) vMin = values[i] + if (values[i] > vMax) vMax = values[i] + } + if (vMax == vMin) return vMin - // Percentile 기반: 소변 영역은 보통 하위 25~50% - val percThr = max(q25 + iqr * 0.3, q50 - iqr * 0.3) + val binWidth = (vMax - vMin) / nBins + 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 adaptive = percThr.coerceIn(lowEchoAmpDefault * 0.7, lowEchoAmpDefault * 1.5) - val blended = lowEchoAmpDefault * 0.4 + adaptive * 0.6 + val centers = DoubleArray(nBins) { vMin + (it + 0.5) * binWidth } + val total = n.toDouble() - 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] } /** diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzerA.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzerA.kt new file mode 100644 index 0000000..c748e20 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzerA.kt @@ -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, 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() + 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> { + val spans = mutableListOf>() + 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?>, + centerCh: List = listOf(0, 1, 2, 3) + ): List?> { + 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() + 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 { + 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 + } +} diff --git a/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt b/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt index 308709a..e48c72b 100644 --- a/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt +++ b/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt @@ -54,7 +54,9 @@ object AdcCsvLogger { val timestamp = timeFmt.format(Date()) val volStr = volumeMl?.let { "%.1f".format(it) } ?: "" 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 sgStr = if (com.example.medilightv2android.managers.PiezoEchoAnalyzer.shared.useSgFilter) "ON" else "OFF" diff --git a/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt b/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt index 2c1267b..d081f16 100644 --- a/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt +++ b/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt @@ -155,7 +155,7 @@ class BleDebugLogger private constructor(private val context: Context) { info("ENDIAN_DETECT ${if (isBigEndian) "BIG" else "LITTLE"} (LE=$firstSampleLE, BE=$firstSampleBE)") } - fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List) { + fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List, otsuThreshold: Double = 0.0) { val scanId = AdcCsvLogger.lastScanId val msg = buildString { append("MEASUREMENT scan=$scanId") @@ -163,6 +163,7 @@ class BleDebugLogger private constructor(private val context: Context) { append(" lr=${"%.2f".format(lrRatio)}") append(" valid=$validChannels/6") append(" urine=[${urineLines.joinToString(",")}]") + if (otsuThreshold > 0) append(" otsu=${"%.0f".format(otsuThreshold)}") } add(Entry(System.currentTimeMillis(), Level.INFO, msg)) } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index ac32780..c7c4f10 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -112,23 +112,38 @@ fun PiezoMonitoringView(appState: AppState) { val validChannels = channels.filter { it.isValid } 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?> = MutableList(6) { null } - for (ch in analysisResult.channels) { + for (ch in channelResults) { if (ch.channel < 6 && ch.result != null) { 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) val centerWallCount = (0..3).count { allWalls[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) { - 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) 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, - (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) com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio) } @@ -586,150 +602,16 @@ fun PiezoMonitoringView(appState: AppState) { if (showSettings) scrollState.animateScrollTo(0) } + Box(modifier = Modifier.weight(1f)) { Column( modifier = Modifier - .weight(1f) + .fillMaxSize() .verticalScroll(scrollState) .padding(horizontal = hPad) .padding(top = if (isCompact) 8.dp else 16.dp, bottom = if (isCompact) 16.dp else 32.dp), horizontalAlignment = Alignment.CenterHorizontally, 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 Text( @@ -865,7 +747,164 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.height(8.dp)) 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 maxVal = 2500f 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() val distPerSample = 1.771f // mm per sample diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index d7aced1..dfee1e8 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -312,6 +312,7 @@ fun PlacementGuideView(appState: AppState) { for (ch in channels) { if (ch.buffer.isNotEmpty() && ch.channel < 6) { 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) { detected[ch.channel] = true urineLens[ch.channel] = analysis.result.urineLen @@ -359,11 +360,11 @@ fun PlacementGuideView(appState: AppState) { if (guideResult.isPass) { isLocked = true 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 { isLocked = false 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 ) ) { - if (isScanning) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = Color.White, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(8.dp)) - } Text( text = when { 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) if (isLocked || detectedCount >= 4) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/onboarding/OnboardingView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/onboarding/OnboardingView.kt index 5e1baab..bcbd7f4 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/onboarding/OnboardingView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/onboarding/OnboardingView.kt @@ -1,20 +1,14 @@ package com.example.medilightv2android.ui.views.onboarding -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background 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.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color 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.ui.components.BladdyIdleRiveView 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 fun OnboardingView(appState: AppState) { - val pagerState = rememberPagerState(pageCount = { pages.size }) - val scope = rememberCoroutineScope() - Column( modifier = Modifier .fillMaxSize() - .background(MlBackground) + .background(MlBackground), + horizontalAlignment = Alignment.CenterHorizontally ) { - HorizontalPager( - 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)) + 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 = pages[page].first, - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) + Text( + text = "Welcome to VesiScan-Basic", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) - Text( - text = pages[page].second, - fontSize = 16.sp, - color = MlSecondaryText, - textAlign = TextAlign.Center - ) + Text( + text = "Smart bladder monitoring\nfor better quality of life", + fontSize = 16.sp, + color = MlSecondaryText, + 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( - onClick = { - if (pagerState.currentPage < pages.size - 1) { - scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } - } else { - appState.completeOnboarding() - } - }, + onClick = { appState.completeOnboarding() }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp) @@ -127,12 +71,7 @@ fun OnboardingView(appState: AppState) { ), contentAlignment = Alignment.Center ) { - Text( - text = if (pagerState.currentPage < pages.size - 1) "Next" else "Get Started", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White - ) + Text("Get Started", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White) } } } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/registration/RegistrationView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/registration/RegistrationView.kt index 5de2a4f..52171a0 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/registration/RegistrationView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/registration/RegistrationView.kt @@ -3,16 +3,14 @@ package com.example.medilightv2android.ui.views.registration import androidx.compose.foundation.background import androidx.compose.foundation.border 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.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -26,131 +24,104 @@ import com.example.medilightv2android.ui.theme.* @Composable fun RegistrationView(appState: AppState) { - var step by remember { mutableIntStateOf(1) } var firstName by remember { mutableStateOf("") } var lastName by remember { mutableStateOf("") } var ageText by remember { mutableStateOf("") } var selectedSex by remember { mutableStateOf(null) } var heightText 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( modifier = Modifier .fillMaxSize() .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( - modifier = Modifier.padding(horizontal = 32.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - when (step) { - 1 -> { - Text("What's your name?", fontSize = 24.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(32.dp)) + + 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 }) + } + Box(modifier = Modifier.weight(1f)) { 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) - 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 -> { - Text("Body measurements", fontSize = 24.sp, fontWeight = FontWeight.Bold) - 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") - } + listOf(Sex.MALE to "남성", Sex.FEMALE to "여성").forEach { (sex, label) -> + val selected = selectedSex == sex + OutlinedButton( + onClick = { selectedSex = sex }, + modifier = Modifier.height(52.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) } } } + + 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)) - - // Next button + // Complete button Button( onClick = { - if (step < totalSteps) { - step++ - } else { - appState.user = appState.user.copy( - firstName = firstName, - lastName = lastName, - age = ageText.toIntOrNull() ?: 0, - sex = selectedSex, - heightCm = heightText.toDoubleOrNull() ?: 0.0, - weightKg = weightText.toDoubleOrNull() ?: 0.0 - ) - appState.completeRegistration() - } + appState.user = appState.user.copy( + firstName = firstName, + lastName = lastName, + age = ageText.toIntOrNull() ?: 0, + sex = selectedSex, + heightCm = heightText.toDoubleOrNull() ?: 0.0, + weightKg = weightText.toDoubleOrNull() ?: 0.0 + ) + appState.completeRegistration() }, - enabled = canProceed, modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp) - .padding(bottom = 48.dp) - .height(56.dp), + .height(52.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), contentPadding = PaddingValues() @@ -159,20 +130,24 @@ fun RegistrationView(appState: AppState) { modifier = Modifier .fillMaxSize() .background( - brush = if (canProceed) Brush.horizontalGradient(listOf(MlTeal, MlPrimary)) - else Brush.horizontalGradient(listOf(Color.Gray.copy(alpha = 0.4f), Color.Gray.copy(alpha = 0.4f))), + brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)), shape = RoundedCornerShape(16.dp) ), contentAlignment = Alignment.Center ) { - Text( - text = if (step < totalSteps) "Next" else "Complete", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White - ) + Text("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) + } } }