feat: developer mode, SWEEP placement, gradient guide, spot flicker fix

- Developer mode: 캐릭터 3탭 토글, dev-only settings/graphs/logs
- SWEEP placement (CKLaw): BladderSphereSeek circle fit + RTracker
  + argmax + coronal lateral + R-peak gate GREEN lock
- Gradient placement: weighted center gradient 기반 상하 판정,
  방향 로직 수정 (ch0=위, ch3=아래), 초기 멘트 "치골 위에 배치"
- Spot 측정: isSpotInProgress 플래그로 중간값 화면 깜빡임 제거
- 좌우 애매한 ↔ 멘트 제거 → 구체적 방향 또는 상태 안내

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 11:32:03 +09:00
parent 4b74b88f76
commit 53a07195f8
7 changed files with 709 additions and 230 deletions
@@ -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)
@@ -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<Double>()
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
}
}
}
@@ -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<Pair<Int, Int>?>,
detected: List<Boolean>,
): FitResult? {
val pts = mutableListOf<DoubleArray>()
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<Pair<Int, Int>?>,
detected: List<Boolean>,
): 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<Pair<Int, Int>?>,
detected: List<Boolean>,
): 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,
)
}
}
@@ -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 {
@@ -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))
@@ -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<Double>() }
var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred<Unit>() }
val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() }
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<Double>()
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()
@@ -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<Double?>(null) }
var bestRMm by remember { mutableStateOf(0.0) }
var sagittalArgMaxCh by remember { mutableStateOf<Int?>(null) }
var lateralOffsetMm by remember { mutableStateOf<Double?>(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<Pair<Int, Int>?> = 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)
}
}