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
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user