feat(align): nch=4 위치가 3곳 이상이면 cap_frac 최소 대신 가운데를 고른다 — 방광 끝 위치 선택으로 BV 과소 추정

알고리즘 팀(2026-10-07): nch=4 인 위치가 여럿이라는 것은 방광이 빔 커버 범위보다 크다는
뜻이다. 그때 cap_frac 최소 규칙은 외삽을 최소화하는 자리를 찾으려 하지만, 방광 끝에 가까운
위치일수록 cap_frac 이 오히려 작게 계산된다 — 한쪽 cap 은 끝 채널이 방광을 스쳐 작아지고,
반대쪽 cap 은 높이 상한에 걸려 그만큼 커지지 못한다. 그 결과 끝 위치가 뽑히고 BV 가 과소
추정됐다.

규칙 ①′: 후보(ch3 게이트 통과) 중 nch=4 가 3곳 이상이면 cm 순 가운데, 짝수면 가운데 둘 중
아래쪽(치골 쪽, 작은 cm). 2곳까지는 종전 규칙(nch 최대 → cap_frac 최소 → 낮은 cm) 그대로.

이 가지는 Python select_supine_anchor 에 아직 없다. 레퍼런스 케이스 200개에는 이 가지를 타는
케이스가 없어(확인) 대조가 그대로 유효하고, 패리티 시험이 그 가정을 검사한다. 새 가지는
AnchorSelectionCenterRuleTest 7건이 잠근다. 어느 가지로 골랐는지 AnchorDecision.rule 로
돌려주고, 요약·labdb 601 params 에 select_rule 로 남긴다 — nch4_center 세션은
selection_verified_against_reference=false. 정렬 완료 안내 문구에도 "채널 4/4 위치 N곳 —
가운데" 를 덧붙인다(화면은 종전대로 "치골 위 N cm" 만 보여 준다).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 16:49:02 +09:00
parent a6b19b3dcc
commit 1592a1454e
6 changed files with 160 additions and 8 deletions
@@ -14,6 +14,7 @@
* |---|---|---|
* | 절차 | 2-pass (전수 스캔 후 재측정) | **단일 pass** (올라가며 결정) |
* | tie | `bv_cv` 최소 | **`cap_frac` 최소** |
* | nch=4 다수 | — | **3곳 이상이면 가운데** (2026-10-07 · Python 에 아직 없음) |
* | 오프셋 | 없음 | **best + 1cm** |
* | 종료 | 없음(5위치 전수) | nch 붕괴 / ch3 소실 / 상한 |
*
@@ -92,6 +93,16 @@ object AnchorConfig {
/** 후보 자격: per-trace CH3 검출률 하한. */
const val CH3_HIT_MIN = 0.80
/** center 채널(CH0~CH3) 전부 검출 = nch 4. */
const val NCH_FULL = 4
/**
* nch=4 인 후보가 이 수 이상이면 cap_frac 대신 **가운데 위치**를 고른다(2026-10-07).
* 2곳까지는 종전 규칙(cap_frac 최소). Python `config_al.py` 에는 아직 없는 값이다 —
* [AnchorSelection.selectBest] 주석 참조.
*/
const val NCH4_CENTER_MIN = 3
/**
* 최종 안내 = best + 이 값. **선택이 아니라 안내**다(아래 [AnchorGuide.anchorCm] 참고).
*
@@ -151,11 +162,27 @@ fun capFraction(walls: List<WallWithSpan?>, missingOutside: Set<Int>? = null): D
}
/** 선택 결과. Python `AnchorDecision`. */
/** [AnchorSelection.selectBest] 가 어느 가지로 골랐나 — 요약(`select_rule`)과 안내 문구에 쓴다. */
enum class AnchorSelectRule {
/** 후보 없음(재부착). */
NONE,
/** nch=4 후보 3곳 이상 → 가운데 (2026-10-07). Python 레퍼런스에 **없는** 가지. */
NCH4_CENTER,
/** 종전 규칙: nch 최대 → cap_frac 최소 → 낮은 cm. Python `select_supine_anchor` 와 1:1. */
MAX_NCH_MIN_CAP;
/** 기록에 쓰는 값. 분석 스크립트가 문자열로 비교하므로 고정이다. */
val wire: String get() = name.lowercase()
}
data class AnchorDecision(
/** null = 유효 위치 없음(재부착). */
val bestCm: Int?,
val nCandidate: Int,
val record: AnchorPosRecord?,
val rule: AnchorSelectRule = AnchorSelectRule.NONE,
)
/**
@@ -174,7 +201,19 @@ object AnchorSelection {
}
/**
* ① ch3 필수 → ② max nch → ③ cap_frac 최소 (동률이면 min cm).
* ① ch3 필수 → ①′ nch=4 후보가 3곳 이상이면 **가운데** → ② max nch → ③ cap_frac 최소
* (동률이면 min cm).
*
* ①′ (2026-10-07, 알고리즘 팀): nch=4 인 위치가 여럿이라는 것은 방광이 빔 커버 범위보다
* 크다는 뜻이다. 그때 cap_frac 최소 규칙은 외삽을 최소화하는 자리를 찾으려 하지만, 방광
* **끝**에 가까운 위치일수록 cap_frac 이 오히려 작게 나온다 — 한쪽 cap 은 끝 채널이 방광을
* 스쳐 작아지고, 반대쪽 cap 은 높이 상한에 걸려 그만큼 커지지 못한다. 그래서 끝 위치가
* 뽑히고 BV 가 과소 추정됐다. cm 순으로 늘어놓은 **가운데**를 고르고, 짝수면 가운데 둘 중
* **아래쪽(치골 쪽, 작은 cm)** 이다. 2곳까지는 종전 규칙 그대로다.
*
* 이 가지는 Python `select_supine_anchor` 에 아직 없다. 레퍼런스 케이스 200개에는 이
* 가지를 타는 케이스가 없어 대조가 그대로 유효하고(AnchorSelectionParityTest 가 확인),
* 이 가지로 고른 세션은 요약의 `select_rule: nch4_center` 로 가른다.
*
* `cap_frac` 이 유한하지 않으면 +∞ 로 취급해 맨 뒤로 보낸다(Python `_tie_val`).
*/
@@ -182,6 +221,11 @@ object AnchorSelection {
if (records.isEmpty()) return AnchorDecision(null, 0, null)
val cand = eligibleCh3(records)
if (cand.isEmpty()) return AnchorDecision(null, 0, null)
val full = cand.filter { it.nch == AnchorConfig.NCH_FULL }.sortedBy { it.cm }
if (full.size >= AnchorConfig.NCH4_CENTER_MIN) {
val mid = full[(full.size - 1) / 2] // 짝수면 가운데 둘 중 아래쪽(작은 cm)
return AnchorDecision(mid.cm, cand.size, mid, AnchorSelectRule.NCH4_CENTER)
}
val maxNch = cand.maxOf { it.nch }
val top = cand.filter { it.nch == maxNch }
val best = top.minWithOrNull(
@@ -190,7 +234,7 @@ object AnchorSelection {
{ it.cm },
)
)
return AnchorDecision(best?.cm, cand.size, best)
return AnchorDecision(best?.cm, cand.size, best, AnchorSelectRule.MAX_NCH_MIN_CAP)
}
}
@@ -241,6 +285,10 @@ class AnchorGuide(
var bestCm: Int? = null
private set
/** [bestCm] 을 고른 가지. 안내 문구에 "가운데" 근거를 적는다. */
var bestRule: AnchorSelectRule = AnchorSelectRule.NONE
private set
var stopReason: String? = null
private set
@@ -327,7 +375,7 @@ class AnchorGuide(
}
searching = false
stopReason = reason
bestCm = AnchorSelection.selectBest(selectableRecords()).bestCm
AnchorSelection.selectBest(selectableRecords()).let { bestCm = it.bestCm; bestRule = it.rule }
if (bestCm == null) {
return out(
AnchorAction.REATTACH,
@@ -349,7 +397,7 @@ class AnchorGuide(
if (searching) {
searching = false
stopReason = stopReason ?: "탐색 위치 소진"
bestCm = AnchorSelection.selectBest(selectableRecords()).bestCm
AnchorSelection.selectBest(selectableRecords()).let { bestCm = it.bestCm; bestRule = it.rule }
}
val best = bestCm
?: return out(
@@ -361,6 +409,7 @@ class AnchorGuide(
fun reset() {
recs.clear(); maxNch = 0.0; searching = true; bestCm = null; stopReason = null
bestRule = AnchorSelectRule.NONE
}
// ── 내부 ────────────────────────────────────────────────────────────────
@@ -405,6 +454,10 @@ class AnchorGuide(
AnchorAction.STOP,
"정렬 완료 — 최적 위치 ${best}cm" +
(b?.let { " (nch ${it.nch} · cap ${fmt3(it.capFrac)})" } ?: "") +
(if (bestRule == AnchorSelectRule.NCH4_CENTER) {
val n = selectableRecords().count { it.eligible && it.nch == AnchorConfig.NCH_FULL }
"\n채널 4/4 위치가 ${n}곳 — 가운데를 골랐습니다."
} else "") +
(if (offsetCm == 0) "\n이 자리(${anchorCm}cm)에 그대로 부착합니다."
else "\n여기서 ${offsetCm}cm 더 올려 ${anchorCm}cm 에 부착하세요."),
rec,
@@ -67,6 +67,7 @@ import com.medithings.vesiscan.managers.AlignAction
import com.medithings.vesiscan.managers.AlignmentConstants
import com.medithings.vesiscan.managers.AnchorAction
import com.medithings.vesiscan.managers.AnchorBasis
import com.medithings.vesiscan.managers.AnchorSelectRule
import com.medithings.vesiscan.managers.AnchorConfig
import com.medithings.vesiscan.managers.AnchorGuide
import com.medithings.vesiscan.managers.AnchorPosRecord
@@ -258,6 +259,8 @@ fun AnchorAlignView(appState: AppState) {
// `select_supine_anchor` 와 1:1 이고 200 케이스로 대조돼 있다(AnchorSelectionParityTest).
// 바뀌는 것은 **후보 집합**뿐이고, Python 의 회고 분석 경로가 이미 전 위치로 판정하므로
// 같은 방식으로 재현·대조할 수 있다.
// (2026-10-07: nch=4 후보 3곳 이상이면 가운데 — Python 에 아직 없는 가지다. 요약의
// `select_rule` 로 가르고, 그 세션은 `selection_verified_against_reference=false` 다.)
//
// 종료 조건(붕괴·ch3 소실·상한)은 **진단용**으로 남는다 — 지표가 어디서 무너졌는지는
// 여전히 알아야 한다. `decided_at_cm` 으로 기록에 남는다.
@@ -1666,7 +1669,8 @@ private fun buildAlignSummary(
confirmChannels?.let { put("confirm_channels", JSONArray(it)) }
// 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor`
// 그대로이고(AnchorSelectionParityTest 200케이스), 바뀐 것은 후보 집합뿐이다.
val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm
val fullDecision = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() })
val fullBest = fullDecision.bestCm
// ── 판정 범위 ──────────────────────────────────────────────────────────
// 2026-09-10 부터 앱은 **잰 위치 전부**로 판정한다. `best_cm`/`anchor_cm` 은 그 값이다.
@@ -1703,7 +1707,9 @@ private fun buildAlignSummary(
put("max_nch", step.maxNch)
// 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다.
// 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치.
put("selection_verified_against_reference", true)
// 2026-10-07: nch4_center 가지는 Python 레퍼런스에 아직 없다 — 그 가지로 고른 세션은 대조 미완이다.
put("select_rule", fullDecision.rule.wire)
put("selection_verified_against_reference", fullDecision.rule != AnchorSelectRule.NCH4_CENTER)
// 좌우 정렬 (SPEC §8 "좌우(LR) 정렬" · LateralGuide).
// done=false 는 "안 맞췄다"가 아니라 "확인 단계를 거치지 않았다"는 뜻이다.
// lateral 이 끝내 안 잡히는 환자도 있어 진행을 막지 않기 때문에 둘을 구분해야 한다.
@@ -0,0 +1,68 @@
package com.medithings.vesiscan.managers
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* ①′ nch=4 후보가 3곳 이상이면 가운데 (2026-10-07, 알고리즘 팀).
*
* 방광이 빔 커버 범위보다 크면 nch=4 인 위치가 여럿 나오고, 그때 cap_frac 최소 규칙은 방광
* **끝** 위치를 고른다(끝 채널이 방광을 스쳐 한쪽 cap 이 작아지고, 반대쪽 cap 은 높이 상한에
* 걸려 그만큼 커지지 못한다) → BV 과소 추정. 그래서 가운데, 짝수면 아래쪽(작은 cm).
* 아래 케이스는 일부러 **끝 위치의 cap 을 제일 작게** 둬서 종전 규칙이 끝을 고르게 만든다.
*/
class AnchorSelectionCenterRuleTest {
private fun rec(cm: Int, nch: Int = 4, cap: Double = 0.30, rate: Double = 1.0) = AnchorPosRecord(
cm = cm, nTrace = 11, nch = nch, ch3Mean = nch >= 4 || nch >= 1, ch3Rate = rate, capFrac = cap,
)
@Test
fun `three nch4 positions pick the middle even when the end has the smallest cap`() {
val d = AnchorSelection.selectBest(listOf(rec(1, cap = 0.40), rec(2, cap = 0.30), rec(3, cap = 0.05)))
assertEquals(2, d.bestCm)
assertEquals(AnchorSelectRule.NCH4_CENTER, d.rule)
}
@Test
fun `four nch4 positions pick the lower of the two middles`() {
val d = AnchorSelection.selectBest((0..3).map { rec(it, cap = if (it == 3) 0.05 else 0.30) })
assertEquals(1, d.bestCm)
assertEquals(AnchorSelectRule.NCH4_CENTER, d.rule)
}
@Test
fun `five nch4 positions pick the center`() {
val d = AnchorSelection.selectBest((0..4).map { rec(it, cap = if (it == 4) 0.05 else 0.30) })
assertEquals(2, d.bestCm)
}
@Test
fun `middle is by cm order, not by index of measurement, and gaps are allowed`() {
// 잰 순서가 뒤섞여도, 0·2·4 처럼 띄엄띄엄이어도 cm 순 가운데다.
assertEquals(2, AnchorSelection.selectBest(listOf(rec(4, cap = 0.05), rec(0), rec(2))).bestCm)
assertEquals(1, AnchorSelection.selectBest(listOf(rec(4, cap = 0.05), rec(1), rec(0))).bestCm)
}
@Test
fun `two nch4 positions keep the old rule (min cap)`() {
val d = AnchorSelection.selectBest(listOf(rec(1, cap = 0.40), rec(2, cap = 0.10), rec(3, nch = 3, cap = 0.05)))
assertEquals(2, d.bestCm)
assertEquals(AnchorSelectRule.MAX_NCH_MIN_CAP, d.rule)
}
@Test
fun `an nch4 position that fails the ch3 gate does not count`() {
// 2cm 는 nch=4 지만 검출률 50% 로 자격 미달 → nch=4 후보는 1·3cm 두 곳 → 종전 규칙(cap 최소 = 3cm).
val d = AnchorSelection.selectBest(listOf(rec(1, cap = 0.40), rec(2, rate = 0.5), rec(3, cap = 0.05)))
assertEquals(3, d.bestCm)
assertEquals(AnchorSelectRule.MAX_NCH_MIN_CAP, d.rule)
}
@Test
fun `no candidate stays a reattach`() {
val d = AnchorSelection.selectBest(listOf(rec(0, nch = 2, rate = 0.0).copy(ch3Mean = false)))
assertEquals(null, d.bestCm)
assertEquals(AnchorSelectRule.NONE, d.rule)
}
}
@@ -58,6 +58,13 @@ class AnchorSelectionParityTest {
if (want == null) nullBest++
}
assertEquals("케이스 수", 200, lines.size)
// 2026-10-07 ①′(nch=4 후보 3곳 이상 → 가운데)는 Python 에 아직 없다. 이 파일에는 그 가지를
// 타는 케이스가 없어 대조가 그대로 유효하다. 파일을 다시 뽑아 이 가정이 깨지면 Python 쪽에도
// 같은 규칙을 넣은 뒤 이 검사를 지운다. 새 가지 자체는 AnchorSelectionCenterRuleTest 가 잠근다.
val centerCases = lines.count {
AnchorSelection.selectBest(parse(it).second).rule == AnchorSelectRule.NCH4_CENTER
}
assertEquals("nch4_center 가지를 타는 레퍼런스 케이스", 0, centerCases)
// 재부착 판정이 실제로 섞여 있어야 대조가 의미 있다.
assertTrue("재부착 케이스가 없다 — 데이터가 한쪽으로 쏠렸다", nullBest > 0)
}