현장 의견(2026-09-29): 판정 뒤 "최적 N cm" 옆에 "아래로 2cm 내리세요" 가 붙어 나와
어디에 붙이라는 건지 헷갈린다. 2026-09-09 에 "최적 위치/부착 위치" 두 숫자가 헷갈려
상대 이동으로 바꿨는데, 상대 이동도 판정 숫자와 나란히 보이면 다시 숫자가 둘이 된다.
치골은 손으로 짚는 기준점이라 **치골 위 절대 위치 하나**만 말한다. 버튼의 숫자
("N cm 측정하기")와 같아 서로를 확인해 주고, 셀 것도 비교할 것도 없다.
수집 치골 위 N cm 에 붙이고 측정
확인 측정 치골 위 N cm 에 다시 붙이고 측정 (이미 그 자리면 "에서 한 번 더 측정")
확정 치골 위 N+1 cm 에 붙이세요 — 재지 말고 좌우로
재부착 더 아래에 다시 붙이세요 (갈 자리가 없어 이것만 상대적)
화살표(↑↓●)는 남긴다 — 글에는 방향을 쓰지 않고 눈으로만 거든다. 좌우 단계 안내도
"1cm 올린 자리" 대신 "치골 위 N cm 에 붙인 그대로" 로.
시험: AlignInstructionTest 문구 갱신 + "재부착 말고는 상대 이동을 말하지 않는다" 추가.
docs/CLINICAL_ALGORITHM.md §3.6 흐름을 새 문구로.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
28 KiB
병원 임상 측정 — 알고리즘 상세
대상:
demo-final브랜치, 2026-09-10 (2e5a1f1이후) 기준. 이 문서는 코드를 읽고 쓴 것이고, 각 항목에 파일·라인을 달았습니다. 값이 의심되면 해당 파일을 보는 것이 이 문서보다 우선입니다.
목차
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 |
CH0 |
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)
후보는 잰 위치 전부입니다 (2026-09-10 변경). 종전에는 "종료 조건이 걸릴 때까지"의 위치만 후보였습니다. 자세히는 3.6.
① 후보 자격 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 실제 진행 순서
치골 위 0cm 에 붙이고 측정 → ↑ 치골 위 1cm 에 붙이고 측정
→ ↑ 치골 위 2cm 에 붙이고 측정 (판정이 나빠져도 멈추지 않습니다)
→ … 4cm 까지
↓
잰 위치 전부로 판정 → best = N
↓
↓/↑ 치골 위 N cm 에 다시 붙이고 측정 (확인 측정 = 확정 신호 · 이미 그 자리면 "에서 한 번 더 측정")
↓
↑ 치골 위 N+1 cm 에 붙이세요 (초록 · 여기서는 측정하지 않음)
↓
좌우 정렬 (치골 위 N+1 cm 그대로)
지시문의 숫자는 치골 위 절대 위치 하나입니다(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" 기술은 갱신이 필요합니다.
감사 가능하게 둘 다 기록합니다:
"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 |
기억할 것 다섯
- 파형은 원신호, 세로선은 필터 후. 살짝 안 맞는 게 정상입니다.
- 프리셋이 틀리면 BV가 통째로 틀립니다.
⚠ 기본값 추정이 뜨면 멈추세요. - 벽보다 오줌을 먼저 찾습니다. 선이 없으면 "어두운 구간을 못 찾았다"가 먼저입니다.
- 좌우를 맞추면 BV가 더 정확합니다. 좌우 미검출이면 방광을 원으로 놓고 계산합니다.
- 정렬 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 |
레퍼런스 ↔ 기존 경로 차이 |