fix: LR 방향 수정 + Almost there 제거 + postMaxIdx 슬라이더

- 좌우 방향 전면 수정: ch4(왼쪽)길다→←, ch5(오른쪽)길다→→
  ch4만 감지→← (기기 오른쪽 치우침), ch5만→→
- Almost there 제거: 방향OK+CV미달 → 그냥 GREEN 통과
- postMaxIdx 런타임 슬라이더 (개발자 설정, 기본 80, 범위 50-95)
  Method A/B 공통 적용 — 바닥반사 peak 무시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 09:41:44 +09:00
parent b1ca5bfd66
commit c819d615d0
5 changed files with 62 additions and 48 deletions
@@ -18,6 +18,8 @@ enum class BvMethod { FRUSTUM, V41 }
object GreenZoneConstants {
var postMaxIdx: Int = 80 // 후벽 탐색 최대 sample index (이 이상 peak 무시)
var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C
var bvMethod: BvMethod = BvMethod.V41
var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
@@ -132,7 +132,7 @@ class PiezoEchoAnalyzer private constructor() {
val lowMinLen: Int = 3
val mergeGapMax: Int = 3
val peakSearchWin: Int = 20
val postMaxIdx: Int = 80
val postMaxIdx: Int get() = GreenZoneConstants.postMaxIdx
val minPeakMargin: Double = 30.0
val minUrineLen: Int = 3
val postRefineTolRatio: Double = 0.05
@@ -27,7 +27,7 @@ class PiezoEchoAnalyzerA private constructor() {
val tvLambda: Double = 3.0
val alpha: Double = 1.0
val beta: Double = 1.0
val postMaxIdx: Int = 80
val postMaxIdx: Int get() = GreenZoneConstants.postMaxIdx
val plateauMinLen: Int = 3
val plateauMergeGap: Int = 5
val scoreWin: Int = 7
@@ -1141,6 +1141,24 @@ fun PiezoMonitoringView(appState: AppState) {
Switch(checked = sgEnabled, onCheckedChange = { sgEnabled = it; analyzer.useSgFilter = it },
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary))
}
HorizontalDivider()
// Post Max Index (바닥반사 무시 경계)
var postMaxVal by remember { mutableStateOf(GreenZoneConstants.postMaxIdx.toFloat()) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Post Max", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(8.dp))
Text("${postMaxVal.toInt()} / 100", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
Spacer(modifier = Modifier.weight(1f))
}
Slider(
value = postMaxVal,
onValueChange = { postMaxVal = it; GreenZoneConstants.postMaxIdx = it.toInt() },
valueRange = 50f..95f,
steps = 8,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
}
HorizontalDivider()
@@ -96,7 +96,6 @@ fun PlacementGuideView(appState: AppState) {
// SWEEP 추가 state
var sweepLastDetected by remember { mutableIntStateOf(0) }
var sweepEverSawAll by remember { mutableStateOf(false) }
var almostThereCount by remember { mutableIntStateOf(0) }
// 화면 진입 시 상태 리셋
LaunchedEffect(Unit) {
@@ -111,7 +110,7 @@ fun PlacementGuideView(appState: AppState) {
rTracker.reset()
currentRMm = null; bestRMm = 0.0
sagittalArgMaxCh = null; lateralOffsetMm = null
sweepLastDetected = 0; sweepEverSawAll = false; almostThereCount = 0
sweepLastDetected = 0; sweepEverSawAll = false
}
// 화면 벗어날 때 LED OFF
@@ -657,25 +656,10 @@ fun PlacementGuideView(appState: AppState) {
lastHintChangeMs = now
}
// "Almost there" 연속 3회 → 자동 GREEN 통과
val isAlmostThere = guideResult.hint.contains("Almost there")
if (isAlmostThere) almostThereCount++ else almostThereCount = 0
val autoPass = guideResult.isPass || almostThereCount >= 3
if (autoPass) {
if (guideResult.isPass) {
isLocked = true
almostThereCount = 0
if (lastLedMode != 6) { bleManager.sendLedMode(6); lastLedMode = 6 }
if (guideResult.isPass) {
bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)}")
} else {
// Auto-pass: 위치 OK인데 CV 노이즈로 3회 연속 미달
directionHint = "Green! Position locked"
directionIcon = "checkmark.circle"
placementScore = 70
placementPhase = PlacementPhase.GREEN
bleManager.debugLogger.info("PLACEMENT_GREEN_AUTO cv=${"%.3f".format(guideResult.cv)} almostThereCount=3")
}
bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)}")
} else {
isLocked = false
if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 }
@@ -1153,13 +1137,13 @@ private fun computePlacementGuide(
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN)
} else {
val lrHint = when {
l4 > 0 && l5 > 0 && l4 > l5 -> "Move right →"
l4 > 0 && l5 > 0 && l5 > l4 -> "Move left ←"
l4 > 0 && l5 > 0 && l4 > l5 -> "Move left ←"
l4 > 0 && l5 > 0 && l5 > l4 -> "Move right →"
else -> "Checking lateral..."
}
val lrIcon = when {
l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right"
l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left"
l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.left"
l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.right"
else -> "checkmark.circle"
}
PlacementGuideResult(lrHint, lrIcon, 90,
@@ -1249,13 +1233,13 @@ private fun computePlacementGuide(
}
val lrHint = when {
l4 > 0 && l5 > 0 && l4 > l5 -> "Move right →"
l4 > 0 && l5 > 0 && l5 > l4 -> "Move left ←"
l4 > 0 && l5 > 0 && l4 > l5 -> "Move left ←"
l4 > 0 && l5 > 0 && l5 > l4 -> "Move right →"
else -> "Checking lateral..."
}
val lrIcon = when {
l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right"
l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left"
l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.left"
l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.right"
else -> "checkmark.circle"
}
val score = (40 + ((1.0 - kotlin.math.abs(gradient) / gradientThr) * 20).toInt())
@@ -1318,17 +1302,19 @@ private fun computePlacementGuide(
}
}
// 한쪽만 detect → amplitude 기반 방향 안내
// 한쪽만 detect → 반대쪽으로 이동 (잡히는 쪽의 반대로)
// ch4(왼쪽)만 잡힘 = 기기가 오른쪽 치우침 = 왼쪽으로
if (ch4det && !ch5det) {
return PlacementGuideResult("Move right →", "arrow.right", 40,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
}
if (ch5det && !ch4det) {
return PlacementGuideResult("Move left ←", "arrow.left", 40,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
}
// ch5(오른쪽)만 잡힘 = 기기가 왼쪽 치우침 = 오른쪽으로
if (ch5det && !ch4det) {
return PlacementGuideResult("Move right →", "arrow.right", 40,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
}
if (!ch4det && !ch5det) {
return PlacementGuideResult("Try moving left ←", "arrow.left", 35,
return PlacementGuideResult("Try moving right →", "arrow.right", 35,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
}
@@ -1349,8 +1335,9 @@ private fun computePlacementGuide(
}
// ch4 > ch5 → 방광이 오른쪽에 있음 → 오른쪽으로 이동
val hint = if (lrDiff > 0) "Move sensor right →" else "Move sensor left ←"
val icon = if (lrDiff > 0) "arrow.right" else "arrow.left"
// lrDiff > 0 = ch4 길다 = 방광이 왼쪽 → 왼쪽으로
val hint = if (lrDiff > 0) "Move sensor left ←" else "Move sensor right →"
val icon = if (lrDiff > 0) "arrow.left" else "arrow.right"
return PlacementGuideResult(hint, icon, 45,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
}
@@ -1420,28 +1407,35 @@ private fun computePlacementGuide(
val weightedCenter = detectedLens.sumOf { (ch, len) -> ch.toDouble() * len } / totalLen
val gradient = weightedCenter - 1.5
val adjustHint: String; val adjustIcon: String; val stayInGreen: Boolean
// 방향이 다 맞는데 CV만 미달 → 그냥 GREEN 통과 (위치는 맞음)
if (lrPass && kotlin.math.abs(gradient) <= 0.3) {
val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(60)
return PlacementGuideResult(
"Green! Optimal position",
"checkmark.circle", score, cv, true, lrDev, lrPass,
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN)
}
// 방향이 틀리면 VERTICAL로 복귀
val adjustHint: String; val adjustIcon: String
when {
!lrPass && lrDev > DEFAULT_LR_DEV_THR -> {
adjustHint = "Move right →"; adjustIcon = "arrow.right"; stayInGreen = false
// d4 > d5 → 방광 왼쪽 → 왼쪽으로
adjustHint = "Move left ←"; adjustIcon = "arrow.left"
}
!lrPass && lrDev < -DEFAULT_LR_DEV_THR -> {
adjustHint = "Move left ←"; adjustIcon = "arrow.left"; stayInGreen = false
// d5 > d4 → 방광 오른쪽 → 오른쪽으로
adjustHint = "Move right →"; adjustIcon = "arrow.right"
}
gradient > 0.3 -> {
adjustHint = "Lower ↓"; adjustIcon = "arrow.down"; stayInGreen = false
}
gradient < -0.3 -> {
adjustHint = "Raise ↑"; adjustIcon = "arrow.up"; stayInGreen = false
adjustHint = "Lower ↓"; adjustIcon = "arrow.down"
}
else -> {
// CV만 간당간당 — GREEN에 머물며 재시도 (VERTICAL로 돌아가지 않음)
adjustHint = "Almost there..."; adjustIcon = "checkmark.circle"; stayInGreen = true
adjustHint = "Raise ↑"; adjustIcon = "arrow.up"
}
}
val nextPh = if (stayInGreen) PlacementPhase.GREEN else PlacementPhase.VERTICAL
return PlacementGuideResult(adjustHint, adjustIcon, 50, cv, false, lrDev, lrPass,
phase = PlacementPhase.GREEN, nextPhase = nextPh)
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL)
}
}