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