Files
VesiscanClinicalAndroid/docs/CLINICAL_ALGORITHM.md
T
dw.jang 6f118f3ee4 feat(hospital): 정렬을 자세마다 — 부착 위치·재부착 확인·정렬 폴더·labdb 세션을 자세별로
임상 프로토콜은 자세마다 정렬을 처음부터 다시 한다(2026-10-01 확인): Supine 정렬 →
Supine 0~100% → Sitting 정렬 → Sitting 0~100% …. 앱은 환자당 한 번이었다 — Sitting 에서
다시 정렬하면 같은 `align/` 의 원시 파일이 덮이고 labdb 세션 ID 도 같아 섞였다.

  · AppState: 부착 위치·근거·기준 채널·재부착 결과를 자세별 맵(AnchorState)으로. 기존
    접근자(anchorCm 등)는 지금 자세의 항목을 읽고 쓴다. 환자·프로브가 바뀌면 전 자세를
    비운다(clearAllAnchors).
  · 저장: `align/` → `align_{자세}/`. 업로드 대상 탐색은 옛 `align` 과 `align_*` 전부.
    labdb 601 세션 이름에 자세 꼬리(`…_align_sitting_HHmm`), 요약에 `posture`.
  · 정렬 화면: 자세 칩은 첫 위치를 재고 나면 잠근다(폴더·세션의 조건이다). 바꾸려면
    [처음부터 다시 정렬].
  · 병원 화면: 환자명 → 자세 → 정렬 카드 순(자세가 정렬보다 위). 카드가 그 자세의 상태를
    보인다 — "Sitting 부착 위치 미정렬" / "✓ Sitting 부착 위치 3cm 확정".

시험: 자세별 폴더 대상 탐색 · 세션 이름 자세 꼬리. 전체 190건 · failures 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 09:32:23 +09:00

662 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 병원 임상 측정 — 알고리즘 상세
> 대상: `demo-final` 브랜치, 2026-09-10 (`2e5a1f1` 이후) 기준.
> 이 문서는 **코드를 읽고 쓴 것**이고, 각 항목에 파일·라인을 달았습니다. 값이 의심되면
> 해당 파일을 보는 것이 이 문서보다 우선입니다.
## 목차
1. [BV(방광 용적) 계산](#1-bv방광-용적-계산)
2. [화면 그래프에 들어간 필터](#2-화면-그래프에-들어간-필터)
3. [정렬 — 최적 위치 판정](#3-정렬--최적-위치-판정)
4. [전벽·후벽 검출](#4-전벽후벽-검출)
5. [한 장 요약](#5-한-장-요약)
---
## 0. 전체 흐름
```
프로브 6채널 ADC (각 100 샘플, 0~4095)
│
├──────────────────────────────► 화면 그래프 (원신호 그대로, 필터 없음)
│
▼
① SG 필터 (7,3) ──► light
│
└─ + OS-CFAR 반복 중앙값 ──► heavy
▼
② TGC (깊이 감쇠 보정)
▼
③ 채널별 벽 검출 (Method D) ──► ant / post ──► 화면 세로선
▼
④ 채널 간 교차보정 (CCC)
▼
⑤ 기하 적분 (BV) ──► mL
```
**중요**: 화면에 보이는 파형은 ①②를 **거치지 않은 원신호**입니다. 세로선만 ①~④를 거친
결과입니다. 자세히는 [2장](#2-화면-그래프에-들어간-필터).
---
## 1. BV(방광 용적) 계산
구현: `managers/PiezoBVEstimator.kt` · 진입점 `managers/ClinicalBv.kt`
### 1.1 한 줄 요약
**채널마다 "방광의 한 단면 지름"을 재고, 그 단면들을 세로로 쌓아 적분하고, 위아래 빠진
부분을 모자(cap)로 덧붙입니다.**
```
BV = Σ(단면 사이 절두원뿔) + 아래 모자 + 위 모자
└── vCore ──────────┘ └ vBottom ┘ └ vTop ┘
```
### 1.2 샘플 번호 → 거리(mm)
벽은 "몇 번째 샘플"로 나옵니다. 이걸 거리로 바꾸는 식:
```
거리(mm) = delayOffsetMm + 샘플번호 × distancePerSample
```
| 프리셋 | `distancePerSample` | `delayOffsetMm` |
|---|---|---|
| V0 / V1 / V2 (ABS 하우징) | **1.936** mm | **6.85** mm |
| R100 / R200 / R300 (실리콘 렌즈) | **1.897** mm | **7.651** mm |
> ⚠ **프리셋이 틀리면 BV가 통째로 틀립니다.** 같은 샘플 번호가 다른 깊이로 매핑되기
> 때문입니다. 실측에서 같은 데이터가 **125 mL ↔ 490 mL** 로 갈렸습니다. 프리셋은 기기
> 이름으로 자동 판정하고, 이름이 규칙에 안 맞으면 기본값으로 떨어집니다 — 그때는 화면에
> 빨간 `⚠ 기본값 추정` 이 붙고 기록에도 `preset_confident: false` 로 남습니다.
### 1.3 채널 기하 — 빔이 부채처럼 퍼집니다
6채널이 세로로 박혀 있고 각각 다른 각도로 쏩니다. BV 계산에 쓰는 건 **CH0~CH3**
(`centerCh`)이고, CH4·CH5 는 좌우 정렬 전용입니다.
`R300` 예 (`PiezoBVEstimator.kt:110-160`):
| 채널 | z 좌표(mm) | SI 빔 각도 |
|---|---|---|
| CH0 | 19.3 | 0° |
| CH1 | 13.0 | −9° |
| CH2 | 6.7 | −18° |
| CH3 | 0.0 | −27° |
| CH4/CH5 | 9.85 | −10° (좌우 ∓5°) |
`V1` 은 각도가 `6.89 / 0 / −6.89 / −13.66°` 로 전혀 다릅니다. **그래서 기기를 바꾸면
정렬도 무효**입니다 — 같은 자리에서도 빔이 다른 곳을 봅니다.
### 1.4 단계별 계산
`estimateBv()` (`PiezoBVEstimator.kt:1089~1490`)
#### ① 유효 채널 추출 + 거리 변환
벽이 검출된 채널만 씁니다. 각 채널에서 전벽까지 `dAnt`, 후벽까지 `dPost` (mm).
#### ② 단면 지름 — 각도 보정
```kotlin
lRaw = dPost − dAnt // 빔 방향 길이
D = lRaw × cos(θ) // 수평 단면 지름으로 투영
```
빔이 기울어 쏘므로 빔 방향 길이가 곧 지름이 아닙니다. `cos θ` 로 눕힙니다.
#### ③ 단면적 — 좌우 비율이 들어갑니다
```kotlin
S = (π/4) × D² × lrRatio
```
`π/4 × D²` 는 지름 D 인 **원**의 면적입니다. 방광은 원이 아니라 타원이라, 좌우/전후
비율 `lrRatio` 를 곱해 타원 면적으로 만듭니다.
`lrRatio` 는 **CH4·CH5(좌우 채널)로 x-z 평면 타원을 맞춰** 구합니다
(`PiezoBVEstimator.kt:289~390`). 구할 수 없으면:
| 상황 | `lrRatio` |
|---|---|
| 좌우 채널 미검출 | **1.0** (원으로 간주) |
| 타원 피팅 실패 | **1.2** |
> 즉 **좌우 정렬을 맞추면 BV가 더 정확해집니다.** 좌우가 안 잡히면 방광을 원으로 놓고
> 계산합니다.
#### ④ 세로 좌표 + y-z 타원 피팅
각 채널의 단면 중심을 세로축(y)에 올립니다:
```kotlin
dMid = (dAnt + dPost) / 2
y = sensZ + dMid × sin(θ) // 센서 높이 + 빔 기울기만큼의 세로 이동
```
그리고 **전벽·후벽 좌표 2n개**로 y-z 평면 타원을 맞춥니다(반복 outlier 제거 포함). 이
타원이 뒤에서 모자 높이를 정하는 근거가 됩니다.
#### ⑤ 몸통 — 절두원뿔 적분
y 오름차순으로 정렬한 뒤 인접 단면 사이를 절두원뿔로 채웁니다:
```
v = (Δy / 3) × (S₁ + S₂ + √(S₁·S₂))
vCore = Σ v
```
단순히 `(S₁+S₂)/2 × Δy` (사다리꼴)가 아니라 √ 항이 들어간 절두원뿔 공식입니다 — 면적이
급히 변할 때 사다리꼴은 과대 추정합니다.
#### ⑥ 모자 — 관측 밖의 위·아래
CH0~CH3 이 덮는 건 방광의 **가운데 띠**뿐입니다. 그 위아래는 관측되지 않으므로 모양을
가정해 덧붙입니다:
```
아래(vBottom) 구면 캡 π·h²·(3R−h)/3 × lrRatio
위(vTop) 원뿔 S_top × h / 3
```
모자 높이 `h` 는 ④의 타원 dome 에서 가져옵니다(`ellipseCapHeight = true`). 타원 피팅이
납작하게 무너졌으면 후벽 arc shrink 기반으로 폴백합니다.
**nch = 2 전용 상한** (`PiezoBVEstimator.kt:1444`, 레퍼런스 경로 전용):
검출 채널이 **정확히 2개**면 위/아래 채널 clamp 가 성립하지 않아(끝 채널이 서로의 이웃)
모자가 무제한으로 외삽됩니다. 그래서 빔 기하로 상한을 겁니다. **단 누운 자세(`supine`)는
건너뜁니다** — 그 자세에서는 적도가 배열 fan 아래로 내려가 "빔이 못 잡았다"가 "방광이
없다"를 뜻하지 않습니다.
> 이 한 줄이 실측에서 **199 mL ↔ 378 mL (1.9배)** 를 갈랐습니다. 2026-09-10 까지 정렬
> 화면이 `supine` 을 안 넘겨 두 화면의 BV가 다르게 계산되던 버그의 원인입니다
> (`ClinicalBvSupineTest` 가 고정).
#### ⑦ 합산
```kotlin
bvMm3 = vCore + vBottom + vTop
volumeMl = bvMm3 / 1000.0
```
### 1.5 세 화면의 BV — 알고리즘은 같고 조건이 다릅니다
세 곳 모두 `ClinicalBv.compute` 한 함수를 부릅니다. **레퍼런스(최종 보고본) 경로 고정**입니다.
| 화면 | 입력 | 조건 | 캡션 |
|---|---|---|---|
| 정렬 | **20 cycle 평균**(mean-scan) | 2.3MHz c3 고정 | `2cm · 2.3MHz c3 · 20회 평균` |
| 수동 확인 | 1 cycle | 2.3MHz c3 고정 | `마지막 측정 · 2.3MHz c3 · 1 cycle` |
| 프로토콜 | 1 cycle | **조합별**(1.8 / 2.3) | `직전 회차 · 1.8MHz c3 · 1 cycle` |
비교 가능성:
- **정렬 ↔ 수동 확인**: 같은 조건. 표본 수만 다름(20회 평균이 더 안정)
- **정렬 ↔ 프로토콜**: 복부 두께 **40mm 이하**면 프로토콜도 2.3MHz c3 이라 **같은 조건**.
40mm 초과면 1.8MHz 회차가 섞이므로 **2.3MHz 회차만** 비교
- 연속 측정 대표값은 **최근 10개의 절사평균**(최대·최소 하나씩 버림), 5개 이상 쌓여야 표시
---
## 2. 화면 그래프에 들어간 필터
구현: `ui/components/ChannelWaveform.kt`
### 2.1 결론 — 파형은 원신호, 세로선은 필터 후
```kotlin
// ChannelWaveform.kt
val y = h - buffer[i].toInt() * yScale // buffer = BLE 원본 UShort
```
| 화면 요소 | 무엇인가 |
|---|---|
| **파형 곡선** | BLE 로 받은 **원신호 그대로**. SG·TGC 전혀 안 들어갑니다 |
| y축 | **0~4095 고정** (자동 스케일 아님 — 채널끼리 비교하려면 축이 같아야 합니다) |
| x축 | 샘플 번호 0~99 |
| **초록 세로선** | `ant` — 필터·TGC·CCC 를 다 거친 전벽 |
| **주황 세로선** | `post` — 같음 |
| 좌상단 숫자 | `a30 p62` = 전벽·후벽 샘플 인덱스 |
| 우상단 숫자 | 그 채널 원신호의 **최대값** (신호 세기 가늠용) |
> ⚠ **그래서 눈으로 본 파형의 봉우리와 세로선이 정확히 안 맞을 수 있습니다.** 세로선은
> 평활·TGC 후의 봉우리 위치이고, 파형은 보정 전입니다. 세로선이 "봉우리에서 살짝 비켜
> 있다"면 정상입니다.
### 2.2 검출 쪽에 들어가는 필터 — 두 가지를 같이 씁니다
`walldetect/algo/methodd/MethodDPreprocessing.kt`
```kotlin
const val SG_WIN = 7 // 창 길이
const val SG_POLY = 3 // 다항식 차수
light = SG(7,3) // 벽 피크·소수점 보정용
heavy = SG(7,3) + OS-CFAR 반복 중앙값(창 9, 4회) // 어두운 구간(span) 검출용
```
**왜 둘인가**
- **heavy** — 링잉(잔향)과 스페클을 더 뭉개 **오줌 구간의 경계를 안정적으로** 찾습니다.
많이 뭉갰으니 봉우리 위치는 믿을 수 없습니다.
- **light** — 덜 뭉개서 **봉우리 위치가 정확**합니다. 어두운 구간을 찾기엔 거칩니다.
**Savitzky-Golay(SG)** 는 구간마다 3차 다항식을 최소제곱으로 맞춰 그 값을 쓰는 평활입니다.
이동평균과 달리 **봉우리 높이를 깎지 않습니다** — 벽 높이가 판정 기준이라 중요합니다.
**OS-CFAR 반복 중앙값**은 창 9짜리 중앙값 필터를 4번 반복해 뾰족한 잡음을 지웁니다.
### 2.3 TGC — 깊이 감쇠 보정
`walldetect/algo/methodd/MethodDTgc.kt`
초음파는 깊이 들어갈수록 약해집니다. 보정 없이는 후벽이 전벽보다 항상 흐려 보입니다.
```
① 채널별로 직선 맞춤 → 기울기 slope (보통 음수: 깊을수록 어두움)
② ratio 결정
|slope| < 3.0 → ratio = 1.0 (보정 안 함)
3.0 ≤ |slope| ≤ 15.0 → ratio = 1 − 0.9 × (|slope|−3)/(15−3)
|slope| > 15.0 → ratio = 0.1 (하한)
③ target_slope = slope × ratio
④ 보정량 = (target_slope − slope) × x
⑤ 보정된 신호 = 원신호 + 보정량
```
즉 **기울기를 `ratio` 배로 눌러 평탄하게** 만듭니다. `ratio` 가 작을수록 강한 보정입니다.
주의할 점 둘:
- **`slope > 0` 인 채널은 건너뜁니다** — 깊을수록 밝아지는 건 비정상이라 보정하면 더 악화
- 보정은 **기울기에 비례**합니다. 감쇠가 약한 채널(`|slope| < 3`)은 손대지 않습니다
TGC 는 `light`·`heavy` **양쪽에 각각** 적용됩니다(`MethodDRunner.kt:125-126`).
### 2.4 정렬 화면의 추가 평균
정렬은 한 위치에서 **20 cycle** 을 받아 `mean-scan`(채널별 샘플 평균)을 만든 뒤 검출합니다.
거기에 **슬라이딩 창 10** 으로 11개 trace 를 만들어 CH3 검출률을 계산합니다
(`AnchorGuide.measure`).
```
20 cycle ──► mean-scan ──► 검출 ──► nch, ch3, cap_frac
└─► 10칸 슬라이딩 11 trace ──► 각각 검출 ──► CH3 검출률
```
---
## 3. 정렬 — 최적 위치 판정
구현: `managers/AnchorGuide.kt` · Python 원본 `vesiscan_test/library/alignment_runners.py`
### 3.1 위치마다 내는 지표 셋
| 지표 | 뜻 | 어떻게 |
|---|---|---|
| `nch` | CH0~CH3 중 벽이 잡힌 채널 수 (0~4) | mean-scan · **CCC off** |
| `ch3` | CH3(제일 기울어진 채널) 검출 여부 | mean-scan · **CCC off** |
| `ch3_rate` | CH3 가 잡힌 trace 비율 (11개 중) | trace별 · **CCC off** |
| `cap_frac` | 모자가 차지하는 비중 | mean-scan · **CCC on** |
> **검출 커버리지는 CCC off, 기하는 CCC on** — 일부러 다릅니다. CCC 는 이웃 채널로 약한
> 채널을 복구하므로, 커버리지를 CCC on 으로 재면 "이 위치가 실제로 잡은 채널 수"가 아니라
> "복구해서 만든 수"가 됩니다.
**`cap_frac`** (`AnchorGuide.kt:135`):
```kotlin
cap_frac = (위 모자 + 아래 모자) / (채널 y 범위 + 모자)
```
0에 가까우면 **관측 단면이 방광을 직접 덮은 것**, 1에 가까우면 **거의 전부 추정으로 채운
것**입니다. `cap_frac = 1.000` 은 기하가 무너진 신호입니다.
### 3.2 후보 자격 — 2단 게이트
```kotlin
eligible = ch3Mean && ch3Rate >= 0.80
```
**왜 둘 다 보나**: 율만 보면 mean-scan 에서 CH3 가 안 잡힌 위치가 통과합니다
(Python 실측 `p4_1 v1 cm2`: `ch3='X'` 인데 율 81%).
**왜 CH3 인가**: CH3 은 제일 기울어진 채널(R300 기준 −27°)로 **방광의 아래쪽**을 봅니다.
여기가 잡히면 프로브가 방광 위에 제대로 올라간 것이고, 안 잡히면 너무 위로 올라간 것입니다.
### 3.3 언제 멈추나 — 종료 조건 셋
`AnchorGuide.terminator()` · **먼저 걸리는 게 이깁니다**
| 조건 | 식 | 뜻 |
|---|---|---|
| ① 커버리지 붕괴 | `nch < maxNch − 1.0` | 잡히던 채널이 줄었다 = 방광을 지나쳤다 |
| ② ch3 소실 | `!eligible && 앞에 eligible 있음` | CH3 를 놓쳤다 |
| ③ 탐색 상한 | `cm >= 4` | 4cm 까지 왔다 |
`maxNch` 는 **eligible 한 위치만** 갱신합니다(`remember()`). 그래서 자격 미달 위치의 낮은
`nch` 가 기준을 끌어내리지 않습니다.
> ②는 "앞에 eligible 이 있을 때만" 걸립니다. 한 번도 CH3 가 안 잡혔으면 ③(상한)까지 갑니다.
### 3.4 최적 선택 — 3단 규칙
`AnchorSelection.selectBest()` (`AnchorGuide.kt:174`)
> **후보는 잰 위치 전부입니다** (2026-09-10 변경). 종전에는 "종료 조건이 걸릴 때까지"의
> 위치만 후보였습니다. 자세히는 [3.6](#36-실제-진행-순서).
```
① 후보 자격 ch3 == "O" AND ch3_rate ≥ 0.8
→ 통과가 0개면 → REATTACH (재부착)
② 규칙 A 남은 후보 중 nch 최대
③ 동률이면 cap_frac 최소 → 그래도 같으면 낮은 cm
```
입력은 **`cap_frac` 이 유한한 위치만**입니다 (`selectableRecords()`).
### 3.5 부착 위치 = 최적 + 1cm, 그 자리는 재지 않습니다
```kotlin
val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1
```
**단순 덧셈**입니다. 측정된 위치로 스냅하지 않습니다 — Python 의
`select_supine_anchor(offset_cm=)` 이 그 스냅을 하지만 그건 **회고 분석 전용**이고,
실시간 안내로는 틀립니다(실측 `p2_7 v2`: best=1 → 4cm 로 튐).
**오프셋 위치는 측정하지 않습니다.** 설계상 그 자리는 지표가 나쁜 쪽이라, 재서 문턱으로
쓰면 "미달"이 떠서 도달 전에 멈춥니다.
### 3.6 실제 진행 순서
**자세마다 처음부터 다시 합니다**(2026-10-01). Supine 정렬 → Supine 0~100% 측정 → Sitting
정렬 → Sitting 0~100% … 부착 위치·재부착 확인·정렬 폴더(`align_{자세}/`)·labdb 세션이
전부 자세별입니다.
```
치골 위 0cm 에 붙이고 측정 → 치골 위 1cm 에 붙이고 측정
→ 치골 위 2cm 에 붙이고 측정 (판정이 나빠져도 멈추지 않습니다)
→ … 4cm 까지
↓
잰 위치 전부로 판정 → best = N
↓
치골 위 N cm 에 다시 붙이고 측정 (확인 측정 = 확정 신호 · 이미 그 자리면 "에서 한 번 더 측정")
↓
치골 위 N+1 cm 에 붙이세요 (초록 · 여기서는 측정하지 않음)
↓
좌우 정렬 (치골 위 N+1 cm 그대로) ← 흐름 전체 + 확인 완료 순간의 창을 저장
↓
(위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기)
↓
재부착 확인 — 병원 화면 정렬 카드에서 20 cycle 재서 정렬 확인 측정과 CH0~3 을 채널별로 대조
(2026-09-29 임상: 이 사이에 CH2 가 사라졌다). 경고만, 막지 않음. 기록에 남음.
```
지시문의 숫자는 **치골 위 절대 위치 하나**입니다(2026-09-29). "2cm 내리고" 같은 상대
이동과 방향 화살표는 판정 숫자와 나란히 보여 헷갈린다는 현장 의견으로 뺐습니다.
[여기까지만 재고 판정하기] 로 수집을 중단할 수 있습니다 — 그 시점까지의 위치로 판정합니다.
#### 판정 범위가 바뀌었습니다 (2026-09-10)
| | 종전 | 현재 |
|---|---|---|
| 측정 | 종료 조건 걸리면 중단 | **0~4cm 무조건 전부** |
| 후보 | 종료까지의 위치 | **잰 위치 전부** |
| 선택 규칙 | 3단 규칙 | **그대로** |
| 부착 | best + 1cm 단순 덧셈 | **그대로** |
| 종료 조건 | 판정을 끝냄 | **진단용** (`decided_at_cm`) |
**왜 바꿨나.** 단일 pass 는 지표가 cm 에 대해 **단봉(unimodal)** 이라고 가정해, 나빠지는
순간 봉우리를 지난 것으로 보고 멈춥니다. 그런데 장내 가스·접촉 불량·호흡으로 **한 위치만**
일시적으로 나빠져도 멈추므로, 더 위가 진짜 좋았는데 놓칠 수 있습니다(알고리즘 팀 질의).
실측에 징후가 있었습니다. 2026-09-09 `kai` 는 **0cm `ch3=X` → 1cm `ch3=O 91%`** 로 지표가
**올라갔습니다.** 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만,
**중간에서는 한 칸만 나빠도 끝났습니다.** 같은 날 `181128` 이 정확히 그 경우로, 1cm 에서
`nch 2→0`·`ch3 100%→0%` 가 되어 `best=0cm` 으로 끝났고 2cm 이후는 알 수 없었습니다.
**레퍼런스는 어떻게 되나.** 선택 규칙 `selectBest` 는 Python `select_supine_anchor` 와
1:1 이고 200 케이스로 대조돼 있습니다(`AnchorSelectionParityTest`) — **손대지 않았습니다.**
바뀐 것은 후보 집합뿐이고, **Python 의 회고 분석 경로가 이미 전 위치로 판정**하므로 같은
방식으로 재현·대조할 수 있습니다. 다만 최종 보고서의 "단일 pass" 기술은 갱신이 필요합니다.
**감사 가능하게 둘 다 기록합니다:**
```json
"selection_scope": "full_sweep",
"best_cm": 3, // 앱이 쓴 값 = 전 위치 판정
"anchor_cm": 4, // best + 1
"best_cm_single_pass": 0, // 옛 단일 pass 가 냈을 답
"single_pass_disagrees": true,
"decided_at_cm": 1, // 종료 조건이 걸린 위치 (진단용)
"swept_to_cm": 4
```
갈리면 화면에도 참고 문구가 뜹니다:
> 참고 — 중간에 지표가 나빠진 적이 있습니다. 거기서 멈추는 옛 방식이면 **0cm** 이 뽑혔을
> 자리입니다. 지금은 잰 위치 전부로 골라 **3cm** 입니다. 기록에 둘 다 남습니다.
`single_pass_disagrees` 빈도가 곧 **"단봉 가정이 얼마나 깨지는가"** 의 지표입니다.
#### 확인 측정은 판정 데이터를 덮지 않습니다
판정된 자리로 내려가 다시 재는 측정은 `align_2cm_confirm.csv` 로 따로 저장합니다. 같은
이름에 쓰면 **판정에 쓰인 데이터가 확인 측정으로 덮여**, 파일로 재판정하면 앱과 답이
갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 **항상** 일어나므로 중요합니다.
#### 기록에 후보 경계를 남깁니다
`decided_at_cm` 은 이제 **판정 경계가 아니라 진단값**입니다 — 종료 조건이 어디서 걸렸는지
(= 지표가 어디서 무너졌는지)를 말합니다. `positions[].in_decision` 도 같은 기준이라,
**현재 판정은 `in_decision` 과 무관하게 전 위치를 씁니다.**
### 3.7 좌우 정렬 — 부착 높이에서
`managers/LateralGuide.kt` + `AlignmentAdvice.advise`
10프레임 누적 → 평균 → 검출(**CCC on**) → 판정. 규칙은 4분기입니다:
| 상태 | 판정 |
|---|---|
| **CH3 미검출** | **↑ 머리방향** — 높이가 흘렀다. 델타는 보지도 않는다 |
| 둘 다 검출 · `\|u4−u5\| ≤ 8` | **■ 완료** |
| 둘 다 검출 · `u4 < u5` | **→ 우측** |
| 둘 다 검출 · `u5 < u4` | **← 좌측** |
| 둘 다 미검출 | **↔ 좌우로 천천히 탐색** |
| 한쪽만 미검출 | 죽은 쪽 반대로 (CH4 없으면 →) |
한 문장으로: **`urine_len` 이 긴 쪽으로 간다.** 짧은 쪽이 방광 바깥으로 벗어난 쪽입니다.
> `LAT_TOL = 8` 은 **샘플 인덱스** 단위입니다. mm 가 아닙니다.
> 위치를 옮기면 버퍼를 비우므로 **옮긴 직후엔 화살표가 잠깐 안 나옵니다** — 고장이 아닙니다.
---
## 4. 전벽·후벽 검출
구현: `walldetect/MethodDDetector.kt` + `walldetect/algo/methodd/*` · Python `method_d/detector.py`
### 4.1 핵심 발상 — 벽을 먼저 찾지 않습니다
**오줌은 균질해서 반사가 거의 없습니다.** 파형에서 푹 꺼진 구간이 오줌이고, 벽은 그 구간의
**경계**에 있습니다. 그래서 어두운 구간을 먼저 찾고, 그 양 끝 근처에서만 벽을 고릅니다.
```
Stage 1 평활 (SG heavy / light)
Stage 2 어두운 구간(span) 찾기 ← 오줌
Stage 3 구간 양 끝에서 벽 후보 점수
Stage 3.5 벽/내부 비 게이트 + 최대 2회 복구
Stage 4 TGC 오검출 게이트
Stage 5 소수점 보정
```
### 4.2 Stage 2 — 어두운 구간 찾기
`MethodDSpan.extractLowEchoSpan`
```
1) Otsu 문턱 × 0.88 = low_amp
2) heavy ≤ low_amp 인 연속 구간 (길이 ≥ 3)
3) 가까운 구간끼리 합침 (간격 ≤ 3, 단 그 사이 봉우리 돌출도 ≥ 50 이면 합치지 않음)
4) 첫 후보 채택 (길이 ≥ 5)
5) 후벽 검색 상한(샘플 100) 초과면 기각
6) 양쪽에서 골짜기 평탄부까지 안으로 좁힘 (창 4, 기울기 허용 10)
7) 좁힌 결과가 5 미만이면 기각
```
**Otsu** 는 히스토그램에서 밝은 쪽/어두운 쪽을 가르는 문턱을 **자동으로** 잡습니다.
고정 문턱이 아니라 그 파형 안에서 상대적으로 어두운 곳을 찾으므로, 이득이 달라도 따라갑니다.
**heavy 실패 시 light 로 재시도**합니다. 단 light 의 Otsu 분리도가 0.75 미만이면 폴백을
거부합니다 — 분리가 안 되는 신호에서 억지로 구간을 만들면 엉뚱한 벽이 나옵니다.
### 4.3 Stage 3 — 후보 점수
`MethodDWallSelect` · **light** 신호에서 고릅니다
```
탐색창 전벽: 구간 시작 ±18 샘플 후벽: 구간 끝 ±15 샘플
후보 두 종류
peak 국소 최대
shoulder 2차미분 국소최소 & 음수 (봉우리는 아닌데 꺾이는 곳)
±1 샘플 안의 peak 와 중복이면 제거
점수 = 돌출도^1.5 / (1 + 0.1 × |후보 − 구간끝|)
돌출도 = 신호[후보] − (후보↔구간끝 사이 최저점)
```
| 설계 의도 | 어떻게 |
|---|---|
| 절대 높이가 아니라 **얼마나 솟았는지** | 돌출도(prominence) |
| **가까운 쪽 우선** | 분모가 거리 |
| 큰 봉우리를 더 크게 | 지수 1.5 |
| 어깨는 불리하게 | 최소 돌출도 100(봉우리는 50) + 점수 **15% 페널티** |
어깨 페널티는 **선이 봉우리↔어깨 사이에서 토글하는 것을 막기 위한** 데드밴드입니다.
> 후벽 전용 예외: 진짜 봉우리가 탐색창 끝에 걸리면 국소 최대 확정에 실패해 어깨로
> 오분류됩니다. 그래서 **후벽은 확정용 view 만 2샘플 넓힙니다**(수용 범위는 그대로).
> 전벽은 적용하지 않습니다 — 경계 확장이 전벽을 피부 쪽으로 흔들어 채널을 탈락시킵니다.
### 4.4 Stage 3.5 / 4 — 게이트
| 검사 | 기준 | 왜 |
|---|---|---|
| 벽/내부 진폭비 | ≥ **1.16** | 벽이 오줌 구간보다 이만큼은 밝아야 |
| raw 후벽 비 | ≥ **1.08** | **TGC 로 부풀린 가짜 후벽** 걸러냄 (원신호로 검사) |
| 오줌 길이 | ≥ **10 샘플** | 너무 짧으면 오줌이 아님 |
| 후벽 위치 | ≤ **100 샘플** | 버퍼 밖 |
떨어지면 **바깥으로 더 센 봉우리를 최대 2번 다시 찾고**(recovery), 그래도 안 되면
**그 채널은 미검출** — 파형에 세로선이 안 그려집니다.
### 4.5 Stage 5 — 소수점 보정
정수 샘플로는 1.9mm 단위라 거칩니다. 봉우리는 **포물선 맞춤**, 어깨는 **2차미분 맞춤**으로
소수점 위치를 냅니다 → `antRefined` / `postRefined`.
> ⚠ **화면 세로선은 정수 `ant`/`post`, BV 계산은 소수 `antRefined`/`postRefined`** 입니다.
> 최대 1샘플(≈1.9mm) 차이라 눈으로는 같은 자리지만, **선 위치를 재서 BV를 손으로 검산하면
> 미세하게 안 맞습니다.**
### 4.6 채널 간 교차보정 (CCC)
임상 화면은 `applyCross = true` 로 돕니다. 한 채널만 보고 끝내지 않습니다.
**레퍼런스 경로와 기존 경로의 단계가 다릅니다** (`MethodDRunner.kt:210~262`):
| 단계 | 기존 | 레퍼런스(병원 임상) |
|---|---|---|
| 포락선 재탐색 | — | **①.9 맨 앞** |
| 전벽 tie-break | ✓ | ✓ |
| λ 후벽 복구 | — | ✓ |
| edge 후벽 복구 | — | ✓ |
| lumen 반사 복구 | — | ✓ |
| 이웃 최상단 검증 | Rule A+B | **Rule A 만** |
| FP 채널 게이트 | — | ✓ (Rule B 를 이쪽으로 옮김) |
포락선 재탐색이 **맨 앞**인 이유: 탐색창이 진짜 벽을 배제한 경우를 고치는 단계라, 이후
재탐색들이 재순위를 매기기 전에 후보를 먼저 확보해야 합니다.
> 그래서 **한 채널의 선이 이웃 채널 때문에 움직일 수 있습니다.**
### 4.7 현장에서 읽는 법
| 증상 | 원인 |
|---|---|
| 선이 **없다** | 게이트 탈락 — 벽/내부 비 1.16 미달. 접촉·젤·이득 확인 |
| 후벽이 **너무 가깝다** | 오줌 구간을 작게 잡음 — 충만도 낮음 또는 빔이 방광 끝을 스침 |
| 후벽이 **너무 멀다** | 장이나 반사를 후벽으로 오인 — `cap_frac` 이 1.0 포화면 이 경우 |
| 채널마다 **들쭉날쭉** | 프로브 기울었거나 한쪽이 떴음 — 채널 접촉 패널 확인 |
| 선이 봉우리에서 **살짝 비켜** | 정상 — 파형은 원신호, 선은 평활·TGC 후 |
---
## 5. 한 장 요약
### 숫자
| 항목 | 값 |
|---|---|
| SG 필터 | 창 **7**, 차수 **3** |
| OS-CFAR | 창 **9**, 반복 **4** |
| TGC 기울기 문턱 / 상한 / ratio 하한 | **3.0** / **15.0** / **0.1** |
| Otsu 비율 | **0.88** |
| 벽 탐색창 (전/후) | **±18** / **±15** 샘플 |
| 점수 지수 / 거리감쇠 | **1.5** / **0.1** |
| 최소 돌출도 (봉우리/어깨) | **50** / **100**, 어깨 페널티 **15%** |
| 벽/내부 비 · raw 후벽 비 | **1.16** · **1.08** |
| 최소 오줌 길이 | **10** 샘플 |
| dps / delay (V계열) | **1.936** mm / **6.85** mm |
| dps / delay (R계열) | **1.897** mm / **7.651** mm |
| CH3 검출률 임계 | **0.80** |
| nch 붕괴 문턱 | **1.0** |
| 탐색 상한 / 오프셋 | **4** cm / **+1** cm |
| 정렬 cycle / 슬라이딩 창 | **20** / **10** (→ 11 trace) |
| 좌우 허용 \|Δu\| / 누적 프레임 | **8** 샘플 / **10** |
| 연속 측정 창 / 최소 표시 | **10** / **5** |
### 기억할 것 다섯
1. **파형은 원신호, 세로선은 필터 후.** 살짝 안 맞는 게 정상입니다.
2. **프리셋이 틀리면 BV가 통째로 틀립니다.** `⚠ 기본값 추정` 이 뜨면 멈추세요.
3. **벽보다 오줌을 먼저 찾습니다.** 선이 없으면 "어두운 구간을 못 찾았다"가 먼저입니다.
4. **좌우를 맞추면 BV가 더 정확합니다.** 좌우 미검출이면 방광을 원으로 놓고 계산합니다.
5. **정렬 BV와 프로토콜 BV는 조건이 다를 수 있습니다.** 캡션의 `MHz·cycle` 을 보세요.
### 관련 파일
| 주제 | 파일 |
|---|---|
| BV 기하 | `managers/PiezoBVEstimator.kt` |
| BV 진입점 | `managers/ClinicalBv.kt` |
| 정렬 판정 | `managers/AnchorGuide.kt` |
| 좌우 정렬 | `managers/LateralGuide.kt`, `managers/AlignmentAdvisorV2.kt` |
| 벽 검출 | `walldetect/MethodDDetector.kt`, `walldetect/MethodDRunner.kt` |
| 전처리·TGC | `walldetect/algo/methodd/MethodDPreprocessing.kt`, `MethodDTgc.kt` |
| 구간·후보 | `walldetect/algo/methodd/MethodDSpan.kt`, `MethodDWallSelect.kt` |
| 파라미터 | `walldetect/algo/methodd/MethodDParams.kt` |
| 그래프 | `ui/components/ChannelWaveform.kt` |
| Python 원본 | `piezo-phantom-test/vesiscan_test/library/alignment_runners.py`, `method_d/`, `volume/` |
### 레퍼런스 대조
Python ↔ Kotlin 일치를 고정하는 테스트 (총 **139개** 통과, 2026-09-10):
| 테스트 | 무엇을 고정하나 |
|---|---|
| `AnchorMeasureParityTest` | 위치별 지표 산출식 |
| `AnchorSelectionParityTest` | 3단 선택 규칙 (200 케이스 sweep) |
| `AnchorSweepTest` | 수집이 판정을 바꾸지 않음 |
| `ClinicalBvTest` | 정렬 `cap_frac` 과 BV 가 **같은 경로** |
| `ClinicalBvSupineTest` | `supine` 이 결과를 바꾼다(호출부가 빠뜨리면 드러나야) |
| `BvProductPathParityTest` | 제품 경로 BV 값 (155.4257 mL ±0.05) |
| `AlgoModeSwitchTest` | 레퍼런스 ↔ 기존 경로 차이 |