fix: UX feedback + BV ellipse outlier (#22) + README v3 update
- #1 배터리 최적화 페이지 이동 제거 (ForegroundService+WakeLock만) - #2 Auto 첫 측정 대기 시 "Measuring... (n/5)" loading 표시 - #3 Placement 멘트 정리 — 방향만 남기기 (CH 언급 제거) - #4 CH0~CH5 dot row 개발자 모드 전용 - #5 Spot 재시도 로직 (최대 6회, 유효 3개 목표, 300ms 간격) - LR 좌우 조건 완화: ±8mm, symmetry≥0.5, lrDev≤0.35 - |len4-len5|≤10 → LATERAL 건너뛰고 바로 GREEN - GREEN 미달 시 CV만 간당간당하면 GREEN 유지 ("Almost there...") - CV 기본 임계 0.08→0.12, 디바운스 3초→1.5초 - BV 타원 피팅 outlier 반복 제거 (appshare #22) - README v3 업데이트 (3모드 placement, BV 파이프라인, 상태표) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -504,31 +504,69 @@ fun estimateBladderVolume(
|
||||
val aCapS = DoubleArray(order.size) { aCap[order[it]] }
|
||||
val sortedCh = order.map { validChannels[it] }
|
||||
|
||||
// 7) y-z 평면 타원 피팅 → cap 높이 추정 (#21 merge)
|
||||
val yw = DoubleArray(2 * n) { i -> if (i < n) yWallAnt[i] else yWallPost[i - n] }
|
||||
val zw = DoubleArray(2 * n) { i -> if (i < n) zWallAnt[i] else zWallPost[i - n] }
|
||||
val nPts = yw.size
|
||||
// 7) y-z 평면 타원 피팅 + 반복 outlier 제거 (#22 merge)
|
||||
val allYw = DoubleArray(2 * n) { i -> if (i < n) yWallAnt[i] else yWallPost[i - n] }
|
||||
val allZw = DoubleArray(2 * n) { i -> if (i < n) zWallAnt[i] else zWallPost[i - n] }
|
||||
val nPts = allYw.size
|
||||
var capKind = "fallback"
|
||||
var z0Ellipse: Double? = null
|
||||
var hCapBot = aCapS[0]
|
||||
var hCapTop = aCapS[n - 1]
|
||||
val ellipseCostThr = 0.5 // 점당 평균 잔차 임계
|
||||
|
||||
if (nPts >= 4) {
|
||||
val ym = yw.average(); val ysStd = std(yw) + 1e-12
|
||||
val zm = zw.average(); val zsStd = std(zw) + 1e-12
|
||||
val yn = DoubleArray(nPts) { (yw[it] - ym) / ysStd }
|
||||
val zn = DoubleArray(nPts) { (zw[it] - zm) / zsStd }
|
||||
// 타원 피팅 helper: 성공 시 (y0, z0, bSi, aAp, residuals) 반환
|
||||
fun fitEllipsePts(ywF: DoubleArray, zwF: DoubleArray): Array<Any>? {
|
||||
val cnt = ywF.size
|
||||
val ym = ywF.average(); val ysS = std(ywF) + 1e-12
|
||||
val zm = zwF.average(); val zsS = std(zwF) + 1e-12
|
||||
val yn = DoubleArray(cnt) { (ywF[it] - ym) / ysS }
|
||||
val zn = DoubleArray(cnt) { (zwF[it] - zm) / zsS }
|
||||
val sol = solveEllipseLSQ(yn, zn, cnt) ?: return null
|
||||
if (sol[0] <= 1e-12 || sol[1] <= 1e-12) return null
|
||||
val rr = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1])
|
||||
if (rr <= 0) return null
|
||||
val y0 = (-sol[2] / (2.0 * sol[0])) * ysS + ym
|
||||
val z0 = (-sol[3] / (2.0 * sol[1])) * zsS + zm
|
||||
val bSi = sqrt(rr / sol[0]) * ysS
|
||||
val aAp = sqrt(rr / sol[1]) * zsS
|
||||
val resid = DoubleArray(cnt) {
|
||||
abs(((ywF[it] - y0) / bSi) * ((ywF[it] - y0) / bSi) +
|
||||
((zwF[it] - z0) / aAp) * ((zwF[it] - z0) / aAp) - 1.0)
|
||||
}
|
||||
return arrayOf(y0, z0, bSi, aAp, resid)
|
||||
}
|
||||
|
||||
val sol = solveEllipseLSQ(yn, zn, nPts)
|
||||
if (sol != null && sol[0] > 1e-12 && sol[1] > 1e-12) {
|
||||
val y0n = -sol[2] / (2.0 * sol[0])
|
||||
val z0n = -sol[3] / (2.0 * sol[1])
|
||||
val rVal = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1])
|
||||
if (rVal > 0) {
|
||||
val bSiN = sqrt(rVal / sol[0])
|
||||
val y0 = y0n * ysStd + ym
|
||||
z0Ellipse = z0n * zsStd + zm
|
||||
val bSi = bSiN * ysStd
|
||||
if (nPts >= 5) {
|
||||
val keep = BooleanArray(nPts) { true }
|
||||
var fit = fitEllipsePts(allYw, allZw)
|
||||
|
||||
if (fit != null) {
|
||||
var y0 = fit[0] as Double; var z0 = fit[1] as Double
|
||||
var bSi = fit[2] as Double; var aAp = fit[3] as Double
|
||||
var resid = fit[4] as DoubleArray
|
||||
var meanRes = resid.average()
|
||||
|
||||
// 반복 outlier 제거: worst 점 하나씩, 최소 5점 유지
|
||||
while (meanRes > ellipseCostThr && keep.count { it } > 5) {
|
||||
val worstLocal = resid.indices.maxByOrNull { resid[it] } ?: break
|
||||
val activeIndices = keep.indices.filter { keep[it] }
|
||||
keep[activeIndices[worstLocal]] = false
|
||||
val keptY = keep.indices.filter { keep[it] }.map { allYw[it] }.toDoubleArray()
|
||||
val keptZ = keep.indices.filter { keep[it] }.map { allZw[it] }.toDoubleArray()
|
||||
val fit2 = fitEllipsePts(keptY, keptZ) ?: break
|
||||
val meanRes2 = (fit2[4] as DoubleArray).average()
|
||||
if (meanRes2 < meanRes) {
|
||||
y0 = fit2[0] as Double; z0 = fit2[1] as Double
|
||||
bSi = fit2[2] as Double; aAp = fit2[3] as Double
|
||||
resid = fit2[4] as DoubleArray; meanRes = meanRes2
|
||||
} else break
|
||||
}
|
||||
|
||||
// 품질 판정: 평균 잔차 ≤ threshold
|
||||
if (meanRes <= ellipseCostThr) {
|
||||
z0Ellipse = z0
|
||||
// b_si 상한: a_ap × 1.3 (해부학적 SI/AP 비율 제한)
|
||||
if (bSi > aAp * 1.3) bSi = aAp * 1.3
|
||||
hCapBot = max(0.0, yS[0] - (y0 - bSi))
|
||||
hCapTop = max(0.0, (y0 + bSi) - yS[n - 1])
|
||||
hCapBot = min(hCapBot, aCapS[0])
|
||||
|
||||
@@ -29,8 +29,8 @@ class BleForegroundService : Service() {
|
||||
const val ACTION_STOP = "com.example.medilightv2android.BLE_STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
// 배터리 최적화 제외 요청 (Doze 면제)
|
||||
requestBatteryOptimizationExemption(context)
|
||||
// 배터리 최적화 제외 요청 제거 — 다른 페이지로 이동되는 UX 문제
|
||||
// Foreground Service + PARTIAL_WAKE_LOCK만으로 Doze 대응
|
||||
|
||||
val intent = Intent(context, BleForegroundService::class.java).apply {
|
||||
action = ACTION_START
|
||||
|
||||
+40
-13
@@ -771,17 +771,38 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
BladdyRiveView(urgency = urgency, size = if (isCompact) 80.dp else 110.dp)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = urgency.accentColor,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MlCardBackground)
|
||||
.shadow(4.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.08f))
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||
)
|
||||
if (isAutoMeasuring && autoVolumeWindow.size < 5 && lastVolumeMl <= 0) {
|
||||
// Auto 측정 대기 중 — trimmed mean 산출 전
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MlCardBackground)
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = Color(0xFFFF9800),
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Measuring... (${autoVolumeWindow.size}/5)",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFFFF9800))
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = urgency.accentColor,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MlCardBackground)
|
||||
.shadow(4.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.08f))
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,11 +860,17 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
spotCooldown = true
|
||||
spotVolumes.clear()
|
||||
spotScope.launch {
|
||||
repeat(3) {
|
||||
// 최대 6회 시도, 유효값 3개 수집 목표
|
||||
val maxAttempts = 6
|
||||
val targetCount = 3
|
||||
var attempts = 0
|
||||
while (spotVolumes.size < targetCount && attempts < maxAttempts) {
|
||||
attempts++
|
||||
measure()
|
||||
while (isMeasuring) { kotlinx.coroutines.delay(100) }
|
||||
kotlinx.coroutines.delay(200)
|
||||
kotlinx.coroutines.delay(300) // 간격 여유 (BLE 겹침 방지)
|
||||
}
|
||||
Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}")
|
||||
// 콜백에서 spotVolumes에 수집됨 (화면 갱신 없이)
|
||||
if (spotVolumes.size >= 2) {
|
||||
spotVolumes.sort()
|
||||
|
||||
+155
-87
@@ -62,7 +62,12 @@ fun PlacementGuideView(appState: AppState) {
|
||||
var isContinuousMode by remember { mutableStateOf(true) }
|
||||
val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) }
|
||||
val channelUrineLen = remember { mutableStateListOf(0, 0, 0, 0, 0, 0) }
|
||||
var directionHint by remember { mutableStateOf("Place the sensor just above the pubic bone") }
|
||||
val isSweepMode = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode ==
|
||||
com.example.medilightv2android.managers.PlacementGuideMode.SWEEP
|
||||
var directionHint by remember { mutableStateOf(
|
||||
if (isSweepMode) "Place sensor near navel, slide down ↓"
|
||||
else "Place the sensor just above the pubic bone"
|
||||
) }
|
||||
var directionIcon by remember { mutableStateOf("") }
|
||||
var placementScore by remember { mutableIntStateOf(0) }
|
||||
var scanCount by remember { mutableIntStateOf(0) }
|
||||
@@ -98,7 +103,8 @@ fun PlacementGuideView(appState: AppState) {
|
||||
isLocked = false
|
||||
scanCount = 0
|
||||
placementScore = 0
|
||||
directionHint = "Place the sensor just above the pubic bone"
|
||||
directionHint = if (isSweepMode) "Place sensor near navel, slide down ↓"
|
||||
else "Place the sensor just above the pubic bone"
|
||||
directionIcon = ""
|
||||
for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 }
|
||||
rTracker.reset()
|
||||
@@ -330,7 +336,7 @@ fun PlacementGuideView(appState: AppState) {
|
||||
alpha = if (isGreenZone) 0.7f else 0.95f
|
||||
)
|
||||
|
||||
// Score + 감지수 (우측 상단)
|
||||
// Score + 감지수 + R-fit (우측 상단)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
@@ -347,6 +353,25 @@ fun PlacementGuideView(appState: AppState) {
|
||||
else -> Color.Red
|
||||
})
|
||||
}
|
||||
// SWEEP 모드: R-fit + argmax 표시
|
||||
if (isSweepMode && currentRMm != null) {
|
||||
val rColor = when {
|
||||
bestRMm > 0 && (bestRMm - currentRMm!!) < 1.5 -> Color(0xFF4CAF50) // PEAK
|
||||
currentRMm!! > (bestRMm * 0.8) -> Color(0xFF7C3AED) // RISING
|
||||
else -> Color(0xFFFF9800) // FALLING/NOISE
|
||||
}
|
||||
Text("R=${"%.0f".format(currentRMm)}mm", fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold, color = rColor)
|
||||
if (bestRMm > 0) {
|
||||
Text("best=${"%.0f".format(bestRMm)}", fontSize = 10.sp,
|
||||
color = Color(0xFF4CAF50).copy(alpha = 0.7f))
|
||||
}
|
||||
sagittalArgMaxCh?.let { arg ->
|
||||
Text("argmax=CH$arg", fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (arg == 0) Color(0xFF4CAF50) else Color(0xFFFF9800))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -371,33 +396,51 @@ fun PlacementGuideView(appState: AppState) {
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 채널 dot row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
for (i in 0 until 6) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(
|
||||
if (channelDetected[i]) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.35f)
|
||||
))
|
||||
Text("CH$i", fontSize = 8.sp, color = MlSecondaryText)
|
||||
// 채널 dot row (개발자 모드에서만 표시)
|
||||
if (appState.isDevMode) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
for (i in 0 until 6) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(
|
||||
if (channelDetected[i]) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.35f)
|
||||
))
|
||||
Text("CH$i", fontSize = 8.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Step indicator
|
||||
Text(
|
||||
text = when (placementPhase) {
|
||||
PlacementPhase.VERTICAL -> "Step 1/3"
|
||||
PlacementPhase.LATERAL -> "Step 2/3"
|
||||
PlacementPhase.GREEN -> "Step 3/3"
|
||||
},
|
||||
fontSize = 11.sp, color = MlSecondaryText
|
||||
)
|
||||
// Step indicator + mode badge
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = when (placementPhase) {
|
||||
PlacementPhase.VERTICAL -> "Step 1/3"
|
||||
PlacementPhase.LATERAL -> "Step 2/3"
|
||||
PlacementPhase.GREEN -> "Step 3/3"
|
||||
},
|
||||
fontSize = 11.sp, color = MlSecondaryText
|
||||
)
|
||||
if (isSweepMode) {
|
||||
Text(
|
||||
"SWEEP",
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF7C3AED), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-channel echo graphs (개발자 모드에서만 표시)
|
||||
if (appState.isDevMode && lastChannelData.isNotEmpty()) {
|
||||
@@ -554,7 +597,8 @@ fun PlacementGuideView(appState: AppState) {
|
||||
val detachResult = if (fullCh.size >= 6) DetachmentDetection.detect(buffers) else null
|
||||
|
||||
if (detachResult != null && detachResult.isDetached) {
|
||||
directionHint = "Sensor detached. Place it just above the pubic bone"
|
||||
directionHint = if (isSweepMode) "Sensor detached. Place near navel"
|
||||
else "Sensor detached. Place above pubic bone"
|
||||
directionIcon = "exclamationmark.triangle"
|
||||
placementScore = 0
|
||||
if (lastLedMode != 4) { bleManager.sendLedMode(4); lastLedMode = 4 }
|
||||
@@ -599,11 +643,12 @@ fun PlacementGuideView(appState: AppState) {
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastHintChangeMs
|
||||
// GREEN 진입/이탈은 즉시, 일반 힌트는 디바운스
|
||||
// GREEN 진입/이탈 및 phase 전환은 즉시, 같은 phase 내 힌트만 디바운스 (1.5초)
|
||||
val wasGreen = isLocked
|
||||
val isNowGreen = guideResult.isPass
|
||||
val greenChanged = wasGreen != isNowGreen
|
||||
if (greenChanged || (debounceCount >= 2 && elapsed >= 3000)) {
|
||||
val phaseChanged = guideResult.nextPhase != placementPhase
|
||||
if (greenChanged || phaseChanged || (debounceCount >= 2 && elapsed >= 1500)) {
|
||||
directionHint = guideResult.hint
|
||||
directionIcon = guideResult.icon
|
||||
placementScore = guideResult.score
|
||||
@@ -983,8 +1028,8 @@ data class PlacementGuideResult(
|
||||
val nextPhase: PlacementPhase = PlacementPhase.VERTICAL
|
||||
)
|
||||
|
||||
private const val DEFAULT_CV_THR = 0.08
|
||||
private const val DEFAULT_LR_DEV_THR = 0.25
|
||||
private const val DEFAULT_CV_THR = 0.12
|
||||
private const val DEFAULT_LR_DEV_THR = 0.35
|
||||
|
||||
/**
|
||||
* Gradient 기반 Placement Guide
|
||||
@@ -1036,7 +1081,7 @@ private fun computePlacementGuide(
|
||||
val detectedCount = detected.count { it }
|
||||
|
||||
if (detectedCount == 0) {
|
||||
return PlacementGuideResult("Bladder not found. Gradually raise the sensor ↑", "arrow.up", 0,
|
||||
return PlacementGuideResult("Raise the sensor ↑", "arrow.up", 0,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
}
|
||||
|
||||
@@ -1080,35 +1125,44 @@ private fun computePlacementGuide(
|
||||
if (centerDetected == 4) {
|
||||
return when (sagittalArgMaxCh) {
|
||||
0 -> {
|
||||
// Centred! advance to LATERAL
|
||||
// Centred! 좌우 확인
|
||||
val l4 = urineLens.getOrElse(4) { 0 }
|
||||
val l5 = urineLens.getOrElse(5) { 0 }
|
||||
val lrHint = when {
|
||||
l4 > 0 && l5 > 0 && l4 > l5 -> "Centred! Move right →"
|
||||
l4 > 0 && l5 > 0 && l5 > l4 -> "Centred! Move left ←"
|
||||
else -> "Centred! Checking lateral..."
|
||||
val ch4ok = detected.getOrElse(4) { false }
|
||||
val ch5ok = detected.getOrElse(5) { false }
|
||||
// 좌우도 이미 균등하면 바로 GREEN
|
||||
if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 10) {
|
||||
PlacementGuideResult("Position OK! Final check...",
|
||||
"checkmark.circle", 90,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN)
|
||||
} else {
|
||||
val lrHint = when {
|
||||
l4 > 0 && l5 > 0 && l4 > l5 -> "Move right →"
|
||||
l4 > 0 && l5 > 0 && l5 > l4 -> "Move left ←"
|
||||
else -> "Checking lateral..."
|
||||
}
|
||||
val lrIcon = when {
|
||||
l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right"
|
||||
l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left"
|
||||
else -> "checkmark.circle"
|
||||
}
|
||||
PlacementGuideResult(lrHint, lrIcon, 90,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL)
|
||||
}
|
||||
val lrIcon = when {
|
||||
l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right"
|
||||
l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left"
|
||||
else -> "checkmark.circle"
|
||||
}
|
||||
PlacementGuideResult(lrHint, lrIcon, 90,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL)
|
||||
}
|
||||
1 -> PlacementGuideResult("All 4 — Ch1 peak, slide down slightly ↓", "arrow.down", 75,
|
||||
1 -> PlacementGuideResult("Slide down slightly ↓", "arrow.down", 75,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
2 -> PlacementGuideResult("All 4 — Ch2 peak, slide down ↓", "arrow.down", 70,
|
||||
2 -> PlacementGuideResult("Slide down ↓", "arrow.down", 70,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
3 -> PlacementGuideResult("All 4 — Ch3 peak, slide further down ↓", "arrow.down", 65,
|
||||
3 -> PlacementGuideResult("Slide further down ↓", "arrow.down", 65,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
else -> PlacementGuideResult("All 4 detected — hold still", "checkmark.circle", 80,
|
||||
else -> PlacementGuideResult("Hold still", "checkmark.circle", 80,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: channels coming online (1..3)
|
||||
return PlacementGuideResult("Keep sliding down ↓ ($centerDetected/4 detected)", "arrow.down",
|
||||
return PlacementGuideResult("Slide down ↓", "arrow.down",
|
||||
20 + centerDetected * 10,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
}
|
||||
@@ -1120,24 +1174,14 @@ private fun computePlacementGuide(
|
||||
if (centerDetected <= 1) {
|
||||
val hint: String; val icon: String
|
||||
when {
|
||||
// ch0만 잡힘 → 방광이 위쪽에 걸림 → 위로 올려야
|
||||
ch0det && !ch1det && !ch2det && !ch3det -> {
|
||||
hint = "Signal at CH0. Raise the sensor ↑"; icon = "arrow.up"
|
||||
}
|
||||
// ch0 또는 ch1만 → 방광이 위쪽 → 위로
|
||||
(ch0det || ch1det) && !ch2det && !ch3det -> {
|
||||
hint = "Gradually raise the sensor ↑"; icon = "arrow.up"
|
||||
hint = "Raise the sensor ↑"; icon = "arrow.up"
|
||||
}
|
||||
// ch3만 잡힘 → 방광이 아래쪽에 걸림 → 아래로 내려야
|
||||
ch3det && !ch2det && !ch1det && !ch0det -> {
|
||||
hint = "Signal at CH3. Lower the sensor ↓"; icon = "arrow.down"
|
||||
}
|
||||
// ch2 또는 ch3만 → 방광이 아래쪽 → 아래로
|
||||
(ch3det || ch2det) && !ch0det && !ch1det -> {
|
||||
hint = "Gradually lower the sensor ↓"; icon = "arrow.down"
|
||||
hint = "Lower the sensor ↓"; icon = "arrow.down"
|
||||
}
|
||||
else -> {
|
||||
hint = "Gradually raise the sensor ↑"; icon = "arrow.up"
|
||||
hint = "Raise the sensor ↑"; icon = "arrow.up"
|
||||
}
|
||||
}
|
||||
return PlacementGuideResult(hint, icon, centerDetected * 10,
|
||||
@@ -1175,13 +1219,23 @@ private fun computePlacementGuide(
|
||||
.format(gradient, weightedCenter, lenVariation))
|
||||
|
||||
if (kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.5) {
|
||||
// 상하 OK → 좌우 탐색으로
|
||||
// 상하 OK → 좌우 확인
|
||||
val l4 = urineLens.getOrElse(4) { 0 }
|
||||
val l5 = urineLens.getOrElse(5) { 0 }
|
||||
val ch4ok = detected.getOrElse(4) { false }
|
||||
val ch5ok = detected.getOrElse(5) { false }
|
||||
|
||||
// 좌우도 이미 균등하면 LATERAL 건너뛰고 바로 GREEN
|
||||
if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 10) {
|
||||
return PlacementGuideResult("Position OK! Final check...",
|
||||
"checkmark.circle", 65,
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN)
|
||||
}
|
||||
|
||||
val lrHint = when {
|
||||
l4 > 0 && l5 > 0 && l4 > l5 -> "Vertical OK! Move right →"
|
||||
l4 > 0 && l5 > 0 && l5 > l4 -> "Vertical OK! Move left ←"
|
||||
else -> "Vertical OK! Checking lateral..."
|
||||
l4 > 0 && l5 > 0 && l4 > l5 -> "Move right →"
|
||||
l4 > 0 && l5 > 0 && l5 > l4 -> "Move left ←"
|
||||
else -> "Checking lateral..."
|
||||
}
|
||||
val lrIcon = when {
|
||||
l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right"
|
||||
@@ -1197,12 +1251,12 @@ private fun computePlacementGuide(
|
||||
// gradient 기반 방향 안내
|
||||
// ch0=위, ch3=아래. gradient>0 = ch3쪽 길다 = 방광 중심이 아래 = 내려야
|
||||
if (gradient > 0) {
|
||||
val intensity = if (gradient > 0.8) "Lower the sensor ↓" else "Lower the sensor slightly ↓"
|
||||
val intensity = if (gradient > 0.8) "Lower ↓" else "Lower slightly ↓"
|
||||
return PlacementGuideResult(intensity, "arrow.down",
|
||||
(20 + centerDetected * 5).coerceAtMost(35),
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
} else {
|
||||
val intensity = if (gradient < -0.8) "Raise the sensor ↑" else "Raise the sensor slightly ↑"
|
||||
val intensity = if (gradient < -0.8) "Raise ↑" else "Raise slightly ↑"
|
||||
return PlacementGuideResult(intensity, "arrow.up",
|
||||
(20 + centerDetected * 5).coerceAtMost(35),
|
||||
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
@@ -1218,14 +1272,22 @@ private fun computePlacementGuide(
|
||||
|
||||
// 상하 유지 확인 — gradient 재계산
|
||||
if (centerDetected < 2) {
|
||||
return PlacementGuideResult("Position lost — readjust vertically", "arrow.up", 20,
|
||||
return PlacementGuideResult("Readjust vertically", "arrow.up", 20,
|
||||
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL)
|
||||
}
|
||||
|
||||
// 절대 차이 10 sample 이하 → 좌우 충분히 균등, 바로 GREEN
|
||||
if (ch4det && ch5det && kotlin.math.abs(len4 - len5) <= 10) {
|
||||
return PlacementGuideResult(
|
||||
"LR balanced. Final check...",
|
||||
"checkmark.circle", 60,
|
||||
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN)
|
||||
}
|
||||
|
||||
// SWEEP 모드 + lateralOffsetMm 있을 때: mm 기반 정밀 안내
|
||||
if (lateralOffsetMm != null) {
|
||||
val absOff = kotlin.math.abs(lateralOffsetMm)
|
||||
if (absOff <= 5.0) {
|
||||
if (absOff <= 8.0) {
|
||||
return PlacementGuideResult(
|
||||
"LR centred (${"%.0f".format(absOff)}mm). Final check...",
|
||||
"checkmark.circle", 60,
|
||||
@@ -1242,15 +1304,15 @@ private fun computePlacementGuide(
|
||||
|
||||
// 한쪽만 detect → amplitude 기반 방향 안내
|
||||
if (ch4det && !ch5det) {
|
||||
return PlacementGuideResult("CH4 only detected. Move right →", "arrow.right", 40,
|
||||
return PlacementGuideResult("Move right →", "arrow.right", 40,
|
||||
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
|
||||
}
|
||||
if (ch5det && !ch4det) {
|
||||
return PlacementGuideResult("CH5 only detected. Move left ←", "arrow.left", 40,
|
||||
return PlacementGuideResult("Move left ←", "arrow.left", 40,
|
||||
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
|
||||
}
|
||||
if (!ch4det && !ch5det) {
|
||||
return PlacementGuideResult("Lateral sensors not detected. Try moving left ←", "arrow.left", 35,
|
||||
return PlacementGuideResult("Try moving left ←", "arrow.left", 35,
|
||||
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
|
||||
}
|
||||
|
||||
@@ -1263,7 +1325,7 @@ private fun computePlacementGuide(
|
||||
"LATERAL ch4=$ch4det(len=$len4) ch5=$ch5det(len=$len5) diff=%.2f sym=%.2f"
|
||||
.format(lrDiff, lrSymmetry))
|
||||
|
||||
if (lrSymmetry >= 0.7) {
|
||||
if (lrSymmetry >= 0.5) {
|
||||
return PlacementGuideResult(
|
||||
"Symmetry OK! Final check...",
|
||||
"checkmark.circle", 60,
|
||||
@@ -1297,7 +1359,7 @@ private fun computePlacementGuide(
|
||||
}
|
||||
|
||||
if (depths.size < 2) {
|
||||
return PlacementGuideResult("Position lost — readjust", "exclamationmark.triangle", 30,
|
||||
return PlacementGuideResult("Readjust position", "exclamationmark.triangle", 30,
|
||||
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL)
|
||||
}
|
||||
|
||||
@@ -1342,22 +1404,28 @@ private fun computePlacementGuide(
|
||||
val weightedCenter = detectedLens.sumOf { (ch, len) -> ch.toDouble() * len } / totalLen
|
||||
val gradient = weightedCenter - 1.5
|
||||
|
||||
val adjustHint = when {
|
||||
!lrPass && lrDev > DEFAULT_LR_DEV_THR -> "Adjust right → (CV=${"%.2f".format(cv)})"
|
||||
!lrPass && lrDev < -DEFAULT_LR_DEV_THR -> "Adjust left ← (CV=${"%.2f".format(cv)})"
|
||||
gradient > 0.3 -> "Adjust downward ↓ (CV=${"%.2f".format(cv)})"
|
||||
gradient < -0.3 -> "Adjust upward ↑ (CV=${"%.2f".format(cv)})"
|
||||
else -> "Almost there! Hold steady (CV=${"%.2f".format(cv)})"
|
||||
}
|
||||
val adjustIcon = when {
|
||||
!lrPass && lrDev > DEFAULT_LR_DEV_THR -> "arrow.right"
|
||||
!lrPass && lrDev < -DEFAULT_LR_DEV_THR -> "arrow.left"
|
||||
gradient > 0.3 -> "arrow.down"
|
||||
gradient < -0.3 -> "arrow.up"
|
||||
else -> "checkmark.circle"
|
||||
val adjustHint: String; val adjustIcon: String; val stayInGreen: Boolean
|
||||
when {
|
||||
!lrPass && lrDev > DEFAULT_LR_DEV_THR -> {
|
||||
adjustHint = "Move right →"; adjustIcon = "arrow.right"; stayInGreen = false
|
||||
}
|
||||
!lrPass && lrDev < -DEFAULT_LR_DEV_THR -> {
|
||||
adjustHint = "Move left ←"; adjustIcon = "arrow.left"; stayInGreen = false
|
||||
}
|
||||
gradient > 0.3 -> {
|
||||
adjustHint = "Lower ↓"; adjustIcon = "arrow.down"; stayInGreen = false
|
||||
}
|
||||
gradient < -0.3 -> {
|
||||
adjustHint = "Raise ↑"; adjustIcon = "arrow.up"; stayInGreen = false
|
||||
}
|
||||
else -> {
|
||||
// CV만 간당간당 — GREEN에 머물며 재시도 (VERTICAL로 돌아가지 않음)
|
||||
adjustHint = "Almost there..."; adjustIcon = "checkmark.circle"; stayInGreen = true
|
||||
}
|
||||
}
|
||||
val nextPh = if (stayInGreen) PlacementPhase.GREEN else PlacementPhase.VERTICAL
|
||||
return PlacementGuideResult(adjustHint, adjustIcon, 50, cv, false, lrDev, lrPass,
|
||||
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL)
|
||||
phase = PlacementPhase.GREEN, nextPhase = nextPh)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user