Files
VesiscanClinicalAndroid/VesiScan_Android_Pipeline_Summary.md
T
dw.jang fde4d0ad05 docs: low echo detection + Placement CV threshold 문서 업데이트
- Low echo: VALLEY_STOP_RISE=50, EDGE_DIST_DECAY=0.12, edge boundary, score 공식
- Placement: CV threshold 방식 전체 설명 (CV 계산, SI/LR 판정, severity, relaxation)
- 기존 urineLen score 방식 → CV threshold 방식으로 전면 교체

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 10:30:41 +09:00

909 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| `lowMinLen` | 3 | low-echo span 최소 길이 |
| `mergeGapMax` | 3 | 인접 span 합치기 최대 gap |
| `peakSearchWin` | 20 | 벽 peak 탐색 범위 |
| `postMaxIdx` | 80 | 후벽 최대 인덱스 |
| `minPeakMargin` | 30 | peak 최소 높이 = low_mean + 30 |
| `minUrineLen` | 3 | 최소 소변 구간 길이 |
| `maxPeakCandidates` | 3 | 방향별 최대 후보 수 |
| `valleyStopRise` | **50** | valley 스캔 시 상승 early stop 임계 (기존 10) |
| `edgeDistDecay` | **0.12** | edge 거리 penalty 계수 (신규) |
### 알고리즘 (Prominence + Edge Distance, 2026-04-24 업데이트)
```
1. 디노이징된 신호에서 1150 이하인 연속 구간 찾기
→ "여기가 소변이겠구나" (low-echo span)
2. 인접한 구간 합치기 (gap ≤ 3칸이면 합침)
단, gap 안에 1155 이상인 peak이 있으면 합치지 않음
3. 양쪽에서 벽 peak 후보 찾기 (±20칸 범위)
- edge 포함 boundary: left [leftLo:edge+1], right [edge-1:rightHi+1]
- 후보 중 score가 가장 높은 것을 선택
score = prominence / (1 + 0.12 × distance_from_edge)
prominence = peak 높이 - valley(소변 방향)
distance = |peak위치 - low-echo edge|
→ low-echo에 가까운 peak이 더 높은 점수 (먼 peak은 패널티)
valley 스캔 시 최소값에서 50 이상 올라가면 early stop
(기존 10에서 50으로 상향 → 더 깊은 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단계: CV threshold 정밀 가이드 (center 3채널 이상 잡힌 후)
4채널 이상 잡히더라도, 방광 **중앙**에 정확히 위치해야 측정이 정확합니다.
각 채널의 **cos-corrected depth(mm)**의 변동계수(CV)로 배치 품질을 판정합니다.
### CV (Coefficient of Variation) 계산
```
1. 각 center 채널(CH0~CH3)의 ant/post wall 위치를 mm로 변환:
depth_mm = (post_mm - ant_mm) × cos(beam_angle)
2. 유효 채널(≥3개)의 depth 평균과 표준편차 계산:
CV = std(depths) / mean(depths)
3. 판정:
CV ≤ 0.15 → PASS (Best Placement)
CV > 0.15 → FAIL → 방향 안내
```
### 왜 CV인가?
```
잘 맞은 경우 (중앙): 치우친 경우 (가장자리):
CH0: depth=45mm CH0: depth=15mm ← 짧음
CH1: depth=52mm CH1: depth=48mm
CH2: depth=50mm CH2: depth=42mm
CH3: depth=42mm CH3: depth=10mm ← 짧음
CV = 0.09 → PASS ✅ CV = 0.52 → FAIL ❌
weakest = CH3 → "MOVE UP"
```
중앙을 관통하면 모든 채널의 depth가 비슷 → CV 작음.
가장자리를 스치면 일부 채널이 짧음 → CV 큼.
### SI(세로) 방향 판정
```
deviation[i] = (depth[i] - mean) / mean
weakest = 가장 deviation이 큰 채널
severity:
|dev| < 0.10 → slight
0.10 ≤ < 0.25 → moderate
|dev| ≥ 0.25 → severe
방향:
weakest가 CH0/CH1 (top) → MOVE DOWN
weakest가 CH2/CH3 (bottom) → MOVE UP
```
### LR(가로) 판정
```
depth_CH4 = raw_depth × cos(SI_angle) × cos(LR_angle)
depth_CH5 = raw_depth × cos(SI_angle) × cos(LR_angle)
lr_dev = (depth_CH4 - depth_CH5) / mean(depth_CH4, depth_CH5)
|lr_dev| ≤ 0.15 → PASS
lr_dev > 0 → MOVE RIGHT (왼쪽이 더 길음)
lr_dev < 0 → MOVE LEFT (오른쪽이 더 길음)
```
### 최종 판정
```
is_pass = CV ≤ cv_thr AND |lr_dev| ≤ lr_dev_thr
cv_thr = 0.15 + repeatCount × 0.02 (반복 시 완화)
lr_dev_thr = 0.15 (고정)
```
### Threshold Relaxation (반복 완화)
```
Scan 흐름:
1회차: CV_thr = 0.15
2회차: CV_thr = 0.17
3회차: CV_thr = 0.19
...
n회차: CV_thr = 0.15 + n × 0.02
→ Best Placement가 아닌 경우 반복 측정 동안 기준을 완화하여
사용자가 무한히 조절하지 않도록 함
```
## 연속 스캔 동작
```
"Start Scanning" 버튼 탭
↓
즉시 첫 스캔 (maa 6채널)
↓ 결과: CV, 방향, Score 표시
3초 대기
↓
자동 재스캔 (repeatCount 증가 → threshold 완화)
↓
... 반복 (사용자가 Stop 누를 때까지)
is_pass = true → Lock → "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)` + `LaunchedEffect` 30초 루프
## 측정 실패 시 자동 재시도
```
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에서 확인 필요 |
| 인체 임상 검증 | 🔜 예정 | 상수 재조정 |