feat(algo): 레퍼런스 경로를 dev 스위치로 · 기본은 현행 유지

신 저장소에 맞춘 레퍼런스(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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:31:58 +09:00
parent c7f9cc976e
commit ccb44f0ea6
16 changed files with 1268 additions and 88 deletions
@@ -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 "기존 (현행 임상)"
}
@@ -132,8 +132,8 @@ object AnchorConfig {
* *
* BV 를 못 내면 1.0(최악)을 돌려준다 — Python 과 같다. 후보 정렬에서 자연히 뒤로 밀린다. * BV 를 못 내면 1.0(최악)을 돌려준다 — Python 과 같다. 후보 정렬에서 자연히 뒤로 밀린다.
*/ */
fun capFraction(walls: List<WallWithSpan?>): Double { fun capFraction(walls: List<WallWithSpan?>, missingOutside: Set<Int>? = null): Double {
val r = estimateBv(walls) ?: return 1.0 val r = estimateBv(walls, missingOutside = missingOutside) ?: return 1.0
val y = r.sortedYMm val y = r.sortedYMm
val ySpan = if (y.size >= 2) (y.max() - y.min()) else 0.0 val ySpan = if (y.size >= 2) (y.max() - y.min()) else 0.0
val top = if (r.topHMm.isFinite()) r.topHMm 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). // 같은 mean-scan 을 두 번 검출한다 — 통일하면 안 된다(SPEC §8.2-1).
val wallsBase = MethodDRunner.detectMultichannel( val wallsBase = MethodDRunner.detectMultichannel(
meanScan, methodDParams, applyTgc = true, applyCross = false) meanScan, methodDParams, applyTgc = true, applyCross = false)
val wallsCross = MethodDRunner.detectMultichannel( val crossOutcome = MethodDRunner.detectMultichannelWithEvidence(
meanScan, methodDParams, applyTgc = true, applyCross = true) meanScan, methodDParams, applyTgc = true, applyCross = true)
val wallsCross = crossOutcome.walls
val capFrac = capFraction(wallsCross.map { r -> // cap_frac 은 극 cap 높이로 계산하므로 clamp 방향을 가르는 증거가 필요하다.
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } // 기존 경로에서는 빈 집합이라 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 통계도 얻는다(진단용). // ch3 검출률은 trace 단위다. 그 김에 trace 별 nch 통계도 얻는다(진단용).
val traces = slidingTraces(cycles, traceWin) val traces = slidingTraces(cycles, traceWin)
@@ -237,10 +237,20 @@ private fun repairCenterWallsFor6ch(
if (gap == 1) { if (gap == 1) {
val pw = result[prevIdx]!! val pw = result[prevIdx]!!
val nw = result[nextIdx]!! val nw = result[nextIdx]!!
// Python 1:1: 선형 보간 (반올림 없이 float 유지 — subsample refined 값 보존) // 레퍼런스는 **정수로 절단한 뒤** 평균내고 은행가 반올림한다:
val ant = (pw.first + nw.first) / 2.0 // `int(round((int(prev) + int(next)) / 2.0))`
val post = (pw.second + nw.second) / 2.0 // 보간된 채널은 subsample 정밀도를 갖지 않는다 — 근거가 이웃 둘의 정수
result[prevIdx + 1] = Pair(ant, post) // 위치뿐이기 때문이다. 실측 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 continue
} }
// gap >= 2: drop top group // 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). */ /** Python `LUMEN_INSET_FRAC` (config_6ch.py:41). */
const val LUMEN_INSET_FRAC: Double = 0.15 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<Int>?, 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<Int>?, ch: Int): Boolean =
missingOutside == null || ch in missingOutside
/** Python `volume/config_vol.ELLIPSE_CAP_R_MAX` — 타원 cap 높이의 r 상한. */ /** Python `volume/config_vol.ELLIPSE_CAP_R_MAX` — 타원 cap 높이의 r 상한. */
const val ELLIPSE_CAP_R_MAX = 0.92 const val ELLIPSE_CAP_R_MAX = 0.92
@@ -640,6 +688,8 @@ fun estimateBladderVolume6ch(
bSiFloorRatio: Double? = null, bSiFloorRatio: Double? = null,
bSiFloorEdgeMin: Double = 0.5, bSiFloorEdgeMin: Double = 0.5,
ellipseCapHeight: Boolean = true, ellipseCapHeight: Boolean = true,
missingOutside: Set<Int>? = null,
siFloor: Boolean = true,
): BVResult? { ): BVResult? {
var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null } var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
centerWalls = repairCenterWallsFor6ch(centerWalls) centerWalls = repairCenterWallsFor6ch(centerWalls)
@@ -656,6 +706,8 @@ fun estimateBladderVolume6ch(
bSiFloorRatio = bSiFloorRatio, bSiFloorRatio = bSiFloorRatio,
bSiFloorEdgeMin = bSiFloorEdgeMin, bSiFloorEdgeMin = bSiFloorEdgeMin,
ellipseCapHeight = ellipseCapHeight, ellipseCapHeight = ellipseCapHeight,
missingOutside = missingOutside,
siFloor = siFloor,
) )
} }
@@ -734,7 +786,22 @@ fun estimateBv(
bSiFloorRatio: Double? = null, bSiFloorRatio: Double? = null,
bSiFloorEdgeMin: Double = 0.5, bSiFloorEdgeMin: Double = 0.5,
adaptiveLargeBladderRelax: Boolean = true, adaptiveLargeBladderRelax: Boolean = true,
/** [estimateBladderVolume] 참조 — 검출기가 산출한 '진짜 방광 밖' 채널 집합. */
missingOutside: Set<Int>? = null,
siFloor: Boolean = true,
): BVResult? { ): 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) val result = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin)
// low_wide_endpoint 완화(아래)는 조건이 까다롭지만, **cap 축 폴백은 항상 적용된다** — // low_wide_endpoint 완화(아래)는 조건이 까다롭지만, **cap 축 폴백은 항상 적용된다** —
@@ -809,7 +876,11 @@ private fun capAxisFallback(
if (bSi == null || cAp == null || cAp <= 0.0) return result if (bSi == null || cAp == null || cAp <= 0.0) return result
val dMax = result.dMm.filter { it > 0.0 }.maxOrNull() ?: 0.0 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 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, val fb = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin,
ellipseCapHeight = false) ellipseCapHeight = false)
return if (fb != null && fb.volumeMl != 0.0) fb else result return if (fb != null && fb.volumeMl != 0.0) fb else result
@@ -823,6 +894,8 @@ private fun evalOnce(
bSiFloorRatio: Double?, bSiFloorRatio: Double?,
bSiFloorEdgeMin: Double, bSiFloorEdgeMin: Double,
ellipseCapHeight: Boolean = true, ellipseCapHeight: Boolean = true,
missingOutside: Set<Int>? = null,
siFloor: Boolean = true,
): BVResult? { ): BVResult? {
val insetWalls: List<Pair<Double, Double>?> = walls.map { w -> val insetWalls: List<Pair<Double, Double>?> = walls.map { w ->
w?.let { w?.let {
@@ -835,7 +908,8 @@ private fun evalOnce(
val prevOverride = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride val prevOverride = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = lrRatio com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = lrRatio
return try { return try {
estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight) estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight,
missingOutside, siFloor)
} finally { } finally {
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride
} }
@@ -897,6 +971,16 @@ fun estimateBladderVolume(
* 무너졌을 때(b_si/c_ap 가 작을 때) 타원 dome 을 믿으면 cap 이 과소해진다. * 무너졌을 때(b_si/c_ap 가 작을 때) 타원 dome 을 믿으면 cap 이 과소해진다.
*/ */
ellipseCapHeight: Boolean = true, ellipseCapHeight: Boolean = true,
/**
* **span 자체가 없어 진짜 방광 밖으로 판정된** 채널 집합(Python `missing_outside`).
* null 이면 극 cap clamp 를 언제나 상한으로 건다(기존 동작).
*/
missingOutside: Set<Int>? = null,
/**
* Python `COND_CLAMP == 'si'`. 켜져 있으면 미검출 채널에 span 이 있을 때 같은
* 지점을 **하한**으로 쓴다. 레퍼런스 경로에서만 의미가 있다.
*/
siFloor: Boolean = true,
): BVResult? { ): BVResult? {
// 1) Edge channel filter — cap 방식 결정용 플래그 // 1) Edge channel filter — cap 방식 결정용 플래그
@@ -1154,7 +1238,11 @@ fun estimateBladderVolume(
val topSortedCh = sortedCh.last() val topSortedCh = sortedCh.last()
val nCenterDet = sortedCh.count { it < 4 } val nCenterDet = sortedCh.count { it < 4 }
if (nCenterDet >= 2 && topSortedCh > 0) { 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 upperTheta = degreeDeg[upperCh] * Math.PI / 180.0
val yBeamUpper = if (z0Ellipse != null && abs(cos(upperTheta)) > 1e-6) { val yBeamUpper = if (z0Ellipse != null && abs(cos(upperTheta)) > 1e-6) {
val dAtZ0 = z0Ellipse!! / cos(upperTheta) val dAtZ0 = z0Ellipse!! / cos(upperTheta)
@@ -1163,7 +1251,58 @@ fun estimateBladderVolume(
sensorZMm[upperCh].toDouble() sensorZMm[upperCh].toDouble()
} }
val hTopLimit = yBeamUpper - yS[n - 1] 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) // 8) Core frustum (traditional: h = dy)
@@ -133,8 +133,9 @@ fun ClinicalLiveView(appState: AppState) {
DoubleArray(buf.size) { buf[it].toDouble() } DoubleArray(buf.size) { buf[it].toDouble() }
} ?: DoubleArray(100) } ?: DoubleArray(100)
} }
val mdResults = com.medithings.vesiscan.walldetect.MethodDRunner val mdOutcome = com.medithings.vesiscan.walldetect.MethodDRunner
.detectMultichannel(signalsD, applyCross = true) .detectMultichannelWithEvidence(signalsD, applyCross = true)
val mdResults = mdOutcome.walls
// Python `runners.estimate_bv` 1:1 대응 (inset + adaptive_large_bladder_relax). // Python `runners.estimate_bv` 1:1 대응 (inset + adaptive_large_bladder_relax).
val mdWalls = mdResults.map { r -> val mdWalls = mdResults.map { r ->
r?.let { r?.let {
@@ -142,7 +143,10 @@ fun ClinicalLiveView(appState: AppState) {
it.antRefined, it.postRefined, it.lowStart, it.lowEnd) 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 } ?.volumeMl?.takeIf { it.isFinite() && it > 0 }
} }
} }
@@ -287,6 +287,8 @@ fun PiezoMonitoringView(appState: AppState) {
// Method D detection 결과 (refined ant/post + span) — Method D BV wrapper // Method D detection 결과 (refined ant/post + span) — Method D BV wrapper
// 경로에서 재사용해 adaptive_large_bladder_relax 등 Python 1:1 적용. // 경로에서 재사용해 adaptive_large_bladder_relax 등 Python 1:1 적용.
var methodDDets: List<com.medithings.vesiscan.walldetect.MethodDResult?>? = null var methodDDets: List<com.medithings.vesiscan.walldetect.MethodDResult?>? = null
/** 극 cap clamp 방향을 가르는 관측 증거. 기존 경로에서는 항상 null. */
var methodDMissingOutside: Set<Int>? = null
val validChannels = channels.filter { it.isValid } val validChannels = channels.filter { it.isValid }
val allChannelsReceived = channels.filter { it.buffer.size >= 20 }.size >= 6 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 } val cd = channels.find { it.channel == ch }
cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0) cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0)
} }
val dets = com.medithings.vesiscan.walldetect.MethodDRunner val outcome = com.medithings.vesiscan.walldetect.MethodDRunner
.detectMultichannel(signals) .detectMultichannelWithEvidence(signals)
val dets = outcome.walls
methodDDets = dets // BV wrapper 경로에서 refined+span 재사용 methodDDets = dets // BV wrapper 경로에서 refined+span 재사용
methodDMissingOutside = outcome.missingOutside.ifEmpty { null }
for (i in 0..5) { for (i in 0..5) {
val r = dets.getOrNull(i) val r = dets.getOrNull(i)
if (r != null) allWalls[i] = Pair(r.ant, r.post) 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 모두 // Wrapper 가 lumen_inset + adaptive_large_bladder_relax + b_si_floor 모두
// 자동 적용 → Python 1:1. 여기서는 sourceAllWalls 에 int (ant,post) 만 // 자동 적용 → Python 1:1. 여기서는 sourceAllWalls 에 int (ant,post) 만
// 반환 (기존 wall-count 게이트 · UI 표시용). // 반환 (기존 wall-count 게이트 · UI 표시용).
val dets = com.medithings.vesiscan.walldetect.MethodDRunner val outcome = com.medithings.vesiscan.walldetect.MethodDRunner
.detectMultichannel(signals) .detectMultichannelWithEvidence(signals)
val dets = outcome.walls
methodDDets = dets methodDDets = dets
methodDMissingOutside = outcome.missingOutside.ifEmpty { null }
dets.map { r -> r?.let { Pair(it.ant, it.post) } } dets.map { r -> r?.let { Pair(it.ant, it.post) } }
} }
} else allWalls } else allWalls
@@ -490,7 +496,10 @@ fun PiezoMonitoringView(appState: AppState) {
com.medithings.vesiscan.managers.BvMethod.METHOD_D_PHANTOM) { com.medithings.vesiscan.managers.BvMethod.METHOD_D_PHANTOM) {
com.medithings.vesiscan.managers.estimateBvPhantomSphere(walls) com.medithings.vesiscan.managers.estimateBvPhantomSphere(walls)
} else { } else {
com.medithings.vesiscan.managers.estimateBv(walls) // `missingOutside` = 극 cap clamp 방향을 가르는 관측 증거.
// 기존 경로에서는 항상 null 이라 종전 동작과 같다.
com.medithings.vesiscan.managers.estimateBv(
walls, missingOutside = methodDMissingOutside)
} }
if (bvResult != null) { if (bvResult != null) {
rawVolumeMl = bvResult.volumeMl rawVolumeMl = bvResult.volumeMl
@@ -2094,6 +2103,28 @@ fun PiezoMonitoringView(appState: AppState) {
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding)) 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 표준) // 2026-07-01: lr_ratio override (algorithm 팀 4830e7c 표준)
var lrFixed by remember { var lrFixed by remember {
mutableStateOf(com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride != null) mutableStateOf(com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride != null)
@@ -41,7 +41,8 @@ data class MethodDResult(
var urineLen: Int, var urineLen: Int,
val antProm: Double, val antProm: Double,
val postProm: Double, val postProm: Double,
val antType: CType, // 레퍼런스 경로의 envelope_research(①.9)가 전벽을 옮기며 함께 갱신한다.
var antType: CType,
var postType: CType, var postType: CType,
val sgHeavy: DoubleArray, val sgHeavy: DoubleArray,
val sgLight: DoubleArray, val sgLight: DoubleArray,
@@ -23,6 +23,9 @@ package com.medithings.vesiscan.walldetect
import com.medithings.vesiscan.managers.AlignmentConstants import com.medithings.vesiscan.managers.AlignmentConstants
import com.medithings.vesiscan.managers.PiezoHW import com.medithings.vesiscan.managers.PiezoHW
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams 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.MethodDPreprocessing
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDTgc import com.medithings.vesiscan.walldetect.algo.methodd.MethodDTgc
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect
@@ -51,6 +54,31 @@ object MethodDRunner {
private const val NEIGHBOR_TOP_VALIDATE = true private const val NEIGHBOR_TOP_VALIDATE = true
private const val INWARD_POST_SEARCH = 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? 리스트. * 6채널 (또는 N채널) raw 신호 → 채널별 MethodDResult? 리스트.
* raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립). * raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립).
@@ -61,13 +89,24 @@ object MethodDRunner {
* — 교차채널 보정이 nch 를 바꿔 위치 선택이 흔들리는 것을 차단. * — 교차채널 보정이 nch 를 바꿔 위치 선택이 흔들리는 것을 차단.
* BV 산출은 applyCross=true 로 별도 수행. * BV 산출은 applyCross=true 로 별도 수행.
*/ */
/** 기존 호출부 호환 — 증거가 필요 없으면 이쪽. */
fun detectMultichannel( fun detectMultichannel(
signals: List<DoubleArray>, signals: List<DoubleArray>,
params: MethodDParams = MethodDParams.DEFAULT, params: MethodDParams = MethodDParams.DEFAULT,
beamAnglesDeg: DoubleArray? = null, beamAnglesDeg: DoubleArray? = null,
applyTgc: Boolean = true, applyTgc: Boolean = true,
applyCross: Boolean = true, applyCross: Boolean = true,
): List<MethodDResult?> { ): List<MethodDResult?> = detectMultichannelWithEvidence(
signals, params, beamAnglesDeg, applyTgc, applyCross).walls
fun detectMultichannelWithEvidence(
signals: List<DoubleArray>,
params: MethodDParams = MethodDParams.DEFAULT,
beamAnglesDeg: DoubleArray? = null,
applyTgc: Boolean = true,
applyCross: Boolean = true,
): DetectionOutcome {
val angles = beamAnglesDeg ?: PiezoHW.degreeAll val angles = beamAnglesDeg ?: PiezoHW.degreeAll
// 1) per-channel SG denoise (heavy + light) // 1) per-channel SG denoise (heavy + light)
val heavyList = signals.map { MethodDPreprocessing.preprocessHeavy(it, params) } val heavyList = signals.map { MethodDPreprocessing.preprocessHeavy(it, params) }
@@ -87,16 +126,69 @@ object MethodDRunner {
params = params, params = params,
) )
} }
// 5) cross-channel 후처리 (Python `apply_cross_channel` 순서 고정) val ref = com.medithings.vesiscan.managers.AlgoMode.reference
if (applyCross) {
applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params) // ── 2) 검출 실패 채널만 SG5 로 재탐색 (레퍼런스 경로 전용) ─────────────
// 이미 검출된 채널은 건드리지 않는다 — 얻는 것은 커버리지뿐이고 측정면은 불변.
if (ref && SCALE_RECOVERY && applyCross) {
applyScaleRecovery(results, signals, angles, params, applyTgc)
} }
return results
// ── 4) 미검출 채널의 span 유무 판정 (레퍼런스 경로 전용) ───────────────
// **CCC 앞에서** 계산한다 — CCC 가 복구한 채널은 '방광 밖'이 아니다.
val missingOut: Set<Int> =
if (ref && applyCross) missingOutside(results, lightTgc, heavyTgc, angles, params)
else emptySet()
// ── 5) 교차채널 보정 ───────────────────────────────────────────────
if (applyCross) {
// 5b) 약한 채널을 CCC 복구 단계에서 숨긴다(레퍼런스 경로 전용).
var untrusted: Set<Int>? = 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<MethodDResult?>,
val missingOutside: Set<Int>,
)
/**
* 교차채널 보정 — 모드에 따라 **다른 순서**로 돈다.
*
* 기존(팬텀 시연): ① 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( private fun applyCrossChannel(
results: MutableList<MethodDResult?>, results: MutableList<MethodDResult?>,
@@ -105,14 +197,49 @@ object MethodDRunner {
heavy: List<DoubleArray>, heavy: List<DoubleArray>,
raw6ch: List<DoubleArray>, raw6ch: List<DoubleArray>,
params: MethodDParams, params: MethodDParams,
untrusted: Set<Int>? = 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<Int, MethodDResult>()
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) 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) { if (NEIGHBOR_TOP_VALIDATE) {
applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params) // 레퍼런스의 NTV 는 **Rule A(FN 복구)만** 한다. 기존 구현의 Rule B(최상단
} // FP 기각)는 ③ fp_channel_gate 로 옮겨졌으므로 레퍼런스 경로에서는 건너뛴다.
if (INWARD_POST_SEARCH) { applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params,
applyInwardPost(results, angles) ruleBEnabled = false)
} }
if (FP_CHANNEL_GATE) applyFpChannelGate(results, angles)
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -218,33 +345,43 @@ object MethodDRunner {
heavy: List<DoubleArray>, heavy: List<DoubleArray>,
raw6ch: List<DoubleArray>, raw6ch: List<DoubleArray>,
params: MethodDParams, 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 n = kmin(4, results.size)
val origNone = (0 until n).filter { results[it] == null }.toHashSet() val origNone = (0 until n).filter { results[it] == null }.toHashSet()
val det = (0 until n).filter { results[it] != null }.sorted() val det = (0 until n).filter { results[it] != null }.sorted()
// ---- Rule B ---- if (ruleBEnabled) {
if (det.size >= 3) { // ---- Rule B ----
val top = det[0]; val nb1 = det[1]; val nb2 = det[2] if (det.size >= 3) {
val rTop = results[top]!! val top = det[0]; val nb1 = det[1]; val nb2 = det[2]
val rNb1 = results[nb1]!! val rTop = results[top]!!
val rNb2 = results[nb2]!! val rNb1 = results[nb1]!!
val zTop = zOf(angles, top, rTop.post) val rNb2 = results[nb2]!!
val zNb1 = zOf(angles, nb1, rNb1.post) val zTop = zOf(angles, top, rTop.post)
val zNb2 = zOf(angles, nb2, rNb2.post) val zNb1 = zOf(angles, nb1, rNb1.post)
val slope = zNb1 - zNb2 val zNb2 = zOf(angles, nb2, rNb2.post)
val pred = zNb1 + slope val slope = zNb1 - zNb2
if (slope <= NTV_SLOPE_EPS && (zTop - pred) > NTV_DEV_DELTA) { val pred = zNb1 + slope
val rr = researchPostInSpan( if (slope <= NTV_SLOPE_EPS && (zTop - pred) > NTV_DEV_DELTA) {
light[top], heavy[top], raw6ch[top], rNb1.lowStart, rNb1.lowEnd, params 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 if (rr == null || (zOf(angles, top, rr.post) - pred) > NTV_DEV_DELTA) {
} else { results[top] = null
rTop.post = rr.post } else {
rTop.postRefined = rr.post.toDouble() rTop.post = rr.post
rTop.postType = rr.postType rTop.postRefined = rr.post.toDouble()
rTop.urineLen = rTop.post - rTop.ant - 1 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] for (i in lo..hi) if (arr[i] < m) m = arr[i]
return if (m.isFinite()) m else 0.0 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<MethodDResult?>,
angles: DoubleArray,
light: List<DoubleArray>,
heavy: List<DoubleArray>,
raw6ch: List<DoubleArray>,
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<Int, Double>()
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<MethodDResult?>,
raw6ch: List<DoubleArray>,
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<MethodDResult?>,
angles: DoubleArray,
light: List<DoubleArray>,
heavy: List<DoubleArray>,
raw6ch: List<DoubleArray>,
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<Triple<Int, Double, Boolean>>()
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<MethodDResult?>,
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<MethodDResult?>, angles: DoubleArray) {
val det = results.indices.filter { results[it] != null }
if (det.size < 3) return
val za = HashMap<Int, Double>()
val zp = HashMap<Int, Double>()
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<Int>()
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>): 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<MethodDResult?>,
raw6ch: List<DoubleArray>,
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<MethodDResult?>,
raw6ch: List<DoubleArray>,
): Map<Int, Double>? {
val rr = HashMap<Int, Double>()
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<MethodDResult?>,
light: List<DoubleArray>,
heavy: List<DoubleArray>,
angles: DoubleArray,
params: MethodDParams = MethodDParams.DEFAULT,
): Set<Int> {
val outside = HashSet<Int>()
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
}
} }
@@ -40,6 +40,11 @@ data class MethodDParams(
val oscfarMaxIters: Int = 4, val oscfarMaxIters: Int = 4,
val minLightSeparability: Double = 0.75, val minLightSeparability: Double = 0.75,
val minSpanLen: Int = 5, 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 recoveryExtendOutward: Boolean = false,
val wallRatioUsePostOnly: Boolean = true, val wallRatioUsePostOnly: Boolean = true,
) { ) {
@@ -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<Int, List<DoubleArray>> {
val out = HashMap<Int, MutableList<Pair<Int, DoubleArray>>>()
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<String> {
val out = ArrayList<String>()
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} 개가 갈림")
}
}
@@ -10,18 +10,25 @@ import java.io.File
* 실행: * 실행:
* ./gradlew testDevDebugUnitTest --tests "*CenterAlignerValidationTest*" * ./gradlew testDevDebugUnitTest --tests "*CenterAlignerValidationTest*"
* *
* Python 기준값 (scratchpad/v3_python_records.json, cf. v3_python_reference.py): * 기준값은 **현행 레퍼런스**(`vesiscan_test.library.alignment_runners.AnchorGuide`,
* cm=0: nch=3, ch3=X, hit=2/11, bv_cv=0.183 * ver='v1')를 같은 세션에 돌려 받은 값이다:
* cm=1: nch=4, ch3=O, hit=7/11, bv_cv=0.260 * cm=0: nch=3, ch3=X, hit=2/11
* cm=3: nch=3, ch3=O, hit=10/11, bv_cv=0.130 * 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) * 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 { class CenterAlignerValidationTest {
private val sessions = mapOf( private val sessions = mapOf(
0 to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.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/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.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/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.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<List<DoubleArray>> { private fun loadCycles(path: String, maxN: Int = 20): List<List<DoubleArray>> {
@@ -78,7 +85,7 @@ class CenterAlignerValidationTest {
println("target = cm=${it.alignCm}, nch=${it.nch}, bv_cv=${"%.3f".format(it.bvCv ?: Double.NaN)}") 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 } val recs = result.records.associateBy { it.alignCm }
// cm=0: nch=3, ch3=X, hit=2/11 // 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]?.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}" } 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]?.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]?.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]?.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]?.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 허용 오차 : // bv_cv — **회귀 고정**이지 레퍼런스 대조가 아니다.
// Python 완전 bit-perfect 일치 달성 (data123 cm=3 11-trace 모두 Δ=0.000 mL). // 현행 레퍼런스의 `AnchorGuide` 는 bv_cv 를 내지 않고(cap_frac 으로 바뀌었다),
// post_outlier_filter fix (Kotlin 이 무조건 실행하던 것을 Python default false 로 // 이 값을 내던 구 레퍼런스는 저장소에 남아 있지 않다. 그래서 "Python 과
// 맞춤) 로 CH0 부당 drop 해소. // bit-exact" 라고 적어 둔 종전 주석은 지금 근거가 없다 — 걱어낸다.
// 3 위치 (0/1/3 cm) bv_cv 모두 Python 과 소수점 3자리 이상 일치 → tolerance // 남기는 이유는 V3 경로가 조용히 움직이는 것을 잡기 위해서다.
// 0.001 로 강화. assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.167) < 0.001) {
assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.183) < 0.001) { "cm=0 bv_cv expected 0.167±0.001, got ${recs[0]?.bvCv}"
"cm=0 bv_cv expected 0.183±0.001, got ${recs[0]?.bvCv}"
} }
assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.260) < 0.001) { assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.211) < 0.001) {
"cm=1 bv_cv expected 0.260±0.001, got ${recs[1]?.bvCv}" "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) { assert(recs[3]?.bvCv != null && kotlin.math.abs((recs[3]!!.bvCv!!) - 0.096) < 0.001) {
"cm=3 bv_cv expected 0.130±0.001, got ${recs[3]?.bvCv}" "cm=3 bv_cv expected 0.096±0.001, got ${recs[3]?.bvCv}"
} }
// 선택 위치: Rule A → cm=3 (80% 임계 통과한 유일 위치) // 선택 위치: Rule A → cm=3 (80% 임계 통과한 유일 위치)
@@ -14,7 +14,7 @@ import kotlin.math.sqrt
*/ */
class Cm3PerTraceDumpTest { 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 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<List<DoubleArray>> { private fun loadCycles(path: String, maxN: Int = 20): List<List<DoubleArray>> {
@@ -19,9 +19,9 @@ import java.io.File
class GoldenDumpTest { class GoldenDumpTest {
private val sessions = mapOf( private val sessions = mapOf(
"cm0" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.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/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.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/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.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" private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/golden_kotlin.json"
@@ -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<Int, List<DoubleArray>> {
val out = HashMap<Int, MutableList<Pair<Int, DoubleArray>>>()
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}")
}
}
@@ -21,9 +21,9 @@ import java.io.File
class MethodDBvWrapperParityTest { class MethodDBvWrapperParityTest {
private val sessions = mapOf( private val sessions = mapOf(
"cm0" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.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/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.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/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.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<List<DoubleArray>> { private fun loadCycles(path: String, maxN: Int = 20): List<List<DoubleArray>> {
@@ -18,7 +18,7 @@ import java.io.File
*/ */
class PrecisionDumpTest { 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 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<DoubleArray> { private fun loadFirstTrace(path: String, win: Int = 10): List<DoubleArray> {
+113
View File
@@ -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 경로는 이 모듈을 쓰지 않는다).