# VesiScan Android App — 완전 가이드 ## 작성일: 2026-04-23 ## 작성자: dwjang ## 마지막 업데이트: 2026-05-26 (v6) — 1.0.0-design (versionCode 24) ## 권장 펌웨어: **VBTFW0116** (pending slot 1→8 확장으로 ADC drop 안정) ### v6 주요 변경 요약 (2026-05-12 ~ 2026-05-26) - **BLE throttle 상태 기반 게이트화** — 시간 단독(800ms)에서 `isMultiChannel && !isComplete` + 시간(600ms) 복합 게이트로 전환. 잔여 reb 폐기로 인한 CH0/CH1만 도착 + BV_FAIL/BV_SKIP 증상 해소 - **CONNECTION_PRIORITY_HIGH 요청 추가** — CCCD write 완료 시점에 `gatt.requestConnectionPriority(HIGH)` 호출. peripheral이 수락 시 interval ~15ms로 단축 - **MTU 247 협상 확정** — VBT26050202 + VBTFW0116 조합에서 247 정상 협상 (logcat 검증) - **측정 사이클 시간 1.2s → 0.33s** (4배 향상). Placement loop 1000ms → 600ms, Auto loop 1500ms → 600ms - **PinView DEMO 우회**, **DeviceScanView Dev 측정모드 진입 버튼**, **AppState placementFromMonitoring 라우팅 분기** 추가 - **PiezoPersonalizationView 평행 2-카드 리디자인** (Maximum Bladder Capacity + Catheter Threshold, StepperButton, roundToInt fix) - **PlacementGuideView Sensor Alignment 리디자인** (3-stop gradient, 38sp 큰 타이틀, "pubic bone" 빨강 강조, Canvas 화살표/아치 제거, chestY 0.09) - **PiezoMonitoringView 누적 업데이트** (Voiding/ 텍스트, 48sp Recorded Dialog, useScrollLayout, Volume 72sp ExtraBold, Bladdy 0.483, Home 아이콘, 설정 패널 폰/태블릿 자동 분기) - **HomeView Bladdy 3× 확대** (220 → 660dp + BoxWithConstraints 90% 캡) --- # 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) └ 체크섬 ``` ### Connection Parameter 협상 (v6 신규) **MTU 247**: 연결 직후 `gatt.requestMtu(247)` 호출. 208B `reb:` 패킷을 단일 노티로 전송하여 단편화 drop 위험 제거. 실측 VBT26050202 + VBTFW0116에서 247 정상 협상 확인. **CONNECTION_PRIORITY_HIGH**: CCCD write 성공 직후(서비스 ready 시점) 호출: ```kotlin gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) debugLogger.info("CONN_PRIORITY HIGH requested (ok=$priorityOk)") ``` peripheral이 connection parameter update를 수락하면 interval ~15ms로 협상되어 Central Link Layer ACK 속도가 향상됨 → 펌웨어 SoftDevice TX queue 포화 빈도 감소. 수락 여부는 안드로이드 API로 직접 확인 불가(HCI 레벨 차단) — 응답 시간 측정으로 간접 확인. ### maa Throttle (v6 상태 기반 게이트로 강화) 이전: 시간 단독 `now - lastMaaSentMs < 800` 차단 → 펌웨어 응답이 1.2s 걸릴 때 `startMultiChannel(6)`이 응답 도중 호출되어 잔여 reb 패킷이 폐기되는 race 발생. 신규: [BleManager.kt:464-481](app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L464-L481) `canSendMaa()` ```kotlin private fun canSendMaa(caller: String): Boolean { val now = System.currentTimeMillis() val collectorBusy = piezoCollector.isMultiChannel && !piezoCollector.isComplete val sinceLast = now - lastMaaSentMs if (collectorBusy) { if (sinceLast < 3000) { Log.w("BleManager", "maa BUSY [$caller] — prev response in progress") return false // (A) 상태 차단 } Log.w("BleManager", "maa FORCE [$caller] — prev incomplete after ${sinceLast}ms") } if (sinceLast < 600) { Log.w("BleManager", "maa THROTTLED [$caller] — ${sinceLast}ms since last") return false // (B) 시간 차단 } return true } ``` | 게이트 | 차단 조건 | 풀림 조건 | |---|---|---| | (A) State | `isMultiChannel && !isComplete` (raa 미도착) | raa 도착 OR 3초 경과 (FORCE) | | (B) Time | 마지막 송신 후 600ms 미만 | 600ms 경과 | 두 게이트 모두 통과해야 maa 송신. State gate가 안전망, time gate가 펌웨어 burst 방어. ### 측정 사이클 실측 (VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH) logcat 측정 (2026-05-26): | 사이클 | TX maa | RX raa | 소요 | |---|---|---|---| | Placement #1 | 57.333 | 57.652 | **319ms** | | Monitor #1 | 00.952 | 01.233 | **281ms** | | Monitor #2 | 02.851 | 03.191 | **340ms** | | Monitor #3 | 04.414 | 04.719 | **305ms** | | Monitor #4 | 05.982 | 06.292 | **310ms** | | Monitor #5 | 07.549 | 07.972 | **423ms** | **평균 ~330ms**. 이전 ~1.2~1.4s 대비 4배 향상. 모든 사이클에서 6/6 채널 도착, 유실 0건. ### 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 계산 — x-z 타원 피팅 (2026-05-07 업데이트, appshare #21) ### 알고리즘 (x-z 평면 6점 타원 피팅) ``` 1. CH4/CH5 벽 좌표 (x-z 평면): - ant/post 벽의 x좌표: sx + d × cos(α) × sin(β) - ant/post 벽의 z좌표: d × cos(α) × cos(β) → 최대 4개 lateral 점 2. Center 벽 좌표를 lateral SI 높이로 보간: - CH1, CH2의 z_ant, z_post를 선형 보간 (x=0) → 2개 center 점 3. 6개 경계점으로 x-z 축 정렬 타원 피팅: - 정규화 후 α·x̂² + β·ẑ² + γ·x̂ + δ·ẑ = 1 - least squares → b_lr (LR 반축), a_ap (AP 반축) - lr_ratio = b_lr / a_ap (하한 1.0) 4. Shrinkage: - confidence = ((1 - avg_chord_ratio) / 0.10).clamp(0, 1) - lr_final = lr_prior(1.2) + (lr_raw - 1.2) × confidence ``` --- ## 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 + 뚜껑 ``` ### 파이프라인 (2026-05-07 업데이트, appshare #21/#22) | Step | 뭘 하나? | 수식 | |------|---------|------| | 0 | Center wall 보간/정리 | gap=1 보간, gap≥2 top 그룹 제거 | | 1 | Edge 채널 필터 | CH3이 CH1/CH2 대비 0.9배 미만이면 short | | 2 | 칸 번호 → 실제 거리(mm) | d = 6.85 + 칸번호 × DPS | | 2-1 | Post median outlier 제거 | median ±25% 밖 채널 제거 (≥3ch) | | 3 | 방광 직경 구하기 (각도 보정) | D = (뒷벽거리 - 앞벽거리) × cos(각도) | | 4 | 단면적 구하기 | S = (π/4) × D² × lr_ratio | | 5 | 각 층의 높이 위치 + 벽 좌표 | y, z 벽 좌표 (타원 피팅용) | | 6 | 아래→위 순서로 정렬 | - | | 7 | y-z 타원 피팅 → cap 높이 | 반복 outlier 제거 (잔차>0.5 시 worst 제거, 최소 5점) | | 7-1 | b_si 상한 | a_ap × 1.3 (해부학적 SI/AP 제한) | | 7-2 | Top cap 상한 | 미검출 상위 채널 빔 y좌표 기준 제한 | | 8 | 층별 부피 합산 | 절두체 공식 | | 9 | 바닥 뚜껑 (spherical cap) | V = S×h/2 + π×h³/6 | | 10 | 윗 뚜껑 (cone) | 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) ``` ## 방향 안내 로직 — 3모드 (2026-05-11 업데이트) 개발자 모드 Settings에서 전환 가능: **Gradient** (기본) / Boundary / **SWEEP** ### 조작 흐름 (Start 버튼 필수) ``` 화면 진입 → "Place VesiScan above the pubic bone, then press Start" ↓ Start Alignment 버튼 탭 (96dp 대형) 전체 상태 리셋 → 1초 간격 연속 스캔 시작 ↓ VERTICAL → LATERAL → GREEN (순차, 건너뛰기 없음) ↓ GREEN 7초 유지 + 3회 연속 실패 안 하면 Start Scanning 버튼 활성화 (초록색) ``` ### Gradient 모드 (기본) — 신호 길이 비교 ``` 시작: 치골 위 1단계 VERTICAL: 0~1채널: ch0/ch1 감지→↑, ch2/ch3 감지→↓ 2+채널: weighted center gradient weightedCenter = Σ(ch_pos × len) / Σ(len) gradient = weightedCenter - 1.5 gradient > 0 → "Slide down ↓" (ch3쪽 길다 = 방광 아래) gradient < 0 → "Slide up ↑" (ch0쪽 길다 = 방광 위) |gradient| < 0.3 + centerDetected >= 4 → "Checking..." → LATERAL 2단계 LATERAL: |len4 - len5| ≤ 10 sample → "Final check..." → GREEN lrSymmetry ≥ 0.5 → "Final check..." → GREEN 아니면 "Slide left ←" / "Slide right →" 3단계 GREEN: CV ≤ threshold (V0=0.07, V1/V2=0.10, 5회마다 +0.03 완화) |lrDev| ≤ 0.20 둘 다 통과 → "In position!" (7초 hold) 3회 연속 실패 → "Position lost. Restart above pubic bone." + 스캔 중지 ``` ### SWEEP 모드 (CKLaw) — 위에서 아래로 쓸어내리기 ``` 시작: 배꼽 근처 → 아래로 내림 1단계 VERTICAL: ① 0채널: "Place near navel, slide down ↓" ② 1~3채널: "Slide down ↓" (채널이 하나씩 켜짐) ③ 4채널: sagittalArgMaxCh로 정렬 판정 argmax=0 → 정중앙! → LATERAL argmax=1~3 → "Slide down" (아직 더 내려야) ④ 4채널→줄어듦: "Past optimum — slide back up ↑" + 좌우 차이 ≤10 → LATERAL 건너뛰고 바로 GREEN 2단계 LATERAL: lateralOffsetMm (mm 단위 정밀 안내) |offset| ≤ 8mm → GREEN 3단계 GREEN: CV + LR dev + R-peak gate (bestR - currentR ≤ 1.5mm) ``` ### Boundary 모드 — 올라갔다 내려오기 ``` 시작: 치골 위 → 위로 올림 채널 수 최대→감소 감지 → "Too high, lower" → 복귀 시 LATERAL ``` ### GREEN 판정 상수 | 상수 | 값 | 의미 | |------|-----|------| | DEFAULT_CV_THR | V0=0.07, V1/V2=0.10, 기타=0.08 | 기본 CV 임계 | | CV 완화 | +0.03 per 5 scans | 5회마다 임계값 증가 | | DEFAULT_LR_DEV_THR | 0.20 | LR 비대칭 임계 | | 절대 LR 차이 | ≤10 sample | LATERAL 즉시 통과 | | R-peak tolerance | 1.5mm | SWEEP 모드 GREEN gate | | GREEN hold | 7초 | GREEN 진입 후 최소 유지 시간 | | GREEN exit | 3회 연속 실패 | hold 이후 이탈 조건 | | 디바운스 | 1.5초 (같은 phase) / 3초 (phase 전환) | | | Phase 보호 | 3스캔 | phase 전환 후 힌트 강제 유지 | ### 힌트 텍스트 (모든 방향 힌트에 화살표 포함) | 상황 | 텍스트 | |------|--------| | 상하 이동 | "Slide up ↑" / "Slide down ↓" / "Slide up slightly ↑" / "Slide down slightly ↓" | | 좌우 이동 | "Slide left ←" / "Slide right →" | | 확인 중 | "Checking..." | | 최종 확인 | "Final check..." | | 최적 위치 | "In position!" | | 위치 이탈 | "Position lost. Restart above pubic bone." | | 센서 미부착 | "Sensor detached. Restart above pubic bone." | ## 연속 스캔 동작 (2026-05-26 업데이트) ``` "Start Alignment" 버튼 탭 (waitingForStart → false) ↓ 전체 상태 리셋 (phase, score, debounce, greenFail 등) ↓ 즉시 첫 스캔 (maa 6채널) ↓ 결과: CV, 방향, Score 표시 (28sp 대형 힌트) 600ms 대기 (이전 1000ms → 600ms 단축, BleManager throttle과 동기화) ↓ 자동 재스캔 (scanCount 증가 → CV threshold 완화) ↓ ... 반복 (GREEN 도달 or 이탈 시 자동 중지) is_pass = true → Lock → 7초 hold → "Start Scanning" 버튼 활성화 ``` ### v6 화면 디자인 변경 - 큰 "Sensor Alignment" 38sp ExtraBold 검정 (Maximum Bladder Capacity 카드와 톤 통일) - Hint 텍스트 "pubic bone" 인라인 빨강 ExtraBold (AnnotatedString + SpanStyle) - 3-stop vertical gradient 배경 (MlTeal 14% / 흰 / MlPrimary 10%) — GREEN 진입 시 단색 톤 - Canvas 방향 화살표 4종 제거 (hint 텍스트만으로 안내) - 치골 아치 라인 + halo + endpoint 모두 제거 - 토르소 chestY 0.06 → 0.09 (실루엣 위로 16% shift) - Body 상단 Spacer 8dp --- # 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" 표시! (도넛차트 + 레벨 업데이트) ``` ## Auto Scan 모드 (2026-05-26 업데이트) "Auto Scan" 버튼으로 자동 반복 측정: - **최소 600ms 간격** 자동 측정 (BLE maa state-based throttle 보장) - LaunchedEffect loop delay: 1500ms → **600ms** (FW VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH 적용 후 응답 ~330ms이므로 일관 단축) - 첫 5회: "—" 표시 (데이터 수집 중) - 5회 이상: **sliding window trimmed mean** (10개 윈도우, 최대/최소 각 1개 제외 → 8개 평균) - "Stop Scan" 버튼으로 종료 - **Voiding/Catheterization 버튼 클릭 시 Auto 자동 stop + 측정 상태 정규화** (displayMaxVolumeMl/window/timer 모두 0) — v6 신규 - Auto fail: 2ch+ missing 5회 연속 → "Position lost. Restart from alignment" 다이얼로그 ## Single Scan 모드 (2026-05-11 업데이트) "Single Scan" 버튼 1탭 → **5회 측정 → trimmed mean**: - 최대 8회까지 재시도 (유효값 5개 수집 목표, 최소 3개) - 측정 간격: maa 600ms throttle이 간격 보장 (v6에서 800 → 600 단축) - 중간 값 화면 미표시 (isSpotInProgress 플래그) - 1초 쿨다운 후 재사용 가능 ## 측정 실패 시 자동 재시도 ``` 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 ← 전역 상태 관리 + 화면 라우팅 (v6: placementFromMonitoring 플래그 + enterPlacementFromMonitoring() / backFromPlacementGuide()) ``` ## 포팅 원본 대조 | 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 ~ **VBTFW0116**)는 파라미터 없는 명령어에도 **`cmd? `(물음표+공백)** 포맷이 필요합니다. ### VBTFW0116 신규 사항 (펌웨어 측 패치) ``` 원인: - Central(앱)의 Link Layer ACK / connection event 처리 속도보다 Peripheral이 notify enqueue를 더 빨리 시도해서 SoftDevice TX queue 포화 - 기존 FW: pending slot 1개만 보관 (이미 pending 있으면 다음 패킷 drop) - PC 환경에선 Central 처리 빨라 문제 없음 → 실제 앱 환경에선 OS/connection 영향으로 포화 잦음 개선: - pending slot 1 → 8개 확장 → TX queue 포화 시 보관/재전송 → 중간 채널 데이터 유실 감소 - 결과: ADC drop 현상 재현되지 않음 (장시간 반복 테스트 진행 중) 안드로이드 측 짝꿍 변경: - MTU 247 협상 (208B reb 단일 노티) - CONNECTION_PRIORITY_HIGH 요청 (interval 단축 시도) - maa throttle을 state-based gate로 강화 (잔여 reb 폐기 방지) ``` ```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 Scanning" 버튼 활성화 (초록, 96dp) ``` ### 태블릿 대응 ``` fontScale = (screenWidth / 360f).coerceIn(1.0, 1.8) → 폰(360dp)=1.0배, 태블릿(600dp+)=~1.6배 버튼/힌트 텍스트, InfoCard, 배터리/카테터 표시 등 전체 적용 ``` ### 패킷 드랍 대응 ``` 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채널 수신 | ✅ 완료 | | | Foreground Service (화면 잠금 BLE 유지) | ✅ 완료 | PARTIAL_WAKE_LOCK 4시간 | | Low-echo Detection Method B | ✅ 완료 | VALLEY_STOP=50, EDGE_DECAY=0.12 | | V4.1 CharlesKWON Method C | ✅ 완료 | 45 파일 walldetect 패키지 | | Method A (Plateau-based) | ✅ 완료 | contrastRatio × cos(θ) | | Frustum BV 추정 (6ch) | ✅ 완료 | y-z 타원 피팅 cap + outlier 제거 (#21/#22) | | lr_ratio x-z 타원 피팅 | ✅ 완료 | 6점 타원 피팅 (#21) | | Center wall repair | ✅ 완료 | gap=1 보간, gap≥2 top 제거 (#21) | | BV 타원 피팅 outlier 제거 | ✅ 완료 | 반복 worst 제거, 잔차≤0.5, b_si≤a_ap×1.3 (#22) | | Back Reflection 처리 | ✅ 완료 | consensus, amp_ratio=1.5, min_votes=3 | | Placement Guide 3모드 | ✅ 완료 | Gradient / Boundary / SWEEP | | SWEEP (BladderSphereSeek) | ✅ 완료 | Kasa circle fit + RTracker + argmax | | Gradient placement | ✅ 완료 | weighted center + R-peak gate GREEN | | 개발자 모드 | ✅ 완료 | 캐릭터 3탭 토글, dev-only settings/graphs/logs | | Auto 측정 (sliding window) | ✅ 완료 | 10-sample trimmed mean, 대기 중 loading | | Spot 측정 (재시도) | ✅ 완료 | 3회 median, 최대 6회 재시도, 깜빡임 없음 | | 미부착 감지 | ✅ 완료 | mean/std < 30 → LED 4 | | BLE 자동 reconnect + watchdog | ✅ 완료 | 15초 timeout, IMU heartbeat | | BLE 디버그 로거 | ✅ 완료 | 실시간 파일 append | | ADC CSV 저장 | ✅ 완료 | 세션별 파일 | | Canvas 하복부 실루엣 | ✅ 완료 | PNG 제거, CKLaw Bezier torso | | i18n (한국어/영어) | ✅ 완료 | strings.xml | | UI 텍스트 통일 | ✅ 완료 | revised 문서 반영 (Slide up ↑, In position! 등) | | 태블릿 대응 | ✅ 완료 | fontScale, 비율 기반 레이아웃, 힌트/버튼 확대 | | 페어링 다이얼로그 | ✅ 완료 | bonding 대기 중 안내 표시 | | 앱 아이콘 | ✅ 완료 | 방광이 캐릭터 PNG (5 density) | | Placement 화살표 수정 | ✅ 완료 | 디바운스/보호 중에도 화살표 항상 표시 | | GREEN 안정성 | ✅ 완료 | 7초 hold + 3회 연속 실패 exit + phase 보호 | | MTU 247 + CONN_PRIORITY HIGH | ✅ 완료 (v6) | VBT26050202 + VBTFW0116 정상 협상 | | maa throttle state-based gate | ✅ 완료 (v6) | isComplete + 600ms + 3s FORCE | | 측정 사이클 1.2s → 0.33s | ✅ 완료 (v6) | 4배 향상, 6/6 유실 0 | | Placement loop 600ms 단축 | ✅ 완료 (v6) | 1000 → 600ms | | Auto loop 600ms 단축 | ✅ 완료 (v6) | 1500 → 600ms | | placementFromMonitoring 라우팅 분기 | ✅ 완료 (v6) | enterPlacementFromMonitoring/backFromPlacementGuide | | PiezoPersonalization 평행 2-카드 | ✅ 완료 (v6) | Maximum Bladder Capacity + Catheter Threshold | | PlacementGuide Sensor Alignment 리디자인 | ✅ 완료 (v6) | 3-stop gradient + 38sp 큰 타이틀 + pubic bone 빨강 | | PiezoMonitoring Home 아이콘 / Voiding/ / Recorded Dialog | ✅ 완료 (v6) | | | HomeView Bladdy 3× (660dp 캡) | ✅ 완료 (v6) | BoxWithConstraints 90% cap | | 설정 패널 폰/태블릿 자동 분기 | ✅ 완료 (v6) | fontScale (360dp→1.0, 600dp+→1.8) | | PinView DEMO 자동통과 | ✅ DEMO 빌드 한정 | 배포 전 if(false) 블록 제거 | | IMU 모션 감지 | 🔜 예정 | vesiscanbasicAndroid에서 포팅 예정 | | Placement 팬텀 테스트 | 🔜 예정 | Gradient vs SWEEP 비교 | | 5-Anchor calibration | 🔜 검토 | CKLaw 전체 포팅 여부 결정 | | 인체 임상 검증 | 🔜 예정 | 상수 재조정 |