fix(posture): pitch → tilt(Z축) 방향 독립적 분류 — 손 테스트 시 Y축 세로 문제 해결

## 문제
"5초 누워있다가 세워서 5초 움직였는데 다시 누움이 뜸"
= 세워 놓은 5초 동안 NON_LYING 이 뜨지 않음.

## 원인
기존 pitch = atan(ax / √(ay² + az²))
→ AX 축이 세로일 때만 큰 pitch, NON_LYING 인식.
→ AY 축이 세로면 (손으로 기기를 세로로 잡는 흔한 자세) pitch=0 → LYING.

정상 착용 (배 위 부착) 시엔 AX가 세로가 되도록 설계됐지만,
손 테스트에선 AY 축 세로가 되기 쉬움. 특히 흔들기 테스트할 때.

## 수정
tilt = atan(√(ax² + ay²) / |az|)
→ **Z축이 중력방향에서 얼마나 벗어났는지** 측정. 방향 독립적.
- 기기 평평 (AZ up)         → tilt=0°       → LYING     ✓
- X축 세로 (AX up)         → tilt=90°     → NON_LYING ✓
- Y축 세로 (AY up)         → tilt=90°     → NON_LYING ✓ ★ 신규
- 완전 옆으로 세워짐 (az≈0) → tilt=90°     → NON_LYING ✓

## 임계값
`pitchLyingThreshold = 25f` 유지 (Dev 슬라이더 그대로).
- 이전 의미: X축 tilt ≤ 25° = LYING
- 신규 의미: Z축 tilt ≤ 25° = LYING (기기 평평 여유 ±25°)

정상 착용에선 동일하게 동작. hand-test 시나리오 추가 커버.

## 다음
Walking detector 는 pitch 를 그대로 재활용. tilt=90° 근처면
walkingMinPitchDeg=45° 통과. 이전엔 손 세로 잡기로 walking 감지도 못
했지만 이제 가능.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 11:07:41 +09:00
parent 7a2abfa65b
commit cd8b48a811
@@ -6,18 +6,27 @@
* *
* 단계: * 단계:
* 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부 * 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부
* 2. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부 * 2. Tilt 계산 (Z축과 중력방향 사이 각도):
* 3. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π * horizontal = √(ax² + ay²)
* 4. 분류 (|pitch| 기준, threshold = GreenZoneConstants.pitchLyingThreshold): * tilt = atan(horizontal / |az|) × 180/π
* > threshold → LYING (누움) * → 방향 독립적. X축이든 Y축이든 어느쪽이 세로여도 동일 결과.
* ≤ threshold → NON_LYING (앉음/섬) * 3. 분류 (tilt 기준, threshold = GreenZoneConstants.pitchLyingThreshold):
* threshold default = 25°, dev 모드 설정에서 변경 가능. * ≤ threshold → LYING (Z축이 수직 = 기기 평평)
* > threshold → NON_LYING (Z축이 기울어짐 = 기기 세워짐)
* threshold default = 25° = 기기 평평기준 ±25° 여유.
*
* 이력:
* 2026-07-03: pitch(atan(ax/...)) → tilt(atan(hxy/|az|)) 로 교체.
* 기존은 X축 세로만 감지 → 손 테스트 시 Y축이 세로면 항상 LYING.
* Z축 tilt 방식은 orientation-independent 라 정상 착용 (배 위 부착) +
* hand-test 시나리오 모두 커버.
*/ */
package com.medithings.vesiscan.managers package com.medithings.vesiscan.managers
import com.medithings.vesiscan.ble.ImuSample import com.medithings.vesiscan.ble.ImuSample
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.atan import kotlin.math.atan
import kotlin.math.hypot
import kotlin.math.sqrt import kotlin.math.sqrt
enum class Posture { UNKNOWN, LYING, NON_LYING, WALKING } enum class Posture { UNKNOWN, LYING, NON_LYING, WALKING }
@@ -57,8 +66,18 @@ object ImuPostureClassifier {
val accMag = sqrt(ax * ax + ay * ay + az * az) val accMag = sqrt(ax * ax + ay * ay + az * az)
val gyroMag = sqrt(gx * gx + gy * gy + gz * gz) val gyroMag = sqrt(gx * gx + gy * gy + gz * gz)
// Pitch 는 acc gate 통과 여부와 무관하게 항상 계산 (walking detector 가 사용). // Tilt = Z축과 중력방향 사이 각도 (방향 독립적).
val pitchPre = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat() // horizontal = √(ax² + ay²) = XY 평면 내 중력 성분
// tilt = atan(horizontal / |az|) — Z축이 중력과 얼마나 어긋났는지 (0°=평평, 90°=세로)
// 정상 착용 (배 위) + hand-test 어느 시나리오에서도 동일 동작.
// 기기 평평 (AZ up) → horizontal=0, tilt=0° → LYING
// 기기 X축 세로 (AX up, 정상 서있음) → horizontal=1, |az|=0, tilt=90° → NON_LYING
// 기기 Y축 세로 (손으로 세로 잡음) → horizontal=1, |az|=0, tilt=90° → NON_LYING ★ 이전엔 실패
val absAz = abs(az)
val horizontalMag = hypot(ax, ay)
val pitchPre = if (absAz > 1e-4f)
(atan(horizontalMag / absAz) * 180.0 / Math.PI).toFloat()
else 90f // Z축 성분 거의 없음 = 완전히 옆으로 세워짐
if (accMag < GreenZoneConstants.postureAccMagMin || accMag > GreenZoneConstants.postureAccMagMax) { if (accMag < GreenZoneConstants.postureAccMagMin || accMag > GreenZoneConstants.postureAccMagMax) {
return Result(Posture.UNKNOWN, pitchPre, accMag, gyroMag, return Result(Posture.UNKNOWN, pitchPre, accMag, gyroMag,
@@ -66,13 +85,11 @@ object ImuPostureClassifier {
} }
// Gyro gate 제거 — bias 큰 디바이스에서 영구 fail 문제 (위 주석 참조) // Gyro gate 제거 — bias 큰 디바이스에서 영구 fail 문제 (위 주석 참조)
val absPitch = abs(pitchPre)
val thr = GreenZoneConstants.pitchLyingThreshold val thr = GreenZoneConstants.pitchLyingThreshold
// Pitch 의미: AX 가 중력과 일치할수록 큰 값 (≈90°), AZ 가 중력과 일치하면 작은 값 (≈0°). // tilt 의미: 기기 평평 (AZ up) 이면 0°, 세워지면 90° 로 향함.
// 사용자 직관: 센서 평평하게 둠 (AZ up) = LYING, 세움 (AX up) = NON_LYING (upright). // → 작은 tilt → LYING (Z축이 수직 유지), 큰 tilt → NON_LYING (기기 세워짐)
// → 작은 pitch → LYING, 큰 pitch → NON_LYING val posture = if (pitchPre <= thr) Posture.LYING else Posture.NON_LYING
val posture = if (absPitch <= thr) Posture.LYING else Posture.NON_LYING
return Result(posture, pitchPre, accMag, gyroMag, "ok") return Result(posture, pitchPre, accMag, gyroMag, "ok")
} }
} }