fix: Python 대조 검증 — CRITICAL 차이 7건 수정

Method A (PiezoEchoAnalyzerA.kt):
- sliding_scores_1d: edge padding 추가 (np.pad mode="edge" 동일)
- robust_z: 짝수 길이 시 두 중간값 평균 (np.median 동일)
- plateau threshold: 전체 배열 기반 quantile (잘린 배열 → 전체)
- urineLen: post-ant → post-ant-1 (Python 동일)
- score: ((ant+post)/2-lowMean)*len → sg[ant]+sg[post] (Python 동일)
- cross-validation: vals 미변경 + Math.round() (Python 동일)

BV Estimation (PiezoBVEstimator.kt):
- computeLrRatio maxRatio 기본값 3.0 → 1.0 (Python 동일)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 19:00:17 +09:00
parent c849943b4e
commit 9e350d6d40
2 changed files with 36 additions and 28 deletions
@@ -117,7 +117,7 @@ object PiezoHW {
fun computeLrRatio(
centerWalls: List<Pair<Int, Int>?>,
lateralWalls: List<Pair<Int, Int>?>,
maxRatio: Double = 3.0
maxRatio: Double = 1.0
): Double {
val hw = PiezoHW
val neighbors = hw.lateralNeighbors
@@ -68,13 +68,16 @@ class PiezoEchoAnalyzerA private constructor() {
if (L < 10) return null
val y = DoubleArray(L) { sg[it] + alpha * abs(d1[it]) + beta * abs(d2[it]) }
// 2) Plateau score
// 2) Plateau score — threshold from full array (Python 동일)
val platScore = slidingScores1d(sg, scoreWin)
val fullSorted = platScore.sorted().toDoubleArray()
val qIdx = (fullSorted.size * plateauQ).toInt().coerceIn(0, fullSorted.size - 1)
val scoreThr = if (qIdx > 0 && qIdx < fullSorted.size - 1) {
(fullSorted[qIdx] + fullSorted[qIdx + 1]) / 2.0
} else fullSorted[qIdx]
val limit = min(platScore.size, postMaxIdx + 1)
val scoreSorted = platScore.sliceArray(0 until limit).sorted().toDoubleArray()
val scoreThr = scoreSorted[(scoreSorted.size * plateauQ).toInt().coerceIn(0, scoreSorted.size - 1)]
// 3) Plateau spans + merge
// 3) Plateau spans + merge (postMaxIdx 이내만)
var plateauSpans = findPlateauSpans(platScore, limit, scoreThr, plateauMinLen)
if (plateauSpans.size > 1) {
val merged = mutableListOf(plateauSpans[0])
@@ -138,18 +141,17 @@ class PiezoEchoAnalyzerA private constructor() {
}
if (bestLeft < 0 || bestRight < 0 || bestRight <= bestLeft) continue
val urineLen = bestRight - bestLeft
val urineLen = bestRight - bestLeft - 1
if (urineLen < minUrineLen) continue
val lowSlice = sg.sliceArray(spanStart..spanEnd)
val lowMean = lowSlice.average()
val wallScore = (sg[bestLeft] + sg[bestRight]) / 2.0 - lowMean
return LowEchoResult(
ant = bestLeft, post = bestRight,
lowStart = spanStart, lowEnd = spanEnd,
lowMean = lowMean, urineLen = urineLen,
score = wallScore * urineLen,
score = (sg[bestLeft] + sg[bestRight]).toDouble(),
innerPeaks = PiezoEchoAnalyzer.shared.findInnerPeaks(sg, bestLeft, bestRight)
)
}
@@ -166,42 +168,49 @@ class PiezoEchoAnalyzerA private constructor() {
val slope = DoubleArray(T)
val low = DoubleArray(T)
// Edge padding (mode="edge") — Python np.pad 동일
val xp = DoubleArray(T + 2 * half)
for (i in 0 until half) xp[i] = x[0]
for (i in 0 until T) xp[i + half] = x[i]
for (i in 0 until half) xp[T + half + i] = x[T - 1]
val tt = DoubleArray(w) { (it - half).toDouble() }
var ttSqSum = 0.0
for (t in tt) ttSqSum += t * t
ttSqSum += 1e-12
for (i in 0 until T) {
val wStart = max(0, i - half)
val wEnd = min(T - 1, i + half)
val wLen = wEnd - wStart + 1
val ww = DoubleArray(w) { xp[i + it] }
var sum = 0.0; var sqSum = 0.0
val vals = mutableListOf<Double>()
for (j in wStart..wEnd) { sum += x[j]; sqSum += x[j] * x[j]; vals.add(x[j]) }
val mean = sum / wLen
flat[i] = sqrt(max(0.0, sqSum / wLen - mean * mean))
for (v in ww) { sum += v; sqSum += v * v }
val mean = sum / w
flat[i] = sqrt(max(0.0, sqSum / w - mean * mean))
vals.sort()
low[i] = vals[(wLen * 0.2).toInt().coerceIn(0, wLen - 1)]
val sorted = ww.sorted().toDoubleArray()
low[i] = sorted[(w * 0.2).toInt().coerceIn(0, w - 1)]
var slopeNum = 0.0
for (j in wStart..wEnd) {
slopeNum += (j - i).toDouble() * (x[j] - mean)
for (j in 0 until w) {
slopeNum += tt[j] * (ww[j] - mean)
}
slope[i] = abs(slopeNum / ttSqSum)
}
return DoubleArray(T) { robustZ(low)[it] + robustZ(flat)[it] + robustZ(slope)[it] }
val rzLow = robustZ(low)
val rzFlat = robustZ(flat)
val rzSlope = robustZ(slope)
return DoubleArray(T) { rzLow[it] + rzFlat[it] + rzSlope[it] }
}
private fun robustZ(a: DoubleArray): DoubleArray {
val sorted = a.sorted().toDoubleArray()
val med = sorted[sorted.size / 2]
val deviations = DoubleArray(a.size) { abs(a[it] - med) }
val n = sorted.size
val med = if (n % 2 == 0) (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 else sorted[n / 2]
val deviations = DoubleArray(n) { abs(a[it] - med) }
val devSorted = deviations.sorted().toDoubleArray()
val mad = devSorted[devSorted.size / 2] + 1e-12
return DoubleArray(a.size) { (a[it] - med) / (1.4826 * mad) }
val madMed = if (n % 2 == 0) (devSorted[n / 2 - 1] + devSorted[n / 2]) / 2.0 else devSorted[n / 2]
val mad = madMed + 1e-12
return DoubleArray(n) { (a[it] - med) / (1.4826 * mad) }
}
// ── Plateau span detection ──
@@ -256,7 +265,7 @@ class PiezoEchoAnalyzerA private constructor() {
if (validCenter.size < 3) return corrected
for (field in listOf("ant", "post")) {
val vals = validCenter.map { if (field == "ant") corrected[it]!!.first else corrected[it]!!.second }.toMutableList()
val vals = validCenter.map { if (field == "ant") corrected[it]!!.first else corrected[it]!!.second }
for (j in vals.indices) {
val neighbors = mutableListOf<Int>()
if (j > 0) neighbors.add(vals[j - 1])
@@ -264,11 +273,10 @@ class PiezoEchoAnalyzerA private constructor() {
if (neighbors.isEmpty()) continue
val neighborMean = neighbors.average()
if (abs(vals[j] - neighborMean) > crossValMaxGradient) {
val newVal = neighborMean.toInt()
val newVal = Math.round(neighborMean).toInt()
val chIdx = validCenter[j]
val w = corrected[chIdx]!!
corrected[chIdx] = if (field == "ant") Pair(newVal, w.second) else Pair(w.first, newVal)
vals[j] = newVal
}
}
}