feat: Placement Guide 2단계 순차 탐색으로 전면 교체 (대표님 요구사항)

[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) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 15:13:26 +09:00
parent e9c5e4536b
commit 9b92f09e88
@@ -62,13 +62,16 @@ fun PlacementGuideView(appState: AppState) {
var isContinuousMode by remember { mutableStateOf(false) } var isContinuousMode by remember { mutableStateOf(false) }
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("") } var directionHint by remember { mutableStateOf("센서를 아래에서 위로 천천히 이동하세요") }
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) }
var isLocked by remember { mutableStateOf(false) } var isLocked by remember { mutableStateOf(false) }
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) } var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
// 2단계 순차 탐색: VERTICAL → LATERAL → GREEN
var placementPhase by remember { mutableStateOf(PlacementPhase.VERTICAL) }
val detectedCount = channelDetected.count { it } val detectedCount = channelDetected.count { it }
Column( Column(
@@ -126,7 +129,11 @@ fun PlacementGuideView(appState: AppState) {
) { ) {
// Instructions // Instructions
Text( 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, fontSize = 14.sp,
color = MlSecondaryText, color = MlSecondaryText,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -332,18 +339,19 @@ 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 guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount) val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase)
directionHint = guideResult.hint directionHint = guideResult.hint
directionIcon = guideResult.icon directionIcon = guideResult.icon
placementScore = guideResult.score placementScore = guideResult.score
placementPhase = guideResult.nextPhase
if (guideResult.isPass) { if (guideResult.isPass) {
isLocked = true isLocked = true
bleManager.sendLedMode(6) // ALIGN_COMPLETE 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 { } else {
bleManager.sendLedMode(5) // ALIGN_SEARCHING 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( data class PlacementGuideResult(
val hint: String, val hint: String,
val icon: String, val icon: String,
@@ -630,59 +644,133 @@ data class PlacementGuideResult(
val isPass: Boolean = false, val isPass: Boolean = false,
val lrDev: Double = Double.NaN, val lrDev: Double = Double.NaN,
val lrPass: Boolean = true, val lrPass: Boolean = true,
val direction: String = "", val phase: PlacementPhase = PlacementPhase.VERTICAL,
val severity: String = "" val nextPhase: PlacementPhase = PlacementPhase.VERTICAL
) )
private const val DEFAULT_CV_THR = 0.15 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(변동계수)로 판정: * [1단계] 상하 탐색: CH3,2,1,0 low echo 검출
* CV ≤ threshold → PASS (Best Placement) * - 3개 이상 + 크기 유사 또는 3≥2≥1≥0 → PASS → 2단계로
* CV > threshold → FAIL → weakest channel 기반 방향 안내 * - 미달 → "더 위로" / "더 아래로"
* *
* n회 반복 시 threshold를 완화할 수 있음 (repeatCount 기반) * [2단계] 좌우 탐색: CH4,CH5 대칭성
* - |len4-len5| / max < 0.3 → 대칭 PASS → 3단계로
* - 미달 → "왼쪽으로" / "오른쪽으로"
*
* [3단계] 최종 Green: CV 확인
* - CV ≤ threshold → Green (LED 6)
*/ */
private fun computePlacementGuide( private fun computePlacementGuide(
detected: List<Boolean>, detected: List<Boolean>,
urineLens: List<Int>, urineLens: List<Int>,
walls: List<Pair<Int, Int>?> = emptyList(), walls: List<Pair<Int, Int>?> = emptyList(),
repeatCount: Int = 0 repeatCount: Int = 0,
currentPhase: PlacementPhase = PlacementPhase.VERTICAL
): PlacementGuideResult { ): PlacementGuideResult {
val detectedCount = detected.count { it } val detectedCount = detected.count { it }
if (detectedCount == 0) { 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 } } val centerDetected = (0..3).count { detected.getOrElse(it) { false } }
if (centerDetected < 3) { val len0 = centerLens[0]; val len1 = centerLens[1]
val ch0 = detected.getOrElse(0) { false } val len2 = centerLens[2]; val len3 = centerLens[3]
val ch3 = detected.getOrElse(3) { false }
val ch4 = detected.getOrElse(4) { true } // ═══ 1단계: 상하 탐색 ═══
val ch5 = detected.getOrElse(5) { true } if (currentPhase == PlacementPhase.VERTICAL) {
val hint = when { // 조건: 3개 이상 center에서 low echo 검출
!ch3 && ch0 -> "Move sensor UP" if (centerDetected < 3) {
ch3 && !ch0 -> "Move sensor DOWN" val ch0 = detected.getOrElse(0) { false }
!ch4 && ch5 -> "Move sensor LEFT" val ch3 = detected.getOrElse(3) { false }
ch4 && !ch5 -> "Move sensor RIGHT" val hint = when {
else -> "Adjust sensor position slowly" 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" // 3개 이상 검출 — 크기 패턴 확인 (3≥2≥1≥0 또는 유사)
ch3 && !ch0 -> "arrow.down" val validLens = (0..3).filter { detected.getOrElse(it) { false } }.map { centerLens[it] }
!ch4 && ch5 -> "arrow.left" val maxLen = validLens.maxOrNull() ?: 0
ch4 && !ch5 -> "arrow.right" val minLen = validLens.minOrNull() ?: 0
else -> "arrow.up.arrow.down" 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 hw = PiezoHW
val dps = hw.distancePerSample val dps = hw.distancePerSample
val offset = hw.delayOffsetMm val offset = hw.delayOffsetMm
@@ -690,116 +778,59 @@ private fun computePlacementGuide(
val centerCh = hw.centerCh val centerCh = hw.centerCh
val depths = mutableListOf<Double>() val depths = mutableListOf<Double>()
val depthPerCh = DoubleArray(4) { Double.NaN }
for (localIdx in centerCh.indices) { for (localIdx in centerCh.indices) {
val ch = centerCh[localIdx] val ch = centerCh[localIdx]
val w = if (ch < walls.size) walls[ch] else null val w = if (ch < walls.size) walls[ch] else null
if (w != null) { if (w != null) {
val antMm = offset + w.first * dps val rawDepth = (offset + w.second * dps) - (offset + w.first * dps)
val postMm = offset + w.second * dps
val rawDepth = postMm - antMm
val theta = degAll[ch] * Math.PI / 180.0 val theta = degAll[ch] * Math.PI / 180.0
val corrected = rawDepth * kotlin.math.cos(theta) val corrected = rawDepth * kotlin.math.cos(theta)
if (corrected > 0) { if (corrected > 0) depths.add(corrected)
depths.add(corrected)
depthPerCh[localIdx] = corrected
}
} }
} }
if (depths.size < 3) { 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 mean = depths.average()
val std = kotlin.math.sqrt(depths.map { (it - mean) * (it - mean) }.average()) val std = kotlin.math.sqrt(depths.map { (it - mean) * (it - mean) }.average())
val cv = if (mean > 0) std / mean else Double.MAX_VALUE val cv = if (mean > 0) std / mean else Double.MAX_VALUE
// CV threshold (반복 시 완화)
// 5회 단위 단계적 완화, 최대 0.30
val relaxStep = (repeatCount / 5).coerceAtMost(3) 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 기반) // LR 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)
var lrDev = Double.NaN var lrDev = Double.NaN
var lrDirection = "UNKNOWN"
var lrPass = true var lrPass = true
val w4 = if (4 < walls.size) walls[4] else null val w4 = if (4 < walls.size) walls[4] else null
val w5 = if (5 < walls.size) walls[5] else null val w5 = if (5 < walls.size) walls[5] else null
if (w4 != null && w5 != null) { if (w4 != null && w5 != null) {
val d4raw = (offset + w4.second * dps) - (offset + w4.first * dps) val d4 = ((offset + w4.second * dps) - (offset + w4.first * dps)) * kotlin.math.cos(degAll[4] * Math.PI / 180.0)
val d5raw = (offset + w5.second * dps) - (offset + w5.first * dps) val d5 = ((offset + w5.second * dps) - (offset + w5.first * dps)) * kotlin.math.cos(degAll[5] * Math.PI / 180.0)
val t4 = degAll[4] * Math.PI / 180.0 val lrMean = (d4 + d5) / 2.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
if (lrMean > 0) { if (lrMean > 0) {
lrDev = (depth4 - depth5) / lrMean lrDev = (d4 - d5) / lrMean
lrPass = kotlin.math.abs(lrDev) <= DEFAULT_LR_DEV_THR 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 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) { if (isPass) {
hint = "Best Placement! CV=${"%.2f".format(cv)} (≤${"%.2f".format(cvThr)})" val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70)
icon = "checkmark.circle" return PlacementGuideResult(
} else if (!lrPass) { "Green! 최적 위치 확인 (CV=${"%.2f".format(cv)})",
hint = "$lrDirection — LR dev=${"%.2f".format(lrDev)}" "checkmark.circle", score, cv, true, lrDev, lrPass,
icon = if (lrDirection == "MOVE RIGHT") "arrow.right" else "arrow.left" phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN)
} else { } else {
hint = "$siDirection ($severity) — CV=${"%.2f".format(cv)}" // CV 미달 → 상하부터 다시
icon = siIcon 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 @Composable