feat: 6ch 알고리즘 포팅 + urineLen 기반 정밀 Placement Guide
6ch 알고리즘 (config_6ch.py / bv_estimation.py 포팅): - PiezoHW: V0/V1/V2 프리셋 (Snell's law 굴절 각도) - computeLrRatio(): CH4/CH5 lateral에서 타원 단면 비율 동적 계산 - estimateBladderVolume6ch(): center 4ch + lateral lr_ratio - denoise(): TVD 제거, SG 필터만 사용 - LOW_ECHO_AMP: 1600→1150, distancePerSample: 1.771 Placement Guide 2단계: - 1단계: 이진 판정 (잡힘/안잡힘) → 기본 방향 - 2단계: urineLen 기반 정밀 가이드 (대칭+균형+크기) - 세로: CH0/CH3 비율 1.5배 이상이면 방향 제시 - 가로: CH4/CH5 차이 5칸 이상이면 방향 제시 - Placement Score = 세로대칭(30%) + 가로균형(30%) + 중앙크기(40%) - 각 채널에 urineLen 숫자 표시 VesiScan_Android_Pipeline_Summary.md 문서 작성 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,652 @@
|
|||||||
|
# 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)` + `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. 향후 과제
|
||||||
|
|
||||||
|
| 항목 | 상태 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 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에서 확인 필요 |
|
||||||
|
| 인체 임상 검증 | 🔜 예정 | 상수 재조정 |
|
||||||
@@ -38,7 +38,7 @@ object GreenZoneConstants {
|
|||||||
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
|
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
|
||||||
* Python 원본: 1600 (low_echo_detection_method_b.py LOW_ECHO_AMP)
|
* Python 원본: 1600 (low_echo_detection_method_b.py LOW_ECHO_AMP)
|
||||||
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
|
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
|
||||||
const val lowEchoAmp: Float = 1600f
|
const val lowEchoAmp: Float = 1150f
|
||||||
|
|
||||||
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
|
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
|
||||||
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
|
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
|
||||||
|
|||||||
@@ -20,106 +20,186 @@ import kotlin.math.sqrt
|
|||||||
* Probe geometry — probe model별 고정
|
* Probe geometry — probe model별 고정
|
||||||
*/
|
*/
|
||||||
object PiezoHW {
|
object PiezoHW {
|
||||||
const val nCh = 5 // BV estimation에 사용할 수직 채널 수
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Device Presets — 기기별 센서 배치
|
// Device Presets — 6ch 기기 3가지 버전 (Snell's law 굴절 보정 적용)
|
||||||
|
// config_6ch.py 1:1 포팅
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
enum class DevicePreset {
|
enum class DevicePreset {
|
||||||
/** 기존 5ch 수직 배열 (placeholder 각도) */
|
|
||||||
LEGACY_5CH,
|
LEGACY_5CH,
|
||||||
/** 전채널 0° (BLE 테스트용, 각도 보정 없음) */
|
V0, // All 0° (BLE 테스트용)
|
||||||
FLAT,
|
V1, // Max 20° housing → Snell's law refraction
|
||||||
/** 새 기기 Case A: 0° / 10° / 20° / 30° + 좌우 ±5° */
|
V2 // Max 30° housing → Snell's law refraction
|
||||||
NEW_CASE1,
|
|
||||||
/** 새 기기 Case B: -10° / 0° / 10° / 20° + 좌우 ±5° */
|
|
||||||
NEW_CASE2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 현재 활성 preset — 기기 연결 시 자동 감지, 수동 변경도 가능 */
|
var activePreset: DevicePreset = DevicePreset.V0
|
||||||
var activePreset: DevicePreset = DevicePreset.FLAT
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BLE 기기 이름으로 preset 자동 감지.
|
|
||||||
* VBT26040x0y: x=0 → FLAT, x=2 → NEW_CASE2, x=3 → NEW_CASE1
|
|
||||||
*/
|
|
||||||
fun autoDetectPreset(deviceName: String) {
|
fun autoDetectPreset(deviceName: String) {
|
||||||
if (deviceName.startsWith("VBT2604") && deviceName.length >= 9) {
|
if (deviceName.startsWith("VBT2604") && deviceName.length >= 9) {
|
||||||
when (deviceName[7]) {
|
when (deviceName[7]) {
|
||||||
'0' -> activePreset = DevicePreset.FLAT
|
'0' -> activePreset = DevicePreset.V0
|
||||||
'2' -> activePreset = DevicePreset.NEW_CASE2
|
'2' -> activePreset = DevicePreset.V1
|
||||||
'3' -> activePreset = DevicePreset.NEW_CASE1
|
'3' -> activePreset = DevicePreset.V2
|
||||||
}
|
}
|
||||||
} else if (deviceName.startsWith("2025MEDIP")) {
|
} else if (deviceName.startsWith("2025MEDIP")) {
|
||||||
activePreset = DevicePreset.LEGACY_5CH
|
activePreset = DevicePreset.LEGACY_5CH
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BV용 수직 채널 (CH0~CH3)
|
val centerCh = intArrayOf(0, 1, 2, 3)
|
||||||
val sensorZMm: DoubleArray
|
val lateralCh = intArrayOf(4, 5)
|
||||||
|
val lateralNeighbors = intArrayOf(1, 2)
|
||||||
|
|
||||||
|
// 6채널 전체 z 좌표 (CH0~CH5)
|
||||||
|
val sensorZMmAll: DoubleArray
|
||||||
get() = when (activePreset) {
|
get() = when (activePreset) {
|
||||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0)
|
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
|
||||||
DevicePreset.FLAT -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
|
DevicePreset.V0 -> doubleArrayOf(21.0, 14.0, 7.0, 0.0, 10.5, 10.5)
|
||||||
DevicePreset.NEW_CASE1 -> doubleArrayOf(22.0, 16.5, 11.0, 5.5)
|
DevicePreset.V1 -> doubleArrayOf(20.1, 12.8, 7.0, 0.0, 9.9, 9.9)
|
||||||
DevicePreset.NEW_CASE2 -> doubleArrayOf(22.0, 16.5, 11.0, 5.5)
|
DevicePreset.V2 -> doubleArrayOf(19.3, 13.0, 6.7, 0.0, 9.85, 9.85)
|
||||||
}
|
}
|
||||||
|
|
||||||
val sensorXMm: DoubleArray
|
// 6채널 전체 x 좌표
|
||||||
|
val sensorXMmAll: DoubleArray
|
||||||
get() = when (activePreset) {
|
get() = when (activePreset) {
|
||||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
|
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||||
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
else -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -10.0, 10.0)
|
||||||
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
|
|
||||||
DevicePreset.NEW_CASE2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SI beam angle (degrees) — BV 계산용 */
|
// 6채널 전체 SI 빔 각도 (Snell's law 굴절 후)
|
||||||
val degree: DoubleArray
|
val degreeAll: DoubleArray
|
||||||
get() = when (activePreset) {
|
get() = when (activePreset) {
|
||||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8)
|
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8, 0.0)
|
||||||
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||||
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 10.0, 20.0, 30.0)
|
DevicePreset.V1 -> doubleArrayOf(6.89, 0.0, -6.89, -13.66, -6.87, -6.87)
|
||||||
DevicePreset.NEW_CASE2 -> doubleArrayOf(-10.0, 0.0, 10.0, 20.0)
|
DevicePreset.V2 -> doubleArrayOf(0.0, -6.89, -13.66, -20.20, -6.87, -6.87)
|
||||||
}
|
}
|
||||||
|
|
||||||
val degreeLR: DoubleArray
|
// 6채널 전체 LR 빔 각도 (Snell's law 굴절 후)
|
||||||
|
val degreeLRAll: DoubleArray
|
||||||
get() = when (activePreset) {
|
get() = when (activePreset) {
|
||||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
|
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||||
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||||
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
|
DevicePreset.V1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
|
||||||
DevicePreset.NEW_CASE2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
|
DevicePreset.V2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 좌우 날개 채널 각도 (Placement Guide + lr_ratio 계산용)
|
// BV용 center 채널만 (CH0~CH3)
|
||||||
* CH4: 왼쪽, CH5: 오른쪽 */
|
val sensorZMm: DoubleArray get() = centerCh.map { sensorZMmAll[it] }.toDoubleArray()
|
||||||
const val lateralDegreeSI: Double = 10.0 // SI 방향 (아래쪽을 봄)
|
val sensorXMm: DoubleArray get() = centerCh.map { sensorXMmAll[it] }.toDoubleArray()
|
||||||
const val lateralDegreeLR: Double = 5.0 // LR 방향 (바깥쪽)
|
val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray()
|
||||||
|
val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray()
|
||||||
|
|
||||||
/** Acoustic calibration */
|
/** Acoustic calibration */
|
||||||
const val distancePerSample: Double = 1.974 // mm/sample (390 ksps, 1540 m/s)
|
const val distancePerSample: Double = 1.771 // mm/sample
|
||||||
const val delayOffsetMm: Double = 6.85 // mm (first ADC sample 이전 고정 전파 지연)
|
const val delayOffsetMm: Double = 6.85
|
||||||
|
|
||||||
/** Volume model */
|
/** Volume model */
|
||||||
val areaK: Double = Math.PI / 4.0 // S = areaK · D² · lr_ratio
|
val areaK: Double = Math.PI / 4.0
|
||||||
const val defaultLrRatio: Double = 1.0 // 원형 단면 가정
|
const val defaultLrRatio: Double = 1.0
|
||||||
|
|
||||||
/** Phantom presets */
|
// lr_ratio fallbacks
|
||||||
fun lrRatio(phantom: String): Double {
|
const val lrRatioNoDetection: Double = 1.0
|
||||||
if (phantom.contains("bp1_70")) return 1.56
|
const val lrRatioInvalid: Double = 1.2
|
||||||
if (phantom.contains("bp2_500")) return 1.0
|
const val lrPrior: Double = 1.2
|
||||||
return defaultLrRatio
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 현재 preset 이름 (로그용) */
|
|
||||||
val presetName: String
|
val presetName: String
|
||||||
get() = when (activePreset) {
|
get() = when (activePreset) {
|
||||||
DevicePreset.LEGACY_5CH -> "Legacy 5ch"
|
DevicePreset.LEGACY_5CH -> "Legacy 5ch"
|
||||||
DevicePreset.FLAT -> "Flat (all 0°)"
|
DevicePreset.V0 -> "V0 (all 0°)"
|
||||||
DevicePreset.NEW_CASE1 -> "Case1 (0/10/20/30)"
|
DevicePreset.V1 -> "V1 (max 20°, Snell)"
|
||||||
DevicePreset.NEW_CASE2 -> "Case2 (-10/0/10/20)"
|
DevicePreset.V2 -> "V2 (max 30°, Snell)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LR Ratio Computation (from lateral CH4/CH5) ──
|
||||||
|
|
||||||
|
fun computeLrRatio(
|
||||||
|
centerWalls: List<Pair<Int, Int>?>,
|
||||||
|
lateralWalls: List<Pair<Int, Int>?>,
|
||||||
|
maxRatio: Double = 3.0
|
||||||
|
): Double {
|
||||||
|
val hw = PiezoHW
|
||||||
|
val neighbors = hw.lateralNeighbors
|
||||||
|
|
||||||
|
// Center AP chord from neighbors
|
||||||
|
val centerDs = mutableListOf<Double>()
|
||||||
|
for (ni in neighbors) {
|
||||||
|
if (ni < centerWalls.size) {
|
||||||
|
val w = centerWalls[ni] ?: continue
|
||||||
|
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
|
||||||
|
val theta = hw.degreeAll[hw.centerCh[ni]] * Math.PI / 180.0
|
||||||
|
centerDs.add((dFar - dNear) * cos(theta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (centerDs.isEmpty()) return hw.lrRatioNoDetection
|
||||||
|
val dCenter = centerDs.average()
|
||||||
|
if (dCenter <= 0) return hw.lrRatioInvalid
|
||||||
|
|
||||||
|
// Lateral chords
|
||||||
|
data class LatResult(val ratio: Double, val yMid: Double)
|
||||||
|
val latResults = mutableListOf<LatResult>()
|
||||||
|
|
||||||
|
for ((li, lCh) in hw.lateralCh.withIndex()) {
|
||||||
|
if (li >= lateralWalls.size) continue
|
||||||
|
val w = lateralWalls[li] ?: continue
|
||||||
|
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
|
||||||
|
val alpha = hw.degreeAll[lCh] * Math.PI / 180.0
|
||||||
|
val beta = hw.degreeLRAll[lCh] * Math.PI / 180.0
|
||||||
|
val sx = hw.sensorXMmAll[lCh]
|
||||||
|
|
||||||
|
val pFactor = cos(alpha) * cos(beta)
|
||||||
|
val qFactor = cos(alpha) * sin(beta)
|
||||||
|
|
||||||
|
val dLateral = (dFar - dNear) * pFactor
|
||||||
|
val dMid = (dNear + dFar) / 2.0
|
||||||
|
val yMid = sx + dMid * qFactor
|
||||||
|
|
||||||
|
val ratio = dLateral / dCenter
|
||||||
|
if (ratio <= 0 || ratio >= maxRatio) continue
|
||||||
|
if (abs(yMid) < 1e-3) continue
|
||||||
|
latResults.add(LatResult(ratio, yMid))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latResults.isEmpty()) return hw.lrRatioNoDetection
|
||||||
|
|
||||||
|
val lrRaw: Double
|
||||||
|
if (latResults.size >= 2) {
|
||||||
|
// Two laterals: solve ellipse
|
||||||
|
val yL = latResults[0].yMid
|
||||||
|
val rL = latResults[0].ratio
|
||||||
|
val yR = latResults[1].yMid
|
||||||
|
val rR = latResults[1].ratio
|
||||||
|
|
||||||
|
val denom = yR * (rL * rL - 1.0) - yL * (rR * rR - 1.0)
|
||||||
|
if (abs(denom) < 1e-12) return hw.lrRatioInvalid
|
||||||
|
|
||||||
|
val u = yL * yR * (yR - yL) / denom
|
||||||
|
if (u <= 0) return hw.lrRatioInvalid
|
||||||
|
|
||||||
|
val d = -(u * (rL * rL - 1.0) + yL * yL) / (2.0 * yL)
|
||||||
|
val bSq = u + d * d
|
||||||
|
if (bSq <= 0) return hw.lrRatioInvalid
|
||||||
|
|
||||||
|
lrRaw = 2.0 * sqrt(u) / dCenter
|
||||||
|
} else {
|
||||||
|
// Single lateral: assume center
|
||||||
|
val r = latResults[0].ratio
|
||||||
|
val yMid = latResults[0].yMid
|
||||||
|
if (r >= 1.0) return hw.lrRatioInvalid
|
||||||
|
val bEst = abs(yMid) / sqrt(1.0 - r * r)
|
||||||
|
val aEst = dCenter / 2.0
|
||||||
|
lrRaw = bEst / aEst
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lrRaw <= 0 || lrRaw >= maxRatio) return hw.lrRatioInvalid
|
||||||
|
|
||||||
|
// Shrinkage toward prior
|
||||||
|
val avgRatio = latResults.map { it.ratio }.average()
|
||||||
|
val confidence = ((1.0 - avgRatio) / 0.10).coerceIn(0.0, 1.0)
|
||||||
|
return hw.lrPrior + (lrRaw - hw.lrPrior) * confidence
|
||||||
|
}
|
||||||
|
|
||||||
// ── Result ──
|
// ── Result ──
|
||||||
|
|
||||||
data class BVResult(
|
data class BVResult(
|
||||||
@@ -315,8 +395,26 @@ private fun capVolume(
|
|||||||
* Traditional frustum BV 추정 (cos 보정 + SI 축 기반)
|
* Traditional frustum BV 추정 (cos 보정 + SI 축 기반)
|
||||||
* 1:1 port of _bv_core(mode="traditional")
|
* 1:1 port of _bv_core(mode="traditional")
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 6ch BV 추정 — center 4채널(CH0~CH3) + lateral 2채널(CH4/CH5)에서 lr_ratio 계산
|
||||||
|
* config_6ch.py / bv_estimation.py estimate_bladder_volume_6ch 1:1 포팅
|
||||||
|
*/
|
||||||
|
fun estimateBladderVolume6ch(
|
||||||
|
allWalls: List<Pair<Int, Int>?>, // 6채널 전체 (ant, post), null = invalid
|
||||||
|
): BVResult? {
|
||||||
|
val centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||||
|
val lateralWalls = PiezoHW.lateralCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||||
|
val lrRatio = computeLrRatio(centerWalls, lateralWalls)
|
||||||
|
return estimateBladderVolume(
|
||||||
|
walls = centerWalls,
|
||||||
|
lrRatio = lrRatio,
|
||||||
|
edgeCh = 3,
|
||||||
|
edgeRefChannels = listOf(1, 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun estimateBladderVolume(
|
fun estimateBladderVolume(
|
||||||
walls: List<Pair<Int, Int>?>, // 5채널 (ant, post), null = invalid
|
walls: List<Pair<Int, Int>?>,
|
||||||
distancePerSample: Double = PiezoHW.distancePerSample,
|
distancePerSample: Double = PiezoHW.distancePerSample,
|
||||||
delayOffsetMm: Double = PiezoHW.delayOffsetMm,
|
delayOffsetMm: Double = PiezoHW.delayOffsetMm,
|
||||||
sensorZMm: DoubleArray = PiezoHW.sensorZMm,
|
sensorZMm: DoubleArray = PiezoHW.sensorZMm,
|
||||||
@@ -324,28 +422,26 @@ fun estimateBladderVolume(
|
|||||||
areaK: Double = PiezoHW.areaK,
|
areaK: Double = PiezoHW.areaK,
|
||||||
lrRatio: Double = PiezoHW.defaultLrRatio,
|
lrRatio: Double = PiezoHW.defaultLrRatio,
|
||||||
applyAngleCorrection: Boolean = true,
|
applyAngleCorrection: Boolean = true,
|
||||||
ch4MinRatio: Double? = 0.9,
|
edgeCh: Int = walls.size - 1,
|
||||||
ch4RefChannels: List<Int> = listOf(2, 3)
|
edgeRefChannels: List<Int> = listOf(walls.size - 2, walls.size - 3)
|
||||||
): BVResult? {
|
): BVResult? {
|
||||||
|
|
||||||
// 1) CH4 filter — cap 방식 결정용 플래그
|
// 1) Edge channel filter — cap 방식 결정용 플래그
|
||||||
var ch4IsShort = false
|
var edgeIsShort = false
|
||||||
if (ch4MinRatio != null && walls.size >= 5) {
|
if (edgeCh < walls.size) {
|
||||||
val w4 = walls[4]
|
val wEdge = walls[edgeCh]
|
||||||
if (w4 != null) {
|
if (wEdge != null) {
|
||||||
val l4 = w4.second - w4.first
|
val lEdge = wEdge.second - wEdge.first
|
||||||
val refLens = mutableListOf<Int>()
|
val refLens = mutableListOf<Int>()
|
||||||
for (ci in ch4RefChannels) {
|
for (ci in edgeRefChannels) {
|
||||||
if (ci < walls.size) {
|
if (ci in walls.indices) {
|
||||||
val w = walls[ci]
|
val w = walls[ci]
|
||||||
if (w != null) {
|
if (w != null) refLens.add(w.second - w.first)
|
||||||
refLens.add(w.second - w.first)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val minRef = refLens.minOrNull()
|
val minRef = refLens.minOrNull()
|
||||||
if (minRef != null && l4.toDouble() < ch4MinRatio * minRef.toDouble()) {
|
if (minRef != null && lEdge.toDouble() < 0.9 * minRef.toDouble()) {
|
||||||
ch4IsShort = true
|
edgeIsShort = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,7 +508,7 @@ fun estimateBladderVolume(
|
|||||||
capMode = capMode, shape = "sphere")
|
capMode = capMode, shape = "sphere")
|
||||||
|
|
||||||
// Top: cone (정상) / sphere (CH4 short)
|
// Top: cone (정상) / sphere (CH4 short)
|
||||||
val topCap = if (ch4IsShort) {
|
val topCap = if (edgeIsShort) {
|
||||||
capVolume(
|
capVolume(
|
||||||
sEdge = sS[n - 1], aCapEdge = aCapS[n - 1], bEff = bEff, sMax = sMax,
|
sEdge = sS[n - 1], aCapEdge = aCapS[n - 1], bEff = bEff, sMax = sMax,
|
||||||
capMode = "fallback", shape = "sphere")
|
capMode = "fallback", shape = "sphere")
|
||||||
|
|||||||
@@ -211,10 +211,9 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
|
|
||||||
// ── Denoising Pipeline ──
|
// ── Denoising Pipeline ──
|
||||||
|
|
||||||
/** TVD → SG 디노이징 */
|
/** SG 디노이징 (6ch 알고리즘: TVD 제거, SG만 사용) */
|
||||||
fun denoise(signal: DoubleArray): DoubleArray {
|
fun denoise(signal: DoubleArray): DoubleArray {
|
||||||
val tvd = tvDenoiseChambolle1D(signal)
|
return sgSmooth(signal)
|
||||||
return sgSmooth(tvd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 1D Total Variation Denoising — Chambolle (2004) dual projected gradient */
|
/** 1D Total Variation Denoising — Chambolle (2004) dual projected gradient */
|
||||||
|
|||||||
+9
-8
@@ -41,7 +41,7 @@ import com.example.medilightv2android.ble.BleManager
|
|||||||
import com.example.medilightv2android.ble.PiezoChannelData
|
import com.example.medilightv2android.ble.PiezoChannelData
|
||||||
import com.example.medilightv2android.managers.PiezoEchoAnalyzer
|
import com.example.medilightv2android.managers.PiezoEchoAnalyzer
|
||||||
import com.example.medilightv2android.managers.PiezoConstants
|
import com.example.medilightv2android.managers.PiezoConstants
|
||||||
import com.example.medilightv2android.managers.estimateBladderVolume
|
import com.example.medilightv2android.managers.estimateBladderVolume6ch
|
||||||
import com.example.medilightv2android.models.BladderLevel
|
import com.example.medilightv2android.models.BladderLevel
|
||||||
import com.example.medilightv2android.models.UrgencyLevel
|
import com.example.medilightv2android.models.UrgencyLevel
|
||||||
import com.example.medilightv2android.ui.components.BladdyRiveView
|
import com.example.medilightv2android.ui.components.BladdyRiveView
|
||||||
@@ -111,20 +111,21 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
if (validChannels.isNotEmpty()) {
|
if (validChannels.isNotEmpty()) {
|
||||||
val analysisResult = analyzer.analyzeMultiChannel(validChannels)
|
val analysisResult = analyzer.analyzeMultiChannel(validChannels)
|
||||||
|
|
||||||
// Build walls list for BV estimation (CH0~CH3, 세로 4채널만)
|
// Build walls list for 6ch BV estimation
|
||||||
val walls: MutableList<Pair<Int, Int>?> = MutableList(4) { null }
|
val allWalls: MutableList<Pair<Int, Int>?> = MutableList(6) { null }
|
||||||
for (ch in analysisResult.channels) {
|
for (ch in analysisResult.channels) {
|
||||||
if (ch.channel < 4 && ch.result != null) {
|
if (ch.channel < 6 && ch.result != null) {
|
||||||
walls[ch.channel] = Pair(ch.result.ant, ch.result.post)
|
allWalls[ch.channel] = Pair(ch.result.ant, ch.result.post)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frustum BV estimation
|
// 6ch BV estimation (center 4ch + lateral lr_ratio)
|
||||||
val bvResult = estimateBladderVolume(walls = walls)
|
val bvResult = estimateBladderVolume6ch(allWalls)
|
||||||
if (bvResult != null) {
|
if (bvResult != null) {
|
||||||
lastVolumeMl = bvResult.volumeMl
|
lastVolumeMl = bvResult.volumeMl
|
||||||
maxVolumeMl = maxOf(maxVolumeMl, bvResult.volumeMl)
|
maxVolumeMl = maxOf(maxVolumeMl, bvResult.volumeMl)
|
||||||
Log.d("PiezoMonitor", "BV: ${"%.1f".format(bvResult.volumeMl)}ml (frustum, ${bvResult.validChannels.size}ch)")
|
Log.d("PiezoMonitor", "BV: ${"%.1f".format(bvResult.volumeMl)}ml (6ch, lr=${
|
||||||
|
"%.2f".format(bvResult.lrRatio)}, ${bvResult.validChannels.size}ch)")
|
||||||
} else {
|
} else {
|
||||||
Log.d("PiezoMonitor", "BV: estimation failed (< 2 valid channels)")
|
Log.d("PiezoMonitor", "BV: estimation failed (< 2 valid channels)")
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-67
@@ -58,8 +58,10 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
|
|
||||||
var isScanning by remember { mutableStateOf(false) }
|
var isScanning by remember { mutableStateOf(false) }
|
||||||
val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) }
|
val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) }
|
||||||
|
val channelUrineLen = remember { mutableStateListOf(0, 0, 0, 0, 0, 0) }
|
||||||
var directionHint by remember { mutableStateOf("") }
|
var directionHint by remember { mutableStateOf("") }
|
||||||
var directionIcon by remember { mutableStateOf("") }
|
var directionIcon by remember { mutableStateOf("") }
|
||||||
|
var placementScore by remember { mutableIntStateOf(0) }
|
||||||
var scanCount by remember { mutableIntStateOf(0) }
|
var scanCount by remember { mutableIntStateOf(0) }
|
||||||
var isLocked by remember { mutableStateOf(false) }
|
var isLocked by remember { mutableStateOf(false) }
|
||||||
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
|
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
|
||||||
@@ -186,7 +188,7 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
for (i in 0 until 6) {
|
for (i in 0 until 6) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -197,21 +199,33 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
else Color.Red.copy(alpha = 0.4f)
|
else Color.Red.copy(alpha = 0.4f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text("CH$i", fontSize = 9.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
text = "CH$i",
|
if (channelUrineLen[i] > 0) {
|
||||||
fontSize = 9.sp,
|
Text("${channelUrineLen[i]}", fontSize = 8.sp, color = Color.Cyan)
|
||||||
fontWeight = FontWeight.SemiBold,
|
}
|
||||||
color = MlSecondaryText
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Text(
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
text = "$detectedCount/6",
|
Text(
|
||||||
fontSize = 16.sp,
|
text = "$detectedCount/6",
|
||||||
fontWeight = FontWeight.Bold,
|
fontSize = 16.sp,
|
||||||
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800)
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800)
|
||||||
|
)
|
||||||
|
if (placementScore > 0) {
|
||||||
|
Text(
|
||||||
|
text = "Score: $placementScore%",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = when {
|
||||||
|
placementScore >= 80 -> Color(0xFF4CAF50)
|
||||||
|
placementScore >= 50 -> Color(0xFFFF9800)
|
||||||
|
else -> Color.Red
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-channel echo graphs (after scan)
|
// Per-channel echo graphs (after scan)
|
||||||
@@ -275,21 +289,20 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
isScanning = false
|
isScanning = false
|
||||||
scanCount++
|
scanCount++
|
||||||
|
|
||||||
// Run UrinAI on each channel
|
|
||||||
val detected = MutableList(6) { false }
|
val detected = MutableList(6) { false }
|
||||||
|
val urineLens = MutableList(6) { 0 }
|
||||||
|
|
||||||
for (ch in channels) {
|
for (ch in channels) {
|
||||||
if (ch.buffer.isNotEmpty() && ch.channel < 6) {
|
if (ch.buffer.isNotEmpty() && ch.channel < 6) {
|
||||||
val gate = UrinAI.detect(ch.buffer)
|
val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel)
|
||||||
detected[ch.channel] = gate.detected
|
if (analysis.isValid && analysis.result != null) {
|
||||||
|
detected[ch.channel] = true
|
||||||
// Echo analysis as backup
|
urineLens[ch.channel] = analysis.result.urineLen
|
||||||
if (!gate.detected) {
|
} else {
|
||||||
val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel)
|
val gate = UrinAI.detect(ch.buffer)
|
||||||
if (analysis.isValid) {
|
if (gate.detected) {
|
||||||
val r = analysis.result
|
detected[ch.channel] = true
|
||||||
if (r != null && r.urineLen >= 10) {
|
urineLens[ch.channel] = gate.bw - gate.fw
|
||||||
detected[ch.channel] = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,16 +310,18 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
|
|
||||||
for (i in 0 until 6) {
|
for (i in 0 until 6) {
|
||||||
channelDetected[i] = detected[i]
|
channelDetected[i] = detected[i]
|
||||||
|
channelUrineLen[i] = urineLens[i]
|
||||||
}
|
}
|
||||||
lastChannelData = channels
|
lastChannelData = channels
|
||||||
|
|
||||||
// Direction guidance
|
// Direction guidance (urineLen-aware)
|
||||||
val hints = updateDirectionGuide(detected)
|
val guideResult = computePlacementGuide(detected, urineLens)
|
||||||
directionHint = hints.first
|
directionHint = guideResult.hint
|
||||||
directionIcon = hints.second
|
directionIcon = guideResult.icon
|
||||||
|
placementScore = guideResult.score
|
||||||
|
|
||||||
// Lock if 4+ detected
|
// Lock if score >= 70 or 4+ detected
|
||||||
if (detected.count { it } >= 4) {
|
if (guideResult.score >= 70 || detected.count { it } >= 4) {
|
||||||
isLocked = true
|
isLocked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,48 +573,99 @@ private fun SensorLayoutCanvas(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
data class PlacementGuideResult(
|
||||||
* Compute direction guidance based on which channels are detected.
|
val hint: String,
|
||||||
* Returns (hint text, icon identifier).
|
val icon: String,
|
||||||
*/
|
val score: Int
|
||||||
private fun updateDirectionGuide(detected: List<Boolean>): Pair<String, String> {
|
)
|
||||||
val topDetected = detected[3] // CH3 = top
|
|
||||||
val bottomDetected = detected[0] // CH0 = bottom
|
|
||||||
val leftDetected = if (detected.size > 4) detected[4] else true // CH4
|
|
||||||
val rightDetected = if (detected.size > 5) detected[5] else true // CH5
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2단계 placement guide:
|
||||||
|
* 1단계: 잡힘/안잡힘 → 기본 방향 가이드
|
||||||
|
* 2단계: urineLen 기반 정밀 가이드 (대칭 + 균형 + 크기)
|
||||||
|
*/
|
||||||
|
private fun computePlacementGuide(detected: List<Boolean>, urineLens: List<Int>): PlacementGuideResult {
|
||||||
|
val ch0 = detected[0]; val ch3 = detected[3]
|
||||||
|
val ch4 = if (detected.size > 4) detected[4] else true
|
||||||
|
val ch5 = if (detected.size > 5) detected[5] else true
|
||||||
val detectedCount = detected.count { it }
|
val detectedCount = detected.count { it }
|
||||||
|
|
||||||
if (detectedCount >= 4) {
|
// 0 channels: no signal
|
||||||
return "Good position! Ready to measure." to "checkmark.circle"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical guidance
|
|
||||||
// CH0=위(배꼽), CH3=아래(치골)
|
|
||||||
// CH0✅ CH3❌ → 방광이 센서보다 위 → 센서를 위로
|
|
||||||
// CH0❌ CH3✅ → 방광이 센서보다 아래 → 센서를 아래로
|
|
||||||
if (!topDetected && bottomDetected) {
|
|
||||||
return "Move sensor UP" to "arrow.up"
|
|
||||||
}
|
|
||||||
if (topDetected && !bottomDetected) {
|
|
||||||
return "Move sensor DOWN" to "arrow.down"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal guidance
|
|
||||||
if (!leftDetected && rightDetected) {
|
|
||||||
return "Move sensor LEFT" to "arrow.left"
|
|
||||||
}
|
|
||||||
if (leftDetected && !rightDetected) {
|
|
||||||
return "Move sensor RIGHT" to "arrow.right"
|
|
||||||
}
|
|
||||||
|
|
||||||
// No signal
|
|
||||||
if (detectedCount == 0) {
|
if (detectedCount == 0) {
|
||||||
return "No bladder detected. Check sensor contact." to "exclamationmark.triangle"
|
return PlacementGuideResult("No bladder detected. Check sensor contact.", "exclamationmark.triangle", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partial
|
// 1단계: 이진 판정 (4채널 미만일 때)
|
||||||
return "Adjust sensor position slowly" to "arrow.up.arrow.down"
|
if (detectedCount < 4) {
|
||||||
|
val hint = when {
|
||||||
|
!ch3 && ch0 -> "Move sensor UP"
|
||||||
|
ch3 && !ch0 -> "Move sensor DOWN"
|
||||||
|
!ch4 && ch5 -> "Move sensor LEFT"
|
||||||
|
ch4 && !ch5 -> "Move sensor RIGHT"
|
||||||
|
else -> "Adjust sensor position slowly"
|
||||||
|
}
|
||||||
|
val icon = when {
|
||||||
|
!ch3 && ch0 -> "arrow.up"
|
||||||
|
ch3 && !ch0 -> "arrow.down"
|
||||||
|
!ch4 && ch5 -> "arrow.left"
|
||||||
|
ch4 && !ch5 -> "arrow.right"
|
||||||
|
else -> "arrow.up.arrow.down"
|
||||||
|
}
|
||||||
|
return PlacementGuideResult(hint, icon, detectedCount * 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: 4채널 이상 잡힘 → urineLen 기반 정밀 가이드
|
||||||
|
val len0 = urineLens[0].toDouble() // CH0 (배꼽)
|
||||||
|
val len3 = urineLens[3].toDouble() // CH3 (치골)
|
||||||
|
val len1 = urineLens[1].toDouble() // CH1
|
||||||
|
val len2 = urineLens[2].toDouble() // CH2
|
||||||
|
val len4 = urineLens.getOrElse(4) { 0 }.toDouble() // CH4 (왼)
|
||||||
|
val len5 = urineLens.getOrElse(5) { 0 }.toDouble() // CH5 (오)
|
||||||
|
|
||||||
|
// 세로 대칭: CH0과 CH3의 urineLen 비율
|
||||||
|
val vertMax = maxOf(len0, len3, 1.0)
|
||||||
|
val vertSymmetry = 1.0 - kotlin.math.abs(len0 - len3) / vertMax
|
||||||
|
|
||||||
|
// 가로 균형: CH4와 CH5의 urineLen 차이
|
||||||
|
val latMax = maxOf(len4, len5, 1.0)
|
||||||
|
val latBalance = if (len4 > 0 && len5 > 0) 1.0 - kotlin.math.abs(len4 - len5) / latMax else 0.5
|
||||||
|
|
||||||
|
// 중앙 채널 크기 (절대적 — 클수록 방광 중심에 가까움)
|
||||||
|
val centerAvg = (len1 + len2) / 2.0
|
||||||
|
val centerScore = (centerAvg / 40.0).coerceAtMost(1.0) // 40칸이면 만점
|
||||||
|
|
||||||
|
// 종합 점수
|
||||||
|
val rawScore = vertSymmetry * 0.3 + latBalance * 0.3 + centerScore * 0.4
|
||||||
|
val score = (rawScore * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// 정밀 방향 가이드
|
||||||
|
val hint: String
|
||||||
|
val icon: String
|
||||||
|
|
||||||
|
if (score >= 80) {
|
||||||
|
hint = "Perfect position! Score: $score%"
|
||||||
|
icon = "checkmark.circle"
|
||||||
|
} else if (len0 > 0 && len3 > 0 && len0 / maxOf(len3, 1.0) > 1.5) {
|
||||||
|
hint = "Move sensor UP (CH0 longer)"
|
||||||
|
icon = "arrow.up"
|
||||||
|
} else if (len0 > 0 && len3 > 0 && len3 / maxOf(len0, 1.0) > 1.5) {
|
||||||
|
hint = "Move sensor DOWN (CH3 longer)"
|
||||||
|
icon = "arrow.down"
|
||||||
|
} else if (len4 > 0 && len5 > 0 && (len4 - len5) > 5) {
|
||||||
|
hint = "Move sensor RIGHT (CH4 longer)"
|
||||||
|
icon = "arrow.right"
|
||||||
|
} else if (len4 > 0 && len5 > 0 && (len5 - len4) > 5) {
|
||||||
|
hint = "Move sensor LEFT (CH5 longer)"
|
||||||
|
icon = "arrow.left"
|
||||||
|
} else if (centerAvg < 10) {
|
||||||
|
hint = "Adjust position — weak center signal"
|
||||||
|
icon = "arrow.up.arrow.down"
|
||||||
|
} else {
|
||||||
|
hint = "Good position! Score: $score%"
|
||||||
|
icon = "checkmark.circle"
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlacementGuideResult(hint, icon, score)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user