feat(dev): 자세/walking 모든 임계 슬라이더 추가 (튜닝용)

사용자: walking 이 너무 예민하게 뜸. 모든 임계를 dev settings 에서 조절하고 싶음.

추가된 슬라이더 (Pitch Threshold 아래에 그룹별 정렬):

1) Posture
   - Pitch Threshold (lying ↔ non-lying)         기존
   - Poll Interval                                 기존

2) Walking Thresholds
   - Min Pitch (walking 인정 최소 pitch)           기존
   - Start Wmean (시작 평균 gyro)                  기존
   - Start Wstd (시작 표준편차)                    기존
   - Keep Wmean (유지 평균) — range 0.5~30 (이전 10) 신규 범위↑
   - **Start Hold** (시작 조건 hold 시간, 0.2~5초)  ⭐신규
   - **Keep Grace** (종료 grace 시간, 0.5~10초)     ⭐신규
   - **Window** (sliding window 크기, 0.5~5초)     ⭐신규

3) Acc Magnitude Gate
   - **Min** (UNKNOWN 하한, 0.3~1.0g)              ⭐신규
   - **Max** (UNKNOWN 상한, 1.0~3.0g)              ⭐신규

내부 변경:
  - GreenZoneConstants 에 postureAccMagMin/Max 추가
  - ImuPostureClassifier 의 const ACC_MAG_MIN/MAX → GreenZoneConstants 참조
    (런타임 변경 가능)

총 11개 슬라이더 — 사용자가 실측하며 튜닝 가능.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:37:46 +09:00
parent 6bf399c7b9
commit 18a4cee00d
3 changed files with 64 additions and 5 deletions
@@ -31,6 +31,11 @@ object GreenZoneConstants {
/** Posture pitch 임계값 (deg). |pitch| > 이 값 → LYING, ≤ 이 값 → NON_LYING. dev 모드에서 조정 가능. */ /** Posture pitch 임계값 (deg). |pitch| > 이 값 → LYING, ≤ 이 값 → NON_LYING. dev 모드에서 조정 가능. */
@Volatile var pitchLyingThreshold: Float = 25f @Volatile var pitchLyingThreshold: Float = 25f
/** Posture acc magnitude gate min (g). 이 값 미만이면 classifier UNKNOWN. */
@Volatile var postureAccMagMin: Float = 0.8f
/** Posture acc magnitude gate max (g). 이 값 초과면 classifier UNKNOWN. */
@Volatile var postureAccMagMax: Float = 1.2f
/** Posture polling 간격 (ms). PiezoMonitoring 에서 mtb 보내는 주기. dev 모드에서 조정 가능. */ /** Posture polling 간격 (ms). PiezoMonitoring 에서 mtb 보내는 주기. dev 모드에서 조정 가능. */
@Volatile var posturePollIntervalMs: Long = 1000L @Volatile var posturePollIntervalMs: Long = 1000L
@@ -24,12 +24,10 @@ enum class Posture { UNKNOWN, LYING, NON_LYING, WALKING }
object ImuPostureClassifier { object ImuPostureClassifier {
// 게이트 임계값 // Acc gate 임계값은 GreenZoneConstants.postureAccMagMin/Max 사용 (dev 조절 가능).
private const val ACC_MAG_MIN = 0.8f
private const val ACC_MAG_MAX = 1.2f
// Gyro gate 비활성화 — 일부 디바이스의 gyro calibration bias (-25 dps 등) 가 // Gyro gate 비활성화 — 일부 디바이스의 gyro calibration bias (-25 dps 등) 가
// flowchart 임계(9.16) 보다 커서 정지 상태에서도 fail → 분류 영구 불가. // flowchart 임계(9.16) 보다 커서 정지 상태에서도 fail → 분류 영구 불가.
// accel magnitude gate (0.8-1.2g) 가 이미 큰 모션 거름. gyro 게이트 제거. // accel magnitude gate 가 이미 큰 모션 거름. gyro 게이트 제거.
// 단 dev 모드 디버그 위해 magnitude 는 계속 계산해 로그/Result 에 노출. // 단 dev 모드 디버그 위해 magnitude 는 계속 계산해 로그/Result 에 노출.
/** 자세 + 진단 정보 (UI/log용). */ /** 자세 + 진단 정보 (UI/log용). */
@@ -62,7 +60,7 @@ object ImuPostureClassifier {
// Pitch 는 acc gate 통과 여부와 무관하게 항상 계산 (walking detector 가 사용). // Pitch 는 acc gate 통과 여부와 무관하게 항상 계산 (walking detector 가 사용).
val pitchPre = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat() val pitchPre = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
if (accMag < ACC_MAG_MIN || accMag > ACC_MAG_MAX) { if (accMag < GreenZoneConstants.postureAccMagMin || accMag > GreenZoneConstants.postureAccMagMax) {
return Result(Posture.UNKNOWN, pitchPre, accMag, gyroMag, return Result(Posture.UNKNOWN, pitchPre, accMag, gyroMag,
"acc mag out of range (%.2f)".format(accMag)) "acc mag out of range (%.2f)".format(accMag))
} }
@@ -1998,7 +1998,63 @@ fun PiezoMonitoringView(appState: AppState) {
} }
Slider(value = walkKeepWmean, onValueChange = { Slider(value = walkKeepWmean, onValueChange = {
walkKeepWmean = it; GreenZoneConstants.walkingKeepWmean = it walkKeepWmean = it; GreenZoneConstants.walkingKeepWmean = it
}, valueRange = 0.5f..30f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
// Walking 시간 변수 (sec)
var walkStartHold by remember { mutableStateOf(GreenZoneConstants.walkingStartHoldMs / 1000f) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Start Hold", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("%.1f초".format(walkStartHold), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(value = walkStartHold, onValueChange = {
walkStartHold = it; GreenZoneConstants.walkingStartHoldMs = (it * 1000).toLong()
}, valueRange = 0.2f..5f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
var walkKeepHold by remember { mutableStateOf(GreenZoneConstants.walkingKeepHoldMs / 1000f) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Keep Grace", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("%.1f초".format(walkKeepHold), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(value = walkKeepHold, onValueChange = {
walkKeepHold = it; GreenZoneConstants.walkingKeepHoldMs = (it * 1000).toLong()
}, valueRange = 0.5f..10f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) }, valueRange = 0.5f..10f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
var walkWindow by remember { mutableStateOf(GreenZoneConstants.walkingWindowMs / 1000f) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Window", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("%.1f초".format(walkWindow), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(value = walkWindow, onValueChange = {
walkWindow = it; GreenZoneConstants.walkingWindowMs = (it * 1000).toLong()
}, valueRange = 0.5f..5f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))
// Acc magnitude gate (classifier UNKNOWN 결정)
Text("Acc Magnitude Gate", fontSize = labelSize, fontWeight = FontWeight.SemiBold, color = MlPrimary)
var accMagMin by remember { mutableStateOf(GreenZoneConstants.postureAccMagMin) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Min", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("%.2fg".format(accMagMin), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(value = accMagMin, onValueChange = {
accMagMin = it; GreenZoneConstants.postureAccMagMin = it
}, valueRange = 0.3f..1.0f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
var accMagMax by remember { mutableStateOf(GreenZoneConstants.postureAccMagMax) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Max", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("%.2fg".format(accMagMax), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
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)) HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))