9665216aad
"치골 위 N cm" 하나만 말하기로 한 마당에 화살표가 남아 있으면 다시 방향을, 그러면 "얼마나"를 세게 된다(2026-09-29 현장 의견). AlignInstruction 에서 arrow 를 없앤다 — 좌우 정렬 단계의 ←/→/■ 는 좌우로 얼마나 움직일지 알려 주는 다른 것이라 그대로다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
653 lines
28 KiB
Markdown
653 lines
28 KiB
Markdown
# 병원 임상 측정 — 알고리즘 상세
|
||
|
||
> 대상: `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 실제 진행 순서
|
||
|
||
```
|
||
치골 위 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" 기술은 갱신이 필요합니다.
|
||
|
||
**감사 가능하게 둘 다 기록합니다:**
|
||
|
||
```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` | 레퍼런스 ↔ 기존 경로 차이 |
|