From cd8b48a811df165b17bd6913c50916343e9f3bb9 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Fri, 3 Jul 2026 11:07:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(posture):=20pitch=20=E2=86=92=20tilt(Z?= =?UTF-8?q?=EC=B6=95)=20=EB=B0=A9=ED=96=A5=20=EB=8F=85=EB=A6=BD=EC=A0=81?= =?UTF-8?q?=20=EB=B6=84=EB=A5=98=20=E2=80=94=20=EC=86=90=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=20Y=EC=B6=95=20=EC=84=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 문제 "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 --- .../vesiscan/managers/ImuPostureClassifier.kt | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/managers/ImuPostureClassifier.kt b/app/src/main/java/com/medithings/vesiscan/managers/ImuPostureClassifier.kt index 8f6b3af..54d65b6 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/ImuPostureClassifier.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/ImuPostureClassifier.kt @@ -6,18 +6,27 @@ * * 단계: * 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부 - * 2. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부 - * 3. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π - * 4. 분류 (|pitch| 기준, threshold = GreenZoneConstants.pitchLyingThreshold): - * > threshold → LYING (누움) - * ≤ threshold → NON_LYING (앉음/섬) - * threshold default = 25°, dev 모드 설정에서 변경 가능. + * 2. Tilt 계산 (Z축과 중력방향 사이 각도): + * horizontal = √(ax² + ay²) + * tilt = atan(horizontal / |az|) × 180/π + * → 방향 독립적. X축이든 Y축이든 어느쪽이 세로여도 동일 결과. + * 3. 분류 (tilt 기준, threshold = GreenZoneConstants.pitchLyingThreshold): + * ≤ 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 import com.medithings.vesiscan.ble.ImuSample import kotlin.math.abs import kotlin.math.atan +import kotlin.math.hypot import kotlin.math.sqrt enum class Posture { UNKNOWN, LYING, NON_LYING, WALKING } @@ -57,8 +66,18 @@ object ImuPostureClassifier { val accMag = sqrt(ax * ax + ay * ay + az * az) val gyroMag = sqrt(gx * gx + gy * gy + gz * gz) - // Pitch 는 acc gate 통과 여부와 무관하게 항상 계산 (walking detector 가 사용). - val pitchPre = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat() + // Tilt = Z축과 중력방향 사이 각도 (방향 독립적). + // 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) { return Result(Posture.UNKNOWN, pitchPre, accMag, gyroMag, @@ -66,13 +85,11 @@ object ImuPostureClassifier { } // Gyro gate 제거 — bias 큰 디바이스에서 영구 fail 문제 (위 주석 참조) - val absPitch = abs(pitchPre) val thr = GreenZoneConstants.pitchLyingThreshold - // Pitch 의미: AX 가 중력과 일치할수록 큰 값 (≈90°), AZ 가 중력과 일치하면 작은 값 (≈0°). - // 사용자 직관: 센서 평평하게 둠 (AZ up) = LYING, 세움 (AX up) = NON_LYING (upright). - // → 작은 pitch → LYING, 큰 pitch → NON_LYING - val posture = if (absPitch <= thr) Posture.LYING else Posture.NON_LYING + // tilt 의미: 기기 평평 (AZ up) 이면 0°, 세워지면 90° 로 향함. + // → 작은 tilt → LYING (Z축이 수직 유지), 큰 tilt → NON_LYING (기기 세워짐) + val posture = if (pitchPre <= thr) Posture.LYING else Posture.NON_LYING return Result(posture, pitchPre, accMag, gyroMag, "ok") } }