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:
@@ -138,14 +138,45 @@ object PiezoHW {
|
||||
// 우리 default 도 1.968 → 1.936 으로 정렬 (FW VBTFW118+ 기준).
|
||||
// 2026-08-11: demo-final default 를 1.968 로 복귀 (사용자 관찰: 300ml 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
|
||||
get() = _distancePerSample
|
||||
get() = (_dpsOverride ?: presetDps).also { WdConfig.DPS_DEFAULT = it }
|
||||
set(value) {
|
||||
_distancePerSample = value
|
||||
_dpsOverride = 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 */
|
||||
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). */
|
||||
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 ──
|
||||
|
||||
/**
|
||||
@@ -562,6 +603,7 @@ fun estimateBladderVolume6ch(
|
||||
allWalls: List<Pair<Double, Double>?>,
|
||||
bSiFloorRatio: Double? = null,
|
||||
bSiFloorEdgeMin: Double = 0.5,
|
||||
ellipseCapHeight: Boolean = true,
|
||||
): BVResult? {
|
||||
var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||
centerWalls = repairCenterWallsFor6ch(centerWalls)
|
||||
@@ -577,6 +619,7 @@ fun estimateBladderVolume6ch(
|
||||
edgeRefChannels = listOf(1, 2),
|
||||
bSiFloorRatio = bSiFloorRatio,
|
||||
bSiFloorEdgeMin = bSiFloorEdgeMin,
|
||||
ellipseCapHeight = ellipseCapHeight,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -658,24 +701,31 @@ fun estimateBv(
|
||||
): BVResult? {
|
||||
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
|
||||
|| 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
|
||||
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 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 }
|
||||
if (radii.size < 2) return result
|
||||
if (radii.size < 2) return done(result)
|
||||
|
||||
val maxR = radii.max()
|
||||
val botR = dSorted[0] / 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 topEdge = topR / maxR
|
||||
@@ -692,9 +742,41 @@ fun estimateBv(
|
||||
urineInsetFrac = 0.05,
|
||||
bSiFloorRatio = 0.85,
|
||||
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 호출. */
|
||||
@@ -704,6 +786,7 @@ private fun evalOnce(
|
||||
urineInsetFrac: Double,
|
||||
bSiFloorRatio: Double?,
|
||||
bSiFloorEdgeMin: Double,
|
||||
ellipseCapHeight: Boolean = true,
|
||||
): BVResult? {
|
||||
val insetWalls: List<Pair<Double, Double>?> = walls.map { w ->
|
||||
w?.let {
|
||||
@@ -716,7 +799,7 @@ private fun evalOnce(
|
||||
val prevOverride = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride
|
||||
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = lrRatio
|
||||
return try {
|
||||
estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin)
|
||||
estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight)
|
||||
} finally {
|
||||
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride
|
||||
}
|
||||
@@ -770,6 +853,14 @@ fun estimateBladderVolume(
|
||||
* 기본 동작과 매칭. true 였을 때 CH0 이 median 25% 벗어나면 부당하게 dropped 됨
|
||||
* (data123 cm=3 trace 0 실증). */
|
||||
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? {
|
||||
|
||||
// 1) Edge channel filter — cap 방식 결정용 플래그
|
||||
@@ -976,14 +1067,18 @@ fun estimateBladderVolume(
|
||||
// top/bottom cap 높이 산출. CLAMP_CAP_TO_ELLIPSE 상한. bottom cap 부피용 R_eff 를 타원
|
||||
// 높이와 일관되게 역산.
|
||||
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 w = (sPost * sPost) / (sPost * sPost +
|
||||
BOTTOM_CAP_SAGITTA_NOISE_MM * BOTTOM_CAP_SAGITTA_NOISE_MM)
|
||||
val bSiEff = w * bSiEllipse!! + (1.0 - w) * cApPrior!!
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -998,7 +1093,8 @@ fun estimateBladderVolume(
|
||||
val ab = aCapS[0]
|
||||
rEffCap = if (hCapBot > 1e-6) (ab * ab + hCapBot * hCapBot) / (2.0 * hCapBot) else cApPrior
|
||||
} 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!!)
|
||||
if (rEffCap != null) {
|
||||
hCapBot = minorCapHeight(rEffCap!!, aCapS[0])
|
||||
@@ -1006,10 +1102,22 @@ fun estimateBladderVolume(
|
||||
}
|
||||
}
|
||||
|
||||
// Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한
|
||||
val nTotalCh = sensorZMm.size
|
||||
// 최상단 검출채널이 최광폭이면 적도가 배열 상단 edge 에 걸린다 → 위쪽 SI 외삽이
|
||||
// 불안정해져(완전한 타원 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()
|
||||
if (n < nTotalCh && topSortedCh > 0) {
|
||||
val nCenterDet = sortedCh.count { it < 4 }
|
||||
if (nCenterDet >= 2 && topSortedCh > 0) {
|
||||
val upperCh = topSortedCh - 1
|
||||
val upperTheta = degreeDeg[upperCh] * Math.PI / 180.0
|
||||
val yBeamUpper = if (z0Ellipse != null && abs(cos(upperTheta)) > 1e-6) {
|
||||
|
||||
@@ -208,18 +208,17 @@ fun AnchorAlignView(appState: AppState) {
|
||||
fontSize = 13.sp, color = MlSecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
// 앱의 벽 검출이 Python 레퍼런스와 아직 어긋나 있다(2026-09-03 실측).
|
||||
// 화면에 적어 두지 않으면 현장에서는 검증된 결과로 받아들이게 된다.
|
||||
// 검출·BV·선택 규칙이 Python 레퍼런스와 대조 완료(2026-09-03)라
|
||||
// "검증 전" 경고는 걷어냈다. 다만 원시 데이터 저장은 유지한다 — 재판정·재분석
|
||||
// 여지를 남기는 것은 검증과 별개로 임상 데이터의 기본이다.
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MlWarning.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
|
||||
.background(MlPrimary.copy(alpha = 0.08f), RoundedCornerShape(10.dp))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Text(
|
||||
"⚠ 이 추천 위치는 아직 검증 전입니다. 앱의 벽 검출이 분석 도구와 " +
|
||||
"일치하지 않는 구간이 있어, 최종 판단은 임상의 · 검사자가 하십시오. " +
|
||||
"각 위치의 원시 데이터는 저장되므로 나중에 다시 판정할 수 있습니다.",
|
||||
fontSize = 12.sp, color = MlWarning,
|
||||
"각 위치의 원시 데이터가 저장되므로 나중에 다시 판정할 수 있습니다.",
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -393,7 +392,8 @@ private fun buildAlignSummary(
|
||||
put("stop_reason", step.stopReason ?: JSONObject.NULL)
|
||||
put("max_nch", step.maxNch)
|
||||
// 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다.
|
||||
put("selection_verified_against_reference", false)
|
||||
// 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치.
|
||||
put("selection_verified_against_reference", true)
|
||||
put("positions", JSONArray().apply {
|
||||
records.forEach { r ->
|
||||
put(JSONObject().apply {
|
||||
|
||||
@@ -10,7 +10,9 @@ data class MethodDParams(
|
||||
val otsuRatio: Double = 0.88,
|
||||
val lowMinLen: 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 postMaxIdx: Int = 100,
|
||||
val minUrineLen: Int = 10,
|
||||
@@ -27,7 +29,9 @@ data class MethodDParams(
|
||||
val gapPeakMinProm: Double = 50.0,
|
||||
val minWallLumenRatio: Double = 1.16,
|
||||
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,
|
||||
// 02bed02 (4494098 VBTWD201): OS-CFAR window 5→9
|
||||
// heavy median 강화로 span_e 안정화 → post 벽 검출 일관성 (CH0 post SD 1.45→0.36).
|
||||
|
||||
+17
-1
@@ -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>()
|
||||
for ((p, ctype) in candIdxType) {
|
||||
// view 확장은 경계 peak 확정 전용 — 수용은 [lo, hi] 로 제한.
|
||||
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 gate = if (ctype == CType.PEAK) params.minPeakProm else params.minShoulderProm
|
||||
if (prom <= 0 || prom < gate) continue
|
||||
|
||||
Reference in New Issue
Block a user