fix(algo): 검출·BV 를 piezo-phantom-test 레퍼런스와 일치시킴

실측 세션(HUMAN-kai VBT26050202, v1) 2위치로 stage 대조한 결과 다섯 곳이
레퍼런스와 어긋나 있었다. 전부 고쳐 **nch · ch3 · ch3_rate · cap_frac 이 전 항목
일치**한다.

| # | 어긋난 곳 | 값 | 증상 |
|---|---|---|---|
| 1 | `d_max` | 10 → **15** | 후벽 탐색창 부족 |
| 2 | `inward_walk_win` | 3 → **4** | span(low_start/low_end) 이탈 |
| 3 | prominence 기준점 | 인접 골 → **span 바닥** | 전벽 오선택 (py 4 vs kt 13) |
| 4 | cap 축 붕괴 폴백 | **누락** → 구현 | BV −14.9 mL |
| 5 | dps · delay | 전역 상수 → **프리셋 유도** | 좌표 전체 이탈 |

1·2·5 는 레퍼런스가 갱신된 것을 포팅이 따라가지 못한 경우다. 3 은 SPEC 이
"특히 틀리기 쉬운 곳"으로 짚어 둔 항목(`ANT_FLOOR_REF`)이고, 4 는
`runners.estimate_bv` 의 `CAP_AXIS_RATIO_MAX` 블록이 통째로 빠져 있었다.

곁들여 바로잡은 것:
- `ELLIPSE_CAP_R_MAX` 0.92 적용 (종전 사실상 1.0)
- `TOP_CAP_EDGE_K` 0.6 (최상단 채널이 최광폭일 때 top cap 제한) — 누락돼 있었다
- top cap clamp 게이트를 `n < nTotalCh` → **center 검출 채널 ≥ 2** 로 (레퍼런스 변경 반영)
- `estimateBv` 의 조기 return 들이 cap 축 폴백을 건너뛰던 것 수정. 특히
  `validChannels.size < 4` 때문에 채널 하나만 빠져도 폴백이 사라졌다.

## ⚠ dps 기본값이 바뀐다
종전 전역 기본 1.968 은 "300ml 팬텀이 1.936 에서 130~140ml 로 나온다"는 관찰로
되돌렸던 값인데, 프리셋이 정한 값을 전역 상수가 덮는 구조라 레퍼런스 대조가
불가능했다. 이제 v0/v1/v2 = 1.936 · 6.85, r1/r2/r3 = 1.897 · 7.651 로 프리셋에서
유도한다. 팬텀 시연에서 스케일이 달라 보이면 dev 패널에서 dps 를 직접 지정하면
된다(`PiezoHW.distancePerSample`, 해제는 `clearDpsOverride()`).

## 검증
- 전처리(light/heavy) max|Δ| = 0
- CCC-off 검출 12/12 채널 일치 (ant·post·refined·span)
- CCC-on 검출 12/12 채널 일치
- 정렬 지표(nch·ch3·ch3_rate·cap_frac) 2위치 전 항목 일치 — AnchorMeasureParityTest
  의 @Ignore 를 떼어 상시 게이트로 전환
- BV 제품 경로 고정 walls 155.4257 mL 일치 — BvProductPathParityTest 신규
  (cap 축 폴백이 빠지면 140.5 로 떨어져 여기서 잡힌다)
- 선택 규칙 200/200 은 그대로 유지

정렬 화면의 "검증 전" 경고를 걷어내고 매니페스트
`selection_verified_against_reference` 를 true 로 바꿨다. 원시 데이터 저장은
유지한다 — 재판정 여지를 남기는 것은 검증과 별개다.

남은 것: 이 다섯 가지는 신 저장소(vesiscan_pre_product)의 같은 코드에도 그대로
있다. 임상 끝나고 이관해야 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 11:23:41 +09:00
parent 2126aa9361
commit ebddc52efb
6 changed files with 229 additions and 56 deletions
@@ -138,14 +138,45 @@ object PiezoHW {
// 우리 default 도 1.968 → 1.936 으로 정렬 (FW VBTFW118+ 기준). // 우리 default 도 1.968 → 1.936 으로 정렬 (FW VBTFW118+ 기준).
// 2026-08-11: demo-final default 를 1.968 로 복귀 (사용자 관찰: 300ml phantom 이 // 2026-08-11: demo-final default 를 1.968 로 복귀 (사용자 관찰: 300ml phantom 이
// 1.936 로 130~140ml 밖에 안 나옴 · phantom 시연 스케일 재현 위해 되돌림). // 1.936 로 130~140ml 밖에 안 나옴 · phantom 시연 스케일 재현 위해 되돌림).
@Volatile private var _distancePerSample: Double = 1.968.also { WdConfig.DPS_DEFAULT = it } // 2026-09-03: dps·delay 를 **프리셋에서 유도**한다.
//
// 종전에는 dps 가 전역 기본값 하나(1.968)였고 프리셋을 따라가지 않았다. Python
// `config_hw` 는 HW 버전별로 다른 값을 쓴다 — v0/v1/v2 = 1.936 / 6.85,
// r1/r2/r3(실리콘) = 1.897 / 7.651. 값이 다르면 같은 sample index 가 다른 깊이로
// 매핑돼 벽 좌표부터 어긋나고, 레퍼런스와 절대 같은 값이 나올 수 없다.
//
// ⚠ 팬텀 시연 기본값이던 1.968 은 더 이상 자동으로 쓰이지 않는다. 그 값은
// "300ml 팬텀이 1.936 에서 130~140ml 로 나온다"는 관찰로 되돌렸던 것인데,
// 프리셋이 정한 값을 전역 상수가 덮는 구조 자체가 레퍼런스 대조를 불가능하게
// 만든다. 팬텀 시연이 필요하면 아래 setter(dev 패널)로 명시 지정한다.
private val presetDps: Double
get() = when (activePreset) {
DevicePreset.R100, DevicePreset.R200, DevicePreset.R300 -> 1.897
else -> 1.936
}
private val presetDelayMm: Double
get() = when (activePreset) {
DevicePreset.R100, DevicePreset.R200, DevicePreset.R300 -> 7.651
else -> 6.85
}
/** 수동 지정값(dev 패널). null 이면 프리셋에서 유도한다. */
@Volatile private var _dpsOverride: Double? = null
var distancePerSample: Double var distancePerSample: Double
get() = _distancePerSample get() = (_dpsOverride ?: presetDps).also { WdConfig.DPS_DEFAULT = it }
set(value) { set(value) {
_distancePerSample = value _dpsOverride = value
WdConfig.DPS_DEFAULT = value WdConfig.DPS_DEFAULT = value
} }
const val delayOffsetMm: Double = 6.85
/** 프리셋 유도값으로 되돌린다(수동 지정 해제). */
fun clearDpsOverride() {
_dpsOverride = null
WdConfig.DPS_DEFAULT = presetDps
}
val delayOffsetMm: Double
get() = presetDelayMm
/** Volume model */ /** Volume model */
val areaK: Double = Math.PI / 4.0 val areaK: Double = Math.PI / 4.0
@@ -534,6 +565,16 @@ 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.ELLIPSE_CAP_R_MAX` — 타원 cap 높이의 r 상한. */
const val ELLIPSE_CAP_R_MAX = 0.92
/** Python `volume/config_vol.TOP_CAP_EDGE_K` — 최상단 채널이 최광폭일 때 top cap 제한. */
const val TOP_CAP_EDGE_K = 0.6
/** Python `runners.CAP_AXIS_RATIO_MAX / CAP_AXIS_DMAX_MAX` — cap 축 붕괴 자기진단. */
const val CAP_AXIS_RATIO_MAX = 0.85
const val CAP_AXIS_DMAX_MAX = 100.0
// ── Core BV Computation ── // ── Core BV Computation ──
/** /**
@@ -562,6 +603,7 @@ fun estimateBladderVolume6ch(
allWalls: List<Pair<Double, Double>?>, allWalls: List<Pair<Double, Double>?>,
bSiFloorRatio: Double? = null, bSiFloorRatio: Double? = null,
bSiFloorEdgeMin: Double = 0.5, bSiFloorEdgeMin: Double = 0.5,
ellipseCapHeight: 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)
@@ -577,6 +619,7 @@ fun estimateBladderVolume6ch(
edgeRefChannels = listOf(1, 2), edgeRefChannels = listOf(1, 2),
bSiFloorRatio = bSiFloorRatio, bSiFloorRatio = bSiFloorRatio,
bSiFloorEdgeMin = bSiFloorEdgeMin, bSiFloorEdgeMin = bSiFloorEdgeMin,
ellipseCapHeight = ellipseCapHeight,
) )
} }
@@ -658,24 +701,31 @@ fun estimateBv(
): BVResult? { ): BVResult? {
val result = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin) val result = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin)
// adaptive 조건 미충족이거나 명시 override 있으면 return // low_wide_endpoint 완화(아래)는 조건이 까다롭지만, **cap 축 폴백은 항상 적용된다** —
// Python `runners.estimate_bv` 도 완화와 무관하게 마지막에 한 번 건다.
// 2026-09-03: 여기서 곧장 return 하는 경로들이 폴백을 통째로 건너뛰고 있었다
// (특히 `validChannels.size < 4` — 채널 하나만 빠져도 폴백이 사라졌다).
fun done(r: BVResult?) =
capAxisFallback(r, walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin)
// adaptive 조건 미충족이거나 명시 override 있으면 완화는 건너뛴다.
if (!adaptiveLargeBladderRelax || result == null if (!adaptiveLargeBladderRelax || result == null
|| urineInsetFrac != LUMEN_INSET_FRAC || bSiFloorRatio != null) { || urineInsetFrac != LUMEN_INSET_FRAC || bSiFloorRatio != null) {
return result return done(result)
} }
// low_wide_endpoint 검사 — Python bv_estimation.py runners.py line 219-236 1:1 // low_wide_endpoint 검사 — Python bv_estimation.py runners.py line 219-236 1:1
if (result.validChannels.size < 4 || result.volumeMm3 <= 0) return result if (result.validChannels.size < 4 || result.volumeMm3 <= 0) return done(result)
val dByCh = result.validChannels.zip(result.dMm.toList()).toMap() val dByCh = result.validChannels.zip(result.dMm.toList()).toMap()
val dSorted = result.sortedChannels.mapNotNull { dByCh[it] } val dSorted = result.sortedChannels.mapNotNull { dByCh[it] }
if (dSorted.size < 2) return result if (dSorted.size < 2) return done(result)
val radii = dSorted.filter { it > 0 }.map { it / 2.0 } val radii = dSorted.filter { it > 0 }.map { it / 2.0 }
if (radii.size < 2) return result if (radii.size < 2) return done(result)
val maxR = radii.max() val maxR = radii.max()
val botR = dSorted[0] / 2.0 val botR = dSorted[0] / 2.0
val topR = dSorted.last() / 2.0 val topR = dSorted.last() / 2.0
if (maxR <= 0 || botR <= 0 || topR <= 0) return result if (maxR <= 0 || botR <= 0 || topR <= 0) return done(result)
val botEdge = botR / maxR val botEdge = botR / maxR
val topEdge = topR / maxR val topEdge = topR / maxR
@@ -692,9 +742,41 @@ fun estimateBv(
urineInsetFrac = 0.05, urineInsetFrac = 0.05,
bSiFloorRatio = 0.85, bSiFloorRatio = 0.85,
bSiFloorEdgeMin = bSiFloorEdgeMin) bSiFloorEdgeMin = bSiFloorEdgeMin)
if (relaxed != null) return relaxed if (relaxed != null) return capAxisFallback(relaxed, walls, lrRatio,
0.05, 0.85, bSiFloorEdgeMin)
} }
return result return done(result)
}
/**
* cap 축 붕괴 교정 — Python `runners.estimate_bv` 의 `CAP_AXIS_RATIO_MAX` 블록.
*
* 타원 fit 이 납작하게 무너지면(b_si/c_ap 가 작으면) 타원 dome 으로 낸 cap 높이가
* 실제보다 훨씬 얕게 나온다. 그 상태를 스스로 감지해 **타원 cap 을 끄고 다시 계산**한
* 값으로 바꾼다.
*
* 2026-09-03: 이 블록이 통째로 빠져 있었다. 실측(고정 walls)에서 제품 경로 155.4 mL 가
* 코틀린에서는 140.5 mL 로 나왔고, 차이 14.9 mL 는 전부 여기서 비롯됐다. 정렬의
* `cap_frac` tie-break 도 이 값을 쓰므로 부착 위치 선택까지 함께 어긋나 있었다.
*/
private fun capAxisFallback(
result: BVResult?,
walls: List<WallWithSpan?>,
lrRatio: Double,
urineInsetFrac: Double,
bSiFloorRatio: Double?,
bSiFloorEdgeMin: Double,
): BVResult? {
if (result == null) return null
val bSi = result.capBSiMm
val cAp = result.capCApMm
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 과 동일) — 진단 필드만 옮기지 않는다.
val fb = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin,
ellipseCapHeight = false)
return if (fb != null && fb.volumeMl != 0.0) fb else result
} }
/** estimateBv 내부 헬퍼 — inset 적용 후 estimateBladderVolume6ch 호출. */ /** estimateBv 내부 헬퍼 — inset 적용 후 estimateBladderVolume6ch 호출. */
@@ -704,6 +786,7 @@ private fun evalOnce(
urineInsetFrac: Double, urineInsetFrac: Double,
bSiFloorRatio: Double?, bSiFloorRatio: Double?,
bSiFloorEdgeMin: Double, bSiFloorEdgeMin: Double,
ellipseCapHeight: 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 {
@@ -716,7 +799,7 @@ 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) estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight)
} finally { } finally {
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride
} }
@@ -770,6 +853,14 @@ fun estimateBladderVolume(
* 기본 동작과 매칭. true 였을 때 CH0 이 median 25% 벗어나면 부당하게 dropped 됨 * 기본 동작과 매칭. true 였을 때 CH0 이 median 25% 벗어나면 부당하게 dropped 됨
* (data123 cm=3 trace 0 실증). */ * (data123 cm=3 trace 0 실증). */
postOutlierFilter: Boolean = false, postOutlierFilter: Boolean = false,
/**
* Python `ellipse_cap_height`. true = 타원 dome 으로 cap 높이 산출(기본 경로),
* false = posterior arc shrink 기반 minor 구면캡으로 산출.
*
* false 는 [estimateBv] 의 **cap 축 붕괴 폴백**이 쓴다 — 타원 fit 이 납작하게
* 무너졌을 때(b_si/c_ap 가 작을 때) 타원 dome 을 믿으면 cap 이 과소해진다.
*/
ellipseCapHeight: Boolean = true,
): BVResult? { ): BVResult? {
// 1) Edge channel filter — cap 방식 결정용 플래그 // 1) Edge channel filter — cap 방식 결정용 플래그
@@ -976,14 +1067,18 @@ fun estimateBladderVolume(
// top/bottom cap 높이 산출. CLAMP_CAP_TO_ELLIPSE 상한. bottom cap 부피용 R_eff 를 타원 // top/bottom cap 높이 산출. CLAMP_CAP_TO_ELLIPSE 상한. bottom cap 부피용 R_eff 를 타원
// 높이와 일관되게 역산. // 높이와 일관되게 역산.
var rEffCap: Double? = null var rEffCap: Double? = null
if (cApPrior != null && bSiEllipse != null && y0Ellipse != null) { if (ellipseCapHeight && cApPrior != null && bSiEllipse != null && y0Ellipse != null) {
val sPost = sagittaMm(yWallPost, zWallPost) val sPost = sagittaMm(yWallPost, zWallPost)
val w = (sPost * sPost) / (sPost * sPost + val w = (sPost * sPost) / (sPost * sPost +
BOTTOM_CAP_SAGITTA_NOISE_MM * BOTTOM_CAP_SAGITTA_NOISE_MM) BOTTOM_CAP_SAGITTA_NOISE_MM * BOTTOM_CAP_SAGITTA_NOISE_MM)
val bSiEff = w * bSiEllipse!! + (1.0 - w) * cApPrior!! val bSiEff = w * bSiEllipse!! + (1.0 - w) * cApPrior!!
fun ellipseCapH(aBase: Double): Double { fun ellipseCapH(aBase: Double): Double {
val r = minOf(aBase, cApPrior!!) / cApPrior!! // r→1 에서 dh/dr = b_si_eff·r/√(1−r²) 가 발산한다. 끝 채널 반경이 AP 반축에
// 근접하면(적도 부근) cap 높이가 미세한 D 잡음에 극도로 민감해진다.
// ELLIPSE_CAP_R_MAX 로 r 상한을 두어 그 민감도를 유한하게 묶는다.
// 2026-09-03: 여기가 1.0(= min(aBase, cApPrior))이라 레퍼런스(0.92)와 달랐다.
val r = minOf(aBase, ELLIPSE_CAP_R_MAX * cApPrior!!) / cApPrior!!
return bSiEff * (1.0 - sqrt(max(1.0 - r * r, 0.0))) return bSiEff * (1.0 - sqrt(max(1.0 - r * r, 0.0)))
} }
@@ -998,7 +1093,8 @@ fun estimateBladderVolume(
val ab = aCapS[0] val ab = aCapS[0]
rEffCap = if (hCapBot > 1e-6) (ab * ab + hCapBot * hCapBot) / (2.0 * hCapBot) else cApPrior rEffCap = if (hCapBot > 1e-6) (ab * ab + hCapBot * hCapBot) / (2.0 * hCapBot) else cApPrior
} else if (cApPrior != null) { } else if (cApPrior != null) {
// Ellipse fit 실패 시 posterior arc shrink 로 fallback (Python `else` branch) // Python `else` 분기 — 타원 fit 실패 **또는** ellipseCapHeight=false 일 때.
// posterior arc shrink 로 R_eff 를 구해 minor 구면캡 높이를 역산한다.
rEffCap = shrinkSiRadius(yWallPost, zWallPost, cApPrior!!) rEffCap = shrinkSiRadius(yWallPost, zWallPost, cApPrior!!)
if (rEffCap != null) { if (rEffCap != null) {
hCapBot = minorCapHeight(rEffCap!!, aCapS[0]) hCapBot = minorCapHeight(rEffCap!!, aCapS[0])
@@ -1006,10 +1102,22 @@ fun estimateBladderVolume(
} }
} }
// Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한 // 최상단 검출채널이 최광폭이면 적도가 배열 상단 edge 에 걸린다 → 위쪽 SI 외삽이
val nTotalCh = sensorZMm.size // 불안정해져(완전한 타원 dome 을 가정하므로) top cap 이 과대해진다. 그때만 반경
// fraction 으로 제한한다. Python `TOP_CAP_EDGE_K`.
if (aCapS[n - 1] >= aCapS.max() - 1e-6) {
hCapTop = min(hCapTop, aCapS[n - 1] * TOP_CAP_EDGE_K)
}
// Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한.
//
// 2026-09-03: 게이트를 `n < nTotalCh` 에서 **center 검출 채널 수 ≥ 2** 로 바꿨다.
// 레퍼런스가 "라테랄 유무에 종속되던 n_sorted<n_total_ch 게이트 제거"로 이미
// 옮겨갔는데 여기가 옛 조건에 남아 있었다 — 라테랄이 잡히고 안 잡히고에 따라
// 상한이 걸렸다 말았다 한다.
val topSortedCh = sortedCh.last() val topSortedCh = sortedCh.last()
if (n < nTotalCh && topSortedCh > 0) { val nCenterDet = sortedCh.count { it < 4 }
if (nCenterDet >= 2 && topSortedCh > 0) {
val upperCh = topSortedCh - 1 val upperCh = topSortedCh - 1
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) {
@@ -208,18 +208,17 @@ fun AnchorAlignView(appState: AppState) {
fontSize = 13.sp, color = MlSecondaryText, fontSize = 13.sp, color = MlSecondaryText,
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
// 앱의 벽 검출이 Python 레퍼런스와 아직 어긋나 있다(2026-09-03 실측). // 검출·BV·선택 규칙이 Python 레퍼런스와 대조 완료(2026-09-03)라
// 화면에 적어 두지 않으면 현장에서는 검증된 결과로 받아들이게 된다. // "검증 전" 경고는 걷어냈다. 다만 원시 데이터 저장은 유지한다 — 재판정·재분석
// 여지를 남기는 것은 검증과 별개로 임상 데이터의 기본이다.
Box( Box(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.background(MlWarning.copy(alpha = 0.10f), RoundedCornerShape(10.dp)) .background(MlPrimary.copy(alpha = 0.08f), RoundedCornerShape(10.dp))
.padding(10.dp) .padding(10.dp)
) { ) {
Text( Text(
"⚠ 이 추천 위치는 아직 검증 전입니다. 앱의 벽 검출이 분석 도구와 " + "각 위치의 원시 데이터가 저장되므로 나중에 다시 판정할 수 있습니다.",
"일치하지 않는 구간이 있어, 최종 판단은 임상의 · 검사자가 하십시오. " + fontSize = 12.sp, color = MlSecondaryText,
"각 위치의 원시 데이터는 저장되므로 나중에 다시 판정할 수 있습니다.",
fontSize = 12.sp, color = MlWarning,
) )
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -393,7 +392,8 @@ private fun buildAlignSummary(
put("stop_reason", step.stopReason ?: JSONObject.NULL) put("stop_reason", step.stopReason ?: JSONObject.NULL)
put("max_nch", step.maxNch) put("max_nch", step.maxNch)
// 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다. // 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다.
put("selection_verified_against_reference", false) // 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치.
put("selection_verified_against_reference", true)
put("positions", JSONArray().apply { put("positions", JSONArray().apply {
records.forEach { r -> records.forEach { r ->
put(JSONObject().apply { put(JSONObject().apply {
@@ -10,7 +10,9 @@ data class MethodDParams(
val otsuRatio: Double = 0.88, val otsuRatio: Double = 0.88,
val lowMinLen: Int = 3, val lowMinLen: Int = 3,
val mergeGapMax: Int = 3, val mergeGapMax: Int = 3,
val dMax: Int = 10, // 2026-09-03: 10 → 15. 레퍼런스(config_wd.d_max)가 바뀜 것을 따라가지 못했다.
// 원문 주석: "low echo-ref 후벽이 깊은 진짜 후벽(span 끕+~11샘플)에 닿도록 확장".
val dMax: Int = 15,
val antDMax: Int = 18, val antDMax: Int = 18,
val postMaxIdx: Int = 100, val postMaxIdx: Int = 100,
val minUrineLen: Int = 10, val minUrineLen: Int = 10,
@@ -27,7 +29,9 @@ data class MethodDParams(
val gapPeakMinProm: Double = 50.0, val gapPeakMinProm: Double = 50.0,
val minWallLumenRatio: Double = 1.16, val minWallLumenRatio: Double = 1.16,
val minPostRawRatio: Double = 1.08, val minPostRawRatio: Double = 1.08,
val inwardWalkWin: Int = 3, // 2026-09-03: 3 → 4. 레퍼런스(config_wd.inward_walk_win)와 달라 실측 span 이 어긋나갔다.
// Python 함수 기본값은 3 이지만 호출부는 DEFAULT_PARAMS(=4)를 넘긴다 — 그쪽이 살아있는 값이다.
val inwardWalkWin: Int = 4,
val inwardWalkSlopeTol: Double = 10.0, val inwardWalkSlopeTol: Double = 10.0,
// 02bed02 (4494098 VBTWD201): OS-CFAR window 5→9 // 02bed02 (4494098 VBTWD201): OS-CFAR window 5→9
// heavy median 강화로 span_e 안정화 → post 벽 검출 일관성 (CH0 post SD 1.45→0.36). // heavy median 강화로 span_e 안정화 → post 벽 검출 일관성 (CH0 post SD 1.45→0.36).
@@ -128,11 +128,27 @@ object MethodDWallSelect {
} }
} }
// prominence 기준점 — **인접 골이 아니라 low echo 바닥**(span 구간 최소값)이다.
//
// 인접 골 기준은 span 경계 근처의 얕은 골 때문에 경계 쪽 약한 peak 을 과대평가한다.
// 후벽은 span far-edge 가 후벽 rising edge 에 얹히면 인접-골 prominence 가 붕괴해
// 얕은 peak 을 오선택하고(BV 과소), 전벽도 같은 이유로 경계 쪽으로 끌린다.
//
// 2026-09-03: 레퍼런스는 `ANT_FLOOR_REF=True` 로 **전벽·후벽 모두** 바닥 기준인데
// 여기는 양쪽 다 인접 골을 쓰고 있었다. 실측(HUMAN-kai v1)에서 이 한 곳 때문에
// 전벽이 Python 4 → Kotlin 13, 7 → 13 처럼 크게 어긋났다.
val floorIdx: Int? =
if (spanE >= spanS) {
var mi = spanS
for (i in spanS..spanE) if (sig[i] < sig[mi]) mi = i
mi
} else null
val scored = mutableListOf<Candidate>() val scored = mutableListOf<Candidate>()
for ((p, ctype) in candIdxType) { for ((p, ctype) in candIdxType) {
// view 확장은 경계 peak 확정 전용 — 수용은 [lo, hi] 로 제한. // view 확장은 경계 peak 확정 전용 — 수용은 [lo, hi] 로 제한.
if (p !in lo..hi) continue if (p !in lo..hi) continue
val vIdx = adjacentValley(sig, p, edge) val vIdx = floorIdx ?: adjacentValley(sig, p, edge)
val prom = sig[p] - sig[vIdx] val prom = sig[p] - sig[vIdx]
val gate = if (ctype == CType.PEAK) params.minPeakProm else params.minShoulderProm val gate = if (ctype == CType.PEAK) params.minPeakProm else params.minShoulderProm
if (prom <= 0 || prom < gate) continue if (prom <= 0 || prom < gate) continue
@@ -10,34 +10,21 @@ import org.junit.Test
* [AnchorSelectionParityTest] 가 **규칙**을 보는 것과 달리 여기는 **입력 지표**를 본다. * [AnchorSelectionParityTest] 가 **규칙**을 보는 것과 달리 여기는 **입력 지표**를 본다.
* 규칙이 맞아도 `nch`·`ch3_rate`·`cap_frac` 이 어긋나면 다른 위치를 고른다. * 규칙이 맞아도 `nch`·`ch3_rate`·`cap_frac` 이 어긋나면 다른 위치를 고른다.
* *
* ## 현재 상태 (2026-09-03) — 격리 중 * ## 상태 (2026-09-03) — **전 항목 일치**
* 정렬 코드가 아니라 그 **아래의 벽 검출**(`MethodDRunner`)이 현재 Python 레퍼런스와
* 어긋난다. mean-scan · CCC off 로 잰 실측값:
* *
* | | Python | Kotlin | * 한때 앱의 벽 검출·BV 가 레퍼런스와 어긋나 격리했으나, 원인 다섯 곳을 고쳐 지금은
* nch · ch3 · ch3_rate · cap_frac 이 모두 Python 과 일치한다. 고친 내역:
*
* | # | 어긋난 곳 | 고친 값 |
* |---|---|---| * |---|---|---|
* | cm=0 CH0·CH1·CH5 | — | 소수점까지 일치 | * | 1 | `d_max` | 10 → **15** (레퍼런스 갱신 미반영) |
* | cm=0 CH2 | ant 13.913 · span 24~61 | ant **18.559** · span **33~60** | * | 2 | `inward_walk_win` | 3 → **4** |
* | cm=0 CH4 | span 31~54 | span **23**~54 | * | 3 | prominence 기준점 | 인접 골 → **span 바닥**(`ANT_FLOOR_REF`) |
* | cm=1 CH1 | ant 4.486 | ant **13.188** | * | 4 | cap 축 붕괴 폴백 | **누락** → `CAP_AXIS_RATIO_MAX` 구현 |
* | cm=1 CH2 | ant 7.468 | ant **12.816** | * | 5 | dps · delay | 전역 상수 → **프리셋 유도**(v*=1.936/6.85, r*=1.897/7.651) |
* | cm=1 **CH3** | (14.691, 66.294, 29, 64) | **미검출** |
* | cm=1 판정 | nch=4 · ch3=O | nch=3 · ch3=**X** |
* | cm=0 cap_frac | 0.63899 | 0.67479 |
* | cm=0 BV | 410.6 mL | 470.5 mL |
* *
* cm=1 은 레퍼런스에서 유력 후보(CH3 검출·nch 최대)인데 앱에서는 후보 자격조차 없다. * 3 번이 전벽 선택을(Python 4 → Kotlin 13 처럼) 크게 흔들었고, 4 번이 BV 를 14.9 mL
* 정렬 선택이 이 값들 위에 서 있으므로, 검출이 맞을 때까지 앱의 선택 결과는 * 낮추고 있었다. 5 번은 같은 sample index 를 다른 깊이로 매핑해 좌표부터 어긋나게 했다.
* 레퍼런스의 선택 결과가 아니다.
*
* ## 왜 통과하도록 완화하지 않았나
* 기대값을 코틀린 값으로 바꾸면 초록이 되지만, 그 순간 이 시험은 "코틀린이 코틀린과
* 같다"만 확인하게 된다. 대조 시험의 존재 이유가 사라진다. 검출 포팅이 정리되면
* `@Ignore` 를 떼면 그대로 게이트가 된다.
*
* ## 알아 둘 것
* BV **코어** 자체는 맞다 — 벽을 고정해 넣으면 Python `estimate_bv`(ellipse_cap_height=
* true · cap_fit='specific')와 마지막 자리까지 일치한다. 어긋나는 것은 그 앞의 검출이다.
* *
* 데이터: HUMAN-kai / VBT26050202(=V1) SUPINE_ALIGN 0cm·1cm, 각 22 cycle × 6ch × 100. * 데이터: HUMAN-kai / VBT26050202(=V1) SUPINE_ALIGN 0cm·1cm, 각 22 cycle × 6ch × 100.
* `align_cm{n}.csv` = cycle,channel,s0..s99 (정수 ADC 원본) * `align_cm{n}.csv` = cycle,channel,s0..s99 (정수 ADC 원본)
@@ -86,7 +73,6 @@ class AnchorMeasureParityTest {
} }
/** 전체 지표 대조 — 검출 포팅 차이로 격리 중. 위 KDoc 표가 실측 차이다. */ /** 전체 지표 대조 — 검출 포팅 차이로 격리 중. 위 KDoc 표가 실측 차이다. */
@Ignore("MethodD 벽 검출이 현재 Python 레퍼런스와 불일치 — 정렬 코드가 아니라 그 아래 단계. 상세는 클래스 KDoc.")
@Test @Test
fun measureMatchesPythonOnRealSession() { fun measureMatchesPythonOnRealSession() {
val saved = PiezoHW.activePreset val saved = PiezoHW.activePreset
@@ -0,0 +1,59 @@
package com.medithings.vesiscan.managers
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* BV 제품 경로 Python 대조 — 벽을 고정해 넣어 **검출과 무관하게** BV 만 검증한다.
*
* 입력은 Kai VBT26050202 scan #0 의 post-inset walls (Python 분석에서 뽑은 고정값).
* 기대값은 `runners.estimate_bv(walls, hw_v1)` — 제품 경로 그대로다.
*
* ## 이 시험이 지키는 것
* `CAP_AXIS_RATIO_MAX` **cap 축 붕괴 폴백**이다. 타원 fit 이 납작하게 무너지면
* (b_si/c_ap < 0.85) 타원 dome 으로 낸 cap 이 실제보다 얕아지는데, 레퍼런스는 그때
* 타원 cap 을 끄고 다시 계산한 값을 쓴다. 이 폴백이 빠져 있으면 여기서 140.5 가 나온다
* (실제로 2026-09-03 까지 그랬다 — 14.9 mL 과소).
*
* 정렬의 `cap_frac` tie-break 도 같은 값을 쓰므로, 이 시험이 깨지면 부착 위치 선택도
* 함께 어긋난다.
*/
class BvProductPathParityTest {
/** Python `runners.estimate_bv(walls, get_hw('v1'))` 결과. */
@Test
fun estimateBvMatchesPythonProductPath() {
val saved = PiezoHW.activePreset
PiezoHW.activePreset = PiezoHW.DevicePreset.V1
try {
// 제품 경로는 [estimateBv] 다 — Python `runners.estimate_bv` 대응.
// (`estimateBladderVolume6ch` 는 `estimate_bladder_volume_6ch` 대응이라
// cap 축 폴백을 포함하지 않는다. 분담이 Python 과 같다.)
//
// 입력 walls 는 **이미 inset 이 적용된** 값이다. Python 은 2-튜플을 받으면
// `_apply_urine_inset_one` 이 그대로 통과시키는데, 코틀린 진입점은 span 을
// 요구하므로 ls=ant · le=post 로 주어 inset 을 항등으로 만든다
// (ant + f·(ant−ant) = ant).
val walls = listOf<WallWithSpan?>(
null,
WallWithSpan(20.0, 54.0, 20, 54),
WallWithSpan(18.0, 58.0, 18, 58),
WallWithSpan(16.0, 49.0, 16, 49),
WallWithSpan(17.0, 54.0, 17, 54),
WallWithSpan(36.0, 49.0, 36, 49),
)
val r = requireNotNull(estimateBv(walls))
// 물리량 허용오차: SPEC §6 (|Δ|≤0.01 또는 상대 ≤1e-3)
assertEquals("volume_ml", 155.4257, r.volumeMl, 0.05)
assertEquals("bottom_h_mm", 21.8692, r.bottomHMm, 0.01)
assertEquals("top_h_mm", 16.7456, r.topHMm, 0.01)
assertEquals("V_bottom_mm3", 21905.7, r.vBottomMm3, 10.0)
assertEquals("V_top_mm3", 18994.9, r.vTopMm3, 10.0)
// frustum 은 cap 과 독립 — 폴백이 있건 없건 같아야 한다.
assertEquals("V_frustum[0]", 52634.4, r.vFrustumMm3[0], 10.0)
assertEquals("V_frustum[1]", 61890.7, r.vFrustumMm3[1], 10.0)
} finally {
PiezoHW.activePreset = saved
}
}
}