Files
VesiscanClinicalAndroid/docs/ALGO_MODE.md
T
dw.jang 441f249410 feat(algo): 미이식 3건 마저 이식 — post_tie_lock · sibeam · F83
레퍼런스에서 남겨 뒀던 세 가지를 옮겼다. 셋 다 레퍼런스 경로 전용이고 기존(현행
임상) 경로는 그대로다.

  · post_tie_lock  후벽 상위 두 후보의 score 가 구조적으로 붙는 구간(전 데이터
                   5773 결정 중 격차<10% 가 17.9%)에서 직전 trace 선택을 유지한다.
                   방향을 강제하지 않는다 — 깊은-peak 고정·인접 골 tie-break 은
                   전부 계통 편향을 낳았고, tie 밴드에서는 정의상 두 후보의 증거가
                   같기 때문이다. 상태기를 넘길 때만 동작하며, 레퍼런스도 streaming
                   strict pass 에서만 건다. streaming.py 이식 전까지 호출부는 없다.
  · _sibeam_cap_bounds  검출 채널이 정확히 2개일 때 미검출 이웃 빔까지의 SI 거리를
                   cap 상한으로. 2개뿐이면 끝 채널이 서로의 이웃이라 기존 top/bottom
                   clamp 가 성립하지 않아 cap 이 무제한 외삽된다. supine 은 건너뛴다.
  · F83            적도-밖 bottom cap 연속 보간(실리콘·supine). 전제인
                   mirror_bottom_cap 도 함께 옮겼다.

좌표계가 두 개라는 것을 확인했다

레퍼런스 cross_channel._z 는 sample_to_ap_depth 를 인자 없이 불러 교차채널 좌표가
언제나 모듈 기본값(1.936/6.85)으로 떨어진다. BV 는 프리셋 값을 명시로 넘긴다.
abs 는 두 값이 같아 표가 안 나지만 실리콘(1.897/7.651)에서는 갈린다 — 실측
f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로 갈려 439.88 vs 416.95 mL 이 됐다.
is_deep 판정이 34.209 > 34.042 처럼 아슬아슬한 자리라 작은 좌표 차가 선택을 뒤집는다.
의도인지 누락인지는 알고리즘팀 확인이 필요하다. 확인 전까지 레퍼런스와 같은 값을
내는 쪽을 택했고, 기존 경로는 프리셋 값을 그대로 쓴다.

missing_outside 의 null 과 빈 집합을 구분했다

null = 계산 안 함 → cap clamp 를 언제나 상한으로. 빈 집합 = 계산했고 '방광 밖'
채널이 없다 → 하한 쪽으로. ifEmpty { null } 로 뭉개면 "미검출이지만 span 은 있다"는
가장 흔한 경우에 si_floor 가 통째로 죽는다.

검증

  · 레퍼런스 대조 6종 전부 불일치 0 — tie 1568행(lock 24건) · 실데이터 BV 784행
    (nch=2 6건) · sibeam 120행(상한 102건) · 합성 BV 640행(mirror 226건) ·
    F83 blend 440행 · F83 apply 12행. 실데이터가 미러·nch=2 에 거의 안 닿아
    그 경로는 합성 입력으로 레퍼런스 함수를 직접 호출한 기대값으로 덮었다.
  · 기존 경로 무변경 — 44 cycle × CCC on/off 덤프가 HEAD 와 88행 전부 일치.
  · 전체 36개 통과 · demoDebug APK 빌드 성공.

LegacyEquivalenceDumpTest 가 PiezoHW.activePreset 을 명시로 고정하게 했다. 전역
가변 상태라 다른 테스트가 먼저 돌면 덤프가 통째로 달라진다 — 실제로 한 번 착시를
만들었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:09:26 +09:00

156 lines
9.7 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.
# 알고리즘 경로 두 갈래 (`AlgoMode`)
demo-final 은 팬텀 시연과 임상 계측이 함께 도는 브랜치다. 여기에 신 저장소
(`vesiscan_pre_product/vesiscan_android_user`)가 레퍼런스
(`piezo-phantom-test/vesiscan_test`)와 1:1 로 맞춘 검출·BV 단계들을 옮기되,
**기본 동작은 그대로 두고** dev 패널에서만 켤 수 있게 했다.
- 코드: [`managers/AlgoMode.kt`](../app/src/main/java/com/medithings/vesiscan/managers/AlgoMode.kt)
- UI: 계측 화면 dev 패널 → **알고리즘** 스위치 (기본 꺼짐)
- 상태는 프로세스 수명 동안만 유지된다. 앱을 다시 켜면 기존 경로로 돌아간다.
## "기존" 이 가리키는 것
`ebddc52`(검출·BV 레퍼런스 정합) 와 `20a48ad`(실리콘 cap 억제) 가 들어간 **현재
demo-final HEAD** 다. 2026-09-03 임상이 이 코드 위에서 돌았으므로, 그 값을 움직이는
변경은 기본 경로에 넣지 않는다.
따라서 아래 항목들은 **양쪽 공통**이며 모드와 무관하다:
| 항목 | 값 | 들어간 커밋 |
|---|---|---|
| `d_max` · `inward_walk_win` | 15 · 4 | `ebddc52` |
| prominence 기준 | span 바닥 (`ANT_FLOOR_REF`) | `ebddc52` |
| cap 축 붕괴 폴백 | `CAP_AXIS_RATIO_MAX` 0.85 / `CAP_AXIS_DMAX_MAX` 100 | `ebddc52` |
| `ELLIPSE_CAP_R_MAX` · `TOP_CAP_EDGE_K` | 0.92 · 0.6 | `ebddc52` |
| `cap_d_adaptive` (실리콘) | ("aspect", 80.0, 2.0) | `20a48ad` |
| dps · delay | 프리셋 유도 (v\*=1.936/6.85, r\*=1.897/7.651) | 기존 |
## 스위치를 켜면 바뀌는 것
부분 적용은 없다. 단계들이 서로를 전제하므로 섞으면 어느 쪽도 아닌 값이 나온다.
### 1. 교차채널 보정 (CCC) — 3단계 → 7단계
| | 기존 | 레퍼런스 |
|---|---|---|
| 순서 | `ant_tiebreak` → NTV(Rule A+B) → `inward_post` | ①.9 `envelope_research` → ① `ant_tiebreak` → ①.7 Λ-dip → ①.8 `edge_post_recovery` → ①.95 `lumen_return_recovery` → ② NTV(**Rule A 만**) → ③ `fp_channel_gate` |
| `inward_post` | 있음 | **없음** |
| NTV Rule B | 있음 | 없음 |
레퍼런스 쪽 데이터에서 각 단계가 결과를 바꾼 횟수(파이썬 ablation, 1512 케이스):
`ant_tiebreak` 158 · `edge_post_recovery` 73 · Λ-dip 7 · `fp_gate` 4 · 나머지 0.
### 2. 검출 파이프라인 — 4개 단계 추가
| 단계 | 하는 일 | 왜 |
|---|---|---|
| 2 `scale_recovery` | 검출 **실패** 채널만 SG5 로 재탐색 | window 를 전역으로 바꾸면 모든 벽이 함께 밀려 부피가 통째로 이동한다. 실패 채널만 건드려 커버리지(nch)만 얻는다. |
| 4 `missing_outside` | span 조차 없는 채널 = 진짜 방광 밖 | **CCC 앞**에서 계산해야 한다. CCC 가 복구한 채널은 방광 밖이 아니다. |
| 5b `channel_confidence` | 약한 채널을 CCC **복구 단계에서만** 숨김 | 판정(NTV·FP) 전에 되돌린다. 끝까지 숨기면 CCC 가 그 채널을 FP 로 기각할 기회를 잃어 오히려 살아남는다. |
| 6 `CONF_DEMOTE` | CCC 후 여전히 약한 채널을 단면에서 제외 | 남는 채널이 3 미만이 되면 아무것도 하지 않는다. |
신뢰도는 **비-TGC** `min(ant_amp, post_amp) / lumen_min` 을 trace 최고값으로 나눈 값이다.
TGC 로 재면 원거리 이득이 약한 채널을 정상 채널에 붙여 놓는다(CH1↔CH3 간격 TGC 0.05 vs
비-TGC 0.21).
### 3. BV
- **`si_floor` / `missing_outside`** — 극 cap clamp 의 방향이 관측 증거로 갈린다.
미검출 채널에 span 이 **없으면** 방광이 그 빔에 없다는 뜻이라 **상한**(종전),
span 이 **있으면** 방광이 거기까지는 온다는 뜻이라 **하한**이다. 외삽을 관측으로
바꾸는 것이다. `SI_FLOOR_WALK` 는 span 이 이어지는 가장 바깥 채널까지 걸어간다.
- **bottom cap 제한 신설** — top 만 있고 bottom 은 제약이 전혀 없었다. sitting 에서
CH3(치골)이 소실될 때 bottom cap 이 과외삽된다.
- **중앙 채널 보간 은행가 반올림** — 레퍼런스는 `int(round((int(prev)+int(next))/2.0))`
이다. 보간 채널은 subsample 정밀도를 가질 근거가 없다(이웃 둘의 정수 위치뿐).
실측 f01 tr11: 이 차이로 CH2 단면이 2971.0 vs 2788.2 mm² 로 갈렸다.
- **`adaptive_large_bladder_relax` 제거** — 레퍼런스에서 빠진 완화다. 남아 있는 동안
`b_si` 를 0.85·`a_ap` 로 바닥 처리해 cap 축 비를 정확히 0.85 로 만들었고, 그래서 바로
다음의 cap 축 폴백(`< 0.85`)이 **경계에서 발동하지 않았다**.
실측 PHANTOM mean: Python 160.03 mL vs 완화가 살아 있는 코틀린 232.49 mL.
폴백 재계산에는 `missing_outside`·`si_floor` 를 **넘기지 않는다** — 레퍼런스
`runners.estimate_bv` 의 폴백 호출이 그 인자를 빼고 부르기 때문이다.
실측 f04 cyc20: 넘기면 249.95 mL, 안 넘기면 278.43 mL(= 레퍼런스 값).
## 검증
| 무엇을 | 어떻게 | 결과 |
|---|---|---|
| 기존 경로 무변경 | `LegacyEquivalenceDumpTest` 가 `align_cm{0,1}.csv` 44 cycle × CCC on/off 를 검출→BV 로 돌려 덤프. HEAD 코드와 작업트리 코드의 덤프를 대조. | **88 행 전부 일치** |
| 토글 배선 | `AlgoModeSwitchTest` — 켜면 값이 달라지고 `missing_outside` 가 산출되며, 끄면 기존 값이 정확히 재현되는지 | 44 cycle 중 **40 개가 갈림**, 끈 뒤 완전 복귀 |
| 정렬 판정 | 현행 레퍼런스 `AnchorGuide(ver='v1')` 를 `data123` 3 세션에 돌려 대조 | nch·ch3·hit **전부 일치** (0cm 3/X/2·11, 1cm 4/O/8·11, 3cm 3/O/11·11) |
| 전체 | `:app:testDemoDebugUnitTest` | **30개 전부 통과** |
`LegacyEquivalenceDumpTest` 는 의도적으로 `AlgoMode` 를 참조하지 않는다 — HEAD 에는 그
타입이 없어서, 참조하면 대조 자체가 불가능해진다.
## 이번에 함께 고친 것
- **테스트 픽스처 경로** — 저장소가 갈리면서 `c:/Projects/medilightv2android/data123` 가
없어졌다. 5개 테스트가 `FileNotFoundException` 으로 조용히 실패하고 있었다.
`c:/Projects/vesiscan-design-archive/data123` 로 옮겼다.
- **`CenterAlignerValidationTest` 기대값** — 위 픽스처가 깨져 있어 `ebddc52` 이후 이
테스트가 한 번도 돌지 않았다. 다시 돌리니 hit 7→8 · 10→11 로 어긋났는데, **현행
레퍼런스를 같은 세션에 돌려 보니 8 · 11 이었다** — 코틀린이 맞고 기대값이 낡은
것이다(구 `v3_python_reference.py` 기준, 현재 저장소에 없음). 기대값을 현행 레퍼런스
산출값으로 갱신했다.
`bv_cv` 는 현행 레퍼런스가 더 이상 내지 않는 값이라 대조할 수단이 없다. "Python 과
bit-exact" 라던 종전 주석은 근거가 없어져 걷어내고, 성격을 **회귀 고정**으로 바꿔
적었다.
## 나중에 추가된 3건 (2026-09-03)
`post_tie_lock` · `_sibeam_cap_bounds` · F83 을 마저 옮겼다. 셋 다 레퍼런스 경로에서만
의미가 있다.
| 항목 | 어디 | 기존 경로에 미치는 영향 |
|---|---|---|
| `post_tie_lock` | `walldetect/PostTieLock.kt` | 없음 — 상태기를 **넘길 때만** 동작하고, 넘기는 호출부가 없다 |
| `_sibeam_cap_bounds` | `PiezoBVEstimator.kt` | 없음 — `AlgoMode.reference` 로 막았다 |
| 미러 + F83 | `PiezoBVF83.kt` | 없음 — `mirrorBottomCap` 을 켜는 호출부가 F83 뿐이다 |
`post_tie_lock` 은 후벽 상위 두 후보의 score 가 구조적으로 붙는 구간에서 **직전 trace 의
선택을 유지**한다(방향을 강제하지 않는다 — 그렇게 한 앞선 시도들은 전부 계통 편향을
낳았다). 레퍼런스도 streaming 경로의 strict pass 에서만 걸고 정렬에서는 쓰지 않으므로,
`streaming.py` 를 옮기기 전까지는 클래스와 배선만 자리를 잡아 둔 상태다.
### 좌표계가 두 개다 — 레퍼런스를 그대로 따랐다
레퍼런스 `cross_channel._z` 는 `sample_to_ap_depth(idx, angle)` 를 **인자 없이** 불러
교차채널 좌표가 언제나 모듈 기본값(1.936 / 6.85)으로 떨어진다. 반면 BV 는 프리셋 값을
명시로 넘긴다. abs(vr\*)는 두 값이 같아 표가 안 나지만 **실리콘(r\*: 1.897/7.651)에서는
갈린다** — 실측 f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로 갈려 부피가 439.88 vs
416.95 mL 이 됐다. `is_deep` 판정이 34.209 > 34.042 처럼 아슬아슬하게 걸리는 자리라
작은 좌표 차이가 그대로 선택을 뒤집는다.
의도인지 누락인지 **알고리즘팀 확인이 필요하다.** 확인 전까지는 레퍼런스와 같은 값을
내는 쪽을 택했고, **기존 경로는 프리셋 값을 그대로 쓴다** — 오늘 임상이 그 위에서 돌았다.
### 대조 결과
`ThreeFeatureParityTest` 가 모드 ON 상태에서 레퍼런스 산출 기대값과 대조한다.
실데이터는 abs 인체 0~4cm 라 미러·nch=2 에 거의 안 닿아, 그 경로는 합성 입력으로
**레퍼런스 함수를 직접 호출한** 기대값으로 덮었다.
| 대조 | 행 | 실제로 밟은 경로 |
|---|---|---|
| tie lock | 1568 | lock 발동 24 건 |
| 실데이터 BV | 784 | nch=2 6 건 |
| sibeam | 120 | 상한 산출 102 건 |
| 합성 BV | 640 | `mirror sphere` 226 건 |
| F83 blend | 440 | weight 0→1 |
| F83 apply | 12 | sub-window 재검출 |
전부 불일치 0.
## 아직 안 옮긴 것
`streaming.py`(연속 측정 상태기). `post_tie_lock` 의 자연스러운 소비처라 함께 설계해야
한다.
레퍼런스 저장소의 `library/analysis/abdomen.py` 가 커밋되어 있지 않아
`pipeline_dump.py` · `compare.py` 가 그대로는 돌지 않는다. 이번 대조는 스크래치 사본에
호출 시 즉시 실패하는 스텁을 놓아 우회했다(정렬·검출·BV 경로는 이 모듈을 쓰지 않는다).