diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 71e57fd..4734681 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -24,6 +24,7 @@ class AppState(private val context: Context) { var bladderLevel by mutableStateOf(BladderLevel()) var isDeviceConnected by mutableStateOf(false) var isDemoMode by mutableStateOf(false) + var isDevMode by mutableStateOf(false) var piezoSettings by mutableStateOf(PiezoSettings()) var catheterCount = mutableIntStateOf(0) diff --git a/app/src/main/java/com/example/medilightv2android/managers/BladderSeekRTracker.kt b/app/src/main/java/com/example/medilightv2android/managers/BladderSeekRTracker.kt new file mode 100644 index 0000000..9aaefbe --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/BladderSeekRTracker.kt @@ -0,0 +1,72 @@ +package com.example.medilightv2android.managers + +import kotlin.math.abs + +/** + * R-fit trend tracker — watches per-frame sphere-fit radius and classifies: + * RISING — sliding toward bladder centre (R increasing) + * PEAK — at / near session maximum R + * FALLING — sliding past centre (R decreasing) + * NOISE — fluctuating without clear trend + * IDLE — no valid fit (too few channels) + * + * Ported from CKLaw BladderSeekRTracker.kt. + */ +class BladderSeekRTracker( + private val historySize: Int = 8, + private val peakToleranceMm: Double = 1.5, + private val noiseDeltaMm: Double = 0.4, +) { + enum class Trend { RISING, PEAK, FALLING, NOISE, IDLE } + + data class Status( + val currentR: Double?, + val bestR: Double, + val trend: Trend, + val improvementMm: Double, + val hintEn: String, + ) + + private val history = ArrayDeque() + var bestR: Double = 0.0 + private set + + fun reset() { + history.clear() + bestR = 0.0 + } + + fun submit(r: Double?): Status { + if (r == null || r.isNaN() || r <= 0.0) { + return Status(null, bestR, Trend.IDLE, 0.0, + "Not enough channels — check signal") + } + history.addLast(r) + while (history.size > historySize) history.removeFirst() + if (r > bestR) bestR = r + + val trend = computeTrend(r) + val gap = r - bestR + val en = when (trend) { + Trend.RISING -> "Getting closer (R=${"%.0f".format(r)}mm)" + Trend.PEAK -> "At peak (R=${"%.0f".format(r)}mm)" + Trend.FALLING -> "Past peak — reverse (best ${"%.0f".format(bestR)}mm)" + Trend.NOISE -> "Searching (R=${"%.0f".format(r)}mm)" + Trend.IDLE -> "" + } + return Status(r, bestR, trend, gap, en) + } + + private fun computeTrend(currentR: Double): Trend { + if (history.size < 3) return Trend.NOISE + if (bestR - currentR < peakToleranceMm) return Trend.PEAK + val prev = history.toList().dropLast(1).takeLast(3) + val prevMean = prev.average() + val delta = currentR - prevMean + return when { + abs(delta) < noiseDeltaMm -> Trend.NOISE + delta > 0 -> Trend.RISING + else -> Trend.FALLING + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/managers/BladderSphereSeek.kt b/app/src/main/java/com/example/medilightv2android/managers/BladderSphereSeek.kt new file mode 100644 index 0000000..8d585f5 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/BladderSphereSeek.kt @@ -0,0 +1,190 @@ +package com.example.medilightv2android.managers + +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Sagittal-plane circle fit + coronal lateral offset + argmax. + * + * Ported from CKLaw BladderSphereSeek.kt, simplified to use PiezoHW + * geometry directly (no SphereFit2Step / Geometry dependencies). + * + * Ch0..Ch3 wall points are projected to the YZ plane (sagittal slice) + * and fitted with a 2D algebraic circle (Kasa method). The fit radius + * serves as real-time alignment quality — maximised when probe is + * centred over the bladder. + */ +object BladderSphereSeek { + + data class FitResult( + val radiusMm: Double, + val centerY: Double, // along body axis (cranial+, caudal-) + val centerZ: Double, // depth from probe surface + val residualStdMm: Double, + val nPoints: Int, // 3..8 wall points + val confidence: Float, // 0..1 + ) + + data class CoronalResult( + val lateralOffsetMm: Double, + val nChannels: Int, + val ch4MidXMm: Double?, + val ch5MidXMm: Double?, + val confidence: Float, + ) + + /** + * Convert wall sample index to YZ coordinates (mm) for a given channel. + * Y = sensor_z + depth * sin(theta) (along body, cranial+) + * Z = depth * cos(theta) (perpendicular depth into body) + */ + private fun wallIdxToYZ(ch: Int, sampleIdx: Int): DoubleArray { + val hw = PiezoHW + val depth = sampleIdx * hw.distancePerSample + hw.delayOffsetMm + val theta = hw.degreeAll[ch] * Math.PI / 180.0 + val y = hw.sensorZMmAll[ch] + depth * sin(theta) + val z = depth * cos(theta) + return doubleArrayOf(y, z) + } + + /** + * Convert wall sample index to X coordinate (mm) for lateral channels. + * X = sensor_x + depth * sin(theta_lr) + */ + private fun wallIdxToX(ch: Int, sampleIdx: Int): Double { + val hw = PiezoHW + val depth = sampleIdx * hw.distancePerSample + hw.delayOffsetMm + val thetaLR = hw.degreeLRAll[ch] * Math.PI / 180.0 + return hw.sensorXMmAll[ch] + depth * sin(thetaLR) + } + + /** + * 2D circle fit (Kasa algebraic method) on YZ wall points from Ch0..Ch3. + * + * @param walls list of 6 Pair(antIdx, postIdx)?, null for undetected channels + * @param detected list of 6 booleans + */ + fun fitSagittalCircle( + walls: List?>, + detected: List, + ): FitResult? { + val pts = mutableListOf() + for (ch in 0..3) { + if (!detected.getOrElse(ch) { false }) continue + val w = walls.getOrNull(ch) ?: continue + pts.add(wallIdxToYZ(ch, w.first)) // ant + pts.add(wallIdxToYZ(ch, w.second)) // post + } + if (pts.size < 3) return null + + // Kasa circle fit: minimize algebraic distance + // Solve [y^2+z^2, y, z, 1] * [1, -2cy, -2cz, cy^2+cz^2-r^2]^T = 0 + val n = pts.size + var syy = 0.0; var sy = 0.0; var sz = 0.0 + var syz = 0.0; var szz = 0.0; var s1 = n.toDouble() + var syyy = 0.0; var syzz = 0.0; var syyz = 0.0; var szzz = 0.0 + var sd = 0.0; var sdy = 0.0; var sdz = 0.0 + + for (p in pts) { + val y = p[0]; val z = p[1] + val d = y * y + z * z + sy += y; sz += z; syy += y * y; szz += z * z; syz += y * z + sd += d; sdy += d * y; sdz += d * z + } + + // Normal equations for Kasa fit + val a11 = syy; val a12 = syz; val a13 = sy + val a21 = syz; val a22 = szz; val a23 = sz + val a31 = sy; val a32 = sz; val a33 = s1 + val b1 = sdy; val b2 = sdz; val b3 = sd + + // Solve 3x3 system via Cramer's rule + val det = a11 * (a22 * a33 - a23 * a32) - + a12 * (a21 * a33 - a23 * a31) + + a13 * (a21 * a32 - a22 * a31) + if (kotlin.math.abs(det) < 1e-12) return null + + val cy = (b1 * (a22 * a33 - a23 * a32) - + a12 * (b2 * a33 - a23 * b3) + + a13 * (b2 * a32 - a22 * b3)) / det / 2.0 + val cz = (a11 * (b2 * a33 - a23 * b3) - + b1 * (a21 * a33 - a23 * a31) + + a13 * (a21 * b3 - b2 * a31)) / det / 2.0 + val c3 = (a11 * (a22 * b3 - b2 * a32) - + a12 * (a21 * b3 - b2 * a31) + + b1 * (a21 * a32 - a22 * a31)) / det + + val rSq = cy * cy + cz * cz + c3 + if (rSq <= 0) return null + val r = sqrt(rSq) + + // Residual: std of (|p - center| - r) + var sumResidSq = 0.0 + for (p in pts) { + val dist = sqrt((p[0] - cy) * (p[0] - cy) + (p[1] - cz) * (p[1] - cz)) + val resid = dist - r + sumResidSq += resid * resid + } + val residStd = sqrt(sumResidSq / n) + + // Sanity: anatomically possible range + if (r < 15.0 || r > 120.0) return null + if (residStd > 5.0) return null + + val nFactor = ((n - 3).coerceAtLeast(0).toFloat() / 5f).coerceIn(0f, 1f) + val residFactor = (1.0 - (residStd / 5.0).coerceIn(0.0, 1.0)).toFloat() + val conf = (0.4f * nFactor + 0.6f * residFactor).coerceIn(0f, 1f) + + return FitResult(r, cy, cz, residStd, n, conf) + } + + /** + * Among Ch0..Ch3, returns the index of the channel with the longest chord. + * 0 = centred over bladder, 1-3 = increasingly off-centre. + * Returns null if any of Ch0..Ch3 is not detected. + */ + fun sagittalArgMaxCh( + walls: List?>, + detected: List, + ): Int? { + var bestCh = -1 + var bestLen = -1 + for (ch in 0..3) { + if (!detected.getOrElse(ch) { false }) return null + val w = walls.getOrNull(ch) ?: return null + val len = w.second - w.first + if (len > bestLen) { bestLen = len; bestCh = ch } + } + return if (bestCh >= 0) bestCh else null + } + + /** + * Coronal lateral offset from Ch4/Ch5 chord midpoints. + * Returns null if neither Ch4 nor Ch5 detected. + */ + fun computeCoronalLateral( + walls: List?>, + detected: List, + ): CoronalResult? { + var ch4Mid: Double? = null + var ch5Mid: Double? = null + for (ch in 4..5) { + if (!detected.getOrElse(ch) { false }) continue + val w = walls.getOrNull(ch) ?: continue + val xAnt = wallIdxToX(ch, w.first) + val xPost = wallIdxToX(ch, w.second) + val midX = (xAnt + xPost) / 2.0 + if (ch == 4) ch4Mid = midX else ch5Mid = midX + } + val xs = listOfNotNull(ch4Mid, ch5Mid) + if (xs.isEmpty()) return null + return CoronalResult( + lateralOffsetMm = xs.average(), + nChannels = xs.size, + ch4MidXMm = ch4Mid, + ch5MidXMm = ch5Mid, + confidence = if (xs.size == 2) 1.0f else 0.5f, + ) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index 3399b75..0668bb5 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -13,7 +13,7 @@ package com.example.medilightv2android.managers * 각 상수의 의미와 영향 범위를 아래 주석 참고. */ enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C } -enum class PlacementGuideMode { SIMPLE, BOUNDARY } +enum class PlacementGuideMode { SIMPLE, BOUNDARY, SWEEP } object GreenZoneConstants { diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt index 87dcdac..ba07ce0 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt @@ -1,18 +1,22 @@ package com.example.medilightv2android.ui.views.home +import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -22,6 +26,10 @@ import com.example.medilightv2android.ui.theme.* @Composable fun HomeView(appState: AppState) { + val context = LocalContext.current + var tapCount by remember { mutableIntStateOf(0) } + var lastTapTime by remember { mutableLongStateOf(0L) } + Column( modifier = Modifier .fillMaxSize() @@ -30,7 +38,29 @@ fun HomeView(appState: AppState) { ) { Spacer(modifier = Modifier.weight(1f)) - BladdyIdleRiveView(size = 220.dp) + Box(contentAlignment = Alignment.Center) { + BladdyIdleRiveView(size = 220.dp) + // 투명 오버레이 — Rive AndroidView가 터치를 가로채므로 위에 덮어씀 + Box( + modifier = Modifier + .size(220.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + val now = System.currentTimeMillis() + if (now - lastTapTime > 1500) tapCount = 0 + lastTapTime = now + tapCount++ + if (tapCount >= 3) { + tapCount = 0 + appState.isDevMode = !appState.isDevMode + val msg = if (appState.isDevMode) "Developer mode ON" else "Developer mode OFF" + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + ) + } Spacer(modifier = Modifier.height(24.dp)) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 6d8425d..9c3be0e 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -81,6 +81,8 @@ fun PiezoMonitoringView(appState: AppState) { var lastVolumeMl by remember { mutableStateOf(0.0) } var wasDetached by remember { mutableStateOf(false) } var maxVolumeMl by remember { mutableStateOf(0.0) } + var isSpotInProgress by remember { mutableStateOf(false) } + val spotVolumes = remember { mutableListOf() } var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred() } val lastMultiChannelResult = remember { mutableStateListOf() } val analyzer = remember { PiezoEchoAnalyzer.shared } @@ -303,13 +305,15 @@ fun PiezoMonitoringView(appState: AppState) { maxVolumeMl = maxOf(maxVolumeMl, displayVol) Log.d("PiezoMonitor", "AUTO trimmedMean=${"%.1f".format(displayVol)}ml from ${autoVolumeWindow.map { "%.0f".format(it) }}") } + } else if (isSpotInProgress) { + if (finalRawVol > 0) spotVolumes.add(finalRawVol) } else { lastVolumeMl = finalRawVol maxVolumeMl = maxOf(maxVolumeMl, finalRawVol) } } - if (lastVolumeMl > 0) { + if (lastVolumeMl > 0 && !isSpotInProgress) { val maxVol = appState.piezoSettings.maxVolume.toDouble() val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0 val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) @@ -825,49 +829,47 @@ fun PiezoMonitoringView(appState: AppState) { } } - // Spot (3회 측정 → median) + // Spot (3회 측정 → median, 중간 값 화면에 안 보임) var spotCooldown by remember { mutableStateOf(false) } - var spotInProgress by remember { mutableStateOf(false) } val spotScope = rememberCoroutineScope() Button( onClick = { - if (!isMeasuring && !isAutoMeasuring && !spotCooldown && !spotInProgress) { - spotInProgress = true + if (!isMeasuring && !isAutoMeasuring && !spotCooldown && !isSpotInProgress) { + isSpotInProgress = true spotCooldown = true - val savedVol = lastVolumeMl // 측정 중 화면 깜빡임 방지 + spotVolumes.clear() spotScope.launch { - val spotResults = mutableListOf() repeat(3) { measure() while (isMeasuring) { kotlinx.coroutines.delay(100) } - if (lastVolumeMl > 0) spotResults.add(lastVolumeMl) - lastVolumeMl = savedVol // 중간 값 복원 kotlinx.coroutines.delay(200) } - if (spotResults.size >= 2) { - spotResults.sort() - val median = spotResults[spotResults.size / 2] + // 콜백에서 spotVolumes에 수집됨 (화면 갱신 없이) + if (spotVolumes.size >= 2) { + spotVolumes.sort() + val median = spotVolumes[spotVolumes.size / 2] lastVolumeMl = median maxVolumeMl = maxOf(maxVolumeMl, median) - Log.d("PiezoMonitor", "SPOT median=${"%.1f".format(median)}ml from ${spotResults.map { "%.0f".format(it) }}") + Log.d("PiezoMonitor", "SPOT median=${"%.1f".format(median)}ml from ${spotVolumes.map { "%.0f".format(it) }}") val maxVol = appState.piezoSettings.maxVolume.toDouble() val pct = if (maxVol > 0) (median / maxVol).coerceAtMost(1.0) else 0.0 val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level) } - spotInProgress = false + spotVolumes.clear() + isSpotInProgress = false handler.postDelayed({ spotCooldown = false }, 1000) } } }, - enabled = !isMeasuring && !isAutoMeasuring && !spotCooldown && !spotInProgress, + enabled = !isMeasuring && !isAutoMeasuring && !spotCooldown && !isSpotInProgress, modifier = Modifier.weight(1f).height(56.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors( containerColor = urgency.gradientColors.first() ) ) { - if (spotInProgress) { + if (isSpotInProgress) { CircularProgressIndicator(color = Color.White, modifier = Modifier.size(18.dp), strokeWidth = 2.dp) } else { Text("Spot", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White) @@ -875,8 +877,8 @@ fun PiezoMonitoringView(appState: AppState) { } } - // ── Developer Tools toggle ── - Button( + // ── Developer Tools toggle (개발자 모드에서만 표시) ── + if (appState.isDevMode) Button( onClick = { showDevPanels = !showDevPanels }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), @@ -896,7 +898,7 @@ fun PiezoMonitoringView(appState: AppState) { } } - if (showDevPanels) { + if (appState.isDevMode && showDevPanels) { ChannelPanel( lastMultiChannelResult = lastMultiChannelResult, analyzer = analyzer, @@ -964,94 +966,97 @@ fun PiezoMonitoringView(appState: AppState) { colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary) ) - HorizontalDivider() + // ── 개발자 전용 설정 ── + if (appState.isDevMode) { + 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)) - ) - } + // 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() + 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)) - ) - } + // 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() + HorizontalDivider() - // Detection Method A/B/C - var selectedMethod by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod) } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text("Detection", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.weight(1f)) - val methods = listOf( - com.example.medilightv2android.managers.DetectionMethod.METHOD_A to "A", - com.example.medilightv2android.managers.DetectionMethod.METHOD_B to "B", - com.example.medilightv2android.managers.DetectionMethod.METHOD_C to "C" - ) - val colors = listOf(Color(0xFF9C27B0), MlPrimary, Color(0xFFFF5722)) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - methods.forEachIndexed { idx, (m, label) -> - val sel = selectedMethod == m - Button( - onClick = { selectedMethod = m; com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod = m }, - modifier = Modifier.height(32.dp), - shape = RoundedCornerShape(8.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (sel) colors[idx] else Color.Gray.copy(alpha = 0.15f) - ), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp) - ) { - Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, - color = if (sel) Color.White else MlSecondaryText) + // Detection Method A/B/C + var selectedMethod by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod) } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Detection", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.weight(1f)) + val methods = listOf( + com.example.medilightv2android.managers.DetectionMethod.METHOD_A to "A", + com.example.medilightv2android.managers.DetectionMethod.METHOD_B to "B", + com.example.medilightv2android.managers.DetectionMethod.METHOD_C to "C" + ) + val colors = listOf(Color(0xFF9C27B0), MlPrimary, Color(0xFFFF5722)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + methods.forEachIndexed { idx, (m, label) -> + val sel = selectedMethod == m + Button( + onClick = { selectedMethod = m; com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod = m }, + modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (sel) colors[idx] else Color.Gray.copy(alpha = 0.15f) + ), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp) + ) { + Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, + color = if (sel) Color.White else MlSecondaryText) + } } } } - } - HorizontalDivider() + 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)) + // 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() diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 4b67aee..85ab952 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -62,7 +62,7 @@ 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("Please attach the sensor") } + var directionHint by remember { mutableStateOf("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) } @@ -72,6 +72,13 @@ fun PlacementGuideView(appState: AppState) { var debounceCount by remember { mutableIntStateOf(0) } var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } var lastHintChangeMs by remember { mutableStateOf(0L) } + + // SWEEP 모드용 sphere-fit state + val rTracker = remember { com.example.medilightv2android.managers.BladderSeekRTracker() } + var currentRMm by remember { mutableStateOf(null) } + var bestRMm by remember { mutableStateOf(0.0) } + var sagittalArgMaxCh by remember { mutableStateOf(null) } + var lateralOffsetMm by remember { mutableStateOf(null) } var showPlacementSettings by remember { mutableStateOf(false) } var lastLedMode by remember { mutableIntStateOf(-1) } @@ -81,6 +88,9 @@ fun PlacementGuideView(appState: AppState) { var maxCenterSeen by remember { mutableIntStateOf(0) } var stableAtMax by remember { mutableIntStateOf(0) } var boundaryHit by remember { mutableStateOf(false) } + // SWEEP 추가 state + var sweepLastDetected by remember { mutableIntStateOf(0) } + var sweepEverSawAll by remember { mutableStateOf(false) } // 화면 진입 시 상태 리셋 LaunchedEffect(Unit) { @@ -88,9 +98,13 @@ fun PlacementGuideView(appState: AppState) { isLocked = false scanCount = 0 placementScore = 0 - directionHint = "Please attach the sensor" + directionHint = "Place the sensor just above the pubic bone" directionIcon = "" for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 } + rTracker.reset() + currentRMm = null; bestRMm = 0.0 + sagittalArgMaxCh = null; lateralOffsetMm = null + sweepLastDetected = 0; sweepEverSawAll = false } // 화면 벗어날 때 LED OFF @@ -136,8 +150,10 @@ fun PlacementGuideView(appState: AppState) { Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.width(4.dp)) } - IconButton(onClick = { showPlacementSettings = !showPlacementSettings }) { - Icon(Icons.Default.Settings, "Settings", tint = if (showPlacementSettings) MlPrimary else MlSecondaryText) + if (appState.isDevMode) { + IconButton(onClick = { showPlacementSettings = !showPlacementSettings }) { + Icon(Icons.Default.Settings, "Settings", tint = if (showPlacementSettings) MlPrimary else MlSecondaryText) + } } } @@ -212,28 +228,8 @@ fun PlacementGuideView(appState: AppState) { drawPath(bodyPath, skinFill) drawPath(bodyPath, skinStroke, style = Stroke(width = 1.6f)) - // ── Costal margin (갈비뼈 라인) ── - val ribY = chestY + h * 0.10f - val ribPathL = Path().apply { - moveTo(cx - chestW * 0.85f, chestY + h * 0.04f) - cubicTo( - cx - chestW * 0.55f, chestY + h * 0.13f, - cx - chestW * 0.20f, chestY + h * 0.13f, - cx, ribY) - } - val ribPathR = Path().apply { - moveTo(cx + chestW * 0.85f, chestY + h * 0.04f) - cubicTo( - cx + chestW * 0.55f, chestY + h * 0.13f, - cx + chestW * 0.20f, chestY + h * 0.13f, - cx, ribY) - } - drawPath(ribPathL, skinShade.copy(alpha = 0.55f * bodyAlpha), - style = Stroke(width = 1.2f)) - drawPath(ribPathR, skinShade.copy(alpha = 0.55f * bodyAlpha), - style = Stroke(width = 1.2f)) - // ── Linea alba (복부 정중선) ── + val ribY = chestY + h * 0.10f val navelY = waistY + (hipY - waistY) * 0.20f drawLine(skinShade.copy(alpha = 0.30f * bodyAlpha), Offset(cx, ribY + 4f), Offset(cx, navelY - 6f), @@ -403,8 +399,8 @@ fun PlacementGuideView(appState: AppState) { fontSize = 11.sp, color = MlSecondaryText ) - // Per-channel echo graphs (after scan) - if (lastChannelData.isNotEmpty()) { + // Per-channel echo graphs (개발자 모드에서만 표시) + if (appState.isDevMode && lastChannelData.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) Card( shape = RoundedCornerShape(14.dp), @@ -558,7 +554,7 @@ fun PlacementGuideView(appState: AppState) { val detachResult = if (fullCh.size >= 6) DetachmentDetection.detect(buffers) else null if (detachResult != null && detachResult.isDetached) { - directionHint = "Sensor not attached. Please attach the sensor" + directionHint = "Sensor detached. Place it just above the pubic bone" directionIcon = "exclamationmark.triangle" placementScore = 0 if (lastLedMode != 4) { bleManager.sendLedMode(4); lastLedMode = 4 } @@ -566,11 +562,32 @@ fun PlacementGuideView(appState: AppState) { Log.w("PlacementGuide", "DETACHED mean=${ "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") } else { - val vs = VerticalState(maxCenterSeen, stableAtMax, boundaryHit) - val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase, vs) + // Sphere-fit 계산 (SWEEP 모드에서 사용, 다른 모드에서는 무시됨) + val fitResult = com.example.medilightv2android.managers.BladderSphereSeek + .fitSagittalCircle(allWalls, detected) + val rStatus = rTracker.submit(fitResult?.radiusMm) + currentRMm = rStatus.currentR + bestRMm = rStatus.bestR + sagittalArgMaxCh = com.example.medilightv2android.managers.BladderSphereSeek + .sagittalArgMaxCh(allWalls, detected) + val coronalResult = com.example.medilightv2android.managers.BladderSphereSeek + .computeCoronalLateral(allWalls, detected) + lateralOffsetMm = coronalResult?.lateralOffsetMm + + val vs = VerticalState(maxCenterSeen, stableAtMax, boundaryHit, + lastDetectedCount = sweepLastDetected, everSawAllFour = sweepEverSawAll) + val guideResult = computePlacementGuide( + detected, urineLens, allWalls, scanCount, placementPhase, vs, + currentRMm = currentRMm, + bestRMm = bestRMm, + sagittalArgMaxCh = sagittalArgMaxCh, + lateralOffsetMm = lateralOffsetMm, + ) maxCenterSeen = vs.maxCenterSeen stableAtMax = vs.stableAtMax boundaryHit = vs.boundaryHit + sweepLastDetected = vs.lastDetectedCount + sweepEverSawAll = vs.everSawAllFour // 디바운스: 같은 phase 2회 연속 + 최소 3초 유지 if (guideResult.nextPhase == lastGuidePhase) { @@ -675,8 +692,8 @@ fun PlacementGuideView(appState: AppState) { Spacer(modifier = Modifier.height(32.dp)) } // end scrollable Column - // Floating settings overlay - if (showPlacementSettings) { + // Floating settings overlay (개발자 모드) + if (appState.isDevMode && showPlacementSettings) { Box( modifier = Modifier .fillMaxSize() @@ -725,20 +742,37 @@ fun PlacementGuideView(appState: AppState) { HorizontalDivider() - // Guide mode - var useBoundary by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode == com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY) } + // Guide mode (Gradient / Boundary / SWEEP) + var selectedGuide by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode) } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text("Guide", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (useBoundary) "Boundary" else "Simple", fontSize = 13.sp, fontWeight = FontWeight.Bold, - color = if (useBoundary) Color(0xFFFF5722) else MlPrimary) Spacer(modifier = Modifier.weight(1f)) - Switch(checked = useBoundary, onCheckedChange = { - useBoundary = it - com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode = - if (it) com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY - else com.example.medilightv2android.managers.PlacementGuideMode.SIMPLE - }, colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFFFF5722))) + val guideModes = listOf( + com.example.medilightv2android.managers.PlacementGuideMode.SIMPLE to "Gradient", + com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY to "Boundary", + com.example.medilightv2android.managers.PlacementGuideMode.SWEEP to "SWEEP" + ) + val guideColors = listOf(MlPrimary, Color(0xFFFF5722), Color(0xFF7C3AED)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + guideModes.forEachIndexed { idx, (mode, label) -> + val sel = selectedGuide == mode + Button( + onClick = { + selectedGuide = mode + com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode = mode + }, + modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (sel) guideColors[idx] else Color.Gray.copy(alpha = 0.15f) + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp) + ) { + Text(label, fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = if (sel) Color.White else MlSecondaryText) + } + } + } } HorizontalDivider() @@ -953,23 +987,37 @@ private const val DEFAULT_CV_THR = 0.08 private const val DEFAULT_LR_DEV_THR = 0.25 /** - * 2단계 순차 Placement Guide (대표님 요구사항) + * Gradient 기반 Placement Guide * - * [1단계] 상하 탐색: CH3,2,1,0 low echo 검출 - * - 3개 이상 + 크기 유사 또는 3≥2≥1≥0 → PASS → 2단계로 - * - 미달 → "더 위로" / "더 아래로" + * 기존 binary detection(잡힘/안잡힘) 의존 로직의 한계: + * - 작은 방광: ch0+ch3 동시 detect 불가 → 상하 경계 판단 불가 + * - 치우친 방광: ch4/ch5 한쪽만 detect → 좌우 중앙 판단 불가 + * - 큰 방광: ch0~ch3 전부 detect → 어디서든 잡혀 중앙 구분 불가 * - * [2단계] 좌우 탐색: CH4,CH5 대칭성 - * - |len4-len5| / max < 0.3 → 대칭 PASS → 3단계로 - * - 미달 → "왼쪽으로" / "오른쪽으로" + * 해결: urine length의 gradient(기울기)와 amplitude를 활용 * - * [3단계] 최종 Green: CV 확인 - * - CV ≤ threshold → Green (LED 6) + * [1단계] 상하 탐색 — Gradient 기반 + * - detect 0~1개: amplitude 최대화 (신호 찾기) + * - detect 2+개: ch0~ch3 urine length gradient → 0에 가까울수록 중앙 + * gradient > 0 (ch3 길다) → "위로 올리세요" (probe 아래에 있음) + * gradient < 0 (ch0 길다) → "아래로 내리세요" (probe 위에 있음) + * |gradient| < threshold → 상하 OK + * - 큰 방광에서도 gradient로 미세 위치 판별 가능 + * + * [2단계] 좌우 탐색 — Gradient 기반 + * - ch4/ch5 urine length 차이 + amplitude 비교 + * - 한쪽만 detect → amplitude가 큰 방향의 반대로 이동 + * - 양쪽 detect → len 차이 최소화 + * + * [3단계] 최종 Green: CV + LR deviation 확인 */ data class VerticalState( var maxCenterSeen: Int = 0, var stableAtMax: Int = 0, - var boundaryHit: Boolean = false + var boundaryHit: Boolean = false, + // SWEEP mode additions + var lastDetectedCount: Int = 0, + var everSawAllFour: Boolean = false, ) private fun computePlacementGuide( @@ -978,7 +1026,12 @@ private fun computePlacementGuide( walls: List?> = emptyList(), repeatCount: Int = 0, currentPhase: PlacementPhase = PlacementPhase.VERTICAL, - verticalState: VerticalState? = null + verticalState: VerticalState? = null, + // SWEEP mode sphere-fit params (optional — null = legacy mode) + currentRMm: Double? = null, + bestRMm: Double = 0.0, + sagittalArgMaxCh: Int? = null, + lateralOffsetMm: Double? = null, ): PlacementGuideResult { val detectedCount = detected.count { it } @@ -987,137 +1040,244 @@ private fun computePlacementGuide( phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } } val centerDetected = (0..3).count { detected.getOrElse(it) { false } } - val ch0 = detected.getOrElse(0) { false } - val ch1 = detected.getOrElse(1) { false } - val ch2 = detected.getOrElse(2) { false } - val ch3 = detected.getOrElse(3) { false } - val len0 = centerLens[0]; val len1 = centerLens[1] - val len2 = centerLens[2]; val len3 = centerLens[3] + val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } } + val ch0det = detected.getOrElse(0) { false } + val ch1det = detected.getOrElse(1) { false } + val ch2det = detected.getOrElse(2) { false } + val ch3det = detected.getOrElse(3) { false } // ═══ 1단계: 상하 탐색 ═══ if (currentPhase == PlacementPhase.VERTICAL) { val guideMode = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode - val vs = verticalState ?: VerticalState() - // ── BOUNDARY 모드: 올라갔다 내려오기 ── - if (guideMode == com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY) { - if (centerDetected < 2) { - vs.stableAtMax = 0 - val hint: String; val icon: String - when { - (ch3 || ch2) && !ch0 && !ch1 -> { hint = "Gradually lower the sensor ↓"; icon = "arrow.down" } - else -> { hint = "Gradually raise the sensor ↑"; icon = "arrow.up" } - } - return PlacementGuideResult(hint, icon, centerDetected * 10, + // ═══ SWEEP 모드 (CKLaw): top-down sweep → Ch3 dropout → pull-back ═══ + if (guideMode == com.example.medilightv2android.managers.PlacementGuideMode.SWEEP) { + val vs = verticalState ?: VerticalState() + val prev = vs.lastDetectedCount + vs.lastDetectedCount = centerDetected + if (centerDetected == 4) vs.everSawAllFour = true + + // Phase 1: nothing detected — start instruction + if (centerDetected == 0) { + val hint = if (vs.everSawAllFour) + "Lost signal — slide back up ↑" + else + "Place sensor near navel, slide slowly down ↓" + val icon = if (vs.everSawAllFour) "arrow.up" else "arrow.down" + return PlacementGuideResult(hint, icon, 5, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - if (centerDetected >= vs.maxCenterSeen) { vs.maxCenterSeen = centerDetected; vs.stableAtMax++ } - if (!vs.boundaryHit && vs.stableAtMax >= 2 && centerDetected < vs.maxCenterSeen) { + + // Phase 4: was at all-4, now lost a channel → overshoot (boundary) + if (vs.everSawAllFour && centerDetected < prev && centerDetected < 4) { vs.boundaryHit = true - return PlacementGuideResult("Too high. Lower the sensor ↓", "arrow.down", 30, + return PlacementGuideResult("Past optimum — slide back up ↑", "arrow.up", 50, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - if (vs.boundaryHit) { - if (centerDetected < vs.maxCenterSeen) { - return PlacementGuideResult("Lower a bit more ↓", "arrow.down", 35, + + // Phase 3: all 4 detected — check argmax for centering + if (centerDetected == 4) { + return when (sagittalArgMaxCh) { + 0 -> { + // Centred! advance to LATERAL + 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 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, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + 2 -> PlacementGuideResult("All 4 — Ch2 peak, slide down ↓", "arrow.down", 70, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + 3 -> PlacementGuideResult("All 4 — Ch3 peak, slide further down ↓", "arrow.down", 65, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + else -> PlacementGuideResult("All 4 detected — hold still", "checkmark.circle", 80, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - vs.boundaryHit = false; vs.stableAtMax = 0 - val l4 = urineLens.getOrElse(4) { 0 }; val l5 = urineLens.getOrElse(5) { 0 } - val lrHint = when { l4 > 0 && l5 > 0 && l4 > l5 -> "Optimal! Move right →"; l4 > 0 && l5 > 0 && l5 > l4 -> "Optimal! Move left ←"; else -> "Optimal! Adjust left/right ↔" } - val lrIcon = when { l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right"; l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left"; else -> "arrow.left.arrow.right" } - return PlacementGuideResult(lrHint, lrIcon, 40, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) } - return PlacementGuideResult("Gradually raise the sensor ↑", "arrow.up", centerDetected * 10, + + // Phase 2: channels coming online (1..3) + return PlacementGuideResult("Keep sliding down ↓ ($centerDetected/4 detected)", "arrow.down", + 20 + centerDetected * 10, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - // ── SIMPLE 모드: 3+채널 안정 → 바로 좌우 ── - if (centerDetected < 3) { + // ═══ Gradient 모드 (기본): weighted center gradient ═══ + // ch0=위쪽(머리방향), ch3=아래쪽(치골방향) + // ch0 감지 → 방광 중심이 위에 있음 → 센서를 위로 올려야 + // ch3 감지 → 방광 중심이 아래에 있음 → 센서를 아래로 내려야 + if (centerDetected <= 1) { val hint: String; val icon: String when { - (ch3 || ch2) && !ch0 && !ch1 -> { hint = "Gradually lower the sensor ↓"; icon = "arrow.down" } - (ch0 || ch1) && !ch3 && !ch2 -> { hint = "Gradually raise the sensor ↑"; icon = "arrow.up" } - else -> { hint = "Gradually raise the sensor ↑"; icon = "arrow.up" } + // 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" + } + // 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" + } + else -> { + hint = "Gradually raise the sensor ↑"; icon = "arrow.up" + } } return PlacementGuideResult(hint, icon, centerDetected * 10, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - if (!ch0 && ch3) { - return PlacementGuideResult("CH0 weak. Lower the sensor slightly ↓", "arrow.down", 25, - phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) - } - val validLens = (0..3).filter { detected.getOrElse(it) { false } }.map { centerLens[it] } + // ── 모드 B: detect 2+개 — urine length gradient ── + // ch0=위쪽, ch3=아래쪽 + // gradient > 0 → ch3쪽이 더 길다 → 방광 중심이 아래 → 아래로 내려야 + // gradient < 0 → ch0쪽이 더 길다 → 방광 중심이 위 → 위로 올려야 + // ~0 → 균등 → 상하 OK + val detectedLens = (0..3).filter { detected.getOrElse(it) { false } } + .map { it to centerLens[it] } + val meanAll = detectedLens.map { it.second }.average() + + // 가중 gradient: 채널 위치(0=top, 3=bottom)를 weight로 사용 + // center = 1.5, deviation = ch_pos - 1.5, gradient = sum(dev * len) / sum(len) + val totalLen = detectedLens.sumOf { it.second }.toDouble().coerceAtLeast(1.0) + val weightedCenter = detectedLens.sumOf { (ch, len) -> ch.toDouble() * len } / totalLen + // weightedCenter: 0에 가까우면 ch0쪽(위), 3에 가까우면 ch3쪽(아래) + // 이상적 중앙 = 1.5 + val gradient = weightedCenter - 1.5 // 양수 = 아래쪽 무거움, 음수 = 위쪽 무거움 + + // gradient threshold: 균등하다고 볼 수 있는 범위 + val gradientThr = 0.4 // |gradient| < 0.4 → 상하 균등 + + // 추가 조건: variation (max-min)/max 도 체크 + val validLens = detectedLens.map { it.second } val maxLen = validLens.maxOrNull() ?: 0 val minLen = validLens.minOrNull() ?: 0 val lenVariation = if (maxLen > 0) (maxLen - minLen).toDouble() / maxLen else 1.0 - if (lenVariation < 0.5) { + android.util.Log.d("PlacementGuide", + "VERTICAL gradient=%.2f (center=%.2f) variation=%.2f detect=$centerDetected lens=$centerLens" + .format(gradient, weightedCenter, lenVariation)) + + if (kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.5) { + // 상하 OK → 좌우 탐색으로 val l4 = urineLens.getOrElse(4) { 0 } val l5 = urineLens.getOrElse(5) { 0 } val lrHint = when { - l4 > 0 && l5 > 0 && l4 > l5 -> "Move to the right →" - l4 > 0 && l5 > 0 && l5 > l4 -> "Move to the left ←" - else -> "Slowly move left or right ↔" + 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..." } val lrIcon = when { l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right" l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left" - else -> "arrow.left.arrow.right" + else -> "checkmark.circle" } - return PlacementGuideResult( - "Vertical OK! $lrHint", - lrIcon, 40, + val score = (40 + ((1.0 - kotlin.math.abs(gradient) / gradientThr) * 20).toInt()) + .coerceIn(40, 60) + return PlacementGuideResult(lrHint, lrIcon, score, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) + } + + // gradient 기반 방향 안내 + // ch0=위, ch3=아래. gradient>0 = ch3쪽 길다 = 방광 중심이 아래 = 내려야 + if (gradient > 0) { + val intensity = if (gradient > 0.8) "Lower the sensor ↓" else "Lower the sensor slightly ↓" + return PlacementGuideResult(intensity, "arrow.down", + (20 + centerDetected * 5).coerceAtMost(35), + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } else { - return if (len3 > len0 * 2) { - PlacementGuideResult("Lower the sensor slightly ↓", "arrow.down", 25, - phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) - } else { - PlacementGuideResult("Raise the sensor slightly ↑", "arrow.up", 25, - phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) - } + val intensity = if (gradient < -0.8) "Raise the sensor ↑" else "Raise the sensor slightly ↑" + return PlacementGuideResult(intensity, "arrow.up", + (20 + centerDetected * 5).coerceAtMost(35), + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } } - // ═══ 2단계: 좌우 대칭 맞추기 ═══ + // ═══ 2단계: 좌우 대칭 맞추기 (Gradient 기반) ═══ if (currentPhase == PlacementPhase.LATERAL) { val len4 = urineLens.getOrElse(4) { 0 } val len5 = urineLens.getOrElse(5) { 0 } - val ch4 = detected.getOrElse(4) { false } - val ch5 = detected.getOrElse(5) { false } + val ch4det = detected.getOrElse(4) { false } + val ch5det = detected.getOrElse(5) { false } - if (centerDetected < 3) { - return PlacementGuideResult("Position lost — readjust up/down", "arrow.up.arrow.down", 20, + // 상하 유지 확인 — gradient 재계산 + if (centerDetected < 2) { + return PlacementGuideResult("Position lost — readjust vertically", "arrow.up", 20, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL) } - if (!ch4 && !ch5) { - return PlacementGuideResult("Lateral sensors not detected. Move left/right ↔", "arrow.left.arrow.right", 40, + // SWEEP 모드 + lateralOffsetMm 있을 때: mm 기반 정밀 안내 + if (lateralOffsetMm != null) { + val absOff = kotlin.math.abs(lateralOffsetMm) + if (absOff <= 5.0) { + return PlacementGuideResult( + "LR centred (${"%.0f".format(absOff)}mm). Final check...", + "checkmark.circle", 60, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN) + } + return if (lateralOffsetMm > 0) { + PlacementGuideResult("Move right ${"%.0f".format(absOff)}mm →", "arrow.right", 45, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) + } else { + PlacementGuideResult("Move left ${"%.0f".format(absOff)}mm ←", "arrow.left", 45, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) + } + } + + // 한쪽만 detect → amplitude 기반 방향 안내 + if (ch4det && !ch5det) { + return PlacementGuideResult("CH4 only detected. Move right →", "arrow.right", 40, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) + } + if (ch5det && !ch4det) { + return PlacementGuideResult("CH5 only detected. 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, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) } + // 양쪽 detect → len gradient val lrMax = maxOf(len4, len5, 1) - val lrSymmetry = 1.0 - kotlin.math.abs(len4 - len5).toDouble() / lrMax - android.util.Log.d("PlacementGuide", "LATERAL ch4=$ch4(len=$len4) ch5=$ch5(len=$len5) sym=${"%.2f".format(lrSymmetry)}") + val lrDiff = (len4 - len5).toDouble() / lrMax // 양수 = ch4 길다, 음수 = ch5 길다 + val lrSymmetry = 1.0 - kotlin.math.abs(lrDiff) + + android.util.Log.d("PlacementGuide", + "LATERAL ch4=$ch4det(len=$len4) ch5=$ch5det(len=$len5) diff=%.2f sym=%.2f" + .format(lrDiff, lrSymmetry)) if (lrSymmetry >= 0.7) { return PlacementGuideResult( "Symmetry OK! Final check...", "checkmark.circle", 60, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN) - } else { - val hint = if (len4 > len5) "Move sensor right →" else "Move sensor left ←" - val icon = if (len4 > len5) "arrow.right" else "arrow.left" - return PlacementGuideResult(hint, icon, 45, - phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) } + + // ch4 > ch5 → 방광이 오른쪽에 있음 → 오른쪽으로 이동 + val hint = if (lrDiff > 0) "Move sensor right →" else "Move sensor left ←" + val icon = if (lrDiff > 0) "arrow.right" else "arrow.left" + return PlacementGuideResult(hint, icon, 45, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) } - // ═══ 3단계: 최종 Green (CV 확인) ═══ + // ═══ 3단계: 최종 Green (CV + Gradient 종합) ═══ val hw = PiezoHW val dps = hw.distancePerSample val offset = hw.delayOffsetMm @@ -1136,7 +1296,7 @@ private fun computePlacementGuide( } } - if (depths.size < 3) { + if (depths.size < 2) { return PlacementGuideResult("Position lost — readjust", "exclamationmark.triangle", 30, phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL) } @@ -1163,7 +1323,10 @@ private fun computePlacementGuide( } } - val isPass = cv <= cvThr && lrPass + // R-peak gate (SWEEP 모드): bestR 근처에 있어야 GREEN 통과 + val rPeakOk = currentRMm == null || bestRMm <= 0.0 || + (bestRMm - currentRMm) <= 1.5 + val isPass = cv <= cvThr && lrPass && rPeakOk if (isPass) { val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70) @@ -1172,10 +1335,28 @@ private fun computePlacementGuide( "checkmark.circle", score, cv, true, lrDev, lrPass, phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN) } else { - // CV 미달 → 상하부터 다시 - return PlacementGuideResult( - "위치 조절 필요 (CV=${"%.2f".format(cv)})", - "arrow.up.arrow.down", 50, cv, false, lrDev, lrPass, + // CV 미달 → gradient 방향으로 미세 조정 안내 + val detectedLens = (0..3).filter { detected.getOrElse(it) { false } } + .map { it to (urineLens.getOrElse(it) { 0 }) } + val totalLen = detectedLens.sumOf { it.second }.toDouble().coerceAtLeast(1.0) + 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" + } + return PlacementGuideResult(adjustHint, adjustIcon, 50, cv, false, lrDev, lrPass, phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL) } }