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:
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -145,6 +145,10 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
wakeupAlarmElapsedSec = elapsed
|
wakeupAlarmElapsedSec = elapsed
|
||||||
showWakeupAlarm = true
|
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: 응답 받아 분류.
|
// IMU polling + 자세 분류 (Phase 1) — 10초 주기로 mtb 명령 → rim: 응답 받아 분류.
|
||||||
// msp 가 펌웨어에서 rsp: 응답 안 보내는 듯해 mtb (alignment) 로 우회.
|
// msp 가 펌웨어에서 rsp: 응답 안 보내는 듯해 mtb (alignment) 로 우회.
|
||||||
// mtb 는 piezo measurement 도 트리거하므로 piezoCollector.skipNextComplete 로
|
// mtb 는 piezo measurement 도 트리거하므로 piezoCollector.skipNextComplete 로
|
||||||
@@ -746,9 +755,19 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
if (com.example.medilightv2android.services.WakeupAlarmPrefs.isEnabled(context)) {
|
if (com.example.medilightv2android.services.WakeupAlarmPrefs.isEnabled(context)) {
|
||||||
showWalkingAlarm = true
|
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
|
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)}° " +
|
Log.d("ImuDbg", "POSTURE ${r.posture}→$composite pitch=${"%.1f".format(r.pitchDeg)}° " +
|
||||||
"accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps | " +
|
"accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps | " +
|
||||||
"WALK wmean=${"%.1f".format(walkSnap.wmean)} wstd=${"%.1f".format(walkSnap.wstd)} " +
|
"WALK wmean=${"%.1f".format(walkSnap.wmean)} wstd=${"%.1f".format(walkSnap.wstd)} " +
|
||||||
@@ -2055,6 +2074,43 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
Slider(value = accMagMax, onValueChange = {
|
Slider(value = accMagMax, onValueChange = {
|
||||||
accMagMax = it; GreenZoneConstants.postureAccMagMax = it
|
accMagMax = it; GreenZoneConstants.postureAccMagMax = it
|
||||||
}, valueRange = 1.0f..3.0f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
|
}, 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))
|
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))
|
||||||
|
|||||||
Reference in New Issue
Block a user