From 2ec874d7f8b8bc389f89fbfdba861212a86c65a6 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 10 Sep 2026 11:14:24 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EB=B3=91=EC=9B=90=20=EC=9E=84=EC=83=81?= =?UTF-8?q?=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=E2=80=94=20BV=20=C2=B7=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=C2=B7=20=EC=A0=95=EB=A0=AC=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EB=B2=BD=20=EA=B2=80=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/CLINICAL_ALGORITHM.md 신규. 네 항목을 코드에서 읽어 정리했고 각 주장에 파일·라인을 달았다. ## ① BV 계산 샘플→mm 변환(프리셋별 dps·delay) → 각도 보정 단면 지름 → 타원 단면적(lrRatio) → y-z 타원 피팅 → 절두원뿔 적분 → 위/아래 모자. 공식과 상수를 그대로 적었다. ## ② 그래프 필터 — 제일 많이 오해할 지점 **화면 파형은 원신호다.** SG도 TGC도 안 들어간다(y축 0~4095 고정). 세로선만 SG·TGC·CCC 를 거친 결과다. 그래서 선이 봉우리에서 살짝 비켜 있는 것이 정상인데, 모르면 검출이 틀린 것으로 읽는다. SG(7,3) heavy/light 를 왜 둘 쓰는지, TGC 의 ratio 식도 적었다. ## ③ 정렬 판정 지표 넷(nch·ch3·ch3_rate·cap_frac)이 왜 CCC on/off 로 갈리는지, 종료 조건 셋의 우선순위, 3단 선택 규칙, 부착=best+1 을 왜 단순 덧셈으로 두는지(스냅은 회고 분석 전용), 2026-09-10 전수 측정으로 바꿨어도 판정이 안 바뀌는 이유. ## ④ 벽 검출 "벽보다 오줌을 먼저 찾는다"는 발상부터 5단계. Otsu 구간, 후보 점수식, 어깨 페널티가 데드밴드인 이유, 게이트 넷, 레퍼런스 경로의 CCC 단계가 기존과 다른 점. ## 문서에 꼭 넣은 세 가지 함정 · 화면 세로선은 정수 ant/post, BV는 소수 antRefined/postRefined — 손으로 검산하면 미세하게 안 맞는다 · 프리셋이 틀리면 BV가 통째로 틀린다(실측 125 ↔ 490 mL) · 좌우 미검출이면 lrRatio=1.0, 즉 방광을 원으로 놓고 계산한다 마지막에 상수 표 한 장과 "기억할 것 다섯", 레퍼런스 대조 테스트 목록을 붙였다. 인용한 테스트 7개와 값(155.4257 mL, anchor_cases 200건)은 파일로 확인했다. Co-Authored-By: Claude Opus 5 --- docs/CLINICAL_ALGORITHM.md | 614 +++++++++++++++++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 docs/CLINICAL_ALGORITHM.md diff --git a/docs/CLINICAL_ALGORITHM.md b/docs/CLINICAL_ALGORITHM.md new file mode 100644 index 0000000..ce28ba6 --- /dev/null +++ b/docs/CLINICAL_ALGORITHM.md @@ -0,0 +1,614 @@ +# 병원 임상 측정 — 알고리즘 상세 + +> 대상: `demo-final` 브랜치, 2026-09-10 (`af16541`) 기준. +> 이 문서는 **코드를 읽고 쓴 것**이고, 각 항목에 파일·라인을 달았습니다. 값이 의심되면 +> 해당 파일을 보는 것이 이 문서보다 우선입니다. + +## 목차 + +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`) + +``` +① 후보 자격 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-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` 가 이 성질을 +고정합니다. + +**기록에 경계를 남깁니다** — 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, +다른 best 가 나와 앱이 틀린 것으로 읽힙니다: + +```json +"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` | 레퍼런스 ↔ 기존 경로 차이 |