c3eba581d4
- 18. Placement: 2단계 순차 흐름도 (상하→좌우→CV Green) 대표님 요구사항 반영 (센서 이동 안내, 크기 패턴, 대칭성) - 패킷 드랍 자동 retry 설명 - 향후 과제: 순차 Placement, 패킷 retry, max volume 완료 항목 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1317 lines
44 KiB
Markdown
1317 lines
44 KiB
Markdown
# VesiScan Android App — 완전 가이드
|
||
|
||
## 작성일: 2026-04-23
|
||
## 작성자: dwjang
|
||
## 마지막 업데이트: 2026-04-24 (v2)
|
||
|
||
---
|
||
|
||
# 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 명령어 포맷 (펌웨어 특이사항)
|
||
|
||
이 기기(VBTFW0111)는 파라미터 없는 명령어에도 **`cmd? `(물음표+공백)** 포맷이 필요합니다.
|
||
|
||
```kotlin
|
||
// ❌ 안 됨 (rxs: cmd_not_supported 응답)
|
||
CRC16.buildCommand("mid") // → "mid?" + CRC
|
||
|
||
// ✅ 됨
|
||
CRC16.buildCommandASCII("mid", " ") // → "mid? " + CRC
|
||
```
|
||
|
||
### 명령어별 포맷
|
||
|
||
| 명령 | 함수 | 포맷 | 응답 |
|
||
|------|------|------|------|
|
||
| `maa?` | `sendChannelsOnly()` | buildCommand("maa", [0]) | reb:×6 → raa: |
|
||
| `mbb? ` | `sendFullMeasurement()` | buildCommandASCII("mbb", " ") | rbb: → reb:×6 → raa: |
|
||
| `msp? ` | `sendImuQuery()` | buildCommandASCII("msp", " ") | rsp: (18B, accel+gyro) |
|
||
| `mid? ` | `sendDeviceInfoQuery()` | buildCommandASCII("mid", " ") | rid: (HW/SN/FW) |
|
||
| `mls?` + state | `sendLedMode(state)` | buildCommand("mls", [state]) | rls: |
|
||
| `msn?` + 0 | `sendBatteryQuery()` | buildCommand("msn", [0]) | rsn: (mV) |
|
||
| `mpa?` + freq/cyc | `sendPiezoPowerOn()` | buildCommand("mpa", [...]) | rpa: |
|
||
|
||
### 기기 정보 (연결 시 자동 조회)
|
||
|
||
```
|
||
연결 직후 mid? 전송 → rid: 응답:
|
||
DEVICE_INFO VBTHW0100 VBT26040001 VBTFW0111
|
||
├ HW Version ├ Serial Number └ FW Version
|
||
```
|
||
|
||
---
|
||
|
||
# 15. 자동 측정 루프 (IMU + 6ch)
|
||
|
||
### 동작 방식
|
||
|
||
```
|
||
자동측정 시작 (Spot 롱프레스)
|
||
↓
|
||
1초차: msp? (IMU) → rsp: accel(x,y,z) + gyro(x,y,z)
|
||
2초차: msp? (IMU)
|
||
...
|
||
9초차: msp? (IMU)
|
||
10초차: maa? (6ch 측정) → reb:×6 → raa: → BV 파이프라인
|
||
11초차: msp? (IMU)
|
||
...
|
||
20초차: maa? (6ch 측정)
|
||
... 반복 (Stop 누를 때까지)
|
||
```
|
||
|
||
### 병렬 통신
|
||
|
||
배터리 쿼리(msn, 5초)와 IMU/측정이 동시에 진행됩니다:
|
||
|
||
```
|
||
13:52:09.549 TX msp IMU query [7B]
|
||
13:52:09.607 TX msn battery query [8B] ← 거의 동시
|
||
13:52:09.707 RX rsp IMU data [18B]
|
||
13:52:09.707 RX rsn battery=4128mV [8B] ← 둘 다 응답 옴
|
||
```
|
||
|
||
---
|
||
|
||
# 16. LED 모드 (Placement Guide 연동)
|
||
|
||
### LED 상태 코드
|
||
|
||
| state | 이름 | 용도 |
|
||
|-------|------|------|
|
||
| 0 | OFF | 기본 |
|
||
| 4 | DETACH_WARNING | 미부착 시 |
|
||
| 5 | ALIGN_SEARCHING | 정렬 모드 탐지 중 |
|
||
| 6 | ALIGN_COMPLETE | 정렬 모드 탐지 완료 |
|
||
| 7 | ERROR | 오류 발생 |
|
||
|
||
### Placement Guide 연동 (3단계 판정)
|
||
|
||
```
|
||
매 3초 스캔 →
|
||
│
|
||
├─ [1단계] 미부착 감지 (DetachmentDetection)
|
||
│ mean(|y|) < 100 AND std(y) < 100 → 미부착
|
||
│ → LED 4 (DETACH_WARNING)
|
||
│ → "Sensor detached — check contact"
|
||
│
|
||
├─ [2단계] Placement 판정 (CV threshold)
|
||
│ CV > 0.15 또는 |lr_dev| > 0.15 → 부적절
|
||
│ → LED 5 (ALIGN_SEARCHING)
|
||
│ → 방향 가이드 (MOVE UP/DOWN/LEFT/RIGHT)
|
||
│
|
||
└─ [3단계] 최적 위치
|
||
CV ≤ 0.15 AND |lr_dev| ≤ 0.15 → PASS
|
||
→ LED 6 (ALIGN_COMPLETE)
|
||
→ "Best Placement!"
|
||
|
||
Stop Scanning → LED 0 (OFF)
|
||
```
|
||
|
||
---
|
||
|
||
# 17. 미부착 감지 (Detachment Detection)
|
||
|
||
### 원본: detachment_detection.py (원은지님, 2026-04-24)
|
||
|
||
### 알고리즘
|
||
|
||
```
|
||
입력: 6채널 ADC raw 데이터 (100 samples × 6ch)
|
||
|
||
1. Center 채널 추출 (CH0~CH3, 4채널)
|
||
|
||
2. Baseline 제거:
|
||
baseline = median(signal[80:100]) ← 끝부분 20개 샘플의 중앙값
|
||
y = signal[20:80] - baseline ← 중간 60개 샘플에서 baseline 차감
|
||
|
||
3. 특징 계산 (채널별):
|
||
mean_abs = mean(|y|) ← 절대값 평균 (신호 크기)
|
||
std = std(y) ← 표준편차 (신호 변동성)
|
||
|
||
4. 4채널 평균:
|
||
mean_abs = avg(mean_abs_ch0, ..., mean_abs_ch3)
|
||
std_val = avg(std_ch0, ..., std_ch3)
|
||
|
||
5. 판정 ("and" rule):
|
||
미부착 = mean_abs < 100 AND std_val < 100
|
||
```
|
||
|
||
### 왜 이 방식인가?
|
||
|
||
```
|
||
부착된 경우: 미부착된 경우:
|
||
초음파가 조직을 통과 → 반사 공기 중 → 신호 거의 없음
|
||
signal[20:80] 구간에 변동 있음 signal[20:80] 구간이 평탄
|
||
mean_abs >> 100, std >> 100 mean_abs < 100, std < 100
|
||
|
||
실측 데이터 (원은지님):
|
||
미부착 max: mean=33.7, std=43.4 ← threshold 100보다 훨씬 낮음
|
||
부적절 min: mean=254.8, std=234.7 ← threshold 100보다 훨씬 높음
|
||
→ ~2.5배 안전 마진
|
||
```
|
||
|
||
### 상수
|
||
|
||
| 상수 | 값 | 의미 |
|
||
|------|-----|------|
|
||
| `WINDOW_START` | 20 | 분석 구간 시작 (ringdown 이후) |
|
||
| `WINDOW_END` | 80 | 분석 구간 끝 |
|
||
| `BASELINE_WINDOW_START` | 80 | baseline 구간 시작 |
|
||
| `BASELINE_WINDOW_END` | 100 | baseline 구간 끝 |
|
||
| `THR_MEAN` | 100 | mean(|y|) 임계값 |
|
||
| `THR_STD` | 100 | std(y) 임계값 |
|
||
|
||
---
|
||
|
||
# 18. Placement 전체 파이프라인 (2단계 순차 탐색 + CV)
|
||
|
||
### 대표님 요구사항 반영 (2026-04-27)
|
||
|
||
### 전체 흐름도
|
||
|
||
```
|
||
Start Scanning (3초 간격 자동 반복)
|
||
↓
|
||
[매 스캔] 6채널 ADC 수신 → Detachment 체크
|
||
↓
|
||
┌───────────────────────────────────────┐
|
||
│ 0. Detachment Detection │
|
||
│ mean(|y|) < 100 AND std < 100? │
|
||
│ → YES: LED 4 (미부착) → 다음 스캔 │
|
||
└───────────────────────────────────────┘
|
||
↓ NO (부착됨)
|
||
╔═══════════════════════════════════════╗
|
||
║ Step 1/3: 상하 탐색 (VERTICAL) ║
|
||
║ ║
|
||
║ 안내: "센서를 아래에서 위로 천천히 ║
|
||
║ 이동하세요" ║
|
||
║ ║
|
||
║ CH3,2,1,0 low echo 검출 ║
|
||
║ 조건: 3개 이상 + 크기 유사 ║
|
||
║ 또는 3≥2≥1≥0 패턴 ║
|
||
║ ║
|
||
║ 미달 → "위로 이동" / "아래로 이동" ║
|
||
║ PASS → Step 2로 자동 전환 ║
|
||
╚═══════════════════════════════════════╝
|
||
↓ PASS
|
||
╔═══════════════════════════════════════╗
|
||
║ Step 2/3: 좌우 탐색 (LATERAL) ║
|
||
║ ║
|
||
║ 안내: "센서를 좌우로 천천히 ║
|
||
║ 이동하세요" ║
|
||
║ ║
|
||
║ CH4, CH5 대칭성 확인 ║
|
||
║ |len4-len5| / max < 0.3 → 대칭 ║
|
||
║ ║
|
||
║ 미달 → "왼쪽으로" / "오른쪽으로" ║
|
||
║ 상하 벗어남 → Step 1로 복귀 ║
|
||
║ PASS → Step 3로 자동 전환 ║
|
||
╚═══════════════════════════════════════╝
|
||
↓ PASS
|
||
╔═══════════════════════════════════════╗
|
||
║ Step 3/3: 최종 Green (CV 확인) ║
|
||
║ ║
|
||
║ CV = std(depths) / mean(depths) ║
|
||
║ CV ≤ threshold → Green! ║
|
||
║ ║
|
||
║ Threshold Relaxation (5회 단위): ║
|
||
║ 1~5회: 0.15 ║
|
||
║ 6~10회: 0.20 ║
|
||
║ 11~15회: 0.25 ║
|
||
║ 16+회: 0.30 (최대) ║
|
||
║ ║
|
||
║ CV 미달 → Step 1부터 다시 ║
|
||
║ PASS → LED 6 (ALIGN_COMPLETE) + Lock ║
|
||
╚═══════════════════════════════════════╝
|
||
↓ Green!
|
||
"Start Measurement" 버튼 활성화
|
||
```
|
||
|
||
### 패킷 드랍 대응
|
||
|
||
```
|
||
maa? → reb 4개만 수신 (CH4,CH5 드랍)
|
||
→ INCOMPLETE_SCAN 4/6 → 자동 retry (최대 2회)
|
||
→ retry 후 6채널 수신 → 정상 진행
|
||
```
|
||
|
||
### BLE 디버그 로그 출력 예시
|
||
|
||
```
|
||
13:45:39 WARN DETACHED mean=12.3 std=8.7
|
||
13:45:42 INFO PLACEMENT_SEARCHING cv=0.234 dir=MOVE UP moderate score=35
|
||
13:45:45 INFO PLACEMENT_SEARCHING cv=0.180 dir=MOVE DOWN slight score=52
|
||
13:45:48 INFO PLACEMENT_PASS cv=0.112 lr_dev=0.089 score=78
|
||
```
|
||
|
||
---
|
||
|
||
# 19. 런타임 캘리브레이션 (Settings 패널)
|
||
|
||
측정 화면 Settings(톱니바퀴)에서 실시간으로 조절 가능:
|
||
|
||
### Low Echo Threshold (주황색 슬라이더, 800~2000)
|
||
|
||
```
|
||
기본값: 1150
|
||
용도: low-echo 판정 임계값. 올리면 벽 경계를 더 일찍 잡음.
|
||
문제: CH4/CH5 lateral에서 threshold가 너무 낮으면 urine을 과도하게 잡음
|
||
→ len이 비정상적으로 길어짐 → lr_ratio 왜곡 → BV 과소평가
|
||
해결: threshold를 1200~1300으로 올려서 테스트
|
||
```
|
||
|
||
### Distance/Sample (초록색 숫자 직접 입력, 0.5~5.0mm)
|
||
|
||
```
|
||
기본값: 1.771 mm/sample
|
||
용도: ADC 샘플 1개당 물리적 거리. BV 계산에 직접 영향.
|
||
d_mm = delay_offset + sample_index × distance_per_sample
|
||
입력: 소수점 3자리까지 정밀 입력 가능 (슬라이더 대신 텍스트 필드)
|
||
조절: 기기별 캘리브레이션 시 팬텀 실측값과 맞추기 위해 사용
|
||
```
|
||
|
||
### SG Filter (토글 스위치, ON/OFF)
|
||
|
||
```
|
||
기본값: ON
|
||
용도: Savitzky-Golay 5-tap 스무딩 필터 on/off
|
||
ON: SG 적용 → 부드러운 파형, 안정적인 벽 탐지
|
||
OFF: Raw ADC 그대로 → 원본 신호로 벽 탐지 (디버깅용)
|
||
효과: 그래프에서 진한 파랑(Denoised)이 연한 파랑(Raw)과 동일해지면 OFF
|
||
```
|
||
|
||
### lr_ratio 하한 제한
|
||
|
||
```
|
||
lr_ratio = computeLrRatio(center, lateral)
|
||
result = max(lr_ratio, 1.0) ← 원형보다 좁은 단면 방지
|
||
|
||
문제: 500ml 팬텀에서 CH4/CH5 짧음 → lr=0.32 → 100ml 과소평가
|
||
수정: 하한 1.0 제한 → 정상 용적 출력
|
||
```
|
||
|
||
---
|
||
|
||
# 20. BLE 명령어 Endian 통일 (테스터 exe 분석)
|
||
|
||
### 테스터 코드 (vsbtester/ble.js buildPacket) 분석 결과
|
||
|
||
```javascript
|
||
// 파라미터 없는 명령: '?' 뒤에 공백 0x20 추가
|
||
if (payloadParams.length === 0) {
|
||
bytes.push(0x20);
|
||
}
|
||
// 파라미터 있는 명령: 16비트 Big Endian
|
||
for (const p of payloadParams) {
|
||
bytes.push((n >> 8) & 0xff); // High byte first
|
||
bytes.push(n & 0xff); // Low byte
|
||
}
|
||
// CRC: Little Endian
|
||
bytes.push(crc & 0xff);
|
||
bytes.push((crc >> 8) & 0xff);
|
||
```
|
||
|
||
### Android 통일 결과
|
||
|
||
| 명령 | 파라미터 | 포맷 | 함수 |
|
||
|------|---------|------|------|
|
||
| maa | 없음 | `maa? ` + CRC(LE) | buildCommandASCII(" ") |
|
||
| mbb | 없음 | `mbb? ` + CRC(LE) | buildCommandASCII(" ") |
|
||
| msp | 없음 | `msp? ` + CRC(LE) | buildCommandASCII(" ") |
|
||
| mid | 없음 | `mid? ` + CRC(LE) | buildCommandASCII(" ") |
|
||
| mpa | [freq,cyc] | `mpa?` + params(BE) + CRC(LE) | buildCommandBE |
|
||
| mec | [6개] | `mec?` + params(BE) + CRC(LE) | buildCommandBE |
|
||
| mls | [state] | `mls?` + [0x00,state](BE) + CRC(LE) | buildCommandBE |
|
||
| msn | [0] | `msn?` + [0x00,0x00](BE) + CRC(LE) | buildCommandBE |
|
||
|
||
---
|
||
|
||
# 21. ADC 데이터 CSV 저장
|
||
|
||
### 파일 위치
|
||
|
||
```
|
||
Downloads/VesiScan_ADC/YYYY-MM-DD.csv
|
||
```
|
||
|
||
하루 1개 CSV 파일에 매 측정마다 append. 파일 탐색기에서 바로 접근 가능.
|
||
|
||
### CSV 포맷
|
||
|
||
```csv
|
||
scan_id,timestamp,volume_ml,lr_ratio,threshold,dps,sg_filter,channel,s0,s1,...,s99
|
||
1,14:30:12,305.2,1.20,1150,1.771,ON,CH0,1823,1801,...,1245
|
||
1,14:30:12,305.2,1.20,1150,1.771,ON,CH1,1756,1734,...,1198
|
||
1,14:30:12,305.2,1.20,1150,1.771,ON,CH2,1802,1788,...,1234
|
||
1,14:30:12,305.2,1.20,1150,1.771,ON,CH3,1834,1812,...,1267
|
||
1,14:30:12,305.2,1.20,1150,1.771,ON,CH4,1645,1623,...,1123
|
||
1,14:30:12,305.2,1.20,1150,1.771,ON,CH5,1721,1698,...,1178
|
||
2,14:30:22,419.0,1.20,1221,1.968,ON,CH0,1830,...
|
||
3,14:30:32,,1.00,1150,1.771,OFF,CH0,1234,...
|
||
```
|
||
|
||
- `scan_id`: 측정 회차 (같은 ID = 같은 측정의 6채널). BLE 로그에도 동일 ID 기록
|
||
- `volume_ml`: BV 추정 결과 (빈칸이면 측정 실패)
|
||
- `lr_ratio`: lr_ratio 계산값
|
||
- `threshold`: Low Echo Threshold (측정 시점의 설정값)
|
||
- `dps`: Distance Per Sample mm (측정 시점의 설정값)
|
||
- `sg_filter`: SG 필터 적용 여부 (ON/OFF)
|
||
- `channel`: CH0~CH5
|
||
- `s0~s99`: Raw ADC 100개 샘플 (SG 적용 전 원본, 항상 raw)
|
||
|
||
### Python에서 읽기
|
||
|
||
```python
|
||
import pandas as pd
|
||
|
||
df = pd.read_csv("2026-04-27.csv")
|
||
|
||
# 1회차 측정 전체
|
||
scan1 = df[df.scan_id == 1]
|
||
|
||
# CH0 ADC 100개
|
||
ch0_adc = scan1[scan1.channel == "CH0"].iloc[0, 8:].values
|
||
|
||
# 전체 측정 용적 추이
|
||
volumes = df[df.channel == "CH0"][["scan_id", "timestamp", "volume_ml", "threshold", "dps"]].drop_duplicates()
|
||
|
||
# threshold 1221로 측정한 것만
|
||
df_1221 = df[df.threshold == 1221]
|
||
|
||
# SG OFF로 측정한 것만
|
||
df_nosg = df[df.sg_filter == "OFF"]
|
||
```
|
||
|
||
### BLE 로그와 대조
|
||
|
||
```
|
||
BLE 로그: INFO MEASUREMENT scan=3 vol=305.2ml lr=1.20 valid=4/6
|
||
CSV: 3,14:30:12,305.2,1.20,1150,1.771,ON,CH0,...
|
||
|
||
→ scan=3으로 동일 측정의 ADC + 설정값 + 로그를 대조 가능
|
||
```
|
||
|
||
### 저장 시점
|
||
|
||
- BV 추정 성공 시: volume_ml + lr_ratio + 설정값 + 6채널 ADC
|
||
- BV 추정 실패 시 (center 부족): volume_ml 빈칸 + 설정값 + 6채널 ADC
|
||
- 0채널 감지 시: volume_ml 빈칸 + 설정값 + 6채널 ADC (raw 보존)
|
||
|
||
### Endian 자동 감지
|
||
|
||
ADC 파싱 시 Big/Little Endian을 자동 감지:
|
||
- VBT* 기기: 기본 Big Endian (forceBigEndian=true)
|
||
- 2025MEDIP: 기본 Little Endian
|
||
- CH0 첫 샘플로 확인, 애매하면 기기 기본값 사용
|
||
- CSV에는 항상 정상 파싱된 0~4095 범위의 raw ADC 저장
|
||
|
||
---
|
||
|
||
# 22. 향후 과제
|
||
|
||
| 항목 | 상태 | 비고 |
|
||
|------|------|------|
|
||
| BLE 6채널 수신 | ✅ 완료 | |
|
||
| Low-echo Detection (prominence + edge penalty) | ✅ 완료 | VALLEY_STOP=50, EDGE_DECAY=0.12 |
|
||
| Frustum BV 추정 (6ch) | ✅ 완료 | center 4ch + lateral lr_ratio(≥1.0) |
|
||
| lr_ratio 동적 계산 | ✅ 완료 | CH4/CH5 타원 추정, 하한 1.0 |
|
||
| Placement Guide 2단계 순차 | ✅ 완료 | 상하(3ch+크기)→좌우(대칭)→CV Green |
|
||
| 미부착 감지 (DetachmentDetection) | ✅ 완료 | mean/std < 100 → LED 4 |
|
||
| LED 모드 (mls?) BE 포맷 | ✅ 완료 | Placement 3단계 (4→5→6→0) |
|
||
| BLE 자동 reconnect | ✅ 완료 | 무한 3초 재시도 + watchdog |
|
||
| BLE 디버그 로거 | ✅ 완료 | TX/RX 파싱 + Downloads 내보내기 |
|
||
| BLE 명령어 Endian 통일 | ✅ 완료 | 테스터 exe와 100% 동일 (BE+공백) |
|
||
| 자동측정 (IMU 1s + 측정 10s) | ✅ 완료 | |
|
||
| 런타임 캘리브레이션 | ✅ 완료 | Threshold 슬라이더 + DPS 직접입력 + SG 토글 |
|
||
| 도넛차트 max volume 표시 | ✅ 완료 | maxVolumeMl 직접 표시 |
|
||
| 패킷 드랍 자동 retry | ✅ 완료 | 6ch 미만 수신 시 자동 재전송 (최대 2회) |
|
||
| ADC CSV 저장 | ✅ 완료 | Downloads/VesiScan_ADC/일별.csv + 설정값 포함 |
|
||
| Endian 감지 안정화 | ✅ 완료 | VBT=BE 기본, 애매한 경우 기기 기반 fallback |
|
||
| Python ↔ Android 1:1 검증 | ✅ 완료 | 전수 상수 비교 |
|
||
| 금요일 APK 배포 | 🔜 예정 | Gitea 릴리즈 |
|
||
| mbb 전체측정 전환 | 🔜 예정 | rbb: 헤더 파싱 검증 후 |
|
||
| Adaptive TGC | 🔜 예정 | 원은지님 코드 확인 필요 |
|
||
| Placement Score 캘리브레이션 | 🔜 예정 | 팬텀 실측 기반 CV threshold 조정 |
|
||
| 인체 임상 검증 | 🔜 예정 | 상수 재조정 |
|