From ccb44f0ea6a5366ad36fa1aacef3ca9f0267061c Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 3 Sep 2026 17:31:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(algo):=20=EB=A0=88=ED=8D=BC=EB=9F=B0?= =?UTF-8?q?=EC=8A=A4=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20dev=20=EC=8A=A4?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EB=A1=9C=20=C2=B7=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EC=9D=80=20=ED=98=84=ED=96=89=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신 저장소에 맞춘 레퍼런스(piezo-phantom-test/vesiscan_test) 단계들을 옮기되, demo-final 의 기본 동작은 건드리지 않았다. 2026-09-03 임상이 현재 HEAD 위에서 돌았으므로 그 값을 움직이는 변경을 기본 경로에 넣을 수 없다. `AlgoMode.reference` (계측 화면 dev 패널 → "알고리즘", 기본 꺼짐) 를 켜면: · CCC 3단계 → 7단계 envelope(①.9) · ant_tiebreak(①) · Λ-dip(①.7) · edge_post(①.8) · lumen_return(①.95) · NTV(② Rule A 만) · fp_gate(③). inward_post 는 빠진다. · 파이프라인 4개 추가 scale_recovery(2) · missing_outside 산출(4) · channel_confidence→untrusted(5b) · CONF_DEMOTE(6). missing_outside 는 CCC **앞**에서 계산한다 — CCC 가 복구한 채널은 방광 밖이 아니다. · BV si_floor/missing_outside 로 극 cap clamp 의 방향(상한/하한)을 관측 증거로 가름, bottom cap 제한 신설, 중앙 채널 보간 은행가 반올림, adaptive_large_bladder_relax 제거(b_si 를 0.85·a_ap 로 바닥 처리해 cap 축 비를 정확히 0.85 로 만들어 바로 다음 cap 축 폴백을 경계에서 막고 있었다). 검출 파라미터(d_max 15 · inward_walk_win 4) · ANT_FLOOR_REF · cap 축 폴백 · ELLIPSE_CAP_R_MAX · TOP_CAP_EDGE_K · cap_d_adaptive · 프리셋 유도 dps/delay 는 ebddc52/20a48ad 에서 이미 기본 경로에 들어가 있어 양쪽 공통이다. 되돌리지 않았다. 검증 · 기존 경로 무변경 — LegacyEquivalenceDumpTest 가 44 cycle × CCC on/off 를 검출→BV 로 돌려 덤프하고, HEAD 코드 덤프와 대조. 88 행 전부 일치. (이 테스트는 일부러 AlgoMode 를 참조하지 않는다 — HEAD 에 없는 타입이라 참조하면 대조 자체가 불가능해진다.) · 토글 배선 — AlgoModeSwitchTest: 켜면 44 중 40 cycle 이 갈리고 missing_outside 가 산출되며, 끄면 기존 값이 정확히 재현된다. · 전체 30개 통과. 함께 고친 것 저장소가 갈리며 없어진 픽스처 경로(medilightv2android/data123)를 vesiscan-design-archive/data123 로 옮겼다. 5개 테스트가 FileNotFoundException 으로 조용히 실패하고 있었다. 그래서 ebddc52 이후 한 번도 안 돌던 CenterAlignerValidationTest 가 다시 돌았고 hit 7→8 · 10→11 로 어긋났다. 현행 레퍼런스 AnchorGuide(ver='v1') 를 같은 세션에 직접 돌려 보니 8 · 11 이었다 — 코틀린이 맞고 기대값이 낡은 것이다(구 v3_python_reference.py 기준, 현재 저장소에 없음). 기대값을 갱신했다. bv_cv 는 현행 레퍼런스가 더 이상 내지 않아 대조할 수단이 없다. "Python 과 bit-exact" 라던 주석은 근거가 없어졌으므로 걷어내고 회귀 고정으로 성격을 바꿨다. Co-Authored-By: Claude Opus 5 --- .../medithings/vesiscan/managers/AlgoMode.kt | 46 ++ .../vesiscan/managers/AnchorGuide.kt | 17 +- .../vesiscan/managers/PiezoBVEstimator.kt | 155 +++- .../ui/views/clinical/ClinicalLiveView.kt | 10 +- .../views/monitoring/PiezoMonitoringView.kt | 41 +- .../vesiscan/walldetect/MethodDDetector.kt | 3 +- .../vesiscan/walldetect/MethodDRunner.kt | 740 +++++++++++++++++- .../walldetect/algo/methodd/MethodDParams.kt | 5 + .../vesiscan/managers/AlgoModeSwitchTest.kt | 83 ++ .../managers/CenterAlignerValidationTest.kt | 54 +- .../vesiscan/managers/Cm3PerTraceDumpTest.kt | 2 +- .../vesiscan/managers/GoldenDumpTest.kt | 6 +- .../managers/LegacyEquivalenceDumpTest.kt | 73 ++ .../managers/MethodDBvWrapperParityTest.kt | 6 +- .../vesiscan/managers/PrecisionDumpTest.kt | 2 +- docs/ALGO_MODE.md | 113 +++ 16 files changed, 1268 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/managers/AlgoMode.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/AlgoModeSwitchTest.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/LegacyEquivalenceDumpTest.kt create mode 100644 docs/ALGO_MODE.md diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AlgoMode.kt b/app/src/main/java/com/medithings/vesiscan/managers/AlgoMode.kt new file mode 100644 index 0000000..966c8f6 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/managers/AlgoMode.kt @@ -0,0 +1,46 @@ +/* + * 알고리즘 경로 전환 — 기존(demo-final 현행) vs 레퍼런스(piezo-phantom-test 1:1). + */ +package com.medithings.vesiscan.managers + +/** + * 검출·BV 알고리즘을 어느 쪽으로 돌릴지. + * + * ## 왜 두 경로를 유지하나 + * demo-final 은 팬텀 시연과 임상 계측이 함께 도는 브랜치다. 여기서 "기존"이란 + * **오늘 임상이 실제로 돌아간 코드**(커밋 `ebddc52`·`20a48ad` 시점의 검출·BV)를 말한다. + * 그 위에 신 저장소가 추가로 맞춘 레퍼런스 단계들은 값을 눈에 띄게 바꾸므로, 시연·계측이 + * 모르는 사이 다른 알고리즘으로 도는 일이 없도록 **기본은 기존 경로**로 두고 레퍼런스 + * 경로는 dev 패널에서 명시적으로 켤 때만 쓴다. + * + * ## 무엇이 갈리나 + * 켜면 아래가 전부 레퍼런스 동작으로 바뀐다. 부분 적용은 없다 — 단계들이 서로를 + * 전제하므로 섞으면 어느 쪽도 아닌 값이 나온다. + * + * · 교차채널 보정 3단계 → **7단계** + * (①.9 envelope · ① ant_tiebreak · ①.7 Λ-dip · ①.8 edge · + * ①.95 lumen_return · ② NTV[Rule A 만] · ③ fp_gate, + * 기존의 `inward_post` 는 빠진다) + * · 파이프라인 `scale_recovery`(2) · `missing_outside` 증거 산출(4) · + * `channel_confidence`→untrusted(5b) · `CONF_DEMOTE`(6) + * · BV 극 cap 의 상한/하한을 관측 증거로 가르는 `si_floor`/`missing_outside`, + * bottom cap 제한 신설, 중앙 채널 보간 은행가 반올림, + * `adaptive_large_bladder_relax` 제거(cap 축 폴백을 가로막고 있었다) + * + * 검출 파라미터(`d_max` 15 · `inward_walk_win` 4)와 prominence 기준(`ANT_FLOOR_REF`), + * cap 축 폴백 · `ELLIPSE_CAP_R_MAX` · `TOP_CAP_EDGE_K` · `cap_d_adaptive`, 프리셋 유도 + * dps/delay 는 **양쪽 공통**이다 — `ebddc52`/`20a48ad` 에서 이미 기존 경로에 들어갔고 + * 오늘 임상이 그 위에서 돌았으므로 여기서 되돌리지 않는다. + * + * ## 상태 보관 + * 프로세스 수명 동안만 유지한다(런타임 토글). 앱을 다시 켜면 기존 경로로 돌아간다. + */ +object AlgoMode { + + /** true = 레퍼런스 1:1 경로. 기본은 false(기존 경로). */ + @Volatile + var reference: Boolean = false + + /** 화면에 표시할 이름. */ + val label: String get() = if (reference) "레퍼런스 (Python 1:1)" else "기존 (현행 임상)" +} diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt b/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt index 42edf70..a2ddd18 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt @@ -132,8 +132,8 @@ object AnchorConfig { * * BV 를 못 내면 1.0(최악)을 돌려준다 — Python 과 같다. 후보 정렬에서 자연히 뒤로 밀린다. */ -fun capFraction(walls: List): Double { - val r = estimateBv(walls) ?: return 1.0 +fun capFraction(walls: List, missingOutside: Set? = null): Double { + val r = estimateBv(walls, missingOutside = missingOutside) ?: return 1.0 val y = r.sortedYMm val ySpan = if (y.size >= 2) (y.max() - y.min()) else 0.0 val top = if (r.topHMm.isFinite()) r.topHMm else 0.0 @@ -266,12 +266,17 @@ class AnchorGuide( // 같은 mean-scan 을 두 번 검출한다 — 통일하면 안 된다(SPEC §8.2-1). val wallsBase = MethodDRunner.detectMultichannel( meanScan, methodDParams, applyTgc = true, applyCross = false) - val wallsCross = MethodDRunner.detectMultichannel( + val crossOutcome = MethodDRunner.detectMultichannelWithEvidence( meanScan, methodDParams, applyTgc = true, applyCross = true) + val wallsCross = crossOutcome.walls - val capFrac = capFraction(wallsCross.map { r -> - r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } - }) + // cap_frac 은 극 cap 높이로 계산하므로 clamp 방향을 가르는 증거가 필요하다. + // 기존 경로에서는 빈 집합이라 null 로 떨어져 종전과 같이 동작한다. + val capFrac = capFraction( + wallsCross.map { r -> + r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } + }, + crossOutcome.missingOutside.ifEmpty { null }) // ch3 검출률은 trace 단위다. 그 김에 trace 별 nch 통계도 얻는다(진단용). val traces = slidingTraces(cycles, traceWin) diff --git a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt index 72413c7..d651e11 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt @@ -237,10 +237,20 @@ private fun repairCenterWallsFor6ch( if (gap == 1) { val pw = result[prevIdx]!! val nw = result[nextIdx]!! - // Python 1:1: 선형 보간 (반올림 없이 float 유지 — subsample refined 값 보존) - val ant = (pw.first + nw.first) / 2.0 - val post = (pw.second + nw.second) / 2.0 - result[prevIdx + 1] = Pair(ant, post) + // 레퍼런스는 **정수로 절단한 뒤** 평균내고 은행가 반올림한다: + // `int(round((int(prev) + int(next)) / 2.0))` + // 보간된 채널은 subsample 정밀도를 갖지 않는다 — 근거가 이웃 둘의 정수 + // 위치뿐이기 때문이다. 실측 f01 tr11: 이 차이로 CH2 단면이 2971.0 vs + // 2788.2 mm² 로 갈려 부피가 296.97 vs 293.67 mL 이 됐다. + // 기존 경로는 float 평균 그대로 둔다(팬텀 시연 결과 불변). + if (AlgoMode.reference) { + val ant = bankersRound((pw.first.toInt() + nw.first.toInt()) / 2.0) + val post = bankersRound((pw.second.toInt() + nw.second.toInt()) / 2.0) + result[prevIdx + 1] = Pair(ant.toDouble(), post.toDouble()) + } else { + result[prevIdx + 1] = Pair((pw.first + nw.first) / 2.0, + (pw.second + nw.second) / 2.0) + } continue } // gap >= 2: drop top group @@ -578,6 +588,44 @@ const val BOTTOM_CAP_SAGITTA_NOISE_MM: Double = 4.0 /** Python `LUMEN_INSET_FRAC` (config_6ch.py:41). */ const val LUMEN_INSET_FRAC: Double = 0.15 +/** + * Python `volume/config_vol.SI_FLOOR_WALK` — span 이 이어지는 가장 바깥 채널까지 하한을 + * 확장할지. 인접 채널에서 멈추지 않고 걸어간다. **레퍼런스 경로 전용**. + */ +const val SI_FLOOR_WALK = true + +/** Python 3 `round()` 는 half-to-even 이다. `kotlin.math.round` 는 half-up 이라 갈린다. */ +private fun bankersRound(x: Double): Int = Math.rint(x).toInt() + +/** + * [start] 에서 [step] 방향으로 span 이 이어지는 동안 걸어가 **가장 바깥 채널**을 준다. + * + * 미검출 채널에 low echo span 이 있으면 방광이 그 빔까지는 온다는 뜻이므로, 연속으로 + * span 이 있으면 가장 바깥 것이 cap 하한이다. span 없는 채널(= [missingOutside] 에 속함)을 + * 만나면 멈춘다. null 반환 = 인접 채널부터 span 이 없다 → 종전대로 상한. + */ +private fun farthestWithSpan(missingOutside: Set?, start: Int, step: Int, stop: Int): Int? { + if (missingOutside == null) return null + var far: Int? = null + var c = start + while ((c - stop) * step <= 0 && c !in missingOutside) { + far = c + c += step + } + return far +} + +/** + * 극 cap clamp 를 **상한으로** 걸어도 되는가. + * + * clamp 의 전제는 "미검출 채널 = 방광이 거기까지 안 감"인데, 레퍼런스 실측상 그 전제가 + * 맞는 경우는 silicon 17.2% / abs 3.5% 뿐이다 — 나머지는 low echo span 이 있는데 벽만 + * 못 잡은 것이다. [missingOutside] 는 **span 자체가 없어 진짜 방광 밖으로 판정된** 채널 + * 집합이고, null 이면 종전대로 항상 상한(= 조건부 비활성). + */ +private fun clampAllowed(missingOutside: Set?, ch: Int): Boolean = + missingOutside == null || ch in missingOutside + /** Python `volume/config_vol.ELLIPSE_CAP_R_MAX` — 타원 cap 높이의 r 상한. */ const val ELLIPSE_CAP_R_MAX = 0.92 @@ -640,6 +688,8 @@ fun estimateBladderVolume6ch( bSiFloorRatio: Double? = null, bSiFloorEdgeMin: Double = 0.5, ellipseCapHeight: Boolean = true, + missingOutside: Set? = null, + siFloor: Boolean = true, ): BVResult? { var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null } centerWalls = repairCenterWallsFor6ch(centerWalls) @@ -656,6 +706,8 @@ fun estimateBladderVolume6ch( bSiFloorRatio = bSiFloorRatio, bSiFloorEdgeMin = bSiFloorEdgeMin, ellipseCapHeight = ellipseCapHeight, + missingOutside = missingOutside, + siFloor = siFloor, ) } @@ -734,7 +786,22 @@ fun estimateBv( bSiFloorRatio: Double? = null, bSiFloorEdgeMin: Double = 0.5, adaptiveLargeBladderRelax: Boolean = true, + /** [estimateBladderVolume] 참조 — 검출기가 산출한 '진짜 방광 밖' 채널 집합. */ + missingOutside: Set? = null, + siFloor: Boolean = true, ): BVResult? { + // ── 레퍼런스 경로: 완화 없이 한 번 계산하고 cap 축 폴백만 건다 ──────────── + // "끝단 단면이 넓고 cap 이 얕으면 urine_inset 0.05 + b_si_floor 0.85 로 재계산" + // 하던 완화가 **레퍼런스에서 제거됐다**. 남아 있는 동안 실제로 해를 끼쳤다: + // b_si 를 0.85·a_ap 로 바닥 처리하는 바람에 cap 축 비가 정확히 0.85 가 되어 바로 + // 다음의 cap 축 폴백(`< 0.85`)이 **경계에서 발동하지 않았다**. + // 실측 PHANTOM mean: Python 160.03 mL vs 완화가 살아 있는 코틀린 232.49 mL. + if (AlgoMode.reference) { + val r = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin, + missingOutside = missingOutside, siFloor = siFloor) + return capAxisFallback(r, walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin) + } + val result = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin) // low_wide_endpoint 완화(아래)는 조건이 까다롭지만, **cap 축 폴백은 항상 적용된다** — @@ -809,7 +876,11 @@ private fun capAxisFallback( if (bSi == null || cAp == null || cAp <= 0.0) return result val dMax = result.dMm.filter { it > 0.0 }.maxOrNull() ?: 0.0 if (bSi / cAp >= CAP_AXIS_RATIO_MAX || dMax >= CAP_AXIS_DMAX_MAX) return result - // 폴백은 **부피를 폴백 계산 그대로** 쓴다(Python 과 동일) — 진단 필드만 옮기지 않는다. + // 폴백은 **부피를 폴백 계산 그대로** 쓴다(Python 과 동일). + // ⚠ 폴백 재계산에는 `missingOutside`·`siFloor` 를 **넘기지 않는다**. 레퍼런스 + // `runners.estimate_bv` 의 폴백 호출이 그 두 인자를 빼고 부르기 때문에 기본값으로 + // 떨어진다. 실측 f04 cyc20: 넘기면 249.95 mL, 안 넘기면 278.43 mL 로 후자가 + // 레퍼런스 값이다. val fb = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight = false) return if (fb != null && fb.volumeMl != 0.0) fb else result @@ -823,6 +894,8 @@ private fun evalOnce( bSiFloorRatio: Double?, bSiFloorEdgeMin: Double, ellipseCapHeight: Boolean = true, + missingOutside: Set? = null, + siFloor: Boolean = true, ): BVResult? { val insetWalls: List?> = walls.map { w -> w?.let { @@ -835,7 +908,8 @@ private fun evalOnce( val prevOverride = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = lrRatio return try { - estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight) + estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight, + missingOutside, siFloor) } finally { com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride } @@ -897,6 +971,16 @@ fun estimateBladderVolume( * 무너졌을 때(b_si/c_ap 가 작을 때) 타원 dome 을 믿으면 cap 이 과소해진다. */ ellipseCapHeight: Boolean = true, + /** + * **span 자체가 없어 진짜 방광 밖으로 판정된** 채널 집합(Python `missing_outside`). + * null 이면 극 cap clamp 를 언제나 상한으로 건다(기존 동작). + */ + missingOutside: Set? = null, + /** + * Python `COND_CLAMP == 'si'`. 켜져 있으면 미검출 채널에 span 이 있을 때 같은 + * 지점을 **하한**으로 쓴다. 레퍼런스 경로에서만 의미가 있다. + */ + siFloor: Boolean = true, ): BVResult? { // 1) Edge channel filter — cap 방식 결정용 플래그 @@ -1154,7 +1238,11 @@ fun estimateBladderVolume( val topSortedCh = sortedCh.last() val nCenterDet = sortedCh.count { it < 4 } if (nCenterDet >= 2 && topSortedCh > 0) { - val upperCh = topSortedCh - 1 + var upperCh = topSortedCh - 1 + // span 이 이어지는 가장 바깥 채널까지 걸어간다 — 방광이 거기까지 온다는 관측이다. + if (siFloor && SI_FLOOR_WALK) { + farthestWithSpan(missingOutside, upperCh, -1, 0)?.let { upperCh = it } + } val upperTheta = degreeDeg[upperCh] * Math.PI / 180.0 val yBeamUpper = if (z0Ellipse != null && abs(cos(upperTheta)) > 1e-6) { val dAtZ0 = z0Ellipse!! / cos(upperTheta) @@ -1163,7 +1251,58 @@ fun estimateBladderVolume( sensorZMm[upperCh].toDouble() } val hTopLimit = yBeamUpper - yS[n - 1] - if (hTopLimit > 0 && hCapTop > hTopLimit) hCapTop = hTopLimit + if (hTopLimit > 0) { + // **같은 지점을 관측 증거에 따라 상한 또는 하한으로 쓴다.** + // 미검출 채널에 span 이 없다 = 방광이 그 빔에 없다 → 상한(기존). + // span 이 있다 = 방광이 그 빔까지는 온다 → **하한**(외삽을 관측으로 대체). + // `missingOutside == null`(기존 경로)이면 clampAllowed 가 항상 true 라 + // 기존 동작과 완전히 같다. + if (clampAllowed(missingOutside, upperCh)) { + if (hCapTop > hTopLimit) hCapTop = hTopLimit + } else if (siFloor) { + hCapTop = max(hCapTop, hTopLimit) + } + } + } + + // Bottom cap 제한 (top 대칭) — 최하단 검출채널이 CH3(최inferior)이 아니면 아래에 + // 미검출 채널이 있다. 그 빔이 방광 중심 깊이(z0)를 지나는 y 로 bottom cap 을 + // 제한한다. sitting 에서 CH3(치골)이 소실될 때 bottom cap 이 과외삽되는 것을 막는다. + // 기존 경로에는 이 제약이 아예 없었으므로 레퍼런스에서만 건다. + val botSortedCh = sortedCh.first() + if (AlgoMode.reference && nCenterDet >= 2 && botSortedCh < 3) { + var lowerCh = botSortedCh + 1 // index+1 = 더 inferior(낮은 y) 채널 + if (siFloor && SI_FLOOR_WALK) { + farthestWithSpan(missingOutside, lowerCh, +1, 3)?.let { lowerCh = it } + } + val lowerTheta = degreeDeg[lowerCh] * Math.PI / 180.0 + // z0: 타원 fit 실패(nch=2 등) 시 단면 wall 중심 깊이로 추정한다. fallback 에서 + // sensor 위치만 쓰면 한계가 헐거워진다. + val z0Eff = z0Ellipse ?: run { + var acc = 0.0 + for (k in zWallAnt.indices) acc += (zWallAnt[k] + zWallPost[k]) / 2.0 + if (zWallAnt.isNotEmpty()) acc / zWallAnt.size else 0.0 + } + val yBeamLower = if (abs(cos(lowerTheta)) > 1e-6) { + sensorZMm[lowerCh] + (z0Eff / cos(lowerTheta)) * sin(lowerTheta) + } else { + sensorZMm[lowerCh].toDouble() + } + val hBotLimit = yS[0] - yBeamLower + var applyH: Double? = null + if (clampAllowed(missingOutside, lowerCh)) { + if (hBotLimit > 0 && hCapBot > hBotLimit) applyH = hBotLimit // 방광 밖 → 상한 + } else if (siFloor && hBotLimit > hCapBot) { + applyH = hBotLimit // span 있음 → 하한 + } + if (applyH != null) { + hCapBot = applyH + // bottom 구면캡 R 을 바뀐 높이로 역산 (base 반경 유지) + if (rEffCap != null && hCapBot > 1e-6) { + val abL = aCapS[0] + rEffCap = (abL * abL + hCapBot * hCapBot) / (2.0 * hCapBot) + } + } } // 8) Core frustum (traditional: h = dy) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt index e2f663f..44a268e 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt @@ -133,8 +133,9 @@ fun ClinicalLiveView(appState: AppState) { DoubleArray(buf.size) { buf[it].toDouble() } } ?: DoubleArray(100) } - val mdResults = com.medithings.vesiscan.walldetect.MethodDRunner - .detectMultichannel(signalsD, applyCross = true) + val mdOutcome = com.medithings.vesiscan.walldetect.MethodDRunner + .detectMultichannelWithEvidence(signalsD, applyCross = true) + val mdResults = mdOutcome.walls // Python `runners.estimate_bv` 1:1 대응 (inset + adaptive_large_bladder_relax). val mdWalls = mdResults.map { r -> r?.let { @@ -142,7 +143,10 @@ fun ClinicalLiveView(appState: AppState) { it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } } - bvMethodD = com.medithings.vesiscan.managers.estimateBv(mdWalls) + // `missingOutside` 는 극 cap clamp 의 방향(상한/하한)을 가르는 관측 증거다. + // 기존 경로에서는 검출기가 빈 집합을 주므로 null 로 넘겨 종전과 같이 동작한다. + bvMethodD = com.medithings.vesiscan.managers.estimateBv( + mdWalls, missingOutside = mdOutcome.missingOutside.ifEmpty { null }) ?.volumeMl?.takeIf { it.isFinite() && it > 0 } } } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index e16949b..f5be15f 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -287,6 +287,8 @@ fun PiezoMonitoringView(appState: AppState) { // Method D detection 결과 (refined ant/post + span) — Method D BV wrapper // 경로에서 재사용해 adaptive_large_bladder_relax 등 Python 1:1 적용. var methodDDets: List? = null + /** 극 cap clamp 방향을 가르는 관측 증거. 기존 경로에서는 항상 null. */ + var methodDMissingOutside: Set? = null val validChannels = channels.filter { it.isValid } val allChannelsReceived = channels.filter { it.buffer.size >= 20 }.size >= 6 @@ -368,9 +370,11 @@ fun PiezoMonitoringView(appState: AppState) { val cd = channels.find { it.channel == ch } cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0) } - val dets = com.medithings.vesiscan.walldetect.MethodDRunner - .detectMultichannel(signals) + val outcome = com.medithings.vesiscan.walldetect.MethodDRunner + .detectMultichannelWithEvidence(signals) + val dets = outcome.walls methodDDets = dets // BV wrapper 경로에서 refined+span 재사용 + methodDMissingOutside = outcome.missingOutside.ifEmpty { null } for (i in 0..5) { val r = dets.getOrNull(i) if (r != null) allWalls[i] = Pair(r.ant, r.post) @@ -444,9 +448,11 @@ fun PiezoMonitoringView(appState: AppState) { // Wrapper 가 lumen_inset + adaptive_large_bladder_relax + b_si_floor 모두 // 자동 적용 → Python 1:1. 여기서는 sourceAllWalls 에 int (ant,post) 만 // 반환 (기존 wall-count 게이트 · UI 표시용). - val dets = com.medithings.vesiscan.walldetect.MethodDRunner - .detectMultichannel(signals) + val outcome = com.medithings.vesiscan.walldetect.MethodDRunner + .detectMultichannelWithEvidence(signals) + val dets = outcome.walls methodDDets = dets + methodDMissingOutside = outcome.missingOutside.ifEmpty { null } dets.map { r -> r?.let { Pair(it.ant, it.post) } } } } else allWalls @@ -490,7 +496,10 @@ fun PiezoMonitoringView(appState: AppState) { com.medithings.vesiscan.managers.BvMethod.METHOD_D_PHANTOM) { com.medithings.vesiscan.managers.estimateBvPhantomSphere(walls) } else { - com.medithings.vesiscan.managers.estimateBv(walls) + // `missingOutside` = 극 cap clamp 방향을 가르는 관측 증거. + // 기존 경로에서는 항상 null 이라 종전 동작과 같다. + com.medithings.vesiscan.managers.estimateBv( + walls, missingOutside = methodDMissingOutside) } if (bvResult != null) { rawVolumeMl = bvResult.volumeMl @@ -2094,6 +2103,28 @@ fun PiezoMonitoringView(appState: AppState) { HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding)) + // 알고리즘 경로 — 기본은 **기존(팬텀 시연)**. 레퍼런스는 Python + // `piezo-phantom-test` 1:1 경로로, 검출(CCC 7단계·파이프라인)과 + // BV(극 cap 증거 clamp) · dps/delay 가 함께 바뀐다. 시연 결과가 + // 달라지므로 여기서 명시적으로 켤 때만 쓴다. + var refAlgo by remember { mutableStateOf(com.medithings.vesiscan.managers.AlgoMode.reference) } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column { + Text("알고리즘", fontSize = labelSize, fontWeight = FontWeight.SemiBold) + Text(com.medithings.vesiscan.managers.AlgoMode.label, + fontSize = 11.sp, color = MlSecondaryText) + } + Spacer(modifier = Modifier.weight(1f)) + Switch(checked = refAlgo, + onCheckedChange = { + refAlgo = it + com.medithings.vesiscan.managers.AlgoMode.reference = it + }, + colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFF7C3AED))) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding)) + // 2026-07-01: lr_ratio override (algorithm 팀 4830e7c 표준) var lrFixed by remember { mutableStateOf(com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride != null) diff --git a/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDDetector.kt b/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDDetector.kt index 6492cc3..1e3911c 100644 --- a/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDDetector.kt +++ b/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDDetector.kt @@ -41,7 +41,8 @@ data class MethodDResult( var urineLen: Int, val antProm: Double, val postProm: Double, - val antType: CType, + // 레퍼런스 경로의 envelope_research(①.9)가 전벽을 옮기며 함께 갱신한다. + var antType: CType, var postType: CType, val sgHeavy: DoubleArray, val sgLight: DoubleArray, diff --git a/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDRunner.kt b/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDRunner.kt index e4a49f3..6ecf14b 100644 --- a/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDRunner.kt +++ b/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDRunner.kt @@ -23,6 +23,9 @@ package com.medithings.vesiscan.walldetect import com.medithings.vesiscan.managers.AlignmentConstants import com.medithings.vesiscan.managers.PiezoHW import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams +import com.medithings.vesiscan.walldetect.algo.MedianFilter +import com.medithings.vesiscan.walldetect.algo.methodd.MethodDSpan +import com.medithings.vesiscan.walldetect.algo.SgSmoothGeneric import com.medithings.vesiscan.walldetect.algo.methodd.MethodDPreprocessing import com.medithings.vesiscan.walldetect.algo.methodd.MethodDTgc import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect @@ -51,6 +54,31 @@ object MethodDRunner { private const val NEIGHBOR_TOP_VALIDATE = true private const val INWARD_POST_SEARCH = true + // ── 레퍼런스 경로 전용 상수 ([AlgoMode.reference] 일 때만 쓰인다) ── + // 값·근거는 piezo-phantom-test `wall_correction/config_wc.py` · `runners.py`. + private const val VDIP_DIP_TOL = 8.0 + private const val VDIP_REACH_MARGIN = 15.0 + private const val LUMEN_RETURN_RECOVERY = true + private const val LUMEN_RETURN_LOOKAHEAD = 12 + private const val LUMEN_RETURN_LEVEL = 1.5 + private const val LUMEN_RETURN_DMAX = 26 + private const val FP_CHANNEL_GATE = true + private const val FP_GATE_PROM_MAX = 0.75 + private const val FP_GATE_DZ_MIN = 5.0 + private const val EDGE_POST_RECOVERY = true + private const val EDGE_CLOSER_TOL_MM = 2.0 + private const val EDGE_POST_TREND = true + private const val ENVELOPE_RESEARCH = true + private const val ENVELOPE_MARGIN = 5 + private const val ENVELOPE_TRIM = true + private const val ENVELOPE_NCH1_ANT_DMAX = 26 + private const val SCALE_RECOVERY = true + private const val RECOVERY_WINDOW = 5 + private const val CCC_EVIDENCE_MIN_CONF = 0.75 + private const val CCC_EVIDENCE_MAX_NCH = 3 + private const val CONF_DEMOTE = 0.58 + private const val CONF_DEMOTE_MIN_KEEP = 3 + /** * 6채널 (또는 N채널) raw 신호 → 채널별 MethodDResult? 리스트. * raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립). @@ -61,13 +89,24 @@ object MethodDRunner { * — 교차채널 보정이 nch 를 바꿔 위치 선택이 흔들리는 것을 차단. * BV 산출은 applyCross=true 로 별도 수행. */ + /** 기존 호출부 호환 — 증거가 필요 없으면 이쪽. */ fun detectMultichannel( + signals: List, params: MethodDParams = MethodDParams.DEFAULT, beamAnglesDeg: DoubleArray? = null, applyTgc: Boolean = true, applyCross: Boolean = true, - ): List { + ): List = detectMultichannelWithEvidence( + signals, params, beamAnglesDeg, applyTgc, applyCross).walls + + fun detectMultichannelWithEvidence( + signals: List, + params: MethodDParams = MethodDParams.DEFAULT, + beamAnglesDeg: DoubleArray? = null, + applyTgc: Boolean = true, + applyCross: Boolean = true, + ): DetectionOutcome { val angles = beamAnglesDeg ?: PiezoHW.degreeAll // 1) per-channel SG denoise (heavy + light) val heavyList = signals.map { MethodDPreprocessing.preprocessHeavy(it, params) } @@ -87,16 +126,69 @@ object MethodDRunner { params = params, ) } - // 5) cross-channel 후처리 (Python `apply_cross_channel` 순서 고정) - if (applyCross) { - applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params) + val ref = com.medithings.vesiscan.managers.AlgoMode.reference + + // ── 2) 검출 실패 채널만 SG5 로 재탐색 (레퍼런스 경로 전용) ───────────── + // 이미 검출된 채널은 건드리지 않는다 — 얻는 것은 커버리지뿐이고 측정면은 불변. + if (ref && SCALE_RECOVERY && applyCross) { + applyScaleRecovery(results, signals, angles, params, applyTgc) } - return results + + // ── 4) 미검출 채널의 span 유무 판정 (레퍼런스 경로 전용) ─────────────── + // **CCC 앞에서** 계산한다 — CCC 가 복구한 채널은 '방광 밖'이 아니다. + val missingOut: Set = + if (ref && applyCross) missingOutside(results, lightTgc, heavyTgc, angles, params) + else emptySet() + + // ── 5) 교차채널 보정 ─────────────────────────────────────────────── + if (applyCross) { + // 5b) 약한 채널을 CCC 복구 단계에서 숨긴다(레퍼런스 경로 전용). + var untrusted: Set? = null + if (ref && CCC_EVIDENCE_MIN_CONF > 0.0) { + val cf = channelConfidence(results, signals) + if (cf != null) { + var weak = cf.filterValues { it < CCC_EVIDENCE_MIN_CONF }.keys.toSet() + if (CCC_EVIDENCE_MAX_NCH > 0 && cf.size > CCC_EVIDENCE_MAX_NCH) weak = emptySet() + if (cf.size - weak.size >= 2) untrusted = weak // 근거가 없어지는 것은 막는다 + } + } + applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params, untrusted) + } + + // ── 6) 약한 채널 강등 (레퍼런스 경로 전용) ───────────────────────── + if (ref && CONF_DEMOTE > 0.0 && applyCross) { + val cf = channelConfidence(results, signals) + if (cf != null) { + val keep = cf.filterValues { it >= CONF_DEMOTE }.keys + if (keep.size >= maxOf(1, CONF_DEMOTE_MIN_KEEP)) { + for ((c, v) in cf) if (v < CONF_DEMOTE && c < results.size) results[c] = null + } + } + } + return DetectionOutcome(results, missingOut) } /** - * Cross-channel 3-stage 후처리 (Python `apply_cross_channel`). - * ① ant_tiebreak → ② neighbor_top_validate → ③ inward_post + * 검출 결과 + **관측 증거**. + * + * [missingOutside] 는 극 cap 의 상한/하한 방향을 가르는 값이라 BV 에 함께 넘겨야 + * 한다. **CCC 앞 시점**이어야 하므로(복구된 채널은 방광 밖이 아니다) 검출기가 + * 만들어 돌려준다. 기존 경로에서는 항상 빈 집합이다. + */ + data class DetectionOutcome( + val walls: List, + val missingOutside: Set, + ) + + /** + * 교차채널 보정 — 모드에 따라 **다른 순서**로 돈다. + * + * 기존(팬텀 시연): ① ant_tiebreak → ② neighbor_top_validate(Rule A+B) → ③ inward_post + * 레퍼런스 : ①.9 envelope → ① ant_tiebreak → ①.7 Λ-dip → ①.8 edge → + * ①.95 lumen_return → ② NTV(Rule A 만) → ③ fp_channel_gate + * + * 두 경로를 섞으면 안 된다. 레퍼런스의 단계들은 서로를 전제한다(예: ①.9 가 먼저 + * 후보를 확보해야 ① 이 제대로 재순위를 매긴다). */ private fun applyCrossChannel( results: MutableList, @@ -105,14 +197,49 @@ object MethodDRunner { heavy: List, raw6ch: List, params: MethodDParams, + untrusted: Set? = null, ) { + if (!com.medithings.vesiscan.managers.AlgoMode.reference) { + applyAntTiebreak(results, angles) + if (NEIGHBOR_TOP_VALIDATE) { + applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params) + } + if (INWARD_POST_SEARCH) { + applyInwardPost(results, angles) + } + return + } + + // ── 레퍼런스 경로 ── + // 신뢰도 낮은 채널은 **복구 단계에서만** 숨긴다(이웃 근거로 안 쓰이도록). + val hidden = HashMap() + if (untrusted != null) { + for (c in untrusted) { + if (c < results.size) { + val r = results[c] + if (r != null) { hidden[c] = r; results[c] = null } + } + } + } + // ①.9 포락선 재탐색은 **맨 앞**이다 — 탐색창이 진짜 벽을 배제한 경우를 고치는 + // 단계라, 이후 재탐색들이 재순위를 매기기 전에 후보를 먼저 확보해야 한다. + if (ENVELOPE_RESEARCH) applyEnvelopeResearch(results, params) applyAntTiebreak(results, angles) + applyLambdaPostRecovery(results, angles, light, heavy, raw6ch, params) + if (EDGE_POST_RECOVERY) { + applyEdgePostRecovery(results, angles, light, heavy, raw6ch, params) + } + if (LUMEN_RETURN_RECOVERY) applyLumenReturnRecovery(results, raw6ch, params) + // 판정 단계 **전에** 복원한다 — 숨긴 채로 두면 CCC 가 그 채널을 FP 로 기각할 + // 기회를 잃어 약한 채널이 오히려 살아남는다. + for ((c, r) in hidden) if (results[c] == null) results[c] = r if (NEIGHBOR_TOP_VALIDATE) { - applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params) - } - if (INWARD_POST_SEARCH) { - applyInwardPost(results, angles) + // 레퍼런스의 NTV 는 **Rule A(FN 복구)만** 한다. 기존 구현의 Rule B(최상단 + // FP 기각)는 ③ fp_channel_gate 로 옮겨졌으므로 레퍼런스 경로에서는 건너뛴다. + applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params, + ruleBEnabled = false) } + if (FP_CHANNEL_GATE) applyFpChannelGate(results, angles) } // ───────────────────────────────────────────────────────────────────────── @@ -218,33 +345,43 @@ object MethodDRunner { heavy: List, raw6ch: List, params: MethodDParams, + /** + * 최상단 채널 FP **기각**(Rule B) 사용 여부. + * + * 레퍼런스는 Rule B 를 걷어내고 FN **복구**(Rule A)만 남겼다 — 기각은 + * ③ `fp_channel_gate` 가 전 채널을 같은 기준으로 판정하는 쪽으로 옮겨졌다. + * 기존(팬텀 시연) 경로는 종전대로 Rule B 를 쓴다. + */ + ruleBEnabled: Boolean = true, ) { val n = kmin(4, results.size) val origNone = (0 until n).filter { results[it] == null }.toHashSet() val det = (0 until n).filter { results[it] != null }.sorted() - // ---- Rule B ---- - if (det.size >= 3) { - val top = det[0]; val nb1 = det[1]; val nb2 = det[2] - val rTop = results[top]!! - val rNb1 = results[nb1]!! - val rNb2 = results[nb2]!! - val zTop = zOf(angles, top, rTop.post) - val zNb1 = zOf(angles, nb1, rNb1.post) - val zNb2 = zOf(angles, nb2, rNb2.post) - val slope = zNb1 - zNb2 - val pred = zNb1 + slope - if (slope <= NTV_SLOPE_EPS && (zTop - pred) > NTV_DEV_DELTA) { - val rr = researchPostInSpan( - light[top], heavy[top], raw6ch[top], rNb1.lowStart, rNb1.lowEnd, params - ) - if (rr == null || (zOf(angles, top, rr.post) - pred) > NTV_DEV_DELTA) { - results[top] = null - } else { - rTop.post = rr.post - rTop.postRefined = rr.post.toDouble() - rTop.postType = rr.postType - rTop.urineLen = rTop.post - rTop.ant - 1 + if (ruleBEnabled) { + // ---- Rule B ---- + if (det.size >= 3) { + val top = det[0]; val nb1 = det[1]; val nb2 = det[2] + val rTop = results[top]!! + val rNb1 = results[nb1]!! + val rNb2 = results[nb2]!! + val zTop = zOf(angles, top, rTop.post) + val zNb1 = zOf(angles, nb1, rNb1.post) + val zNb2 = zOf(angles, nb2, rNb2.post) + val slope = zNb1 - zNb2 + val pred = zNb1 + slope + if (slope <= NTV_SLOPE_EPS && (zTop - pred) > NTV_DEV_DELTA) { + val rr = researchPostInSpan( + light[top], heavy[top], raw6ch[top], rNb1.lowStart, rNb1.lowEnd, params + ) + if (rr == null || (zOf(angles, top, rr.post) - pred) > NTV_DEV_DELTA) { + results[top] = null + } else { + rTop.post = rr.post + rTop.postRefined = rr.post.toDouble() + rTop.postType = rr.postType + rTop.urineLen = rTop.post - rTop.ant - 1 + } } } } @@ -440,4 +577,541 @@ object MethodDRunner { for (i in lo..hi) if (arr[i] < m) m = arr[i] return if (m.isFinite()) m else 0.0 } + + // ═══ 레퍼런스 경로 전용 ([AlgoMode.reference]) ═══════════════════ + + /** z(angle, idx) — angle 을 직접 받는 sample_to_ap_depth. vdipDeepCandidate 재사용. */ + private fun zAt(angleDeg: Double, idx: Int): Double = + (PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(angleDeg)) + + /** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */ + /** + * cur_post 보다 깊고 target_z + margin 이내에서 gate 통과하는 최강진폭 local-max peak + * idx (없으면 null). detector 동일 gate (wall_gate_ok + min_post_raw_ratio) 로 재검증해 + * 얕은 반향 peak 대신 진짜 심부 후벽만 채택. V-dip 심부후벽 재탐색용. + */ + private fun vdipDeepCandidate( + lt: DoubleArray, hv: DoubleArray, raw: DoubleArray, + ai: Int, curPost: Int, angleDeg: Double, + ls: Int, le: Int, targetZ: Double, + params: MethodDParams, + ): Int? { + val L = lt.size + var hi = curPost + 1 + while (hi < L - 1 && zAt(angleDeg, hi) < targetZ + VDIP_REACH_MARGIN) hi++ + val cap = if (params.postMaxIdx > 0) params.postMaxIdx else (L - 1) + hi = kmin(kmin(hi, cap), L - 2) + if (hi <= curPost + 1 || le <= ls) return null + val lumenMin = minInRange(hv, ls, le) + val rawHv = MethodDPreprocessing.preprocessHeavy(raw, params) + val rawLt = MethodDPreprocessing.preprocessLight(raw) + val rawLumen = minInRange(rawHv, ls, le) + var best = -1 + var bestAmp = -1.0 + for (c in (curPost + 2)..hi) { + // local max + if (!(lt[c] >= lt[c - 1] && lt[c] > lt[c + 1])) continue + // wall gate (detector 와 동일: wallRatioUsePostOnly ? post : min(ant,post)) + val wallAmp = if (params.wallRatioUsePostOnly) lt[c] else kmin(lt[ai], lt[c]) + if (wallAmp / maxOf(lumenMin, 1.0) < params.minWallLumenRatio) continue + // raw ratio gate + if (rawLt[c] / maxOf(rawLumen, 1.0) < params.minPostRawRatio) continue + if (lt[c] > bestAmp) { + bestAmp = lt[c]; best = c + } + } + return if (best < 0) null else best + } + + /** + * ①.7 Λ-dip 이웃 심부후벽 복구 — Python `apply_lambda_post_recovery`. + * + * ## 종전 구현(V-dip)과 방향이 반대다 + * 2026-09-03 까지 여기는 "가운데 채널이 이웃보다 **얕게** 잡혔으면 가운데를 깊게" + * 고치는 V-dip 이었다. 레퍼런스는 그 반대다 — "가운데가 이웃보다 **깊게** 잡혔으면 + * (그쪽이 진짜 후벽일 가능성이 높으므로) **이웃 둘을** 그 깊이로 끌어올린다". + * + * 기준선도 다르다: V-dip 은 두 이웃의 평균을, Λ-dip 은 **가운데 채널의 깊이**를 + * 목표로 삼는다. 그리고 레퍼런스에는 prominence 가드가 있다 — 더 약한 후보로는 + * 옮기지 않는다(후벽 재탐색 4종의 공통 규칙). + */ + private fun applyLambdaPostRecovery( + results: MutableList, + angles: DoubleArray, + light: List, + heavy: List, + raw6ch: List, + params: MethodDParams, + dipTol: Double = VDIP_DIP_TOL, + ) { + val det = AlignmentConstants.CENTER_CH + .filter { it < results.size && results[it] != null } + .sorted() + if (det.size < 3) return + + val zc = HashMap() + for (i in det) zc[i] = zOf(angles, i, results[i]!!.post) + + for (pos in 1 until det.size - 1) { + val i = det[pos]; val lo = det[pos - 1]; val hi = det[pos + 1] + val zi = zc[i]!!; val zLo = zc[lo]!!; val zHi = zc[hi]!! + // Λ-dip: 가운데가 양 이웃보다 dipTol 깊고, 두 이웃끼리는 dipTol 이내로 일치 + if (!(zi - zLo > dipTol && zi - zHi > dipTol && abs(zLo - zHi) < dipTol)) continue + + val cons = zi // 기준선 = 가운데 채널의 깊이 + for (j in listOf(lo, hi)) { + val r = results[j] ?: continue + val ang = if (j < angles.size) angles[j] else 0.0 + val newp = vdipDeepCandidate( + light[j], heavy[j], raw6ch[j], r.ant, r.post, ang, + r.lowStart, r.lowEnd, cons, params + ) ?: continue + if (newp <= r.post) continue // 깊어지는 방향만 + // 더 약한 후보로는 옮기지 않는다(prominence 가드). + if (postPromAt(light[j], newp, r.lowStart, r.lowEnd) < + postPromAt(light[j], r.post, r.lowStart, r.lowEnd)) continue + val zNew = zAt(ang, newp) + if (abs(zNew - cons) >= abs(zc[j]!! - cons)) continue // 기준선에 가까워질 때만 + if (zNew > cons + VDIP_REACH_MARGIN) continue // overshoot 방지 + r.post = newp + r.postRefined = newp.toDouble() + r.urineLen = newp - r.ant - 1 + } + } + } + + /** + * 후벽 prominence — 검출기와 같은 정의(low echo 영역 바닥 기준). + * + * `MethodDResult.postProm` 을 쓰지 않는 이유: Rule A 로 만들어진 값은 0.0 이고, + * 단계마다 벽이 옮겨져도 갱신되지 않는다. 같은 바닥으로 양쪽을 다시 재야 비교가 + * 성립한다. + */ + private fun postPromAt(sig: DoubleArray, idx: Int, ls: Int, le: Int): Double { + if (le < ls || idx < 0 || idx >= sig.size || le >= sig.size) return 0.0 + return sig[idx] - minInRange(sig, ls, le) + } + + /** + * 후보 [idx] 뒤에서 신호가 lumen 수준으로 되돌아가는가 = 후보가 방광 **안**이다. + * + * 진짜 후벽 뒤는 조직이라 low echo 수준으로 돌아가지 않는다. 참값이 필요 없는 + * 물리 판별이다. [sig] 는 반드시 **비-TGC**(SG-only)여야 한다 — TGC 는 깊은 곳의 + * lumen 도 들어올려 판정을 무디게 한다. + */ + private fun lumenReturnsAfter(sig: DoubleArray, idx: Int, ls: Int, le: Int, minRun: Int): Boolean { + if (le <= ls || idx >= sig.size - 1) return false + val floor = minInRange(sig, ls, le) + val med = medianInRange(sig, ls, le) + val level = floor + LUMEN_RETURN_LEVEL * maxOf(med - floor, 1.0) + var run = 0 + var k = idx + 1 + val end = kmin(sig.size, idx + 1 + LUMEN_RETURN_LOOKAHEAD) + while (k < end) { + run = if (sig[k] <= level) run + 1 else 0 + if (run >= minRun) return true + k++ + } + return false + } + + private fun medianInRange(sig: DoubleArray, ls: Int, le: Int): Double { + val v = DoubleArray(le - ls + 1) { sig[ls + it] } + v.sort() + val n = v.size + return if (n % 2 == 1) v[n / 2] else 0.5 * (v[n / 2 - 1] + v[n / 2]) + } + + /** + * ①.95 방광 **안**을 후벽으로 잡은 채널을 더 깊이 재탐색 — Python + * `apply_lumen_return_recovery`. + * + * 판정은 **비-TGC 신호**로 한다(SPEC "포팅 시 특히 틀리기 쉬운 곳" 2번). TGC 신호로 + * 하면 깊은 곳의 lumen 이 들어올려져 "되돌아옴"을 못 본다. + */ + private fun applyLumenReturnRecovery( + results: MutableList, + raw6ch: List, + params: MethodDParams, + ) { + for (i in AlignmentConstants.CENTER_CH) { + if (i >= results.size) continue + val r = results[i] ?: continue + val ls = r.lowStart; val le = r.lowEnd + if (le <= ls) continue + val lt = r.sgLight + val chk = MethodDPreprocessing.preprocessLight(raw6ch[i]) // 비-TGC 판정 신호 + val run = params.lowMinLen + if (!lumenReturnsAfter(chk, r.post, ls, le, run)) continue + + val out = MethodDWallSelect.findWallPeakLocal( + lt, ls, le, MethodDWallSelect.Side.POST, params, + dMaxOverride = LUMEN_RETURN_DMAX, inwardWalk = r.inwardWalkPost, + ) + val b = out.best ?: continue + val nw = b.idx + if (nw == r.post || lumenReturnsAfter(chk, nw, ls, le, run)) continue + if (postPromAt(lt, nw, ls, le) < postPromAt(lt, r.post, ls, le)) continue + if (lt.size - 1 - nw < params.minPostOutward) continue // far-field tail FP + if ((nw - r.ant - 1) < params.minUrineLen) continue + r.post = nw + r.postRefined = nw.toDouble() + r.postType = b.type + r.urineLen = nw - r.ant - 1 + } + } + + /** + * ①.8 끝 채널 후벽 심부 복구 — Python `apply_edge_post_recovery`(ends='all'). + * + * Λ-dip 은 **중간 채널**만 본다(양 이웃이 있어야 성립). 끝 채널은 후벽 검증이 전혀 + * 없어, 탐색창이 진짜 벽을 배제해도 아무도 잡지 못한다. 후벽 탐색창 끝은 + * `low_end + d_max + inward_walk_post` 인데, span 끝이 1 샘플만 흔들려도 창 끝이 + * 10 샘플 넘게 갈려 진짜 벽이 창 밖으로 나간다. + * + * ## 깊이는 진입 조건일 뿐이다 + * 끝 채널이 이웃 추세보다 얕은 것은 **아치 곡률의 정상 배치이기도 하다**. 그래서 + * 실제 판별은 prominence 가드가 한다 — 더 약한 후보로는 옮기지 않는다. 곡률로 + * 정상인 채널은 자기가 이미 최강이라 자연히 차단된다. + * + * ## 끝 채널과 중간 채널의 조건이 다르다 + * 끝 채널은 실패 모드가 **탐색창 배제**라 "창 밖 후보만"(needOutside) 조건을 건다. + * 중간 채널은 후보가 창 **안**에 있는데 거리 페널티가 얕은 쪽을 올려 뒤집는 경우라, + * 창밖 조건을 걸면 안 된다. + */ + private fun applyEdgePostRecovery( + results: MutableList, + angles: DoubleArray, + light: List, + heavy: List, + raw6ch: List, + params: MethodDParams, + dipTol: Double = VDIP_DIP_TOL, + ) { + val det = AlignmentConstants.CENTER_CH + .filter { it < results.size && results[it] != null } + .sorted() + if (det.size < 2) return + + fun z(i: Int) = zOf(angles, i, results[i]!!.post) + + // (대상 채널, 목표 깊이, 창밖 요구 여부) + val pairs = ArrayList>() + run { // bottom 끝 + val j = det[det.size - 1]; val nb = det[det.size - 2] + var cons = z(nb) + // 끝채널 목표를 단일 이웃 값에 고정하면 그 이웃의 1-sample jitter 가 + // closer gate 를 뒤집는다. 두 번째 이웃이 있으면 추세를 외삽한다. + if (EDGE_POST_TREND && det.size >= 3) { + cons += kmin(maxOf(z(nb) - z(det[det.size - 3]), 0.0), VDIP_REACH_MARGIN) + } + pairs.add(Triple(j, cons, true)) + } + run { // top 끝 + val j = det[0]; val nb = det[1] + var cons = z(nb) + if (EDGE_POST_TREND && det.size >= 3) { + cons += kmin(maxOf(z(nb) - z(det[2]), 0.0), VDIP_REACH_MARGIN) + } + pairs.add(Triple(j, cons, true)) + } + for (k in 1 until det.size - 1) { // 중간 채널: 더 깊은 인접 이웃을 목표로 + val j = det[k] + val nb = if (z(det[k - 1]) >= z(det[k + 1])) det[k - 1] else det[k + 1] + pairs.add(Triple(j, z(nb), false)) + } + + for ((j, cons, needOutside) in pairs) { + if (z(j) >= cons - dipTol) continue // 이웃 추세보다 tol 넘게 얕을 때만 + val r = results[j] ?: continue + val ang = if (j < angles.size) angles[j] else 0.0 + val new = vdipDeepCandidate( + light[j], heavy[j], raw6ch[j], r.ant, r.post, ang, + r.lowStart, r.lowEnd, cons, params + ) ?: continue + if (new <= r.post) continue + // 검출기가 **볼 수 없었던** 벽만 복구한다. 후보가 탐색창 안이었다면 검출기가 + // 보고도 점수로 밀어낸 것이므로 뒤집지 않는다. + if (needOutside && new <= r.lowEnd + params.dMax + r.inwardWalkPost) continue + if (postPromAt(light[j], new, r.lowStart, r.lowEnd) < + postPromAt(light[j], r.post, r.lowStart, r.lowEnd)) continue + val zNew = zAt(ang, new) + // strict closer 는 1-sample 변화만으로 같은 심부후보를 채택/기각했다. + // 2mm 이내 열세는 동등으로 보고 prominence 증거를 따른다. + if (abs(zNew - cons) > abs(z(j) - cons) + EDGE_CLOSER_TOL_MM || + zNew > cons + VDIP_REACH_MARGIN) continue + r.post = new + r.postRefined = new.toDouble() + r.urineLen = new - r.ant - 1 + } + } + + /** + * ①.9 채널 합의 포락선 안에서 전벽·후벽 재탐색 — Python `apply_envelope_research`. + * + * 검출기의 탐색창은 span 기준이다(전벽 `span_s − ant_d_max − inward_walk`, + * 후벽 `low_end + d_max + inward_walk`). span 이 늦게/일찍 잡히면 **진짜 벽이 창 + * 밖**이 되어 후보에조차 못 든다. 다른 채널들의 합의 포락선까지 창을 넓혀 그 경우만 + * 구제한다. + * + * [ENVELOPE_TRIM]: 포락선을 min/max 로 잡으면 채널 하나가 틀렸을 때 범위가 통째로 + * 끌려간다. CCC 맨 앞이라 아직 아무 재탐색도 거치지 않은 원 검출이어서 그 노출이 + * 크므로, 채널이 3개 이상이면 **최극단 하나를 빼고** 잡는다. + * + * 전벽 교체 가드가 특이하다: **현재 벽이 base 후보 중 최고점이면 교체하지 않는다.** + * 이 단계의 취지는 "진짜 벽이 창 밖이라 후보에 아예 없던" 경우를 구제하는 것인데, + * 이미 최고점이면 그 경우가 아니다. 진폭차 가드만 쓰면 얕을수록 유리해져 근거리 + * ringdown 이 진짜 벽을 이긴다. + */ + private fun applyEnvelopeResearch( + results: MutableList, + params: MethodDParams, + margin: Int = ENVELOPE_MARGIN, + ) { + val det = AlignmentConstants.CENTER_CH.filter { it < results.size && results[it] != null } + if (det.isEmpty()) return + + if (det.size == 1) { + // 교차채널 합의가 없어 포락선을 못 만든다. 전벽 창만 넓혀 재탐색한다. + // (후벽은 ①.95 lumen-return 재탐색이 채널 수와 무관하게 처리한다.) + val r = results[det[0]]!! + val ls = r.lowStart; val le = r.lowEnd + if (le <= ls) return + val lt = r.sgLight + val out = MethodDWallSelect.findWallPeakLocal( + lt, ls, le, MethodDWallSelect.Side.ANT, params, + dMaxOverride = ENVELOPE_NCH1_ANT_DMAX, inwardWalk = r.inwardWalkAnt, + ) + val b = out.best ?: return + val na = b.idx + if (na != r.ant && + postPromAt(lt, na, ls, le) >= postPromAt(lt, r.ant, ls, le) && + (r.post - na - 1) >= params.minUrineLen) { + r.ant = na + r.antRefined = na.toDouble() + r.antType = b.type + r.urineLen = r.post - na - 1 + } + return + } + if (det.size < 2) return + + val posts = det.map { results[it]!!.post }.sorted() + val ants = det.map { results[it]!!.ant }.sorted() + val k = if (ENVELOPE_TRIM && det.size >= 3) 1 else 0 + val postMax = posts[posts.size - 1 - k] + margin + val antMin = ants[k] - margin + + for (i in det) { + val r = results[i] ?: continue + val ls = r.lowStart; val le = r.lowEnd + if (le <= ls) continue + val lt = r.sgLight + + // ── 전벽: 창을 [antMin, span_s] 로 넓혀 재탐색 ── + val iwA = r.inwardWalkAnt + val needA = ls - antMin - iwA + if (needA > params.antDMax) { + val out = MethodDWallSelect.findWallPeakLocal( + lt, ls, le, MethodDWallSelect.Side.ANT, params, + dMaxOverride = needA, inwardWalk = iwA, + ) + val b = out.best + if (b != null) { + val na = b.idx + // 현재 벽이 base 후보 중 최고점이면 교체하지 않는다(위 KDoc 참조). + val cb = r.antCandidates + val keep = cb.isNotEmpty() && + r.ant == cb.maxByOrNull { it.second }!!.first + if (na != r.ant && !keep && + postPromAt(lt, na, ls, le) >= postPromAt(lt, r.ant, ls, le) && + (r.post - na - 1) >= params.minUrineLen) { + r.ant = na + r.antRefined = na.toDouble() + r.antType = b.type + r.urineLen = r.post - na - 1 + } + } + } + + // ── 후벽: 창을 [span_e, postMax] 로 넓혀 재탐색 ── + val iw = r.inwardWalkPost + val need = postMax - le - iw + if (need <= params.dMax) continue // 이미 창 안 — 재탐색 불필요 + val out = MethodDWallSelect.findWallPeakLocal( + lt, ls, le, MethodDWallSelect.Side.POST, params, + dMaxOverride = need, inwardWalk = iw, + ) + val best = out.best ?: continue + val new = best.idx + if (new == r.post) continue + if (postPromAt(lt, new, ls, le) < postPromAt(lt, r.post, ls, le)) continue + if ((new - r.ant - 1) < params.minUrineLen) continue + r.post = new + r.postRefined = new.toDouble() + r.postType = best.type + r.urineLen = new - r.ant - 1 + } + } + + /** + * ③ FP 채널 기각 — Python `apply_fp_channel_gate`. + * + * 양쪽 벽이 **동시에** 이웃 median 보다 [FP_GATE_DZ_MIN] 넘게 깊고, **동시에** + * prominence 비가 [FP_GATE_PROM_MAX] 미만인 채널을 버린다. 한쪽만 어긋난 것은 + * 재탐색 대상이므로 AND 조건이 본질이다. 검출이 3채널 미만이면 기준이 불안정해 + * 발동하지 않는다. + * + * peer 는 **전 6채널**이다. 측방(CH4·CH5)이 x 축으로 ±10mm 벗어나 있어 일관성 + * 면에서는 어색하지만, peer 가 3 → 5 개로 늘어 median 이 안정된다(레퍼런스 주석의 + * 실측 근거: CENTER_CH 로 제한하면 LOSO −0.16pp 로 전원 악화). + */ + private fun applyFpChannelGate(results: MutableList, angles: DoubleArray) { + val det = results.indices.filter { results[it] != null } + if (det.size < 3) return + val za = HashMap() + val zp = HashMap() + for (i in det) { + val ang = if (i < angles.size) angles[i] else 0.0 + za[i] = zAt(ang, results[i]!!.ant) + zp[i] = zAt(ang, results[i]!!.post) + } + val drop = ArrayList() + for (i in det) { + val peers = det.filter { it != i } + if (peers.size < 2) continue + val r = results[i]!! + val pa = r.antProm / maxOf(medianOf(peers.map { results[it]!!.antProm }), 1.0) + val pp = r.postProm / maxOf(medianOf(peers.map { results[it]!!.postProm }), 1.0) + val da = za[i]!! - medianOf(peers.map { za[it]!! }) + val dp = zp[i]!! - medianOf(peers.map { zp[it]!! }) + if (pa < FP_GATE_PROM_MAX && pp < FP_GATE_PROM_MAX && + da > FP_GATE_DZ_MIN && dp > FP_GATE_DZ_MIN) drop.add(i) + } + for (i in drop) results[i] = null + } + + /** numpy `median` 과 같은 정의 — 짝수 개면 가운데 둘의 평균. */ + /** numpy `median` 과 같은 정의 — 짝수 개면 가운데 둘의 평균. */ + private fun medianOf(v: List): Double { + if (v.isEmpty()) return 0.0 + val a = v.sorted() + val n = a.size + return if (n % 2 == 1) a[n / 2] else 0.5 * (a[n / 2 - 1] + a[n / 2]) + } + + /** + * 파이프라인 2 — 검출 실패 채널만 SG`window` 로 재탐색 (Python `apply_scale_recovery`). + * + * prod 전처리는 SG7 인데, 스케일 스윕에서 **SG5 가 검출률이 더 높다**는 것이 + * 확인됐다(SG7 이 약한 후벽 peak 의 prominence 를 일부 뭉갠다). 그런데 window 를 + * 전역으로 바꾸면 **모든 채널의 벽이 함께 이동해 부피가 통째로 밀린다** — window 는 + * 검출식으로 쓰되 측정식으로 쓰면 안 된다. + * + * 그래서 규칙이 좁다: **prod 에서 이미 검출된 채널은 절대 건드리지 않는다.** 실패한 + * 채널만 SG5 로 다시 본다. 게이트는 prod 파라미터를 그대로 써서 FP 는 strict 를 + * 유지한다. 얻는 것은 커버리지(nch)뿐이고 측정면은 불변이다. + */ + private fun applyScaleRecovery( + results: MutableList, + raw6ch: List, + angles: DoubleArray, + params: MethodDParams, + applyTgc: Boolean, + window: Int = RECOVERY_WINDOW, + ) { + val fails = results.indices.filter { results[it] == null } + if (fails.isEmpty()) return + + // 지정 window 로 heavy/light 재생성 (+TGC). prod 흐름과 같은 순서다. + val heavy = raw6ch.map { + MedianFilter.runningMedianRoot( + SgSmoothGeneric.smooth(it, window, MethodDPreprocessing.SG_POLY), + params.oscfarWin, params.oscfarMaxIters) + } + val light = raw6ch.map { SgSmoothGeneric.smooth(it, window, MethodDPreprocessing.SG_POLY) } + val heavyT = if (applyTgc) MethodDTgc.applyTgcPipeline(heavy) else heavy + val lightT = if (applyTgc) MethodDTgc.applyTgcPipeline(light) else light + + for (i in fails) { + val angleDeg = if (i < angles.size) angles[i] else 0.0 + val r = MethodDDetector.detect( + raw = raw6ch[i], + denoisedHeavy = heavyT[i], + denoisedLight = lightT[i], + otsuRatioOverride = params.otsuRatio * cos(Math.toRadians(angleDeg)), + params = params, + ) + if (r != null) results[i] = r + } + } + + /** + * center 채널별 신뢰도 {ch: 0~1} — Python `channel_evidence.channel_confidence`. + * 검출 채널이 2개 미만이면 null. + * + * 값 = **비-TGC** `min(ant_amp, post_amp) / lumen_min` 을 그 trace 최고값으로 나눈 것. + * + * 비-TGC 로 재는 이유: TGC 가 원거리 이득을 올려 **약한 채널을 정상 채널에 붙여 + * 놓는다**(레퍼런스 실측: CH1↔CH3 간격이 TGC 0.05 vs 비-TGC 0.21). 절대값이 아니라 + * trace 안의 상대값이라 피험자·기기·깊이별 신호 세기 차가 정규화된다. + */ + private fun channelConfidence( + results: List, + raw6ch: List, + ): Map? { + val rr = HashMap() + for (ch in 0 until kmin(4, results.size)) { + val d = results[ch] ?: continue + val ls = d.lowStart; val le = d.lowEnd + if (le <= ls) continue + val rh = MethodDPreprocessing.preprocessHeavy(raw6ch[ch]) + val rl = MethodDPreprocessing.preprocessLight(raw6ch[ch]) + val lm = maxOf(minInRange(rh, ls, le), 1.0) + val ai = d.ant; val pi = d.post + if (ai < 0 || ai >= rl.size || pi < 0 || pi >= rl.size) continue + rr[ch] = kmin(rl[ai], rl[pi]) / lm + } + if (rr.size < 2) return null + val mx = rr.values.max() + if (mx <= 0.0) return null + return rr.mapValues { it.value / mx } + } + + /** + * 파이프라인 4 — 미검출 채널 중 **low echo span 조차 없는** 채널 집합. + * Python `channel_evidence.missing_outside`. + * + * span 이 없다 = 그 빔에 방광이 아예 없다("진짜 방광 밖"). 이 판정이 극 cap 처리의 + * **상한/하한 방향**을 가른다: + * · 방광 밖 → 그 빔 위치를 cap 높이의 **상한**으로(종전 동작) + * · span 있음 → 방광이 거기까지는 온다 → 같은 지점을 **하한**으로(외삽을 관측으로 대체) + * + * 레퍼런스 실측상 "미검출 = 방광 밖"이 실제로 맞는 경우는 silicon 17.2% / abs 3.5% + * 뿐이다. 나머지는 span 이 있는데 벽만 못 잡은 것이라, 상한으로만 쓰면 cap 을 부당하게 + * 잘라낸다. + * + * **검출된 채널은 대상이 아니다** — 벽이 있었으니 span 도 있다. + * + * @param light TGC 적용된 light 신호 (검출에 쓴 것과 같은 것) + * @param heavy TGC 적용된 heavy 신호 + */ + fun missingOutside( + results: List, + light: List, + heavy: List, + angles: DoubleArray, + params: MethodDParams = MethodDParams.DEFAULT, + ): Set { + val outside = HashSet() + for (ch in results.indices) { + if (results[ch] != null) continue + val th = if (ch < angles.size) Math.toRadians(angles[ch]) else 0.0 + val sp = MethodDSpan.extractLowEchoSpanWithFallback( + heavy[ch], light[ch], params.otsuRatio * cos(th), params) + if (sp == null) outside.add(ch) + } + return outside + } + } diff --git a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDParams.kt b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDParams.kt index c030aee..fef2759 100644 --- a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDParams.kt +++ b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDParams.kt @@ -40,6 +40,11 @@ data class MethodDParams( val oscfarMaxIters: Int = 4, val minLightSeparability: Double = 0.75, val minSpanLen: Int = 5, + /** + * 후벽 far-field tail FP 게이트 — 후벽 뒤에 남은 샘플이 이보다 적으면 tail ramp 로 + * 보고 차단한다. Python `config_wd.min_post_outward`. 레퍼런스 경로에서만 쓰인다. + */ + val minPostOutward: Int = 6, val recoveryExtendOutward: Boolean = false, val wallRatioUsePostOnly: Boolean = true, ) { diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AlgoModeSwitchTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AlgoModeSwitchTest.kt new file mode 100644 index 0000000..4950ae9 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/AlgoModeSwitchTest.kt @@ -0,0 +1,83 @@ +package com.medithings.vesiscan.managers + +import com.medithings.vesiscan.walldetect.MethodDRunner +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [AlgoMode] 토글이 실제로 배선되어 있는지 확인한다. + * + * 기존 경로가 HEAD 와 같다는 것은 [LegacyEquivalenceDumpTest] 가 덤프 대조로 증명한다. + * 여기서 보는 것은 그 반대편이다 — 레퍼런스를 켰을 때 **정말로 다른 경로가 돌고**, + * 끄면 원래대로 돌아오는지. 켰는데 값이 같으면 배선이 끊긴 것이므로 실패한다. + */ +class AlgoModeSwitchTest { + + @After fun restore() { AlgoMode.reference = false } + + private fun res(name: String) = + javaClass.classLoader!!.getResourceAsStream(name)!!.bufferedReader().use { it.readText() } + + private fun loadCycles(name: String): Map> { + val out = HashMap>>() + for (line in res(name).lineSequence()) { + val f = line.trim().split(",") + if (f.size < 3) continue + val cyc = f[0].toIntOrNull() ?: continue + val ch = f[1].toIntOrNull() ?: continue + out.getOrPut(cyc) { ArrayList() } + .add(ch to DoubleArray(f.size - 2) { f[it + 2].trim().toDouble() }) + } + return out.mapValues { (_, v) -> v.toMap().let { m -> (0..5).map { m[it] ?: DoubleArray(0) } } } + } + + /** cycle 마다 "벽 + BV + missing_outside" 를 한 줄로. */ + private fun run(): List { + val out = ArrayList() + for (cm in 0..1) { + val cycles = loadCycles("align_cm$cm.csv") + for (cyc in cycles.keys.sorted()) { + val o = MethodDRunner.detectMultichannelWithEvidence(cycles[cyc]!!, applyCross = true) + val walls = o.walls.map { r -> + r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } + } + val bv = estimateBv(walls, missingOutside = o.missingOutside.ifEmpty { null }) + out.add("$cm/$cyc " + o.walls.joinToString(",") { r -> + r?.let { "${it.ant}-${it.post}" } ?: "-" + } + " mo=${o.missingOutside.sorted()} bv=${"%.4f".format(bv?.volumeMl ?: -1.0)}") + } + } + return out + } + + @Test + fun `레퍼런스 토글이 검출·BV 경로를 실제로 바꾼다`() { + AlgoMode.reference = false + val legacy = run() + AlgoMode.reference = true + val reference = run() + + assertEquals("두 경로가 같은 수의 cycle 을 처리해야 한다", legacy.size, reference.size) + assertTrue("픽스처가 비어 있다", legacy.size >= 40) + + // 기존 경로는 missing_outside 를 산출하지 않는다(항상 빈 집합). + assertTrue("기존 경로가 missing_outside 를 내면 안 된다", + legacy.all { "mo=[]" in it }) + // 레퍼런스 경로는 파이프라인 4단계에서 증거를 산출한다. + assertTrue("레퍼런스 경로가 missing_outside 를 전혀 못 냈다 — 4단계 미배선", + reference.any { "mo=[]" !in it }) + + assertNotEquals("레퍼런스를 켰는데 결과가 같다 — 토글이 배선되지 않았다", + legacy, reference) + + // 되돌리면 원래 값으로 복귀해야 한다(전역 상태가 남지 않는지). + AlgoMode.reference = false + assertEquals("모드를 끈 뒤 기존 경로가 재현되지 않는다", legacy, run()) + + val diff = legacy.indices.count { legacy[it] != reference[it] } + println("[AlgoMode] cycle ${legacy.size} 중 ${diff} 개가 갈림") + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt index c095d33..fe93f2a 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt @@ -10,18 +10,25 @@ import java.io.File * 실행: * ./gradlew testDevDebugUnitTest --tests "*CenterAlignerValidationTest*" * - * Python 기준값 (scratchpad/v3_python_records.json, cf. v3_python_reference.py): - * cm=0: nch=3, ch3=X, hit=2/11, bv_cv=0.183 - * cm=1: nch=4, ch3=O, hit=7/11, bv_cv=0.260 - * cm=3: nch=3, ch3=O, hit=10/11, bv_cv=0.130 + * 기준값은 **현행 레퍼런스**(`vesiscan_test.library.alignment_runners.AnchorGuide`, + * ver='v1')를 같은 세션에 돌려 받은 값이다: + * cm=0: nch=3, ch3=X, hit=2/11 + * cm=1: nch=4, ch3=O, hit=8/11 + * cm=3: nch=3, ch3=O, hit=11/11 * selected_cm = 3 (Rule A: 80% 임계 통과는 3cm 만 → max nch → 3cm) + * + * 종전 기대값(hit 7/11 · 10/11 · bv_cv 0.183/0.260/0.130)은 **구** Python 레퍼런스 + * (`v3_python_reference.py`, 현재 저장소에 없음) 기준이라 더 이상 맞지 않는다. + * 검출 파라미터(`d_max` 15 · `inward_walk_win` 4)와 cap 축 폴백이 들어오면서 + * 값이 움직였고, 그 방향은 현행 레퍼런스와 일치하는 쪽이다. 픽스처 경로가 + * 깨져 있어 그동안 이 테스트가 돌지 않았다 — 그래서 뒤늦게 드러났다. */ class CenterAlignerValidationTest { private val sessions = mapOf( - 0 to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json", - 1 to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json", - 3 to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json", + 0 to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json", + 1 to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json", + 3 to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json", ) private fun loadCycles(path: String, maxN: Int = 20): List> { @@ -78,7 +85,7 @@ class CenterAlignerValidationTest { println("target = cm=${it.alignCm}, nch=${it.nch}, bv_cv=${"%.3f".format(it.bvCv ?: Double.NaN)}") } - // ── 어설션: Python reference (v3_python_records.json 기준) ── + // ── 어설션: 현행 레퍼런스 AnchorGuide(ver='v1') 산출값 ── val recs = result.records.associateBy { it.alignCm } // cm=0: nch=3, ch3=X, hit=2/11 @@ -87,30 +94,29 @@ class CenterAlignerValidationTest { assert(recs[0]?.ch3Tot == 11) { "cm=0 ch3_tot expected 11, got ${recs[0]?.ch3Tot}" } assert(recs[0]?.ch3Hit == 2) { "cm=0 ch3_hit expected 2, got ${recs[0]?.ch3Hit}" } - // cm=1: nch=4, ch3=O, hit=7/11 + // cm=1: nch=4, ch3=O, hit=8/11 assert(recs[1]?.nch == 4) { "cm=1 nch expected 4, got ${recs[1]?.nch}" } assert(recs[1]?.ch3Detected == true) { "cm=1 ch3 expected O (true)" } - assert(recs[1]?.ch3Hit == 7) { "cm=1 ch3_hit expected 7, got ${recs[1]?.ch3Hit}" } + assert(recs[1]?.ch3Hit == 8) { "cm=1 ch3_hit expected 8, got ${recs[1]?.ch3Hit}" } - // cm=3: nch=3, ch3=O, hit=10/11 (80% 임계 통과) + // cm=3: nch=3, ch3=O, hit=11/11 (80% 임계 통과) assert(recs[3]?.nch == 3) { "cm=3 nch expected 3, got ${recs[3]?.nch}" } assert(recs[3]?.ch3Detected == true) { "cm=3 ch3 expected O (true)" } - assert(recs[3]?.ch3Hit == 10) { "cm=3 ch3_hit expected 10, got ${recs[3]?.ch3Hit}" } + assert(recs[3]?.ch3Hit == 11) { "cm=3 ch3_hit expected 11, got ${recs[3]?.ch3Hit}" } - // bv_cv 허용 오차 : - // Python 완전 bit-perfect 일치 달성 (data123 cm=3 11-trace 모두 Δ=0.000 mL). - // post_outlier_filter fix (Kotlin 이 무조건 실행하던 것을 Python default false 로 - // 맞춤) 로 CH0 부당 drop 해소. - // 3 위치 (0/1/3 cm) bv_cv 모두 Python 과 소수점 3자리 이상 일치 → tolerance - // 0.001 로 강화. - assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.183) < 0.001) { - "cm=0 bv_cv expected 0.183±0.001, got ${recs[0]?.bvCv}" + // bv_cv — **회귀 고정**이지 레퍼런스 대조가 아니다. + // 현행 레퍼런스의 `AnchorGuide` 는 bv_cv 를 내지 않고(cap_frac 으로 바뀌었다), + // 이 값을 내던 구 레퍼런스는 저장소에 남아 있지 않다. 그래서 "Python 과 + // bit-exact" 라고 적어 둔 종전 주석은 지금 근거가 없다 — 걱어낸다. + // 남기는 이유는 V3 경로가 조용히 움직이는 것을 잡기 위해서다. + assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.167) < 0.001) { + "cm=0 bv_cv expected 0.167±0.001, got ${recs[0]?.bvCv}" } - assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.260) < 0.001) { - "cm=1 bv_cv expected 0.260±0.001, got ${recs[1]?.bvCv}" + assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.211) < 0.001) { + "cm=1 bv_cv expected 0.211±0.001, got ${recs[1]?.bvCv}" } - assert(recs[3]?.bvCv != null && kotlin.math.abs((recs[3]!!.bvCv!!) - 0.130) < 0.001) { - "cm=3 bv_cv expected 0.130±0.001, got ${recs[3]?.bvCv}" + assert(recs[3]?.bvCv != null && kotlin.math.abs((recs[3]!!.bvCv!!) - 0.096) < 0.001) { + "cm=3 bv_cv expected 0.096±0.001, got ${recs[3]?.bvCv}" } // 선택 위치: Rule A → cm=3 (80% 임계 통과한 유일 위치) diff --git a/app/src/test/java/com/medithings/vesiscan/managers/Cm3PerTraceDumpTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/Cm3PerTraceDumpTest.kt index c03768b..b068d8d 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/Cm3PerTraceDumpTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/Cm3PerTraceDumpTest.kt @@ -14,7 +14,7 @@ import kotlin.math.sqrt */ class Cm3PerTraceDumpTest { - private val session = "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json" + private val session = "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json" private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/cm3_kotlin.json" private fun loadCycles(path: String, maxN: Int = 20): List> { diff --git a/app/src/test/java/com/medithings/vesiscan/managers/GoldenDumpTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/GoldenDumpTest.kt index d94757b..d666b30 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/GoldenDumpTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/GoldenDumpTest.kt @@ -19,9 +19,9 @@ import java.io.File class GoldenDumpTest { private val sessions = mapOf( - "cm0" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json", - "cm1" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json", - "cm3" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json", + "cm0" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json", + "cm1" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json", + "cm3" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json", ) private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/golden_kotlin.json" diff --git a/app/src/test/java/com/medithings/vesiscan/managers/LegacyEquivalenceDumpTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/LegacyEquivalenceDumpTest.kt new file mode 100644 index 0000000..b53716c --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/LegacyEquivalenceDumpTest.kt @@ -0,0 +1,73 @@ +package com.medithings.vesiscan.managers + +import com.medithings.vesiscan.walldetect.MethodDRunner +import org.junit.Test +import java.io.File + +/** + * 기존(팬텀) 경로가 이번 이식으로 **한 글자도 안 바뀌었는지** 실증하는 덤프 하네스. + * + * 검출 → BV 전 구간을 `align_cm{0,1}.csv` 의 모든 cycle 에 대해 돌려 결과를 파일로 + * 떨군다. HEAD 코드와 작업트리 코드에서 각각 돌린 덤프가 **바이트 단위로 같으면** + * 기존 경로 무변경이 증명된다. + * + * 이 파일은 [AlgoMode] 를 참조하지 않는다 — HEAD 에는 그 타입이 없기 때문이다. + * 기본값이 기존 경로이므로 기본 호출만으로 그 경로가 그대로 실행된다. + */ +class LegacyEquivalenceDumpTest { + + private fun res(name: String) = + javaClass.classLoader!!.getResourceAsStream(name)!!.bufferedReader().use { it.readText() } + + /** cycle → 6채널 신호 (`cycle,channel,s0..s99`). */ + private fun loadCycles(name: String): Map> { + val out = HashMap>>() + for (line in res(name).lineSequence()) { + val t = line.trim() + if (t.isEmpty()) continue + val f = t.split(",") + if (f.size < 3) continue + val cyc = f[0].toIntOrNull() ?: continue + val ch = f[1].toIntOrNull() ?: continue + val sig = DoubleArray(f.size - 2) { f[it + 2].trim().toDouble() } + out.getOrPut(cyc) { ArrayList() }.add(ch to sig) + } + return out.mapValues { (_, v) -> + val byCh = v.toMap() + (0..5).map { byCh[it] ?: DoubleArray(0) } + } + } + + @Test + fun dump() { + val sb = StringBuilder() + for (cm in 0..1) { + val cycles = loadCycles("align_cm$cm.csv") + for (cyc in cycles.keys.sorted()) { + val sig = cycles[cyc]!! + for (cross in listOf(false, true)) { + val dets = MethodDRunner.detectMultichannel(sig, applyCross = cross) + val walls = dets.map { r -> + r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } + } + val bv = estimateBv(walls) + sb.append("cm$cm cyc$cyc cross=$cross") + dets.forEachIndexed { i, r -> + sb.append(if (r == null) " CH$i=-" else + " CH$i=%d/%d/%.4f/%.4f/%d/%d".format( + r.ant, r.post, r.antRefined, r.postRefined, r.lowStart, r.lowEnd)) + } + sb.append(if (bv == null) " BV=-" else + " BV=%.6f lr=%.6f nch=%d topH=%.4f botH=%.4f vT=%.4f vB=%.4f".format( + bv.volumeMl, bv.lrRatio, bv.validChannels.size, + bv.topHMm, bv.bottomHMm, bv.vTopMm3, bv.vBottomMm3)) + sb.append('\n') + } + } + } + val out = File(System.getProperty("legacyDump") ?: "build/legacy_dump.txt") + out.parentFile?.mkdirs() + out.writeText(sb.toString()) + println("[dump] ${out.absolutePath} lines=${sb.lines().size}") + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/managers/MethodDBvWrapperParityTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/MethodDBvWrapperParityTest.kt index 0331bb9..6aa79f8 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/MethodDBvWrapperParityTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/MethodDBvWrapperParityTest.kt @@ -21,9 +21,9 @@ import java.io.File class MethodDBvWrapperParityTest { private val sessions = mapOf( - "cm0" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json", - "cm1" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json", - "cm3" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json", + "cm0" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json", + "cm1" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json", + "cm3" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json", ) private fun loadCycles(path: String, maxN: Int = 20): List> { diff --git a/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt index ea87a4f..faf790e 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt @@ -18,7 +18,7 @@ import java.io.File */ class PrecisionDumpTest { - private val session = "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json" + private val session = "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json" private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/precision_kotlin.json" private fun loadFirstTrace(path: String, win: Int = 10): List { diff --git a/docs/ALGO_MODE.md b/docs/ALGO_MODE.md new file mode 100644 index 0000000..4c1fe28 --- /dev/null +++ b/docs/ALGO_MODE.md @@ -0,0 +1,113 @@ +# 알고리즘 경로 두 갈래 (`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" 라던 종전 주석은 근거가 없어져 걷어내고, 성격을 **회귀 고정**으로 바꿔 + 적었다. + +## 아직 안 옮긴 것 + +| 항목 | 왜 | +|---|---| +| `post_tie_lock` | 상태기 — 스트리밍 경로와 함께 설계해야 한다 | +| `_sibeam_cap_bounds` | nch=2 전용 | +| F83 cap 보간 | 실리콘 + supine 조건 | + +레퍼런스 저장소의 `library/analysis/abdomen.py` 가 커밋되어 있지 않아 +`pipeline_dump.py` · `compare.py` 가 그대로는 돌지 않는다. 이번 대조는 스크래치 사본에 +호출 시 즉시 실패하는 스텁을 놓아 우회했다(정렬·검출·BV 경로는 이 모듈을 쓰지 않는다).