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 bladderLevel by mutableStateOf(BladderLevel())
var isDeviceConnected by mutableStateOf(false) var isDeviceConnected by mutableStateOf(false)
var isDemoMode by mutableStateOf(false) var isDemoMode by mutableStateOf(false)
var isDevMode by mutableStateOf(false)
var piezoSettings by mutableStateOf(PiezoSettings()) var piezoSettings by mutableStateOf(PiezoSettings())
var catheterCount = mutableIntStateOf(0) 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 DetectionMethod { METHOD_A, METHOD_B, METHOD_C }
enum class PlacementGuideMode { SIMPLE, BOUNDARY } enum class PlacementGuideMode { SIMPLE, BOUNDARY, SWEEP }
object GreenZoneConstants { object GreenZoneConstants {
@@ -1,18 +1,22 @@
package com.example.medilightv2android.ui.views.home package com.example.medilightv2android.ui.views.home
import android.widget.Toast
import androidx.compose.foundation.background 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.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -22,6 +26,10 @@ import com.example.medilightv2android.ui.theme.*
@Composable @Composable
fun HomeView(appState: AppState) { fun HomeView(appState: AppState) {
val context = LocalContext.current
var tapCount by remember { mutableIntStateOf(0) }
var lastTapTime by remember { mutableLongStateOf(0L) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -30,7 +38,29 @@ fun HomeView(appState: AppState) {
) { ) {
Spacer(modifier = Modifier.weight(1f)) 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)) Spacer(modifier = Modifier.height(24.dp))
@@ -81,6 +81,8 @@ fun PiezoMonitoringView(appState: AppState) {
var lastVolumeMl by remember { mutableStateOf(0.0) } var lastVolumeMl by remember { mutableStateOf(0.0) }
var wasDetached by remember { mutableStateOf(false) } var wasDetached by remember { mutableStateOf(false) }
var maxVolumeMl by remember { mutableStateOf(0.0) } 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>() } var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred<Unit>() }
val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() } val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() }
val analyzer = remember { PiezoEchoAnalyzer.shared } val analyzer = remember { PiezoEchoAnalyzer.shared }
@@ -303,13 +305,15 @@ fun PiezoMonitoringView(appState: AppState) {
maxVolumeMl = maxOf(maxVolumeMl, displayVol) maxVolumeMl = maxOf(maxVolumeMl, displayVol)
Log.d("PiezoMonitor", "AUTO trimmedMean=${"%.1f".format(displayVol)}ml from ${autoVolumeWindow.map { "%.0f".format(it) }}") 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 { } else {
lastVolumeMl = finalRawVol lastVolumeMl = finalRawVol
maxVolumeMl = maxOf(maxVolumeMl, finalRawVol) maxVolumeMl = maxOf(maxVolumeMl, finalRawVol)
} }
} }
if (lastVolumeMl > 0) { if (lastVolumeMl > 0 && !isSpotInProgress) {
val maxVol = appState.piezoSettings.maxVolume.toDouble() val maxVol = appState.piezoSettings.maxVolume.toDouble()
val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0 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) 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 spotCooldown by remember { mutableStateOf(false) }
var spotInProgress by remember { mutableStateOf(false) }
val spotScope = rememberCoroutineScope() val spotScope = rememberCoroutineScope()
Button( Button(
onClick = { onClick = {
if (!isMeasuring && !isAutoMeasuring && !spotCooldown && !spotInProgress) { if (!isMeasuring && !isAutoMeasuring && !spotCooldown && !isSpotInProgress) {
spotInProgress = true isSpotInProgress = true
spotCooldown = true spotCooldown = true
val savedVol = lastVolumeMl // 측정 중 화면 깜빡임 방지 spotVolumes.clear()
spotScope.launch { spotScope.launch {
val spotResults = mutableListOf<Double>()
repeat(3) { repeat(3) {
measure() measure()
while (isMeasuring) { kotlinx.coroutines.delay(100) } while (isMeasuring) { kotlinx.coroutines.delay(100) }
if (lastVolumeMl > 0) spotResults.add(lastVolumeMl)
lastVolumeMl = savedVol // 중간 값 복원
kotlinx.coroutines.delay(200) kotlinx.coroutines.delay(200)
} }
if (spotResults.size >= 2) { // 콜백에서 spotVolumes에 수집됨 (화면 갱신 없이)
spotResults.sort() if (spotVolumes.size >= 2) {
val median = spotResults[spotResults.size / 2] spotVolumes.sort()
val median = spotVolumes[spotVolumes.size / 2]
lastVolumeMl = median lastVolumeMl = median
maxVolumeMl = maxOf(maxVolumeMl, 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 maxVol = appState.piezoSettings.maxVolume.toDouble()
val pct = if (maxVol > 0) (median / maxVol).coerceAtMost(1.0) else 0.0 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) val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level) appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level)
} }
spotInProgress = false spotVolumes.clear()
isSpotInProgress = false
handler.postDelayed({ spotCooldown = false }, 1000) handler.postDelayed({ spotCooldown = false }, 1000)
} }
} }
}, },
enabled = !isMeasuring && !isAutoMeasuring && !spotCooldown && !spotInProgress, enabled = !isMeasuring && !isAutoMeasuring && !spotCooldown && !isSpotInProgress,
modifier = Modifier.weight(1f).height(56.dp), modifier = Modifier.weight(1f).height(56.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = urgency.gradientColors.first() containerColor = urgency.gradientColors.first()
) )
) { ) {
if (spotInProgress) { if (isSpotInProgress) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(18.dp), strokeWidth = 2.dp) CircularProgressIndicator(color = Color.White, modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else { } else {
Text("Spot", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White) Text("Spot", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
@@ -875,8 +877,8 @@ fun PiezoMonitoringView(appState: AppState) {
} }
} }
// ── Developer Tools toggle ── // ── Developer Tools toggle (개발자 모드에서만 표시) ──
Button( if (appState.isDevMode) Button(
onClick = { showDevPanels = !showDevPanels }, onClick = { showDevPanels = !showDevPanels },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@@ -896,7 +898,7 @@ fun PiezoMonitoringView(appState: AppState) {
} }
} }
if (showDevPanels) { if (appState.isDevMode && showDevPanels) {
ChannelPanel( ChannelPanel(
lastMultiChannelResult = lastMultiChannelResult, lastMultiChannelResult = lastMultiChannelResult,
analyzer = analyzer, analyzer = analyzer,
@@ -964,94 +966,97 @@ fun PiezoMonitoringView(appState: AppState) {
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary) colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
) )
HorizontalDivider() // ── 개발자 전용 설정 ──
if (appState.isDevMode) {
HorizontalDivider()
// Low Echo Threshold (Otsu / manual) // Low Echo Threshold (Otsu / manual)
var otsuEnabled by remember { mutableStateOf(analyzer.useAdaptiveThreshold) } var otsuEnabled by remember { mutableStateOf(analyzer.useAdaptiveThreshold) }
var currentThreshold by remember { mutableStateOf(GreenZoneConstants.lowEchoAmp) } var currentThreshold by remember { mutableStateOf(GreenZoneConstants.lowEchoAmp) }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("Threshold", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) Text("Threshold", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
if (otsuEnabled) "Otsu: ${"%.0f".format(analyzer.lastOtsuThreshold)}" else "${"%.0f".format(currentThreshold)}", if (otsuEnabled) "Otsu: ${"%.0f".format(analyzer.lastOtsuThreshold)}" else "${"%.0f".format(currentThreshold)}",
fontSize = 13.sp, fontWeight = FontWeight.Bold, fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = if (otsuEnabled) Color(0xFF4CAF50) else Color(0xFFFF9500) color = if (otsuEnabled) Color(0xFF4CAF50) else Color(0xFFFF9500)
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text("Otsu", fontSize = 12.sp, color = MlSecondaryText) Text("Otsu", fontSize = 12.sp, color = MlSecondaryText)
Switch( Switch(
checked = otsuEnabled, checked = otsuEnabled,
onCheckedChange = { otsuEnabled = it; analyzer.useAdaptiveThreshold = it }, onCheckedChange = { otsuEnabled = it; analyzer.useAdaptiveThreshold = it },
colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFF4CAF50)) colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFF4CAF50))
) )
} }
if (!otsuEnabled) { if (!otsuEnabled) {
Slider( Slider(
value = currentThreshold, value = currentThreshold,
onValueChange = { currentThreshold = it; GreenZoneConstants.lowEchoAmp = it }, onValueChange = { currentThreshold = it; GreenZoneConstants.lowEchoAmp = it },
valueRange = 800f..2000f, valueRange = 800f..2000f,
colors = SliderDefaults.colors(thumbColor = Color(0xFFFF9500), activeTrackColor = Color(0xFFFF9500)) colors = SliderDefaults.colors(thumbColor = Color(0xFFFF9500), activeTrackColor = Color(0xFFFF9500))
) )
} }
HorizontalDivider() HorizontalDivider()
// DPS // DPS
var dpsText by remember { mutableStateOf("%.3f".format(PiezoHW.distancePerSample)) } var dpsText by remember { mutableStateOf("%.3f".format(PiezoHW.distancePerSample)) }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("DPS", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) Text("DPS", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
OutlinedTextField( OutlinedTextField(
value = dpsText, value = dpsText,
onValueChange = { input -> dpsText = input; input.toDoubleOrNull()?.let { v -> if (v in 0.5..5.0) PiezoHW.distancePerSample = v } }, 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) }, suffix = { Text("mm", fontSize = 12.sp, color = MlSecondaryText) },
singleLine = true, singleLine = true,
modifier = Modifier.width(110.dp), modifier = Modifier.width(110.dp),
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF4CAF50)) textStyle = androidx.compose.ui.text.TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF4CAF50))
) )
} }
HorizontalDivider() HorizontalDivider()
// Detection Method A/B/C // Detection Method A/B/C
var selectedMethod by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod) } var selectedMethod by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Detection", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) Text("Detection", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
val methods = listOf( val methods = listOf(
com.example.medilightv2android.managers.DetectionMethod.METHOD_A to "A", com.example.medilightv2android.managers.DetectionMethod.METHOD_A to "A",
com.example.medilightv2android.managers.DetectionMethod.METHOD_B to "B", com.example.medilightv2android.managers.DetectionMethod.METHOD_B to "B",
com.example.medilightv2android.managers.DetectionMethod.METHOD_C to "C" com.example.medilightv2android.managers.DetectionMethod.METHOD_C to "C"
) )
val colors = listOf(Color(0xFF9C27B0), MlPrimary, Color(0xFFFF5722)) val colors = listOf(Color(0xFF9C27B0), MlPrimary, Color(0xFFFF5722))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
methods.forEachIndexed { idx, (m, label) -> methods.forEachIndexed { idx, (m, label) ->
val sel = selectedMethod == m val sel = selectedMethod == m
Button( Button(
onClick = { selectedMethod = m; com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod = m }, onClick = { selectedMethod = m; com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod = m },
modifier = Modifier.height(32.dp), modifier = Modifier.height(32.dp),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = if (sel) colors[idx] else Color.Gray.copy(alpha = 0.15f) containerColor = if (sel) colors[idx] else Color.Gray.copy(alpha = 0.15f)
), ),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp) contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp)
) { ) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = if (sel) Color.White else MlSecondaryText) color = if (sel) Color.White else MlSecondaryText)
}
} }
} }
} }
}
HorizontalDivider() HorizontalDivider()
// SG Filter // SG Filter
var sgEnabled by remember { mutableStateOf(analyzer.useSgFilter) } var sgEnabled by remember { mutableStateOf(analyzer.useSgFilter) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("SG Filter", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) Text("SG Filter", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Switch(checked = sgEnabled, onCheckedChange = { sgEnabled = it; analyzer.useSgFilter = it }, Switch(checked = sgEnabled, onCheckedChange = { sgEnabled = it; analyzer.useSgFilter = it },
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)) colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary))
}
} }
HorizontalDivider() HorizontalDivider()
@@ -62,7 +62,7 @@ fun PlacementGuideView(appState: AppState) {
var isContinuousMode by remember { mutableStateOf(true) } var isContinuousMode by remember { mutableStateOf(true) }
val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) } val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) }
val channelUrineLen = remember { mutableStateListOf(0, 0, 0, 0, 0, 0) } 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 directionIcon by remember { mutableStateOf("") }
var placementScore by remember { mutableIntStateOf(0) } var placementScore by remember { mutableIntStateOf(0) }
var scanCount 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 debounceCount by remember { mutableIntStateOf(0) }
var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) }
var lastHintChangeMs by remember { mutableStateOf(0L) } 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 showPlacementSettings by remember { mutableStateOf(false) }
var lastLedMode by remember { mutableIntStateOf(-1) } var lastLedMode by remember { mutableIntStateOf(-1) }
@@ -81,6 +88,9 @@ fun PlacementGuideView(appState: AppState) {
var maxCenterSeen by remember { mutableIntStateOf(0) } var maxCenterSeen by remember { mutableIntStateOf(0) }
var stableAtMax by remember { mutableIntStateOf(0) } var stableAtMax by remember { mutableIntStateOf(0) }
var boundaryHit by remember { mutableStateOf(false) } var boundaryHit by remember { mutableStateOf(false) }
// SWEEP 추가 state
var sweepLastDetected by remember { mutableIntStateOf(0) }
var sweepEverSawAll by remember { mutableStateOf(false) }
// 화면 진입 시 상태 리셋 // 화면 진입 시 상태 리셋
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -88,9 +98,13 @@ fun PlacementGuideView(appState: AppState) {
isLocked = false isLocked = false
scanCount = 0 scanCount = 0
placementScore = 0 placementScore = 0
directionHint = "Please attach the sensor" directionHint = "Place the sensor just above the pubic bone"
directionIcon = "" directionIcon = ""
for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 } 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 // 화면 벗어날 때 LED OFF
@@ -136,8 +150,10 @@ fun PlacementGuideView(appState: AppState) {
Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp)) Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
IconButton(onClick = { showPlacementSettings = !showPlacementSettings }) { if (appState.isDevMode) {
Icon(Icons.Default.Settings, "Settings", tint = if (showPlacementSettings) MlPrimary else MlSecondaryText) 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, skinFill)
drawPath(bodyPath, skinStroke, style = Stroke(width = 1.6f)) 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 (복부 정중선) ── // ── Linea alba (복부 정중선) ──
val ribY = chestY + h * 0.10f
val navelY = waistY + (hipY - waistY) * 0.20f val navelY = waistY + (hipY - waistY) * 0.20f
drawLine(skinShade.copy(alpha = 0.30f * bodyAlpha), drawLine(skinShade.copy(alpha = 0.30f * bodyAlpha),
Offset(cx, ribY + 4f), Offset(cx, navelY - 6f), Offset(cx, ribY + 4f), Offset(cx, navelY - 6f),
@@ -403,8 +399,8 @@ fun PlacementGuideView(appState: AppState) {
fontSize = 11.sp, color = MlSecondaryText fontSize = 11.sp, color = MlSecondaryText
) )
// Per-channel echo graphs (after scan) // Per-channel echo graphs (개발자 모드에서만 표시)
if (lastChannelData.isNotEmpty()) { if (appState.isDevMode && lastChannelData.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Card( Card(
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
@@ -558,7 +554,7 @@ fun PlacementGuideView(appState: AppState) {
val detachResult = if (fullCh.size >= 6) DetachmentDetection.detect(buffers) else null val detachResult = if (fullCh.size >= 6) DetachmentDetection.detect(buffers) else null
if (detachResult != null && detachResult.isDetached) { 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" directionIcon = "exclamationmark.triangle"
placementScore = 0 placementScore = 0
if (lastLedMode != 4) { bleManager.sendLedMode(4); lastLedMode = 4 } if (lastLedMode != 4) { bleManager.sendLedMode(4); lastLedMode = 4 }
@@ -566,11 +562,32 @@ fun PlacementGuideView(appState: AppState) {
Log.w("PlacementGuide", "DETACHED mean=${ Log.w("PlacementGuide", "DETACHED mean=${
"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}")
} else { } else {
val vs = VerticalState(maxCenterSeen, stableAtMax, boundaryHit) // Sphere-fit 계산 (SWEEP 모드에서 사용, 다른 모드에서는 무시됨)
val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase, vs) 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 maxCenterSeen = vs.maxCenterSeen
stableAtMax = vs.stableAtMax stableAtMax = vs.stableAtMax
boundaryHit = vs.boundaryHit boundaryHit = vs.boundaryHit
sweepLastDetected = vs.lastDetectedCount
sweepEverSawAll = vs.everSawAllFour
// 디바운스: 같은 phase 2회 연속 + 최소 3초 유지 // 디바운스: 같은 phase 2회 연속 + 최소 3초 유지
if (guideResult.nextPhase == lastGuidePhase) { if (guideResult.nextPhase == lastGuidePhase) {
@@ -675,8 +692,8 @@ fun PlacementGuideView(appState: AppState) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
} // end scrollable Column } // end scrollable Column
// Floating settings overlay // Floating settings overlay (개발자 모드)
if (showPlacementSettings) { if (appState.isDevMode && showPlacementSettings) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -725,20 +742,37 @@ fun PlacementGuideView(appState: AppState) {
HorizontalDivider() HorizontalDivider()
// Guide mode // Guide mode (Gradient / Boundary / SWEEP)
var useBoundary by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode == com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY) } var selectedGuide by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Guide", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) 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)) Spacer(modifier = Modifier.weight(1f))
Switch(checked = useBoundary, onCheckedChange = { val guideModes = listOf(
useBoundary = it com.example.medilightv2android.managers.PlacementGuideMode.SIMPLE to "Gradient",
com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode = com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY to "Boundary",
if (it) com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY com.example.medilightv2android.managers.PlacementGuideMode.SWEEP to "SWEEP"
else com.example.medilightv2android.managers.PlacementGuideMode.SIMPLE )
}, colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFFFF5722))) 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() HorizontalDivider()
@@ -953,23 +987,37 @@ private const val DEFAULT_CV_THR = 0.08
private const val DEFAULT_LR_DEV_THR = 0.25 private const val DEFAULT_LR_DEV_THR = 0.25
/** /**
* 2단계 순차 Placement Guide (대표님 요구사항) * Gradient 기반 Placement Guide
* *
* [1단계] 상하 탐색: CH3,2,1,0 low echo 검출 * 기존 binary detection(잡힘/안잡힘) 의존 로직의 한계:
* - 3개 이상 + 크기 유사 또는 3≥2≥1≥0 → PASS → 2단계로 * - 작은 방광: ch0+ch3 동시 detect 불가 → 상하 경계 판단 불가
* - 미달 → "더 위로" / "더 아래로" * - 치우친 방광: ch4/ch5 한쪽만 detect → 좌우 중앙 판단 불가
* - 큰 방광: ch0~ch3 전부 detect → 어디서든 잡혀 중앙 구분 불가
* *
* [2단계] 좌우 탐색: CH4,CH5 대칭성 * 해결: urine length의 gradient(기울기)와 amplitude를 활용
* - |len4-len5| / max < 0.3 → 대칭 PASS → 3단계로
* - 미달 → "왼쪽으로" / "오른쪽으로"
* *
* [3단계] 최종 Green: CV 확인 * [1단계] 상하 탐색 — Gradient 기반
* - CV ≤ threshold → Green (LED 6) * - 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( data class VerticalState(
var maxCenterSeen: Int = 0, var maxCenterSeen: Int = 0,
var stableAtMax: 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( private fun computePlacementGuide(
@@ -978,7 +1026,12 @@ private fun computePlacementGuide(
walls: List<Pair<Int, Int>?> = emptyList(), walls: List<Pair<Int, Int>?> = emptyList(),
repeatCount: Int = 0, repeatCount: Int = 0,
currentPhase: PlacementPhase = PlacementPhase.VERTICAL, 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 { ): PlacementGuideResult {
val detectedCount = detected.count { it } val detectedCount = detected.count { it }
@@ -987,137 +1040,244 @@ private fun computePlacementGuide(
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) 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 centerDetected = (0..3).count { detected.getOrElse(it) { false } }
val ch0 = detected.getOrElse(0) { false } val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } }
val ch1 = detected.getOrElse(1) { false } val ch0det = detected.getOrElse(0) { false }
val ch2 = detected.getOrElse(2) { false } val ch1det = detected.getOrElse(1) { false }
val ch3 = detected.getOrElse(3) { false } val ch2det = detected.getOrElse(2) { false }
val len0 = centerLens[0]; val len1 = centerLens[1] val ch3det = detected.getOrElse(3) { false }
val len2 = centerLens[2]; val len3 = centerLens[3]
// ═══ 1단계: 상하 탐색 ═══ // ═══ 1단계: 상하 탐색 ═══
if (currentPhase == PlacementPhase.VERTICAL) { if (currentPhase == PlacementPhase.VERTICAL) {
val guideMode = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode val guideMode = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode
val vs = verticalState ?: VerticalState()
// ── BOUNDARY 모드: 올라갔다 내려오기 ── // ═══ SWEEP 모드 (CKLaw): top-down sweep → Ch3 dropout → pull-back ═══
if (guideMode == com.example.medilightv2android.managers.PlacementGuideMode.BOUNDARY) { if (guideMode == com.example.medilightv2android.managers.PlacementGuideMode.SWEEP) {
if (centerDetected < 2) { val vs = verticalState ?: VerticalState()
vs.stableAtMax = 0 val prev = vs.lastDetectedCount
val hint: String; val icon: String vs.lastDetectedCount = centerDetected
when { if (centerDetected == 4) vs.everSawAllFour = true
(ch3 || ch2) && !ch0 && !ch1 -> { hint = "Gradually lower the sensor ↓"; icon = "arrow.down" }
else -> { hint = "Gradually raise the sensor ↑"; icon = "arrow.up" } // Phase 1: nothing detected — start instruction
} if (centerDetected == 0) {
return PlacementGuideResult(hint, icon, centerDetected * 10, 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) 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 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) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} }
if (vs.boundaryHit) {
if (centerDetected < vs.maxCenterSeen) { // Phase 3: all 4 detected — check argmax for centering
return PlacementGuideResult("Lower a bit more ↓", "arrow.down", 35, 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) 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) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} }
// ── SIMPLE 모드: 3+채널 안정 → 바로 좌우 ── // ═══ Gradient 모드 (기본): weighted center gradient ═══
if (centerDetected < 3) { // ch0=위쪽(머리방향), ch3=아래쪽(치골방향)
// ch0 감지 → 방광 중심이 위에 있음 → 센서를 위로 올려야
// ch3 감지 → 방광 중심이 아래에 있음 → 센서를 아래로 내려야
if (centerDetected <= 1) {
val hint: String; val icon: String val hint: String; val icon: String
when { when {
(ch3 || ch2) && !ch0 && !ch1 -> { hint = "Gradually lower the sensor ↓"; icon = "arrow.down" } // ch0만 잡힘 → 방광이 위쪽에 걸림 → 위로 올려야
(ch0 || ch1) && !ch3 && !ch2 -> { hint = "Gradually raise the sensor ↑"; icon = "arrow.up" } ch0det && !ch1det && !ch2det && !ch3det -> {
else -> { hint = "Gradually raise the sensor ↑"; icon = "arrow.up" } 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, return PlacementGuideResult(hint, icon, centerDetected * 10,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) 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 maxLen = validLens.maxOrNull() ?: 0
val minLen = validLens.minOrNull() ?: 0 val minLen = validLens.minOrNull() ?: 0
val lenVariation = if (maxLen > 0) (maxLen - minLen).toDouble() / maxLen else 1.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 l4 = urineLens.getOrElse(4) { 0 }
val l5 = urineLens.getOrElse(5) { 0 } val l5 = urineLens.getOrElse(5) { 0 }
val lrHint = when { val lrHint = when {
l4 > 0 && l5 > 0 && l4 > l5 -> "Move to the right →" l4 > 0 && l5 > 0 && l4 > l5 -> "Vertical OK! Move right →"
l4 > 0 && l5 > 0 && l5 > l4 -> "Move to the left ←" l4 > 0 && l5 > 0 && l5 > l4 -> "Vertical OK! Move left ←"
else -> "Slowly move left or right ↔" else -> "Vertical OK! Checking lateral..."
} }
val lrIcon = when { val lrIcon = when {
l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right" l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right"
l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left" l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left"
else -> "arrow.left.arrow.right" else -> "checkmark.circle"
} }
return PlacementGuideResult( val score = (40 + ((1.0 - kotlin.math.abs(gradient) / gradientThr) * 20).toInt())
"Vertical OK! $lrHint", .coerceIn(40, 60)
lrIcon, 40, return PlacementGuideResult(lrHint, lrIcon, score,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) 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 { } else {
return if (len3 > len0 * 2) { val intensity = if (gradient < -0.8) "Raise the sensor ↑" else "Raise the sensor slightly ↑"
PlacementGuideResult("Lower the sensor slightly ↓", "arrow.down", 25, return PlacementGuideResult(intensity, "arrow.up",
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) (20 + centerDetected * 5).coerceAtMost(35),
} else { phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
PlacementGuideResult("Raise the sensor slightly ↑", "arrow.up", 25,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
}
} }
} }
// ═══ 2단계: 좌우 대칭 맞추기 ═══ // ═══ 2단계: 좌우 대칭 맞추기 (Gradient 기반) ═══
if (currentPhase == PlacementPhase.LATERAL) { if (currentPhase == PlacementPhase.LATERAL) {
val len4 = urineLens.getOrElse(4) { 0 } val len4 = urineLens.getOrElse(4) { 0 }
val len5 = urineLens.getOrElse(5) { 0 } val len5 = urineLens.getOrElse(5) { 0 }
val ch4 = detected.getOrElse(4) { false } val ch4det = detected.getOrElse(4) { false }
val ch5 = detected.getOrElse(5) { false } val ch5det = detected.getOrElse(5) { false }
if (centerDetected < 3) { // 상하 유지 확인 — gradient 재계산
return PlacementGuideResult("Position lost — readjust up/down", "arrow.up.arrow.down", 20, if (centerDetected < 2) {
return PlacementGuideResult("Position lost — readjust vertically", "arrow.up", 20,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL) phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL)
} }
if (!ch4 && !ch5) { // SWEEP 모드 + lateralOffsetMm 있을 때: mm 기반 정밀 안내
return PlacementGuideResult("Lateral sensors not detected. Move left/right ↔", "arrow.left.arrow.right", 40, 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) phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
} }
// 양쪽 detect → len gradient
val lrMax = maxOf(len4, len5, 1) val lrMax = maxOf(len4, len5, 1)
val lrSymmetry = 1.0 - kotlin.math.abs(len4 - len5).toDouble() / lrMax val lrDiff = (len4 - len5).toDouble() / lrMax // 양수 = ch4 길다, 음수 = ch5 길다
android.util.Log.d("PlacementGuide", "LATERAL ch4=$ch4(len=$len4) ch5=$ch5(len=$len5) sym=${"%.2f".format(lrSymmetry)}") 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) { if (lrSymmetry >= 0.7) {
return PlacementGuideResult( return PlacementGuideResult(
"Symmetry OK! Final check...", "Symmetry OK! Final check...",
"checkmark.circle", 60, "checkmark.circle", 60,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN) 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 hw = PiezoHW
val dps = hw.distancePerSample val dps = hw.distancePerSample
val offset = hw.delayOffsetMm 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, return PlacementGuideResult("Position lost — readjust", "exclamationmark.triangle", 30,
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL) 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) { if (isPass) {
val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70) 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, "checkmark.circle", score, cv, true, lrDev, lrPass,
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN) phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN)
} else { } else {
// CV 미달 → 상하부터 다시 // CV 미달 → gradient 방향으로 미세 조정 안내
return PlacementGuideResult( val detectedLens = (0..3).filter { detected.getOrElse(it) { false } }
"위치 조절 필요 (CV=${"%.2f".format(cv)})", .map { it to (urineLens.getOrElse(it) { 0 }) }
"arrow.up.arrow.down", 50, cv, false, lrDev, lrPass, 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) phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL)
} }
} }