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 {
var detectionMethod: DetectionMethod = DetectionMethod.METHOD_B
// ═══════════════════════════════════════════════════════════
// 신호 범위
// ═══════════════════════════════════════════════════════════
@@ -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<Double>, dPost: List<Double>,
validChannels: List<Int>,
sensorZMm: DoubleArray, degreeDeg: DoubleArray,
aCapBot: Double, aCapTop: Double,
yS: DoubleArray
): EllipseCapResult {
val ptsX = mutableListOf<Double>()
val ptsY = mutableListOf<Double>()
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
}
val volume: Double
if (shape == "cone") {
volume = sEdge * h / 3.0
kind += " cone"
} else {
volume = sEdge * h / 2.0 + Math.PI * h * h * h / 6.0
kind += " sphere"
}
return solve4x4(mtm, mtb)
}
return CapResult(h, volume, kind)
private fun solve4x4(A: Array<DoubleArray>, 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]
}
}
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
@@ -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]
}
/**
@@ -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 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"
@@ -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<Int>) {
fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List<Int>, 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))
}
@@ -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<Pair<Int, Int>?> = 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
@@ -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))
@@ -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,34 +18,14 @@ 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)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
) { page ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.background(MlBackground),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(1f))
@@ -60,7 +34,7 @@ fun OnboardingView(appState: AppState) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = pages[page].first,
text = "Welcome to VesiScan-Basic",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
@@ -69,46 +43,16 @@ fun OnboardingView(appState: AppState) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = pages[page].second,
text = "Smart bladder monitoring\nfor better quality of life",
fontSize = 16.sp,
color = MlSecondaryText,
textAlign = TextAlign.Center
)
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)
}
}
}
@@ -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,79 +24,59 @@ 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<Sex?>(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),
modifier = Modifier.height(52.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MlPrimary.copy(alpha = 0.1f) else Color.Transparent
@@ -111,9 +89,10 @@ fun RegistrationView(appState: AppState) {
}
}
}
}
3 -> {
Text("Body measurements", fontSize = 24.sp, fontWeight = FontWeight.Bold)
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")
@@ -122,18 +101,13 @@ fun RegistrationView(appState: AppState) {
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,
@@ -143,14 +117,11 @@ fun RegistrationView(appState: AppState) {
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)
}
}
}