feat(verify): Posture event CSV logger + ground-truth annotation 버튼

알고리즘 검증 인프라.

신규 PostureEventLogger.kt:
  - PiezoMonitoring 진입 시 새 CSV 파일 자동 생성
    경로: Downloads/VesiScan_Posture/yyyy-MM-dd_HHmmss.csv
  - 매 cycle 자세/walking 결과 + 신호값 1 row
  - 알람 발동 시 별도 row (walking_start, walking_end, wakeup_alarm_lying_Ns)
  - 사용자 annotation row (ground truth marker)
  - 컬럼: timestamp_ms, type, posture, isWalking, pitch, wmean, wstd,
          accMag, gyroMag, alarm_event, annotation

PiezoMonitoring 연결:
  - LaunchedEffect(Unit) 에서 startSession 호출
  - imuCollector.onComplete: logCycle 매 cycle 호출
  - WALKING 전이: logAlarm("walking_start" / "walking_end")
  - Wakeup alarm: logAlarm("wakeup_alarm_lying_Ns")

Dev settings — Verification 섹션:
  - 5개 annotation 버튼: LIE / STAND / WALK↑ / WALK↓ / SIT
  - 누르면 timestamp + 라벨이 CSV 에 ANNOTATION row 로 기록
  - Toast 로 "Marked: 걷기시작" 같은 피드백
  - 파일 경로 안내 텍스트

사용 흐름:
  1. PiezoMonitoring 진입 (파일 자동 생성)
  2. 동작 직전 dev settings 의 해당 버튼 누름 (예: WALK↑)
  3. 동작 수행 (걷기)
  4. CSV 회수: adb pull /sdcard/Download/VesiScan_Posture/
  5. Excel / pandas 에서 분석:
     - ANNOTATION row 의 timestamp = ground truth
     - CYCLE row 의 isWalking 변화 = detection
     - 두 시간 차 = latency
     - 미발동 = FN, 과발동 = FP → confusion matrix

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 14:05:43 +09:00
parent 45242f8256
commit 15993aa839
2 changed files with 165 additions and 0 deletions
@@ -0,0 +1,109 @@
/*
* PostureEventLogger — 자세/walking 알고리즘 검증용 CSV logger.
*
* 매 cycle 마다 분류 결과 + walking 상태 + 신호값 기록 +
* 사용자가 dev settings 의 annotation 버튼 누를 때 ground-truth marker 기록.
*
* 파일: Downloads/VesiScan_Posture/yyyy-MM-dd_HHmmss.csv
* 컬럼:
* timestamp_ms, type, posture, isWalking, pitch, wmean, wstd, accMag, gyroMag,
* alarm_event, annotation
*
* Row type:
* CYCLE — 매 cycle 의 자세/walking detector 결과
* ALARM — 알람 발동 (wakeup / walking)
* ANNOTATION — 사용자가 "지금 OOO" 표시 버튼 누름 (ground truth)
*
* 사용:
* 1. logSession(context) 한 번 호출 (PiezoMonitoring 진입 시) — 파일 생성
* 2. logCycle(...) 매 cycle 마다 호출
* 3. logAlarm(...) 알람 발동 시
* 4. annotate("WALK_START") 사용자가 버튼 누를 때
* 5. closeSession() 종료 시 (선택)
*
* 분석: CSV → Excel / python pandas → ANNOTATION 과 CYCLE 의 walking 전이 대조.
*/
package com.example.medilightv2android.services
import android.content.Context
import android.os.Environment
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object PostureEventLogger {
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
@Volatile private var sessionFile: File? = null
val currentFile: File? get() = sessionFile
private fun getDir(): File {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VesiScan_Posture"
)
if (!dir.exists()) dir.mkdirs()
return dir
}
/** 새 세션 파일 생성 + 헤더 작성. */
fun startSession(@Suppress("UNUSED_PARAMETER") context: Context): File {
val f = File(getDir(), "${sessionFmt.format(Date())}.csv")
try {
FileWriter(f, false).buffered().use { w ->
w.write("timestamp_ms,type,posture,isWalking,pitch,wmean,wstd,accMag,gyroMag,alarm_event,annotation")
w.newLine()
}
} catch (_: Exception) {}
sessionFile = f
return f
}
/** Cycle 의 자세 분류 결과 기록. */
fun logCycle(
nowMs: Long,
posture: String,
isWalking: Boolean,
pitch: Float,
wmean: Float,
wstd: Float,
accMag: Float,
gyroMag: Float,
) {
appendRow(
nowMs, "CYCLE", posture, isWalking.toString(),
pitch, wmean, wstd, accMag, gyroMag, "", ""
)
}
/** 알람 발동 (wakeup / walking) 기록. */
fun logAlarm(nowMs: Long, alarmEvent: String) {
appendRow(nowMs, "ALARM", "", "", 0f, 0f, 0f, 0f, 0f, alarmEvent, "")
}
/** 사용자 ground-truth annotation 기록 (예: "WALK_START"). */
fun annotate(label: String, nowMs: Long = System.currentTimeMillis()) {
appendRow(nowMs, "ANNOTATION", "", "", 0f, 0f, 0f, 0f, 0f, "", label)
}
fun closeSession() {
sessionFile = null
}
private fun appendRow(
ts: Long, type: String, posture: String, isWalking: String,
pitch: Float, wmean: Float, wstd: Float, accMag: Float, gyroMag: Float,
alarm: String, annotation: String
) {
val f = sessionFile ?: return
try {
FileWriter(f, true).buffered().use { w ->
w.write("$ts,$type,$posture,$isWalking,${"%.2f".format(pitch)},${"%.2f".format(wmean)},${"%.2f".format(wstd)},${"%.3f".format(accMag)},${"%.2f".format(gyroMag)},$alarm,$annotation")
w.newLine()
}
} catch (_: Exception) {}
}
}
@@ -145,6 +145,10 @@ fun PiezoMonitoringView(appState: AppState) {
wakeupAlarmElapsedSec = elapsed
showWakeupAlarm = true
}
com.example.medilightv2android.services.PostureEventLogger.logAlarm(
System.currentTimeMillis(),
"wakeup_alarm_lying_${elapsed}s"
)
}
}
}
@@ -707,6 +711,11 @@ fun PiezoMonitoringView(appState: AppState) {
}
}
// Posture event 로깅 — 검증용 CSV (앱 진입 시 새 파일)
LaunchedEffect(Unit) {
com.example.medilightv2android.services.PostureEventLogger.startSession(context)
}
// IMU polling + 자세 분류 (Phase 1) — 10초 주기로 mtb 명령 → rim: 응답 받아 분류.
// msp 가 펌웨어에서 rsp: 응답 안 보내는 듯해 mtb (alignment) 로 우회.
// mtb 는 piezo measurement 도 트리거하므로 piezoCollector.skipNextComplete 로
@@ -746,9 +755,19 @@ fun PiezoMonitoringView(appState: AppState) {
if (com.example.medilightv2android.services.WakeupAlarmPrefs.isEnabled(context)) {
showWalkingAlarm = true
}
com.example.medilightv2android.services.PostureEventLogger.logAlarm(nowMs, "walking_start")
}
if (!isWalkingNow && prevIsWalking) {
com.example.medilightv2android.services.PostureEventLogger.logAlarm(nowMs, "walking_end")
}
prevIsWalking = isWalkingNow
// CSV logging — 검증용
com.example.medilightv2android.services.PostureEventLogger.logCycle(
nowMs, composite.name, isWalkingNow,
r.pitchDeg, walkSnap.wmean, walkSnap.wstd, r.accMag, r.gyroMagDps,
)
Log.d("ImuDbg", "POSTURE ${r.posture}→$composite pitch=${"%.1f".format(r.pitchDeg)}° " +
"accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps | " +
"WALK wmean=${"%.1f".format(walkSnap.wmean)} wstd=${"%.1f".format(walkSnap.wstd)} " +
@@ -2055,6 +2074,43 @@ fun PiezoMonitoringView(appState: AppState) {
Slider(value = accMagMax, onValueChange = {
accMagMax = it; GreenZoneConstants.postureAccMagMax = it
}, valueRange = 1.0f..3.0f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))
// Verification annotation buttons — ground truth marker.
// 누르는 순간 timestamp 가 CSV 에 ANNOTATION row 로 기록 → 알고리즘 detection 과 대조.
// 파일: Downloads/VesiScan_Posture/yyyy-MM-dd_HHmmss.csv
Text("Verification (Ground Truth)", fontSize = labelSize,
fontWeight = FontWeight.SemiBold, color = MlPrimary)
Text(" 현재 동작 시작 직전에 누르세요 → CSV 에 기록",
fontSize = 11.sp, color = MlSecondaryText)
val annotateLabels = listOf(
"LIE" to "누움",
"STAND" to "일어남",
"WALK↑" to "걷기시작",
"WALK↓" to "걷기멈춤",
"SIT" to "앉음",
)
Row(modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for ((tag, label) in annotateLabels) {
Button(
onClick = {
com.example.medilightv2android.services.PostureEventLogger.annotate(tag)
android.widget.Toast.makeText(context,
"Marked: $label", android.widget.Toast.LENGTH_SHORT).show()
},
modifier = Modifier.weight(1f).height(40.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(2.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.7f))
) {
Text(tag, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
Text(" 파일: Downloads/VesiScan_Posture/*.csv",
fontSize = 10.sp, color = MlSecondaryText)
}
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))