From 9b92f09e88fc5e518caa059c263ae32cb9a4d9ec Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 27 Apr 2026 15:13:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Placement=20Guide=202=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20=EC=88=9C=EC=B0=A8=20=ED=83=90=EC=83=89=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EA=B5=90=EC=B2=B4=20(=EB=8C=80=ED=91=9C?= =?UTF-8?q?=EB=8B=98=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [1단계: 상하 탐색 (VERTICAL)] - 안내: "센서를 아래에서 위로 천천히 이동하세요" - CH3,2,1,0 low echo 검출, 3개 이상 필요 - 크기 패턴: 3≥2≥1≥0 또는 유사(변동 <50%) → PASS - 미달 시: CH0 약하면 "위로", CH3 약하면 "아래로" - PASS → 2단계(LATERAL)로 자동 전환 [2단계: 좌우 탐색 (LATERAL)] - 안내: "센서를 좌우로 천천히 이동하세요" - CH4,CH5 대칭성: |len4-len5|/max < 0.3 → PASS - 상하 벗어나면 1단계로 복귀 - PASS → 3단계(GREEN)로 전환 [3단계: 최종 Green (CV 확인)] - CV = std(depths)/mean(depths) - CV ≤ threshold + LR dev OK → Green (LED 6, Lock) - CV 미달 → 1단계부터 다시 UI: "Step 1/3", "Step 2/3", "Step 3/3" 상단 표시 LED: 4(미부착) → 5(탐색중) → 6(Green) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/views/monitoring/PlacementGuideView.kt | 255 ++++++++++-------- 1 file changed, 143 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 5f07376..f36c7f4 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -62,13 +62,16 @@ fun PlacementGuideView(appState: AppState) { var isContinuousMode by remember { mutableStateOf(false) } 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("") } + var directionHint by remember { mutableStateOf("센서를 아래에서 위로 천천히 이동하세요") } var directionIcon by remember { mutableStateOf("") } var placementScore by remember { mutableIntStateOf(0) } var scanCount by remember { mutableIntStateOf(0) } var isLocked by remember { mutableStateOf(false) } var lastChannelData by remember { mutableStateOf>(emptyList()) } + // 2단계 순차 탐색: VERTICAL → LATERAL → GREEN + var placementPhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } + val detectedCount = channelDetected.count { it } Column( @@ -126,7 +129,11 @@ fun PlacementGuideView(appState: AppState) { ) { // Instructions Text( - text = "Place the sensor on your lower abdomen.\nAdjust until all channels turn green.", + text = when (placementPhase) { + PlacementPhase.VERTICAL -> "Step 1/3: 센서를 아래에서 위로 천천히 이동하세요" + PlacementPhase.LATERAL -> "Step 2/3: 센서를 좌우로 천천히 이동하세요" + PlacementPhase.GREEN -> "Step 3/3: 최종 위치 확인 중..." + }, fontSize = 14.sp, color = MlSecondaryText, textAlign = TextAlign.Center, @@ -332,18 +339,19 @@ fun PlacementGuideView(appState: AppState) { Log.w("PlacementGuide", "DETACHED mean=${ "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") } else { - val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount) + val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase) directionHint = guideResult.hint directionIcon = guideResult.icon placementScore = guideResult.score + placementPhase = guideResult.nextPhase if (guideResult.isPass) { isLocked = true bleManager.sendLedMode(6) // ALIGN_COMPLETE - bleManager.debugLogger.info("PLACEMENT_PASS cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score}") + bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score}") } else { bleManager.sendLedMode(5) // ALIGN_SEARCHING - bleManager.debugLogger.info("PLACEMENT_SEARCHING cv=${"%.3f".format(guideResult.cv)} dir=${guideResult.direction} ${guideResult.severity} score=${guideResult.score}") + bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} hint=${guideResult.hint}") } } @@ -622,6 +630,12 @@ private fun SensorLayoutCanvas( } } +enum class PlacementPhase { + VERTICAL, // 1단계: 상하 탐색 + LATERAL, // 2단계: 좌우 탐색 + GREEN // 최종 Green 확인 +} + data class PlacementGuideResult( val hint: String, val icon: String, @@ -630,59 +644,133 @@ data class PlacementGuideResult( val isPass: Boolean = false, val lrDev: Double = Double.NaN, val lrPass: Boolean = true, - val direction: String = "", - val severity: String = "" + val phase: PlacementPhase = PlacementPhase.VERTICAL, + val nextPhase: PlacementPhase = PlacementPhase.VERTICAL ) private const val DEFAULT_CV_THR = 0.15 -private const val DEFAULT_LR_DEV_THR = 0.15 +private const val DEFAULT_LR_DEV_THR = 0.30 /** - * Placement guide — CV threshold 기반 (placement.py 포팅) + * 2단계 순차 Placement Guide (대표님 요구사항) * - * Center CH0~CH3의 cos-corrected depth의 CV(변동계수)로 판정: - * CV ≤ threshold → PASS (Best Placement) - * CV > threshold → FAIL → weakest channel 기반 방향 안내 + * [1단계] 상하 탐색: CH3,2,1,0 low echo 검출 + * - 3개 이상 + 크기 유사 또는 3≥2≥1≥0 → PASS → 2단계로 + * - 미달 → "더 위로" / "더 아래로" * - * n회 반복 시 threshold를 완화할 수 있음 (repeatCount 기반) + * [2단계] 좌우 탐색: CH4,CH5 대칭성 + * - |len4-len5| / max < 0.3 → 대칭 PASS → 3단계로 + * - 미달 → "왼쪽으로" / "오른쪽으로" + * + * [3단계] 최종 Green: CV 확인 + * - CV ≤ threshold → Green (LED 6) */ private fun computePlacementGuide( detected: List, urineLens: List, walls: List?> = emptyList(), - repeatCount: Int = 0 + repeatCount: Int = 0, + currentPhase: PlacementPhase = PlacementPhase.VERTICAL ): PlacementGuideResult { val detectedCount = detected.count { it } if (detectedCount == 0) { - return PlacementGuideResult("No bladder detected. Check sensor contact.", "exclamationmark.triangle", 0) + return PlacementGuideResult("No bladder detected. Check sensor contact.", "exclamationmark.triangle", 0, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - // Center 채널 미달 — 이진 가이드 + // Center 채널 정보 + val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } } val centerDetected = (0..3).count { detected.getOrElse(it) { false } } - if (centerDetected < 3) { - val ch0 = detected.getOrElse(0) { false } - val ch3 = detected.getOrElse(3) { false } - val ch4 = detected.getOrElse(4) { true } - val ch5 = detected.getOrElse(5) { true } - val hint = when { - !ch3 && ch0 -> "Move sensor UP" - ch3 && !ch0 -> "Move sensor DOWN" - !ch4 && ch5 -> "Move sensor LEFT" - ch4 && !ch5 -> "Move sensor RIGHT" - else -> "Adjust sensor position slowly" + val len0 = centerLens[0]; val len1 = centerLens[1] + val len2 = centerLens[2]; val len3 = centerLens[3] + + // ═══ 1단계: 상하 탐색 ═══ + if (currentPhase == PlacementPhase.VERTICAL) { + // 조건: 3개 이상 center에서 low echo 검출 + if (centerDetected < 3) { + val ch0 = detected.getOrElse(0) { false } + val ch3 = detected.getOrElse(3) { false } + val hint = when { + ch3 && !ch0 -> "센서를 위로 이동하세요 ↑" + !ch3 && ch0 -> "센서를 아래로 이동하세요 ↓" + !ch3 && !ch0 -> "센서를 아래에서 위로 천천히 이동하세요" + else -> "센서를 아래에서 위로 천천히 이동하세요" + } + val icon = when { + ch3 && !ch0 -> "arrow.up" + !ch3 && ch0 -> "arrow.down" + else -> "arrow.up.arrow.down" + } + return PlacementGuideResult(hint, icon, centerDetected * 10, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - val icon = when { - !ch3 && ch0 -> "arrow.up" - ch3 && !ch0 -> "arrow.down" - !ch4 && ch5 -> "arrow.left" - ch4 && !ch5 -> "arrow.right" - else -> "arrow.up.arrow.down" + + // 3개 이상 검출 — 크기 패턴 확인 (3≥2≥1≥0 또는 유사) + val validLens = (0..3).filter { detected.getOrElse(it) { false } }.map { centerLens[it] } + val maxLen = validLens.maxOrNull() ?: 0 + val minLen = validLens.minOrNull() ?: 0 + val lenVariation = if (maxLen > 0) (maxLen - minLen).toDouble() / maxLen else 1.0 + + // 크기가 유사(변동 < 50%) 또는 3≥2≥1≥0 패턴 + val isOrdered = len3 >= len2 - 5 && len2 >= len1 - 5 && (len0 == 0 || len1 >= len0 - 5) + val isSimilar = lenVariation < 0.5 + + if (isSimilar || isOrdered) { + // 상하 PASS → 2단계 좌우로 + return PlacementGuideResult( + "상하 위치 OK! 좌우로 천천히 이동하세요 ↔", + "arrow.left.arrow.right", 40, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) + } else { + // 크기 편차 큼 → 상하 더 조절 + return if (len3 > len0 * 2) { + PlacementGuideResult("센서를 위로 더 이동하세요 ↑ (CH0 약함)", "arrow.up", 25, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + } else { + PlacementGuideResult("센서를 아래로 더 이동하세요 ↓ (CH3 약함)", "arrow.down", 25, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + } } - return PlacementGuideResult(hint, icon, centerDetected * 15) } - // CV 계산 — cos-corrected depth (mm) + // ═══ 2단계: 좌우 탐색 ═══ + 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 } + + // 먼저 상하가 여전히 유효한지 확인 + if (centerDetected < 3) { + return PlacementGuideResult("상하 위치 벗어남 — 다시 위아래 조절", "arrow.up.arrow.down", 20, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL) + } + + if (!ch4 && !ch5) { + return PlacementGuideResult("좌우로 천천히 이동하세요 ↔", "arrow.left.arrow.right", 40, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) + } + + // 좌우 대칭성 판정 + val lrMax = maxOf(len4, len5, 1) + val lrSymmetry = 1.0 - kotlin.math.abs(len4 - len5).toDouble() / lrMax + + if (lrSymmetry >= 0.7 || (ch4 && ch5)) { + // 좌우 PASS → 3단계 CV 확인 + return PlacementGuideResult( + "좌우 대칭 OK! 최종 확인 중...", + "checkmark.circle", 60, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN) + } else { + val hint = if (len4 > len5) "센서를 오른쪽으로 이동 → (CH4 더 김)" else "센서를 왼쪽으로 이동 ← (CH5 더 김)" + val icon = if (len4 > len5) "arrow.right" else "arrow.left" + return PlacementGuideResult(hint, icon, 45, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) + } + } + + // ═══ 3단계: 최종 Green (CV 확인) ═══ val hw = PiezoHW val dps = hw.distancePerSample val offset = hw.delayOffsetMm @@ -690,116 +778,59 @@ private fun computePlacementGuide( val centerCh = hw.centerCh val depths = mutableListOf() - val depthPerCh = DoubleArray(4) { Double.NaN } - for (localIdx in centerCh.indices) { val ch = centerCh[localIdx] val w = if (ch < walls.size) walls[ch] else null if (w != null) { - val antMm = offset + w.first * dps - val postMm = offset + w.second * dps - val rawDepth = postMm - antMm + val rawDepth = (offset + w.second * dps) - (offset + w.first * dps) val theta = degAll[ch] * Math.PI / 180.0 val corrected = rawDepth * kotlin.math.cos(theta) - if (corrected > 0) { - depths.add(corrected) - depthPerCh[localIdx] = corrected - } + if (corrected > 0) depths.add(corrected) } } if (depths.size < 3) { - return PlacementGuideResult("Insufficient data (${depths.size}/4 depths)", "exclamationmark.triangle", centerDetected * 15) + return PlacementGuideResult("위치 벗어남 — 다시 조절", "exclamationmark.triangle", 30, + phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL) } val mean = depths.average() val std = kotlin.math.sqrt(depths.map { (it - mean) * (it - mean) }.average()) val cv = if (mean > 0) std / mean else Double.MAX_VALUE - // CV threshold (반복 시 완화) - // 5회 단위 단계적 완화, 최대 0.30 val relaxStep = (repeatCount / 5).coerceAtMost(3) - val cvThr = DEFAULT_CV_THR + relaxStep * 0.05 // 0.15 → 0.20 → 0.25 → 0.30 + val cvThr = DEFAULT_CV_THR + relaxStep * 0.05 - // SI 방향 가이드 (deviation 기반) - val deviations = DoubleArray(4) { if (depthPerCh[it].isNaN()) 0.0 else (depthPerCh[it] - mean) / mean } - val validDevs = (0..3).filter { !depthPerCh[it].isNaN() } - val weakestIdx = validDevs.maxByOrNull { kotlin.math.abs(deviations[it]) } ?: 0 - val weakestDev = deviations[weakestIdx] - - val siDirection: String - val siIcon: String - val severity: String - - val absWeak = kotlin.math.abs(weakestDev) - severity = when { - absWeak < 0.10 -> "slight" - absWeak < 0.25 -> "moderate" - else -> "severe" - } - - if (cv <= cvThr) { - siDirection = "PASS" - siIcon = "checkmark.circle" - } else { - // CH0/CH1 = top(배꼽) → weakest면 DOWN - // CH2/CH3 = bottom(치골) → weakest면 UP - if (weakestIdx <= 1) { - siDirection = "MOVE DOWN" - siIcon = "arrow.down" - } else { - siDirection = "MOVE UP" - siIcon = "arrow.up" - } - } - - // LR deviation (CH4/CH5) + // LR deviation var lrDev = Double.NaN - var lrDirection = "UNKNOWN" var lrPass = true - val w4 = if (4 < walls.size) walls[4] else null val w5 = if (5 < walls.size) walls[5] else null if (w4 != null && w5 != null) { - val d4raw = (offset + w4.second * dps) - (offset + w4.first * dps) - val d5raw = (offset + w5.second * dps) - (offset + w5.first * dps) - val t4 = degAll[4] * Math.PI / 180.0 - val t5 = degAll[5] * Math.PI / 180.0 - val tLR4 = hw.degreeLRAll[4] * Math.PI / 180.0 - val tLR5 = hw.degreeLRAll[5] * Math.PI / 180.0 - val depth4 = d4raw * kotlin.math.cos(t4) * kotlin.math.cos(tLR4) - val depth5 = d5raw * kotlin.math.cos(t5) * kotlin.math.cos(tLR5) - val lrMean = (depth4 + depth5) / 2.0 + val d4 = ((offset + w4.second * dps) - (offset + w4.first * dps)) * kotlin.math.cos(degAll[4] * Math.PI / 180.0) + val d5 = ((offset + w5.second * dps) - (offset + w5.first * dps)) * kotlin.math.cos(degAll[5] * Math.PI / 180.0) + val lrMean = (d4 + d5) / 2.0 if (lrMean > 0) { - lrDev = (depth4 - depth5) / lrMean + lrDev = (d4 - d5) / lrMean lrPass = kotlin.math.abs(lrDev) <= DEFAULT_LR_DEV_THR - lrDirection = when { - lrPass -> "PASS" - lrDev > 0 -> "MOVE RIGHT" // left longer → move right - else -> "MOVE LEFT" - } } } val isPass = cv <= cvThr && lrPass - val cvPct = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt() - val score = if (isPass) maxOf(cvPct, 70) else cvPct.coerceIn(0, 69) - - val hint: String - val icon: String if (isPass) { - hint = "Best Placement! CV=${"%.2f".format(cv)} (≤${"%.2f".format(cvThr)})" - icon = "checkmark.circle" - } else if (!lrPass) { - hint = "$lrDirection — LR dev=${"%.2f".format(lrDev)}" - icon = if (lrDirection == "MOVE RIGHT") "arrow.right" else "arrow.left" + val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70) + return PlacementGuideResult( + "Green! 최적 위치 확인 (CV=${"%.2f".format(cv)})", + "checkmark.circle", score, cv, true, lrDev, lrPass, + phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN) } else { - hint = "$siDirection ($severity) — CV=${"%.2f".format(cv)}" - icon = siIcon + // CV 미달 → 상하부터 다시 + return PlacementGuideResult( + "위치 조절 필요 (CV=${"%.2f".format(cv)})", + "arrow.up.arrow.down", 50, cv, false, lrDev, lrPass, + phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL) } - - return PlacementGuideResult(hint, icon, score, cv, isPass, lrDev, lrPass, siDirection, severity) } @Composable