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