Files
VesiscanClinicalAndroid/docs/CLINICAL_ALGORITHM.md
T
dw.jang b71a04374f feat(clinical): 전수 판정을 같이 계산해 기록 · 확인 측정이 판정 데이터를 덮지 않게
알고리즘 설계자가 물었다 — "0cm·1cm 만 쟀는데 0cm 가 최적으로 나왔다. 만약 3cm 가
최적이면?"

## 이건 버그가 아니라 설계 가정이다

단일 pass 는 **지표가 cm 에 대해 단봉**이라고 가정한다. 올라가다 나빠지면 봉우리를 지난
것으로 보고 멈춘다. Python 도 같다. 물리적 근거는 있다 — CH3 은 제일 기울어진 채널로
방광 아래쪽을 보므로 위로 가면 점점 벗어난다.

깨질 수 있는 경우는 **한 위치만** 일시적으로 나빠지는 것이다: 장내 가스가 CH3 을 가림,
접촉이 뜸, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데 조기 종료한다.

실측에 징후가 있다. 2026-09-09 `kai` 는 **0cm ch3=X → 1cm ch3=O 91%** 로 지표가
올라갔다. 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만 **중간에서는
한 칸만 나빠도 끝난다.** 같은 날 `181128` 이 정확히 그 경우다 — 1cm 에서 nch 2→0,
ch3 100%→0% 로 두 조건이 동시에 걸려 best=0cm, 2cm 이후는 모른다.

## 판정은 바꾸지 않고 **측정 가능하게** 만든다

규칙을 바꾸면 레퍼런스 대조(단일 pass 기준)를 다시 해야 한다. 그건 알고리즘 팀이 숫자를
보고 정할 일이다. 그래서 전 위치로 고르면 어디가 뽑히는지 **같이 계산해 기록한다**:

    "best_cm": 0,                  앱이 쓰는 값 (단일 pass · 레퍼런스)
    "best_cm_full_sweep": 3,       잰 위치 전부로 고르면
    "sweep_disagrees": true

갈리면 화면에도 참고 문구가 뜬다 — 그 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을
택할 수 있다. 어제 전수 측정으로 바꾼 것이 이 답을 내는 전제였다(조기 종료하면 비교할
위치가 없다).

## 확인 측정이 판정 데이터를 덮고 있었다

부착 위치로 내려가 한 번 더 재면 `align_2cm.csv` 를 **덮어썼다.** 화면 판정은 먼저 잰 것
기준인데 파일은 나중 것이라, 파일로 재판정하면 앱과 답이 갈릴 수 있다. 조기 종료 시절에는
드물었지만 전수 측정으로 바꾼 뒤에는 **항상** 일어난다.

`align_2cm_confirm.csv` 로 분리했다. 판정 근거가 파일에 그대로 남는다.

테스트 139개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:05:16 +09:00

28 KiB
Raw Blame History

병원 임상 측정 — 알고리즘 상세

대상: demo-final 브랜치, 2026-09-10 (af16541) 기준. 이 문서는 코드를 읽고 쓴 것이고, 각 항목에 파일·라인을 달았습니다. 값이 의심되면 해당 파일을 보는 것이 이 문서보다 우선입니다.

목차

  1. BV(방광 용적) 계산
  2. 화면 그래프에 들어간 필터
  3. 정렬 — 최적 위치 판정
  4. 전벽·후벽 검출
  5. 한 장 요약

0. 전체 흐름

프로브 6채널 ADC (각 100 샘플, 0~4095)
        │
        ├──────────────────────────────► 화면 그래프 (원신호 그대로, 필터 없음)
        │
        ▼
  ① SG 필터 (7,3)  ──► light
        │
        └─ + OS-CFAR 반복 중앙값 ──► heavy
        ▼
  ② TGC (깊이 감쇠 보정)
        ▼
  ③ 채널별 벽 검출 (Method D) ──► ant / post  ──► 화면 세로선
        ▼
  ④ 채널 간 교차보정 (CCC)
        ▼
  ⑤ 기하 적분 (BV) ──► mL

중요: 화면에 보이는 파형은 ①②를 거치지 않은 원신호입니다. 세로선만 ①~④를 거친 결과입니다. 자세히는 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).

② 단면 지름 — 각도 보정

lRaw = dPost − dAnt                  // 빔 방향 길이
D    = lRaw × cos(θ)                 // 수평 단면 지름으로 투영

빔이 기울어 쏘므로 빔 방향 길이가 곧 지름이 아닙니다. cos θ 로 눕힙니다.

③ 단면적 — 좌우 비율이 들어갑니다

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)에 올립니다:

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 가 고정).

⑦ 합산

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 결론 — 파형은 원신호, 세로선은 필터 후

// 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

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 CH0CH3 중 벽이 잡힌 채널 수 (04) 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):

cap_frac = (위 모자 + 아래 모자) / (채널 y 범위 + 모자)

0에 가까우면 관측 단면이 방광을 직접 덮은 것, 1에 가까우면 거의 전부 추정으로 채운 것입니다. cap_frac = 1.000 은 기하가 무너진 신호입니다.

3.2 후보 자격 — 2단 게이트

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)

① 후보 자격   ch3 == "O"  AND  ch3_rate ≥ 0.8
              → 통과가 0개면 → REATTACH (재부착)

② 규칙 A      남은 후보 중 nch 최대

③ 동률이면    cap_frac 최소  → 그래도 같으면 낮은 cm

입력은 cap_frac 이 유한한 위치만입니다 (selectableRecords()).

3.5 부착 위치 = 최적 + 1cm, 그 자리는 재지 않습니다

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-09-10 변경)

병원 임상은 위치를 고르는 것만이 목적이 아니라 위치별 원신호를 모으는 것도 목적입니다. 그래서 종료 조건이 걸려도 4cm 까지 전부 잽니다.

0cm 측정 → ↑ 1cm 올리고 측정
1cm 측정 → 종료 조건 걸림 (판정 완료, best 확정)
           ↑ 1cm 올리고 측정
           "부착 위치는 이미 정해졌습니다(1cm 에서 판정).
            4cm 까지는 데이터 수집용으로 잽니다 — 여기서 멈춰도 됩니다."
2·3·4cm 측정 (수집)
→ ↓ N cm 내리고 측정  (부착 위치로)
→ ● 여기서 한 번 더 측정   ← 확정 판정의 입력
→ ↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음)
→ 좌우 정렬

⚠ 판정은 수집 때문에 바뀌지 않습니다

selectBest 는 넘긴 레코드 전부를 후보로 봅니다. 후보가 늘면 답이 뒤집힐 수 있습니다:

0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4
  조기 종료 후보 {0,1} → best 0cm
  전부 넣으면         → best 2cm     ← 다른 답

안전한 이유는 AnchorGuide 가 searching 이 꺾이는 그 순간 한 번만 bestCm 을 계산하고, 이후 step() 은 recs 에만 쌓기 때문입니다. AnchorSweepTest 가 이 성질을 고정합니다.

단일 pass 가 가정하는 것 — 그리고 그 가정을 측정하는 방법

단일 pass 는 지표가 cm 에 대해 단봉(unimodal) 이라고 가정합니다. 올라가다 나빠지면 봉우리를 지난 것으로 보고 멈춥니다. Python 도 같습니다 — "수직 → 좌우 순서로 한 번만 가고 되돌아가지 않는다."

물리적 근거는 있습니다. CH3 은 제일 기울어진 채널로 방광 아래쪽을 보므로, 위로 올라가면 점점 방광을 벗어납니다.

깨질 수 있는 경우는 한 위치만 일시적으로 나빠지는 것입니다 — 장내 가스가 CH3 을 가림, 그 위치에서 접촉이 떴음, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데 조기 종료합니다.

실측에 징후가 있습니다. 2026-09-09 kai 세션은 0cm ch3=X → 1cm ch3=O 91% 로 지표가 올라갔습니다. 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만, 중간에서는 한 칸만 나빠도 끝납니다.

전수 측정이 이 질문에 답을 냅니다. 세션마다 두 값을 같이 기록합니다:

"best_cm": 0,                  // 앱이 쓰는 값 (단일 pass · 레퍼런스)
"best_cm_full_sweep": 3,       // 잰 위치 전부로 고르면
"sweep_disagrees": true        // 갈렸다

갈리면 화면에도 참고 문구가 뜹니다. 판정은 바꾸지 않습니다 — 레퍼런스 대조가 단일 pass 기준이라 규칙을 바꾸면 검증을 다시 해야 합니다. 몇십 세션의 sweep_disagrees 빈도를 보고 규칙을 바꿀지는 알고리즘 팀이 정합니다.

확인 측정은 판정 데이터를 덮지 않습니다

부착 위치로 내려가 한 번 더 재는 측정은 align_2cm_confirm.csv 로 따로 저장합니다. 같은 이름에 쓰면 판정에 쓰인 데이터가 확인 측정으로 덮여, 파일로 재판정하면 앱과 답이 갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 항상 일어나므로 중요합니다.

기록에 경계를 남깁니다 — 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, 다른 best 가 나와 앱이 틀린 것으로 읽힙니다:

"decided_at_cm": 1,
"swept_to_cm": 4,
"positions": [{ "align_cm": 2, "in_decision": false, ... }]

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 레퍼런스 ↔ 기존 경로 차이