From cf476971a5c631b48171f42a7e1df5ae324f50d4 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 23 Jun 2026 12:24:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(posture):=20Phase=201=20=E2=80=94=20flowch?= =?UTF-8?q?art=20=EA=B8=B0=EB=B0=98=20=EC=9E=90=EC=84=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20+=20=EB=8F=84=EB=84=9B=20=EC=9A=B0=EC=83=81?= =?UTF-8?q?=EB=8B=A8=20=EC=95=84=EC=9D=B4=EC=BD=98=20(demo-final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../managers/ImuPostureClassifier.kt | 90 +++++++++++++++++++ .../views/monitoring/PiezoMonitoringView.kt | 58 ++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt diff --git a/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt b/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt new file mode 100644 index 0000000..490a532 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt @@ -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): 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") + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 9d6b468..90afb32 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -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(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