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:
@@ -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")
|
||||
}
|
||||
}
|
||||
+58
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user