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:
@@ -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))
|
||||
|
||||
|
||||
+102
-97
@@ -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()
|
||||
|
||||
+311
-130
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user