feat(posture): Phase 1 — flowchart 기반 자세 분류 + 도넛 우상단 아이콘 (demo-final)

allignment flowchart "Posture classification" 알고리즘 적용. Phase 1
범위: 자세 분류 + 화면 표시만 (알람/감지 없음).

1) ImuPostureClassifier.kt 신규
   flowchart 단계 그대로:
   - Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 (motion/bad reject)
   - Gyro magnitude 게이트: |g| ≤ 9.16 dps (회전 reject)
   - Pitch = atan(ax / sqrt(ay² + az²)) × 180/π
   - 분류:
     · |pitch| < 25°  → SUPINE
     · 25-70°         → SITTING
     · > 70°          → STANDING
   - 게이트 실패 시 UNKNOWN + 사유 반환
   - 입력: ImuSample 리스트 (한 응답 15 sample 평균)
   ※ flowchart 의 "|pitch| > 25; supine" 는 typo 로 판단 → < 25 적용

2) PiezoMonitoringView IMU polling 추가
   - 5초 주기로 sendImuQuery() → rim: 응답 → classifier
   - UNKNOWN 결과 시 직전 자세 유지 (artifact 흔들림 방지)
   - demo mode: SUPINE 으로 fix (BLE 없음)

3) 도넛 차트 우상단 자세 아이콘 표시 (Battery 옆)
   - SUPINE   → Icons.Hotel (파랑)
   - SITTING  → Icons.AirlineSeatReclineNormal (주황)
   - STANDING → Icons.AccessibilityNew (초록)
   - UNKNOWN  → Icons.HelpOutline (회색)
   - 아이콘 + 텍스트 라벨 (Lying/Sitting/Standing/—)

Phase 2 (다음): Settings 토글 + wake-up state machine + 빨간 팝업.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:24:22 +09:00
parent 1fc1a20bb9
commit cf476971a5
2 changed files with 148 additions and 0 deletions
@@ -0,0 +1,90 @@
/*
* Posture classifier — flowchart `Posture classification [Flow chart]` 기반 (2026-06-22).
*
* 단계:
* 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부
* 2. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π
* 3. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부
* 4. 자세 분류 (|pitch| 기준):
* < 25° → SUPINE (누움)
* 25-70° → SITTING
* > 70° → STANDING
*
* 입력: ImuSample 15개 (한 응답 = 300ms 분량). 각 sample 의 평균에서 분류.
* 출력: Posture (UNKNOWN, SUPINE, SITTING, STANDING).
*
* flowchart 의 `|pitch| > 25; supine` 는 typo 로 판단 — `< 25` 로 해석.
*/
package com.example.medilightv2android.managers
import com.example.medilightv2android.ble.ImuSample
import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.sqrt
enum class Posture { UNKNOWN, SUPINE, SITTING, STANDING }
object ImuPostureClassifier {
// 게이트 임계값 — flowchart 그대로
private const val ACC_MAG_MIN = 0.8f
private const val ACC_MAG_MAX = 1.2f
private const val GYRO_MAG_MAX_DPS = 9.16f
// 자세 분류 임계값 (deg)
private const val PITCH_SUPINE_MAX = 25f
private const val PITCH_STANDING_MIN = 70f
/** 자세 + 진단 정보 (UI/log용). */
data class Result(
val posture: Posture,
val pitchDeg: Float,
val accMag: Float,
val gyroMagDps: Float,
val reason: String, // why UNKNOWN, or "ok"
)
fun classify(samples: List<ImuSample>): Result {
if (samples.isEmpty()) {
return Result(Posture.UNKNOWN, 0f, 0f, 0f, "no samples")
}
// sample 평균
var sumAx = 0f; var sumAy = 0f; var sumAz = 0f
var sumGx = 0f; var sumGy = 0f; var sumGz = 0f
for (s in samples) {
sumAx += s.ax; sumAy += s.ay; sumAz += s.az
sumGx += s.gx; sumGy += s.gy; sumGz += s.gz
}
val n = samples.size
val ax = sumAx / n; val ay = sumAy / n; val az = sumAz / n
val gx = sumGx / n; val gy = sumGy / n; val gz = sumGz / n
val accMag = sqrt(ax * ax + ay * ay + az * az)
val gyroMag = sqrt(gx * gx + gy * gy + gz * gz)
// 1. Acc magnitude 게이트
if (accMag < ACC_MAG_MIN || accMag > ACC_MAG_MAX) {
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
"acc mag out of range (%.2f)".format(accMag))
}
// 3. Gyro magnitude 게이트 (motion 거부)
if (gyroMag > GYRO_MAG_MAX_DPS) {
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
"moving (gyro %.1f dps)".format(gyroMag))
}
// 2. Pitch 계산
val pitch = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
val absPitch = abs(pitch)
// 4. 분류
val posture = when {
absPitch < PITCH_SUPINE_MAX -> Posture.SUPINE
absPitch <= PITCH_STANDING_MIN -> Posture.SITTING
else -> Posture.STANDING
}
return Result(posture, pitch, accMag, gyroMag, "ok")
}
}
@@ -4,6 +4,7 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import com.example.medilightv2android.AppScreen
import androidx.compose.material3.OutlinedTextField
@@ -126,6 +127,9 @@ fun PiezoMonitoringView(appState: AppState) {
var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
val analyzer = remember { PiezoEchoAnalyzer.shared }
// Posture (flowchart-based, IMU polling — Phase 1)
var currentPosture by remember { mutableStateOf(com.example.medilightv2android.managers.Posture.UNKNOWN) }
val screenWidth = LocalConfiguration.current.screenWidthDp
val screenHeight = LocalConfiguration.current.screenHeightDp
// donut/Bladdy 같은 정사각 sizing은 portrait/landscape 양쪽에서 화면 밖으로 나가지
@@ -587,6 +591,29 @@ fun PiezoMonitoringView(appState: AppState) {
}
}
// IMU polling + 자세 분류 (Phase 1) — 5초 주기로 msp 명령 → rim: 응답 받아 분류.
// monitoring 페이지 머무는 동안만 작동. demo 모드는 fake 자세 (toggle 없이 SUPINE).
LaunchedEffect(isConnected, isDemoMode) {
if (isDemoMode) {
currentPosture = com.example.medilightv2android.managers.Posture.SUPINE
return@LaunchedEffect
}
bleManager.imuCollector.onComplete = { samples ->
val r = com.example.medilightv2android.managers.ImuPostureClassifier.classify(samples)
// UNKNOWN 이면 직전 posture 유지 (motion artifact 시 잠깐 흔들림 방지)
if (r.posture != com.example.medilightv2android.managers.Posture.UNKNOWN) {
currentPosture = r.posture
}
Log.d("PiezoMonitor", "POSTURE ${r.posture} pitch=${"%.1f".format(r.pitchDeg)}° accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps (${r.reason})")
}
while (isActive) {
if (isConnected) {
bleManager.sendImuQuery()
}
kotlinx.coroutines.delay(5000)
}
}
// Add catheter dialog
if (showAddCatheterDialog) {
var addAmount by remember { mutableStateOf("1") }
@@ -908,6 +935,37 @@ fun PiezoMonitoringView(appState: AppState) {
Spacer(modifier = Modifier.width(8.dp))
// Posture icon (Phase 1) — flowchart-based 자세 분류 결과 표시.
// SUPINE/SITTING/STANDING/UNKNOWN 에 따라 아이콘+색 변경.
val postureIcon = when (currentPosture) {
com.example.medilightv2android.managers.Posture.SUPINE -> Icons.Default.Hotel
com.example.medilightv2android.managers.Posture.SITTING -> Icons.Default.AirlineSeatReclineNormal
com.example.medilightv2android.managers.Posture.STANDING -> Icons.Default.AccessibilityNew
com.example.medilightv2android.managers.Posture.UNKNOWN -> Icons.Default.HelpOutline
}
val postureTint = when (currentPosture) {
com.example.medilightv2android.managers.Posture.SUPINE -> Color(0xFF2196F3)
com.example.medilightv2android.managers.Posture.SITTING -> Color(0xFFFF9800)
com.example.medilightv2android.managers.Posture.STANDING -> Color(0xFF4CAF50)
com.example.medilightv2android.managers.Posture.UNKNOWN -> Color.Gray.copy(alpha = 0.5f)
}
val postureLabel = when (currentPosture) {
com.example.medilightv2android.managers.Posture.SUPINE -> "Lying"
com.example.medilightv2android.managers.Posture.SITTING -> "Sitting"
com.example.medilightv2android.managers.Posture.STANDING -> "Standing"
com.example.medilightv2android.managers.Posture.UNKNOWN -> "—"
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
postureIcon, contentDescription = postureLabel,
tint = postureTint, modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(3.dp))
Text(postureLabel, fontSize = (11 * fontScale).sp,
fontWeight = FontWeight.SemiBold, color = postureTint)
}
Spacer(modifier = Modifier.width(8.dp))
// Catheter count (탭하면 추가 팝업)
var showAddCatheter by remember { mutableStateOf(false) }
val cathCount = appState.catheterCount.intValue