- 전수 상수 비교 테이블 (V0/V1/V2 좌표, 각도, 음향 상수, lr_ratio) - 알고리즘 수준 비교 (전처리, low-echo, lr_ratio, BV 추정) - 결론: 모든 항목 완벽 일치 (Adaptive TGC는 Python에도 미포함) - BLE 재연결 시스템 흐름도 + Watchdog + GATT 에러 경로 설명 - BLE 디버그 로거 사용법 - GreenZoneConstants.kt 주석 오류 수정 (1600→1150) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
28 KiB
VesiScan Android App — 완전 가이드
작성일: 2026-04-23
작성자: dwjang
마지막 업데이트: 2026-04-23
1. 이 앱이 뭔가요?
배에 초음파 센서를 갖다 대면 방광에 소변이 얼마나 차 있는지 알려주는 앱입니다.
병원에 안 가고도 집에서 방광 상태를 확인할 수 있습니다.
iOS 버전과 동일한 기능을 Kotlin + Jetpack Compose로 구현한 Android 버전입니다.
2. iOS 버전과 달라진 점
| 항목 | iOS | Android | 비고 |
|---|---|---|---|
| 전처리 | TVD + SG 필터 | SG 필터만 | TVD 제거 (6ch 알고리즘) |
| LOW_ECHO_AMP | 1600 | 1150 | ADC range 변경 |
| DISTANCE_PER_SAMPLE | 1.974 | 1.771 | 6ch 기기 보정값 |
| 기기 프리셋 | FLAT/CASE1/CASE2 | V0/V1/V2 | Snell's law 굴절 각도 적용 |
| lr_ratio | 고정 1.0 | CH4/CH5에서 동적 계산 | 타원 단면 추정 |
| BV 추정 | 5ch (CH0~CH4) | 6ch (center 4ch + lateral lr_ratio) | estimate_bladder_volume_6ch |
| 배터리 폴링 | 5초 | 5초 | 동일 (keep-alive) |
| BLE reconnect | 수동 | 자동 (3s→5s→10s, 5회) | 백오프 재시도 |
| Spot 자동측정 | 0.8초 롱프레스 | 롱프레스 (detectTapGestures) | 30초 간격 |
3. 앱 사용 순서 (처음부터 끝까지)
① 앱 실행
↓
② 온보딩 (처음 한 번만)
↓
③ 개인정보 동의
↓
④ 사용자 등록 (이름, 나이, 성별, 키/몸무게)
↓
⑤ PIN 설정 (4자리, 셔플 키패드)
↓
⑥ 홈 화면
↓
⑦ "Start" 버튼 → 센서 종류 선택 (Piezo / NIRS)
↓
⑧ BLE 기기 스캔 → 연결 (2025* / VBT* 필터)
↓
⑨ 센서 설정 (자세, 위치, 최대 용량)
↓
⑩ ★ 센서 위치 안내 (Placement Guide)
↓
⑪ ★ 방광 측정 화면 (Piezo Monitoring)
↓
⑫ 결과 확인 + 기록
4. 기기 자동 감지 (프리셋)
BLE 기기 이름으로 센서 각도 preset을 자동 설정합니다.
기기 이름 규칙
VBT26040x0y
│ │
│ └─ y: 기기 번호 (1,2,3...)
└─── x: 각도 타입
0 = V0 (0,0,0,0,0,0) — BLE 테스트용
2 = V1 (max 20° housing, Snell's law)
3 = V2 (max 30° housing, Snell's law)
2025MEDIP = 기존 5ch 기기 (legacy)
Preset별 각도 (Snell's law 굴절 후)
| Preset | CH0 | CH1 | CH2 | CH3 | CH4(L) | CH5(R) | LR(CH4) | LR(CH5) |
|---|---|---|---|---|---|---|---|---|
| V0 | 0° | 0° | 0° | 0° | 0° | 0° | 0° | 0° |
| V1 | 6.89° | 0° | -6.89° | -13.66° | -6.87° | -6.87° | -3.42° | 3.42° |
| V2 | 0° | -6.89° | -13.66° | -20.20° | -6.87° | -6.87° | -3.42° | 3.42° |
| legacy | 0° | -2.2° | -4.4° | -6.6° | -8.8° | — | 0° | 0° |
Housing 각도 → 실제 빔 각도 변환 (Snell's law):
- V1 housing: [10, 0, -10, -20, -10, -10] → refracted: [6.89, 0, -6.89, -13.66, -6.87, -6.87]
- V2 housing: [0, -10, -20, -30, -10, -10] → refracted: [0, -6.89, -13.66, -20.20, -6.87, -6.87]
센서 좌표 (mm)
| Preset | CH0 z | CH1 z | CH2 z | CH3 z | CH4 z | CH5 z | CH4 x | CH5 x |
|---|---|---|---|---|---|---|---|---|
| V0 | 21.0 | 14.0 | 7.0 | 0.0 | 10.5 | 10.5 | -10.0 | 10.0 |
| V1 | 20.1 | 12.8 | 7.0 | 0.0 | 9.9 | 9.9 | -10.0 | 10.0 |
| V2 | 19.3 | 13.0 | 6.7 | 0.0 | 9.85 | 9.85 | -10.0 | 10.0 |
5. 측정 파이프라인 상세
5.1 BLE 통신
사용하는 명령어
| 명령 | 응답 | 용도 |
|---|---|---|
mpa? |
rpa: |
센서 전원 켜기 |
maa? |
reb: × 6 + raa: |
6채널 전체 측정 |
msn? |
rsn: |
배터리 확인 (5초마다, keep-alive) |
패킷 구조 (208 바이트)
reb:(4바이트) + repeat_count(2바이트) + ADC데이터(200바이트) + CRC(2바이트)
└ "reb:" 문자 └ 항상 100 └ 100개 × 2바이트(16-bit) └ 체크섬
Big Endian 자동 감지
reb: 패킷 수신 → 첫 번째 채널(CH0)의 첫 ADC 샘플 확인
LE로 해석한 값 > 4095 AND BE로 해석한 값 ≤ 4095 → Big Endian
그 외 → Little Endian
첫 채널에서 감지 → 나머지 CH1~CH5에 동일 적용
BLE 자동 재연결
예기치 않은 끊김 감지
↓
자동 재연결 시도 (최대 5회)
├── 1차: 3초 후
├── 2차: 5초 후
├── 3차: 10초 후
├── 4차: 10초 후
└── 5차: 10초 후
성공 → 배너 자동 숨김 + 배터리 폴링 재시작
실패 → 빨간 배너 "Could not reconnect" + 수동 Reconnect 버튼
사용자가 수동 disconnect → 자동 재연결 안 함
측정 중 끊김 → isMeasuring 리셋 + 자동측정 중지
5.2 전처리 — 노이즈 제거
iOS와 다른 점
iOS: TVD (Chambolle) → SG 필터
Android: SG 필터만 사용 (TVD 제거)
Savitzky-Golay Filter (5-tap, poly=2)
고정 계수: [-3, 12, 17, 12, -3] / 35
내부 (i=2~n-3):
out[i] = (-3×sig[i-2] + 12×sig[i-1] + 17×sig[i] + 12×sig[i+1] - 3×sig[i+2]) / 35
Edge (i=0,1 / n-2,n-1):
5개 점에 2차 다항식 피팅 → 가장자리 값 보간
효과: 미세한 울퉁불퉁 제거, 벽(높은 곳)은 유지
5.3 UrinAI — 소변 존재 판정 (문지기)
역할
"여기에 소변이 있나요?" → Yes / No
No이면 벽 찾기를 아예 안 함 → 엉뚱한 결과 방지.
상수 (GreenZoneConstants.kt)
| 상수 | 값 | 의미 |
|---|---|---|
liquidThrLoose |
1400 | 이보다 낮으면 "액체" (이진화) |
liquidThrStrict |
1400 | 순수 소변 확인용 |
minLiquidRun |
5 | 연속 소변 최소 5칸 |
minChord |
15 | 앞벽~뒷벽 최소 15칸 |
contrastMin |
0.15 | 벽과 소변의 차이 15% 이상 |
ringSkip |
3 | 처음 3칸은 무시 (센서 자체 반사) |
useSamples |
90 | 앞 90개 샘플만 사용 |
알고리즘
1. 이진화: ADC < 1400 → 액체(true) / ≥ 1400 → 조직(false)
2. fw 찾기: 3번째 칸부터 시작해서
"연속 3개가 전부 액체"인 첫 위치 = 소변 시작점
3. bw 찾기: fw 이후에서 "연속 3개가 전부 조직"인 그룹 중
합계가 가장 큰 그룹의 최대값 위치 = 뒷벽
(못 찾으면 2개로 완화 → 소방광 대응)
4. 순수 소변 확인: fw~bw 사이에서
ADC < 1400이 연속 몇 칸인지 세기
5. 대비 계산: (벽 최대 - 소변 최소) / 벽 최대
6. 판정: 순수소변 ≥ 5칸 AND 깊이 ≥ 15칸 AND 대비 ≥ 0.15
→ 세 개 다 통과해야 "소변 있음!"
5.4 Low-Echo Detection — 벽 찾기 (Method B)
역할
UrinAI가 "소변 있다!" 하면, 정확히 어디서 어디까지인지 찾습니다.
상수 (PiezoEchoAnalyzer.kt)
| 상수 | 값 | 의미 |
|---|---|---|
lowEchoAmp |
1150 | denoised ≤ 이 값 = low-echo (iOS: 1600) |
lowMinLen |
3 | low-echo span 최소 길이 |
mergeGapMax |
3 | 인접 span 합치기 최대 gap |
peakSearchWin |
20 | 벽 peak 탐색 범위 |
postMaxIdx |
80 | 후벽 최대 인덱스 |
minPeakMargin |
30 | peak 최소 높이 = low_mean + 30 |
minUrineLen |
3 | 최소 소변 구간 길이 |
maxPeakCandidates |
3 | 방향별 최대 후보 수 |
알고리즘 (Prominence 기반, 2026-04-20 업데이트)
1. 디노이징된 신호에서 1150 이하인 연속 구간 찾기
→ "여기가 소변이겠구나" (low-echo span)
2. 인접한 구간 합치기 (gap ≤ 3칸이면 합침)
단, gap 안에 1155 이상인 peak이 있으면 합치지 않음
(그건 벽일 수 있으니까!)
3. 양쪽에서 벽 peak 후보 찾기 (±20칸 범위)
후보 중 prominence가 가장 큰 것을 선택
prominence란?
= peak 높이 - 옆 valley(골짜기) 깊이
= "이 peak이 주변보다 얼마나 튀어나왔는가"
전벽(ant): peak 오른쪽(소변 방향) valley 기준
후벽(post): peak 왼쪽(소변 방향) valley 기준
4. 후벽이 80칸 넘으면 → 구간 뒤쪽 절반에서 다시 찾기
5. 결과: ant(전벽 위치), post(후벽 위치), urine_len(소변 길이)
파형 차트에서 보이는 것
색상 의미
─────────────────────
연한 파랑 Raw ADC (원본 신호)
진한 파랑 Denoised (노이즈 제거 후)
주황 점선 Threshold (1150)
노란 영역 Low-echo span (1150 이하 구간)
시안 영역 Urine region (ant ~ post)
초록 선+점 Anterior wall (전벽)
빨강 선+점 Posterior wall (후벽)
5.5 LR Ratio 계산 — 타원 단면 추정
iOS와 다른 점
iOS: lr_ratio = 1.0 (고정, 원형 단면 가정)
Android: CH4/CH5 lateral에서 동적으로 타원 비율 계산
알고리즘
1. Center AP 직경 계산 (CH1, CH2 기준)
- 각 이웃 채널의 (post-ant) × cos(θ) → 평균 = D_center
- D_center ≤ 0 → lr_ratio = 1.2 (invalid fallback)
2. Lateral 직경 계산 (CH4, CH5 각각)
- P = cos(alpha) × cos(beta) [SI 보정]
- Q = cos(alpha) × sin(beta) [LR 성분]
- D_lateral = (dFar - dNear) × P
- y_mid = sensor_x + d_mid × Q [LR 위치]
- ratio = D_lateral / D_center
3. LR ratio 계산
- 2개 lateral → 타원 방정식 풀기:
u = yL × yR × (yR - yL) / denom
d = -(u × (rL² - 1) + yL²) / (2 × yL)
b² = u + d²
lr_raw = 2√u / D_center
- 1개 lateral → 중심 가정:
b = |y_mid| / √(1 - r²)
lr_raw = b / (D_center/2)
4. Shrinkage (Bayesian 사후확률)
- confidence = ((1 - avg_ratio) / 0.10).clamp(0, 1)
- lr_final = lr_prior(1.2) + (lr_raw - 1.2) × confidence
Fallback 값
| 상황 | lr_ratio |
|---|---|
| lateral 데이터 없음 | 1.0 (원형) |
| lateral 있지만 계산 실패 | 1.2 (해부학적 기본값) |
| 정상 계산 | 동적 값 (shrinkage 적용) |
5.6 BV Estimation — 용적 계산
iOS와 다른 점
iOS: estimateBladderVolume(5채널, lr_ratio=1.0)
Android: estimateBladderVolume6ch(6채널)
→ center 4채널 분리 + lateral lr_ratio 계산
→ estimateBladderVolume(4채널, lr_ratio=동적)
Frustum(절두체) 적분
┌──╮ ← Top Cap
╱ ╲
│ CH3 │ ← 층 3 (절두체)
│ │
│ CH2 │ ← 층 2 (절두체)
│ │
│ CH1 │ ← 층 1 (절두체)
╲ ╱
└──╯ ← Bottom Cap
각 층의 부피 = (높이/3) × (위 면적 + 아래 면적 + √(위×아래))
총 부피 = 바닥 + 층1 + 층2 + 층3 + 뚜껑
10단계 파이프라인
| Step | 뭘 하나? | 수식 |
|---|---|---|
| 1 | Edge 채널 필터 | CH3이 CH1/CH2 대비 0.9배 미만이면 short |
| 2 | 칸 번호 → 실제 거리(mm) | d = 6.85 + 칸번호 × 1.771 |
| 3 | 방광 직경 구하기 (각도 보정) | D = (뒷벽거리 - 앞벽거리) × cos(각도) |
| 4 | 단면적 구하기 | S = (π/4) × D² × lr_ratio |
| 5 | 각 층의 높이 위치 | y = 센서위치 + 중간점 × sin(각도) |
| 6 | 아래→위 순서로 정렬 | - |
| 7 | 포물선으로 보정 | 2차 곡선 맞추기 + 이상치 수정 |
| 8 | 층별 부피 합산 | 절두체 공식 |
| 9 | 바닥 뚜껑 (항상 구) | V = S×h/2 + π×h³/6 |
| 10 | 윗 뚜껑 (원뿔 or 구) | 원뿔: V = S×h/3 / 구: 위와 동일 |
물리 상수
| 상수 | 값 | 의미 |
|---|---|---|
distancePerSample |
1.771 mm | 샘플 당 거리 (iOS: 1.974) |
delayOffsetMm |
6.85 mm | ADC 전 고정 전파 지연 |
areaK |
π/4 | 원형 단면적 계수 |
6. Placement Guide — 센서 위치 안내
센서 배치도 (실제 기기 스펙)
[CH3] 30° ← 가장 아래 (치골쪽, y=0mm)
│
[CH4] [CH2] 20° [CH5] ← 중앙 + 좌우 날개
왼쪽 │ 오른쪽
[CH1] 10° ← 위쪽
│
[CH0] 0° ← 가장 위 (배꼽쪽, y=21mm)
방향 안내 로직 (2단계)
1단계: 이진 판정 (4채널 미만일 때)
물리적 프로브 배치 기준:
- CH0 = 배꼽 방향 (위)
- CH3 = 치골 방향 (아래)
| CH0(배꼽) | CH3(치골) | CH4(왼) | CH5(오) | 안내 |
|---|---|---|---|---|
| ✅ | ❌ | - | - | "센서를 위로 올리세요" (UP) |
| ❌ | ✅ | - | - | "센서를 아래로 내리세요" (DOWN) |
| - | - | ❌ | ✅ | "센서를 왼쪽으로 옮기세요" (LEFT) |
| - | - | ✅ | ❌ | "센서를 오른쪽으로 옮기세요" (RIGHT) |
| ❌ | ❌ | ❌ | ❌ | "방광이 안 잡힙니다. 센서 접촉을 확인하세요" |
2단계: urineLen 기반 정밀 가이드 (4채널 이상 잡힌 후)
4채널 이상 잡히더라도, 방광 중앙에 정확히 위치해야 측정이 정확합니다. 각 채널의 소변 구간 길이(urineLen)를 비교하여 정밀 가이드를 제공합니다.
잘 맞은 경우 (대칭): 치우친 경우 (비대칭):
CH0 ████████████ (긴) CH0 ██████ (짧)
CH1 ████████████████(긴) CH1 ████████████ (긴)
CH2 ████████████████(긴) CH2 ██████████ (중간)
CH3 ████████████ (긴) CH3 ████ (짧)
→ Score: 90% → Score: 45%, "Move UP"
세로 판정:
- CH0의 urineLen이 CH3보다 1.5배 이상 → "Move UP" (방광이 위쪽)
- CH3이 CH0보다 1.5배 이상 → "Move DOWN" (방광이 아래쪽)
가로 판정:
- CH4가 CH5보다 5칸 이상 길면 → "Move RIGHT" (왼쪽 치우침)
- CH5가 CH4보다 5칸 이상 길면 → "Move LEFT" (오른쪽 치우침)
Placement Score 계산
점수 = 세로대칭(30%) + 가로균형(30%) + 중앙크기(40%)
세로 대칭 = 1 - |CH0_len - CH3_len| / max(CH0_len, CH3_len)
가로 균형 = 1 - |CH4_len - CH5_len| / max(CH4_len, CH5_len)
중앙 크기 = avg(CH1_len, CH2_len) / 40 (40칸이면 만점)
Score ≥ 80%: "Perfect position! 🎯"
Score ≥ 70%: Lock (측정 시작 가능)
Score < 50%: 방향 가이드 표시
"위치 확인" 버튼을 누르면 무슨 일이?
1. 앱이 센서에 "maa" 명령 (6채널 전부 측정해!)
2. 각 채널마다:
- PiezoEchoAnalyzer로 벽 탐지 + urineLen 계산
- 실패 시 UrinAI.detect()로 fallback
3. 감지된 채널 = 초록 (urineLen 숫자 표시), 안 된 채널 = 빨강
4. 1단계 or 2단계 가이드 표시 + Placement Score
5. Score ≥ 70% 또는 4채널 이상 → "Start Measurement" 버튼 활성화
7. "Spot" 버튼 측정 흐름
Spot 버튼 탭
│
▼
앱 → 센서: "maa" 명령 (6채널 전부 측정해!)
│
▼ (~0.7초 후)
센서 → 앱: 6개 채널의 초음파 에코 데이터
│
▼
[전처리] SG 필터만 (TVD 없음)
│
▼
[벽 찾기] 각 채널에서 방광 앞벽/뒷벽 위치 탐색
│ - 신호가 1150 이하로 떨어지는 구간 = 소변
│ - 그 양옆의 prominence 가장 큰 peak = 벽
│
▼
[lr_ratio] CH4/CH5 lateral에서 타원 비율 계산
│ - 2개 lateral → 타원 방정식 풀기
│ - 1개 → 중심 가정, 0개 → 1.0 (원형)
│
▼
[용적 계산] Frustum(절두체) 적분
│ - CH0~CH3 (center 4채널)만 사용
│ - 단면적 = π/4 × D² × lr_ratio
│ - 층별 절두체 공식 + Cap 보정
│
▼
화면에 "305 ml" 표시! (도넛차트 + 레벨 업데이트)
롱프레스 자동측정
Spot 버튼을 길게 누르면 자동 측정 모드:
- 30초마다 자동으로 측정
- 빨간색 "Auto" 배지 표시
- 한 번 탭하면 중지
- 구현:
detectTapGestures(onLongPress)+LaunchedEffect30초 루프
측정 실패 시 자동 재시도
15초 타임아웃 → retry 1/2 (collector reset + 재전송)
↓
15초 타임아웃 → retry 2/2
↓
실패 → isMeasuring=false, 에러 로그
8. "Catheterize" 버튼 — 배뇨 기록
4가지 입력 방식 (iOS DiaryRecordSheet 스타일):
┌──────────────────────────────┐
│ 배뇨 기록 │
│ 추정 용량: 305 ml │
│ │
│ ✏️ 수기 입력 (주황) → │
│ 🎤 음성 입력 (파랑) → │
│ 📷 카메라 입력 (보라) → │
│ ⚡ 빠른 저장 305 ml → │ ← Quick Save (NEW!)
│ │
│ [취소] │
└──────────────────────────────┘
- 수기: 숫자 키패드로 ml 직접 입력
- 음성: 음성인식 → 숫자 추출
- 카메라: YOLO 소변컵 감지 + OCR 측정
- 빠른 저장: 현재 추정 용량으로 즉시 저장 ⚡
9. 파일 구조
medilightv2android/
├── ble/
│ ├── BleManager.kt ← BLE 통신 (자동 reconnect 포함)
│ ├── PiezoPacketCollector.kt ← 멀티패킷 수집 + Endian 감지
│ └── CRC16.kt ← 체크섬 계산
│
├── managers/
│ ├── PiezoEchoAnalyzer.kt ← Low-echo detection (벽 찾기)
│ ├── PiezoBVEstimator.kt ← 6ch BV 추정 + lr_ratio 계산
│ ├── UrinAI.kt ← 소변 존재 판정 (문지기)
│ ├── GreenZoneConstants.kt ← 공유 상수 (한 곳에서 관리)
│ └── PiezoEchoAnalyzer.kt ← NIRS 산소 포화도 계산
│
├── models/
│ ├── BladderLevel.kt ← 방광 레벨 (0~10)
│ ├── UrgencyLevel.kt ← 긴급도 (Safe/Warning/Urgent)
│ └── PiezoSettings.kt ← 센서 설정 (자세/위치/최대용량)
│
├── services/
│ ├── ReminderService.kt ← 배뇨 알림 (5종 랜덤, 유동 스케줄)
│ ├── MeasurementLogService.kt ← 측정 로그 자동 저장 (JSONL)
│ └── NotificationService.kt ← 긴급도 알림
│
├── ui/views/
│ ├── monitoring/
│ │ ├── PiezoMonitoringView.kt ← ★ 메인 측정 화면
│ │ ├── PlacementGuideView.kt ← ★ 센서 위치 안내
│ │ ├── VivamyoMonitoringView.kt ← NIRS 모니터링
│ │ ├── VoidingDiaryView.kt ← 배뇨일지
│ │ └── UrineCameraScreen.kt ← 소변컵 카메라 측정
│ ├── reminder/
│ │ └── ReminderSettingsView.kt ← 알림 설정 (iOS 동기화)
│ └── (onboarding, registration, pin, ...)
│
└── AppState.kt ← 전역 상태 관리 + 화면 라우팅
포팅 원본 대조
| Kotlin 파일 | 원본 | 원본 언어 |
|---|---|---|
| PiezoEchoAnalyzer | low_echo_detection_method_b.py | Python (원은지) |
| PiezoBVEstimator | bv_estimation.py (6ch) | Python (원은지) |
| UrinAI | UrinAI.kt (xBench) | Kotlin (권오준) |
| GreenZoneConstants | GreenZoneConstants.kt (xBench) | Kotlin (권오준) |
| config (PiezoHW) | config_6ch.py | Python (원은지) |
10. 상수 조정 가이드
증상별 조정
| 증상 | 원인 | 조정 |
|---|---|---|
| "소변 있는데 No Signal" | strict 임계값 너무 낮음 | liquidThrStrict ↑ |
| "소변 없는데 감지됨" | loose 임계값 너무 높음 | liquidThrLoose ↓ |
| "작은 방광 못 잡음" | 최소 기준 너무 높음 | minChord ↓, minLiquidRun ↓ |
| "벽을 못 찾음" | low-echo 임계값 안 맞음 | lowEchoAmp 조정 |
| "용적이 너무 큼/작음" | DPS 캘리브레이션 | distancePerSample 조정 |
11. 측정 로그 자동 저장
저장 위치
Android: app내부저장소/vesiscan_logs/YYYY-MM-DD.jsonl
로그 이벤트
| 이벤트 | 내용 |
|---|---|
measurement |
용적, 채널별 ant/post/score, BV 상세, lr_ratio |
placement |
6채널 감지 상태, 방향 안내 |
ble_connected |
기기 이름, 연결 시각, preset |
ble_disconnected |
기기 이름, 해제 시각 |
보관 정책
- 일별 파일 자동 생성
- 90일 이후 자동 삭제
12. Python ↔ Android 1:1 검증 결과
검증 대상
| Python 파일 | Android 파일 | 검증 결과 |
|---|---|---|
| config_6ch.py | PiezoBVEstimator.kt (PiezoHW) | ✅ 완벽 일치 |
| denoising.py | PiezoEchoAnalyzer.kt (denoise, sgSmooth) | ✅ 완벽 일치 |
| low_echo_detection_method_b.py | PiezoEchoAnalyzer.kt (detectLowEcho) | ✅ 완벽 일치 |
| bv_estimation.py (6ch) | PiezoBVEstimator.kt (estimateBladderVolume6ch) | ✅ 완벽 일치 |
| span_utils.py | PiezoEchoAnalyzer.kt (findPeaks1D, mergeClose) | ✅ 완벽 일치 |
상수 비교 (전수 검증)
하드웨어 프리셋 (config_6ch.py ↔ PiezoHW)
| 항목 | Python V0 | Android V0 | Python V1 | Android V1 | Python V2 | Android V2 |
|---|---|---|---|---|---|---|
| CH0 z | 21.0 | 21.0 ✅ | 20.1 | 20.1 ✅ | 19.3 | 19.3 ✅ |
| CH1 z | 14.0 | 14.0 ✅ | 12.8 | 12.8 ✅ | 13.0 | 13.0 ✅ |
| CH2 z | 7.0 | 7.0 ✅ | 7.0 | 7.0 ✅ | 6.7 | 6.7 ✅ |
| CH3 z | 0.0 | 0.0 ✅ | 0.0 | 0.0 ✅ | 0.0 | 0.0 ✅ |
| CH4/5 z | 10.5 | 10.5 ✅ | 9.9 | 9.9 ✅ | 9.85 | 9.85 ✅ |
| CH0 deg | 0.0 | 0.0 ✅ | 6.89 | 6.89 ✅ | 0.0 | 0.0 ✅ |
| CH1 deg | 0.0 | 0.0 ✅ | 0.0 | 0.0 ✅ | -6.89 | -6.89 ✅ |
| CH2 deg | 0.0 | 0.0 ✅ | -6.89 | -6.89 ✅ | -13.66 | -13.66 ✅ |
| CH3 deg | 0.0 | 0.0 ✅ | -13.66 | -13.66 ✅ | -20.20 | -20.20 ✅ |
| CH4/5 deg | 0.0 | 0.0 ✅ | -6.87 | -6.87 ✅ | -6.87 | -6.87 ✅ |
| CH4/5 LR | 0.0 | 0.0 ✅ | ∓3.42 | ∓3.42 ✅ | ∓3.42 | ∓3.42 ✅ |
음향 상수
| 상수 | Python (6ch) | Android | 일치 |
|---|---|---|---|
| DISTANCE_PER_SAMPLE | 1.771 mm | 1.771 mm | ✅ |
| DELAY_OFFSET_MM | 6.85 mm | 6.85 mm | ✅ |
| AREA_K | π/4 | π/4 | ✅ |
Low-echo Detection 상수
| 상수 | Python | Android | 일치 |
|---|---|---|---|
| LOW_ECHO_AMP | 1150 | 1150 | ✅ |
| LOW_MIN_LEN | 3 | 3 | ✅ |
| MERGE_GAP_MAX | 3 | 3 | ✅ |
| PEAK_SEARCH_WIN | 20 | 20 | ✅ |
| POST_MAX_IDX | 80 | 80 | ✅ |
| MIN_PEAK_MARGIN | 30 | 30 | ✅ |
| MIN_URINE_LEN | 3 | 3 | ✅ |
LR Ratio 상수
| 상수 | Python | Android | 일치 |
|---|---|---|---|
| DEFAULT_LR_RATIO | 1.0 | 1.0 | ✅ |
| LR_RATIO_NO_DETECTION | 1.0 | 1.0 | ✅ |
| LR_RATIO_INVALID | 1.2 | 1.2 | ✅ |
| LR_PRIOR | 1.2 | 1.2 | ✅ |
알고리즘 비교 (로직 수준)
전처리 (denoising.py ↔ PiezoEchoAnalyzer.denoise)
Python: denoise() → sg_smooth() (SG 필터만, TVD 없음)
Android: denoise() → sgSmooth() (SG 필터만, TVD 없음)
SG 계수: [-3, 12, 17, 12, -3] / 35 (양쪽 동일)
Edge 처리: 양 끝 2샘플에 2차 다항식 피팅 (양쪽 동일)
Adaptive TGC: Python 공유 코드에 미포함. 원은지님이 언급했으나 denoising.py에 TGC 코드 없음.
Low-echo Detection (Prominence 기반)
1. low_mask = sg ≤ 1150 (양쪽 동일 ✅)
2. span 추출 (≥3칸) (양쪽 동일 ✅)
3. 인접 span 합치기 (gap≤3) (양쪽 동일 ✅)
- gap 내 peak > 1155면 합치지 않음
4. 양쪽 벽 prominence 탐색 (양쪽 동일 ✅)
- ant: 오른쪽 valley 기준
- post: 왼쪽 valley 기준
- 최대 3개 후보 중 prominence 최대
5. post > 80 → 뒤쪽 절반에서 재탐색 (양쪽 동일 ✅)
6. urineLen = post - ant - 1 (양쪽 동일 ✅)
LR Ratio 계산 (compute_lr_ratio)
1. Center AP 직경: CH1/CH2의 (post-ant)×cos(θ) 평균 (양쪽 동일 ✅)
2. Lateral 직경:
P = cos(α)·cos(β), Q = cos(α)·sin(β)
D_lateral = (dFar-dNear)·P
y_mid = sensor_x + d_mid·Q (양쪽 동일 ✅)
3-A. 2개 lateral → 타원 방정식:
u = yL·yR·(yR-yL) / denom
d = -(u·(rL²-1) + yL²) / (2·yL)
lr_raw = 2√u / D_center (양쪽 동일 ✅)
3-B. 1개 lateral → 중심 가정:
b = |y_mid| / √(1-r²)
lr_raw = b / (D_center/2) (양쪽 동일 ✅)
4. Shrinkage:
confidence = ((1-avg_ratio)/0.10).clamp(0,1)
lr = 1.2 + (lr_raw-1.2)·confidence (양쪽 동일 ✅)
BV 추정 (Frustum + Cap)
1. Edge 필터: CH3 < CH1/CH2 × 0.9 → short (양쪽 동일 ✅)
2. sample → mm: d = 6.85 + idx × 1.771 (양쪽 동일 ✅)
3. 직경: D = (dPost-dAnt) × cos(θ) (양쪽 동일 ✅)
4. 단면적: S = (π/4) × D² × lr_ratio (양쪽 동일 ✅)
5. y좌표: y = sensorZ + dMid × sin(θ) (양쪽 동일 ✅)
6. y 오름차순 정렬 (양쪽 동일 ✅)
7. 포물선 피팅: Phase 0(edge-peak) → Phase 1(1.5σ) → Phase 2(b_eff)
(양쪽 동일 ✅)
8. Frustum: V = (h/3)·(S₁+S₂+√(S₁·S₂)) (양쪽 동일 ✅)
9. Bottom cap: V = S·h/2 + π·h³/6 (항상 구) (양쪽 동일 ✅)
10. Top cap: cone(정상) or sphere(edge short) (양쪽 동일 ✅)
미구현 항목
| 항목 | Python | Android | 비고 |
|---|---|---|---|
| Adaptive TGC | 공유 코드에 없음 | 없음 | 원은지님 확인 필요 |
| Phantom preset (lrRatio) | 있음 | 없음 | 디버깅용, 앱 불필요 |
| Hybrid mode (coord) | 있음 (disabled) | 없음 | Python에서도 미사용 |
13. BLE 자동 재연결 시스템
흐름도
정상 통신 중 (5초마다 msn/rsn 주고받음)
↓
기기와 멀어짐 (RSSI -80 → -88 → 응답 없음)
↓
[경로 A] Android GATT 에러 콜백 (status≠0)
→ GATT_ERROR 로그
→ wasConnected=true → scheduleAutoReconnect()
[경로 B] GATT 콜백 안 옴 (좀비 연결)
→ Watchdog Thread (별도 스레드, 5초마다 체크)
→ 15초 무응답 → WATCHDOG_TIMEOUT
→ 강제 disconnect → scheduleAutoReconnect()
↓
scheduleAutoReconnect()
├── 1차: 3초 후 → BLE 스캔 8초 (기기 이름/MAC 매칭)
├── 2차: 5초 후 → BLE 스캔 8초
├── 3차: 10초 후 → BLE 스캔 8초
├── 4차: 10초 후 → BLE 스캔 8초
└── 5차: 10초 후 → BLE 스캔 8초
↓ 발견 시
connectGatt(autoConnect=false) → 빠른 연결 (1~2초)
↓ 5회 모두 실패 시
빨간 배너 "Could not reconnect" + 수동 Reconnect 버튼
Watchdog 상세
- 실행 위치: 별도 daemon Thread (BLE handler 블로킹 무관)
- 체크 주기: 5초
- 타임아웃: 15초 (마지막 RX 기준)
- GATT close: watchdog Thread에서 직접 호출 (스레드 안전)
- UI 갱신: handler.post로 메인 스레드에서 처리
GATT 에러 경로 (핵심 버그 수정)
Android BLE는 disconnect 시 STATE_DISCONNECTED 대신 GATT 에러(status≠0)로
콜백을 보내는 경우가 많음. 기존에는 에러 경로에서 재연결을 시작하지 않아서
첫 끊김 시 재연결이 안 되는 버그가 있었음.
수정: wasConnected=true이면 에러 경로에서도 scheduleAutoReconnect() 호출.
BLE 디버그 로거
모든 TX/RX 패킷을 파싱된 형태로 기록:
TX: "maa 6ch measure [8B]"
RX: "rsn battery=3850mV (100%) [8B]"
RX: "reb 100samples [208B]"
RX: "raa all-ch complete [8B]"
내보내기: Downloads/VesiScan_BLE_날짜시간.log
14. 향후 과제
| 항목 | 상태 | 비고 |
|---|---|---|
| BLE 6채널 수신 | ✅ 완료 | |
| Low-echo Detection (prominence) | ✅ 완료 | lowEchoAmp=1150 |
| Frustum BV 추정 (6ch) | ✅ 완료 | center 4ch + lateral lr_ratio |
| lr_ratio 동적 계산 | ✅ 완료 | CH4/CH5 타원 추정 |
| UrinAI Gate | ✅ 완료 | |
| Placement Guide | ✅ 완료 | 방향 가이드 수정 완료 |
| 기기 자동 감지 (V0/V1/V2) | ✅ 완료 | Snell's law 각도 |
| Big Endian 자동 감지 | ✅ 완료 | |
| BLE 자동 reconnect | ✅ 완료 | 3s→5s→10s, 5회 |
| 배터리 폴링 5초 | ✅ 완료 | keep-alive |
| iOS 동기화 (UI) | ✅ 완료 | 알림, Quick Save, 일지 |
| 금요일 APK 배포 | 🔜 예정 | Gitea 릴리즈 |
| Adaptive TGC | 🔜 예정 | Python에서 확인 필요 |
| 인체 임상 검증 | 🔜 예정 | 상수 재조정 |