Files
dw.jang 909eac9ca3 docs: V3 CenterAligner 2-Pass 이식 반영
- docs/ALGORITHM_COMPARISON.md
  * "V3: CenterAligner 2-Pass BVCV" 신규 서브섹션
  * Python 원본 매핑 · 검증 방식 · commit ID 명시
  * V1 3-stage / V2 6-stage / V3 2-Pass 세 알고리즘 병행 정리
- VesiScan_Android_Pipeline_Summary.md
  * v7 patch 헤더 2026-07-13 → 2026-07-15
  * Alignment 섹션 V3 항목 신규 (piezophantomtest PR #35 링크,
    Pass 1/2 요약, 검증 결과 요약, UI 후속 작업 표기)

기능 변화 없음. V3 라이브러리 이식 (커밋 9387552 / fcdce12) 을 문서에 반영.
2026-07-15 16:04:42 +09:00

349 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Method A / B / C Algorithm Comparison & Placement Test
_마지막 업데이트: 2026-07-15 — V3 CenterAligner 2-Pass BVCV 이식 추가._
## Wall Detection Comparison
### Pipeline
```
Method A: raw → SG → composite(sg+|d1|+|d2|) → plateau score → plateau spans
→ prominence wall (ANT=nearest, POST=edge_score)
→ cross-validation → BV(frustum+ellipse cap, 1ch=sphere)
※ 채널별 min_wall_contrast_ratio × cos(θ) 보정
Method B: raw → SG → TGC(multichannel log-fit) → initial detect(Otsu×0.9×cos(θ))
→ BR candidate(per-ch, post-only, prominence≥50)
→ consensus(median, outlier_tol=4, min_votes=3)
→ is_reflection_only(shared+cluster, amp_ratio=1.5)
→ resolve(suspicious post: post>ant×1.5)
→ suppress(valley early-stop 50.0) → rerun detect
→ choose(rerun vs initial) → BV(frustum+ellipse cap, 1ch=sphere)
Method C: raw → MedianFilter(win=7) → SG+wavelet
→ ImpulseReject(2-pass Hampel, lumen clean)
→ OS-CFAR(per-sample) → lumen mask
→ prominence wall + subsample refine
→ StaLta impulse purity check
→ anatomical gate (antDepth≥10mm, chord 5-110mm)
→ BModeScore V41 (wamp=0.45, stalta=0.20, far=0.15, dark=0.10)
→ ChordConsensus (Tukey MAD + Fischler-Bolles pair test)
→ BV dispatch(frustum/chord/sphere, trusted channels only)
```
### Stage-by-Stage
| Stage | **A (Plateau)** | **B (Otsu+TGC+BR)** | **C (V4.1 CFAR)** |
|-------|-----------------|----------------------|---------------------|
| Pre-filter | none | none | MedianFilter(win=7) + ImpulseReject(Hampel) |
| Denoising | SG(5,2) | SG(5,2) → log-TGC | SG(5,2) + db4 wavelet 3-level |
| Threshold | sliding plateau score Q=0.5 | Otsu×0.9×cos(θ) | OS-CFAR (win=15, rank=40%, scale=1.05) |
| Low-echo mask | platScore < thr | sg <= otsu (full) | sg <= median(CFAR) |
| Span merge | gap≤5, simple | gap<3: unconditional, ≥3: wall check | mergeGapMax=5, gapPeakMargin=50 |
| Span refine | none | refineRightEdge | none |
| Wall select | ANT=nearest, POST=edge_score, decay=0.15 | ANT=nearest, POST=edge_score, decay=0.12 | prominence + subsample, MAX_CANDIDATES_POST=64 |
| Valley logic | descending-first, rise=5 | descending-first, rise=50 | rise=50 |
| Extra conditions | contrast×cos(θ)≥1.2, prom≥3 | peakMin=max(low+30,thr), sg[ant/post]≥thr, postProm≥30 | Kremkau ant fallback, antMinIdx=7 |
| Score | sg[ant]+sg[post] | asymmetric ant_reliability | BModeScore V41 (wamp+stalta+far+dark+grad) |
| FP filter | cross-validation | score<3000 | AnatomicalGate + ChordConsensus |
| Impulse check | none | none | StaLta (STA/LTA ratio) |
| Channel filter | none | none | ChordConsensus (MAD outlier + pair test) |
| Back reflection | none | consensus multichannel BR | none |
| Fallback | none | ant=max(0,s-(post-e)) | B-mode tier |
### BV Estimation
| | **A** | **B** | **C** |
|---|---|---|---|
| Model | shared with B | frustum + ellipse cap | Multi-dispatch (trusted ch only) |
| 1-channel | sphere V=(4/3)πR³ | same | Verathon chord |
| Channel filter | none | none | ChordConsensus (drop inconsistent) |
| Cap height | 8-point ellipse LSQ | same | fixed: bottom=hemisphere, top=cone |
| LR ratio | shared with B | 2-lateral ellipse solve + shrinkage | 1/avg_ratio, clamp[1.0,1.6] |
| Confidence | none | none | 0.95(4C+2L) ~ 0.20(1ch) |
| DPS | PiezoHW (1.968) | same | 1.9309 |
### Angle Correction (PR#20)
Both Method A and B apply per-channel beam angle correction:
- **Method B**: `otsu_ratio = OTSU_RATIO × cos(θ)` per channel
- **Method A**: `min_wall_contrast_ratio = 1.2 × cos(θ)` per channel
- Larger angles → lower threshold → better detection of weak posterior walls
### Back Reflection System (Method B)
```
Per-channel:
1. initial detect → (ant, post)
2. build_candidate: strongest peak AFTER post (prominence≥50)
- global BR ≈ post (±3) → None (no separate BR)
- else → strongest significant peak after post
Multichannel:
3. consensus: median, outlier_tol=4, min_votes=3
4. is_reflection_only: post > ant × 1.5 (amp_ratio)
- Condition A: shared_idx based
- Condition B: post cluster based
Per-channel resolve:
5. is_suspicious_post: post > ant × 1.5 OR post ≈ shared (±2)
6. select_idx → resolve → suppress(valley early-stop 50.0)
7. rerun detect → choose(rerun vs initial, min_gap=8)
```
### Method C V4.1 New Features (2026-05-04)
```
Pre-processing:
MedianFilter(win=7) → remove speckle bumps
ImpulseReject(2-pass Hampel) → clean 1-3 sample impulses in lumen
Detection:
DetectLumenFirst V41_PARAMS:
mergeGapMax=5, gapPeakMargin=50
maxCandidatesPost=64, medianWin=7, antMinIdx=7
Kremkau half-amplitude ant fallback
Quality:
StaLta: STA/LTA ratio → wall edge vs reverberation discrimination
BModeScore V41_WEIGHTS:
wamp=0.45 (wall-peak amplitude)
stalta=0.20 (impulse purity)
far=0.15, dark=0.10, ant=0.05, post=0.05
Channel Filtering:
ChordConsensus: Tukey MAD outlier + Fischler-Bolles pair test
→ drop geometrically inconsistent channels before BV
Anatomical Gate:
antDepthMin: 22 → 10mm (Neyman-Pearson loose prior)
strict threshold removed → delegated to B-mode score
```
---
## Python vs Kotlin Sync Status (2026-05-04)
### Method A/B: 18 functions compared — CRITICAL 0, MINOR 5
All config constants match: LOW_ECHO_AMP=1250, OTSU_RATIO=0.9, MIN_SCORE=3000,
EDGE_DIST_DECAY=0.12, VALLEY_STOP_RISE=50, etc.
### Method C: Direct copy from CharlesKWONsLaw (45 files, package renamed)
---
## Placement Test Algorithm
### Overview (2026-05-26 updated — v6)
```
Screen entry → "Place VesiScan above the pubic bone, then press Start"
↓ Start Alignment 버튼 탭 (waitingForStart=false)
전체 상태 리셋
↓
[600ms interval loop] ← v6: 1000ms → 600ms (BleManager throttle과 동기화)
↓
mpa (first time, 500ms delay) → maa → 6ch reb
↓ (maa 전 BleManager.canSendMaa() 게이트 통과 필요)
│ - State gate: !piezoCollector.isComplete 이면 차단 (3s 후 FORCE)
│ - Time gate: 마지막 송신 < 600ms 이면 차단
│
Detachment check (6ch received, THR=30) → LED 4 if detached
↓
Wall detection (Method A/B/C) → urineLen ≥ 12
↓
computePlacementGuide() → 3-stage guide (VERTICAL → LATERAL → GREEN)
↓
Debounce (2 consecutive + 1.5s/3s hold) + Phase protection (3 scans)
↓
Arrow direction: always updated (directionIcon synced with guideResult)
↓
LED: stage change only (4=detach, 5=searching, 6=complete)
↓
GREEN: 7s hold + 3 consecutive fail to exit
↓
Screen exit → LED OFF
```
### v6 BLE 사이클 실측 (VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH)
한 측정 사이클 (TX maa → RX raa): **평균 ~330ms** (이전 ~1.2~1.4s 대비 4배 향상).
600ms loop delay 안에 한 사이클이 안정적으로 완료됨.
### Guide Modes
**Gradient** (default): weighted center gradient → LATERAL → GREEN
**Boundary**: up → detect boundary (max drops) → come back → LATERAL
**SWEEP** (CKLaw): navel → slide down → sagittalArgMaxCh → LATERAL → GREEN
### 3-Stage Guide (V1 — 일반 사용자 진입, `computePlacementGuide`)
**Stage 1 VERTICAL**: detect count + gradient + "Slide up/down ↑↓"
**Stage 2 LATERAL**: |len4-len5| ≤ 10 → GREEN, else "Slide left/right ←→"
**Stage 3 GREEN**: CV ≤ threshold AND |LR dev| ≤ 0.20 → "In position!" (7s hold)
### 6-Stage Guide (V2 — Clinical Alignment 세션, `AlignGuide4Stage`)
`ClinicalHome → Sensor Alignment` 진입 시 자동 활성화. Method D chord 기반
CV 로 정밀 vertical 조정 + CH3 flicker 대응 sliding-window majority.
| # | Phase | 조건 |
|:---:|---|---|
| 1 | INITIAL_ACCUM | baseline 10 cycle 누적 |
| 2 | VERTICAL_CLIMB | CH3 sliding window majority — 최근 6 프레임 중 hit ≥ 3 |
| 3 | CH3_STABILIZE | 10 cycle 위치 유지, lost 판정도 sliding (window=4, majority=3) |
| 4 | CENTER_OPTIMIZE | CH0~CH3 chord CV ≤ 0.15 (3연속 hysteresis) |
| 5 | LR_BALANCE | \|u4−u5\| ≤ 8 samples, lost 채널 3연속 hysteresis |
| 6 | FINAL_CONFIRM | buf clear → 5 fresh 프레임 재확인 통과 → GREEN |
**CH3 flicker 대응 3-Layer** (2026-07-06 이후, commit e808b5b / 9261bc7 / 4f8d536):
- **Layer 1 — Sliding-Window Majority**: 3 연속 hit 대체. 인체 flick 패턴
(YYNNYY 등) 통과 가능.
- **Layer 2 — Stuck Detection + Relaxed Mode**: VERTICAL_CLIMB 20 프레임 (~5초)
갇힘 + CH3 hit 저조 + CH0~CH2 all detected → CH3 없이 상단 3채널로 CENTER_OPT
진입 허용. `AlignmentConstants.VERTICAL_STUCK_THRESHOLD`.
- **Layer 3 — UX Soft-Hint**: 12 프레임 (~3초) 반복 시 "↑ 위로" →
"CH3 신호 확인 중 · 위치 유지" 로 문구 자동 완화.
**Python replay 검증** (`tools/align_analyze.py`, 2026-07-10~13): data123/ 인체
4 세션 (Phantom + Human 0/1/3 CM). NEW vs LEGACY 병행 replay 결과 Human 0CM
(CH3 31.8%) 회귀 방지 + Human 1CM (77.3%) 조기 진입 확인, 팬텀 false-positive
없음. 상세 표: `tools/README.md`.
### V3: CenterAligner 2-Pass BVCV (`AlignmentAdvisorV3.kt`, 2026-07-15)
piezophantomtest [`ffd436b`](https://gitea.medithings.net/medithings-rnd/piezo-phantom-test/commit/32e22a17e8a5733e5a8ea0790d84c3fa9b54ebdb)
(PR #35 "feat: alignment bvcv 규칙 추가") 의 `vesiscan_test.alignment.CenterAligner`
를 Kotlin 으로 이식. 기존 V2 (실시간 sliding 6-stage) 와 병행. Clinical 세션
전용 batch 방식.
**Pass 1 — `scanSelect(cyclesByCm)`**
- 사용자가 치골 위 0/1/2/3/4 cm 각각에서 **20 cycle 씩** 측정.
- 각 위치마다:
- 20 cycle → sliding window 10 → **11 trace**
- `mean_scan` (전체 20 cycle 평균) → `detectMultichannel(applyCross=false)` → `nch`, `ch3`
- `per-trace` → `detectMultichannel(applyCross=true)` → `estimateBladderVolume6ch` → volume_ml 리스트 → `bv_cv = std/mean`
- **Rule A + bvcv tie 로 최적 위치 선택**:
1. ch3 필수 (mean_scan base 검출)
2. per-trace ch3 검출률 ≥ **`CH3_HIT_MIN = 0.80`** (임계 통과 없으면 ch3=O 전체 fallback)
3. max `nch`
4. 동률 → min `bv_cv`
- 선택 위치의 record 를 `target` 으로 저장 (Pass 2 기준값).
**Pass 2 — `guide(cycles, cm)`**
- 재측정한 한 위치 (20 cycle) 가 target 기준 충족 시 `STOP`:
- `ch3='O'`
- per-trace ch3 검출률 ≥ 80%
- `rec.nch ≥ target.nch`
- `rec.bv_cv ≤ target.bv_cv × (1 + bvCvTol)` (기본 `bvCvTol = 0.15`)
- 미충족: `MOVE_UP` (다음 위치로 이동 안내) + 실패 사유 한글 표시.
**Python 1:1 매핑**:
- `_traces(cycles, win=10)` ↔ `buildRecord()` sliding
- `alignment_selection.select_cm(rule='A', tie='bvcv')` ↔ `selectCm()`
- `CenterAligner._fail_reason()` ↔ `failReason()`
- BV 리스트 축적: Python `if bv is not None: bvs.append(volume_ml)` ↔ Kotlin `bv?.let { bvs.add(it.volumeMl) }` (nan/0 필터 없음 — Python 1:1)
**검증** (`app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt`,
data123/ 인체 3 세션):
- Python reference 와 `nch / ch3 / ch3_hit` **완전 일치**
- `bv_cv` 오차 <2% (정상 케이스). BV 발산 케이스만 큰 오차 — Rule A 필터링으로 선택 영향 없음
- 최종 선택 위치 = **cm=3 (Python 과 일치)**
주의: unit test 는 `PiezoHW.activePreset = DevicePreset.V1` 명시 설정 필요.
실기기는 `BleManager.autoDetectPreset(deviceName)` 이 자동 처리.
UI 통합 (Pass 1 위치 안내 · 진행바 · Pass 2 판정 화면) 은 후속 작업.
관련 commit: `9387552` (이식) + `fcdce12` (Python 1:1 nan/0 필터 제거).
### Measurement Modes (v6 업데이트)
- **Auto Scan**: **min 600ms gap** (state-based throttle: `!isComplete` + 600ms), 10-sample trimmed mean (max/min removed, 8 avg)
- **Single Scan (Spot)**: 5회 측정 → trimmed mean (max 8 attempts, min 3)
- valid ≥ 4 channels only
- Analysis on coroutine (Dispatchers.Default)
- Auto fail: 2ch+ missing 5 consecutive → realign dialog
- **Voiding/Catheterization 클릭 시 Auto Scan 자동 stop + 측정 상태 정규화** (displayMaxVolumeMl/window/timer 모두 0)
- Detachment → LED 4 (once), recovery → LED 0 (once)
- Foreground Service: BLE connection maintained during screen off
- BLE 로그: 실시간 파일 기록 (Downloads/VesiScan_BLE_*.log, 화면 잠금에도 보존)
- CSV: 세션별 파일 (YYYY-MM-DD_HHmmss.csv)
### Placement GREEN 기준
```
CV threshold = DEFAULT_CV_THR + (scanCount / 5) × 0.03
DEFAULT_CV_THR: V0=0.07, V1/V2=0.10, 기타=0.08
LR deviation ≤ 0.20
GREEN hold: 7초 (greenLockedAtMs 기준)
GREEN exit: 3회 연속 실패 (greenFailCount)
```
### Test Results (2026-05-04, 500ml phantom, Method C V4.1)
```
총 측정: 223회
평균: 492.4ml (오차 -1.5%)
±15% 이내: 223/223 (100%)
범위: 441~537ml
```
---
## BLE maa Throttle — State-Based Gate (v6, 2026-05-26)
### 배경: CH0/CH1만 도착 + BV_FAIL/BV_SKIP 증상
이전 throttle은 시간 단독 `now - lastMaaSentMs < 800ms` 만 검사. 펌웨어 응답이
~1.2~1.4초 걸리는 환경에서 800ms 시점에 새 maa가 송신되면:
1. `sendChannelsOnly()` 통과 → `piezoCollector.startMultiChannel(6)` → collector reset
2. **큐에 떠다니던 이전 사이클의 reb / raa 패킷이 새 collector의 시작 부분에 잘못 들어감**
3. 결과: 한 채널만 들고 raa로 종료 → `BV_FAIL no valid channels` 또는 `BV_SKIP center=0 total=5/6`
실측 로그 (FW VBTFW0111, 2026-05-22):
```
TX maa #1 → reb×5 → (raa 큐 밀림) → TX maa #2 → reb×1 + raa → BV_FAIL scan=656
```
### v6 신규 게이트
```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) return false // (A) state 차단
// 3s 넘으면 강제 통과 (FORCE) — deadlock 방지
}
if (sinceLast < 600) return false // (B) time 차단
return true
}
```
| 게이트 | 차단 | 풀림 |
|---|---|---|
| (A) State | `isMultiChannel && !isComplete` | raa 도착 OR 3s FORCE |
| (B) Time | `sinceLast < 600` | 600ms 경과 |
두 게이트 모두 통과해야 maa 송신. State gate가 race 방어, time gate가 burst 방어.
### 효과 (FW VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH 조합)
- 한 사이클 응답: 1.2s → **330ms** (4배)
- 6/6 채널 도착률: 부분응답 잦음 → **100% 도착, 유실 0건**
- BV_FAIL/BV_SKIP 빈도: 자주 → 관찰되지 않음
### Logcat 키워드
| 로그 | 의미 |
|---|---|
| `maa BUSY [...] — prev response in progress (Xms)` | State gate 차단 (정상 방어) |
| `maa FORCE [...] — prev incomplete after Xms` | 3s 넘게 raa 없음 → 포기하고 진행 |
| `maa THROTTLED [...] — Xms since last` | Time gate 차단 (정상 방어) |
| (로그 없음) | 정상 송신 |
| `MTU changed: 247 (status=0)` | MTU 정상 협상 (CCCD write 직전) |
| `CONN_PRIORITY HIGH requested (ok=true)` | priority 요청 큐잉 성공 (수락 여부는 sniffer로만 확인 가능) |