fix: 후면반사 오판방지 수정 + 1채널 구 BV 추가

BackReflection.kt (Python 동기화):
- consensusIdx: min_votes=3 (후보 3개 미만이면 None)
- buildCandidate: post≈global_br이면 None 반환 (post를 BR로 판단 안함)
- isSuspiciousPostPeak: post>ant → post>ant×1.5 (amp_ratio=1.5)
- isReflectionOnlyMultichannel: post>ant → post>ant×1.5

BV Estimation:
- 1채널 구(sphere) 계산 추가 (Python _single_channel_bv)
  - V = (4/3)πR³, R = D/2 (cos 보정)
  - centerWallCount >= 1이면 BV 계산 (기존 3 → 1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 17:42:30 +09:00
parent 002a99d163
commit 3370fd8087
3 changed files with 44 additions and 15 deletions
@@ -99,16 +99,18 @@ object BackReflection {
fun buildCandidate(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, postMatchTol: Int = POST_MATCH_TOL): Int? { fun buildCandidate(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, postMatchTol: Int = POST_MATCH_TOL): Int? {
val globalBr = findBackReflectionIdx(tgcSg, postMaxIdx) val globalBr = findBackReflectionIdx(tgcSg, postMaxIdx)
if (abs(globalBr - postIdx) <= postMatchTol) return postIdx if (abs(globalBr - postIdx) <= postMatchTol) return null // 후위벽≈전체BR → 별도 BR 없음
return findBackReflectionAfterPost(tgcSg, postIdx, postMaxIdx) return findBackReflectionAfterPost(tgcSg, postIdx, postMaxIdx)
} }
// ── consensus_back_reflection_idx ── // ── consensus_back_reflection_idx ──
fun consensusIdx(candidates: List<Int?>, outlierTol: Int = OUTLIER_TOL): Int? { private const val MIN_VOTES = 3
private const val AMP_RATIO = 1.5
fun consensusIdx(candidates: List<Int?>, outlierTol: Int = OUTLIER_TOL, minVotes: Int = MIN_VOTES): Int? {
val vals = candidates.filterNotNull() val vals = candidates.filterNotNull()
if (vals.isEmpty()) return null if (vals.size < minVotes) return null
if (vals.size == 1) return vals[0]
val sorted = vals.sorted() val sorted = vals.sorted()
val med = sorted[sorted.size / 2].toDouble() val med = sorted[sorted.size / 2].toDouble()
val inliers = vals.filter { abs(it - med) <= outlierTol } val inliers = vals.filter { abs(it - med) <= outlierTol }
@@ -119,11 +121,11 @@ object BackReflection {
// ── is_suspicious_post_peak ── // ── is_suspicious_post_peak ──
fun isSuspiciousPostPeak(tgcSg: DoubleArray, postIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, sharedTol: Int = 2): Boolean { fun isSuspiciousPostPeak(tgcSg: DoubleArray, postIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, sharedTol: Int = 2, ampRatio: Double = AMP_RATIO): Boolean {
val n = tgcSg.size val n = tgcSg.size
if (postIdx < 0 || postIdx >= n) return false if (postIdx < 0 || postIdx >= n) return false
var suspicious = false var suspicious = false
if (antIdx != null && antIdx in 0 until n && tgcSg[postIdx] > tgcSg[antIdx]) suspicious = true if (antIdx != null && antIdx in 0 until n && tgcSg[postIdx] > tgcSg[antIdx] * ampRatio) suspicious = true
if (sharedIdx != null && abs(postIdx - sharedIdx) <= sharedTol) suspicious = true if (sharedIdx != null && abs(postIdx - sharedIdx) <= sharedTol) suspicious = true
return suspicious return suspicious
} }
@@ -171,13 +173,15 @@ object BackReflection {
wallPairs: List<Pair<Int?, Int?>>, wallPairs: List<Pair<Int?, Int?>>,
sharedIdx: Int? = null, sharedIdx: Int? = null,
sharedTol: Int = SHARED_TOL, sharedTol: Int = SHARED_TOL,
minChannels: Int = MIN_CHANNELS minChannels: Int = MIN_CHANNELS,
ampRatio: Double = AMP_RATIO
): Boolean { ): Boolean {
data class Valid(val i: Int, val ant: Int, val post: Int) data class Valid(val i: Int, val ant: Int, val post: Int)
val valid = wallPairs.mapIndexedNotNull { i, (ant, post) -> val valid = wallPairs.mapIndexedNotNull { i, (ant, post) ->
if (ant != null && post != null) Valid(i, ant, post) else null if (ant != null && post != null) Valid(i, ant, post) else null
} }
if (valid.size < minChannels) return false if (valid.size < minChannels) return false
val ratio = ampRatio
// 조건 A: shared_idx 기반 // 조건 A: shared_idx 기반
var effectiveShared = sharedIdx var effectiveShared = sharedIdx
@@ -188,7 +192,7 @@ object BackReflection {
if (inliers.size >= minChannels) effectiveShared = med.roundToInt() if (inliers.size >= minChannels) effectiveShared = med.roundToInt()
} }
if (effectiveShared != null) { if (effectiveShared != null) {
if (valid.all { abs(it.post - effectiveShared!!) <= sharedTol && tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] }) return true if (valid.all { abs(it.post - effectiveShared!!) <= sharedTol && tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] * ratio }) return true
} }
// 조건 B: post 클러스터 // 조건 B: post 클러스터
@@ -196,7 +200,7 @@ object BackReflection {
val medPost = postVals[postVals.size / 2].toDouble() val medPost = postVals[postVals.size / 2].toDouble()
val inlierTriples = valid.filter { abs(it.post - medPost) <= sharedTol } val inlierTriples = valid.filter { abs(it.post - medPost) <= sharedTol }
if (inlierTriples.size >= minChannels) { if (inlierTriples.size >= minChannels) {
if (inlierTriples.all { tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] }) return true if (inlierTriples.all { tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] * ratio }) return true
} }
return false return false
} }
@@ -447,7 +447,31 @@ fun estimateBladderVolume(
dPost.add(dFar) dPost.add(dFar)
} }
if (validChannels.size < 2) return null // 1채널 케이스: 구(sphere) 부피 (Python _single_channel_bv)
if (validChannels.size < 2) {
if (validChannels.size == 1) {
val ch = validChannels[0]
val theta = degreeDeg[ch] * Math.PI / 180.0
val dRaw = dPost[0] - dAnt[0]
val D = if (applyAngleCorrection) dRaw * cos(theta) else dRaw
val R = D / 2.0
val bvMm3 = (4.0 / 3.0) * Math.PI * R * R * R
val S = areaK * D * D * lrRatio
val yMid = sensorZMm[ch] + (dAnt[0] + dPost[0]) / 2.0 * sin(theta)
return BVResult(
volumeMl = bvMm3 / 1000.0, volumeMm3 = bvMm3,
validChannels = validChannels, dAntMm = dAnt.toDoubleArray(), dPostMm = dPost.toDoubleArray(),
dMm = doubleArrayOf(D), sMm2 = doubleArrayOf(S),
sortedChannels = listOf(ch), sortedYMm = doubleArrayOf(yMid), sortedSMm2 = doubleArrayOf(S),
vFrustumMm3 = doubleArrayOf(), vCoreMm3 = 0.0,
vBottomMm3 = bvMm3, vTopMm3 = 0.0,
bottomHMm = R, topHMm = 0.0,
bottomKind = "sphere", topKind = "sphere",
lrRatio = lrRatio, distancePerSample = distancePerSample, delayOffsetMm = delayOffsetMm
)
}
return null
}
val n = validChannels.size val n = validChannels.size
// theta, sensor_z for valid channels // theta, sensor_z for valid channels
@@ -229,21 +229,22 @@ fun PiezoMonitoringView(appState: AppState) {
(0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }) (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 })
logService.logMeasurement(methodCBvMl, channelLogs, rawADC) logService.logMeasurement(methodCBvMl, channelLogs, rawADC)
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, methodCBvMl) com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, methodCBvMl)
} else if (centerWallCount >= 3) { } else if (centerWallCount >= 1) {
val bvResult = estimateBladderVolume6ch(allWalls) val bvResult = estimateBladderVolume6ch(allWalls)
if (bvResult != null) { if (bvResult != null) {
rawVolumeMl = bvResult.volumeMl rawVolumeMl = bvResult.volumeMl
rawLrRatio = bvResult.lrRatio rawLrRatio = bvResult.lrRatio
Log.d("PiezoMonitor", "BV[$methodLabel]: ${"%.1f".format(bvResult.volumeMl)}ml (6ch, lr=${"%.2f".format(bvResult.lrRatio)}, center=${centerWallCount}/4)") val mode = if (centerWallCount == 1) "sphere" else "frustum"
Log.d("PiezoMonitor", "BV[$methodLabel]: ${"%.1f".format(bvResult.volumeMl)}ml ($mode, lr=${"%.2f".format(bvResult.lrRatio)}, center=${centerWallCount}/4)")
bleManager.debugLogger.measurementResult(bvResult.volumeMl, bvResult.lrRatio, totalWallCount, bleManager.debugLogger.measurementResult(bvResult.volumeMl, bvResult.lrRatio, totalWallCount,
(0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }, (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 },
otsuThreshold = analyzer.lastOtsuThreshold) otsuThreshold = analyzer.lastOtsuThreshold)
logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC) logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC)
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio) com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio)
} }
} else if (centerWallCount > 0) { } else if (totalWallCount > 0) {
Log.w("PiezoMonitor", "Insufficient center channels: $centerWallCount/4 (need 3+)") Log.w("PiezoMonitor", "No center channels: center=0 total=$totalWallCount/6")
bleManager.debugLogger.warn("BV_SKIP scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} center=$centerWallCount/4 total=$totalWallCount/6") bleManager.debugLogger.warn("BV_SKIP scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} center=0 total=$totalWallCount/6")
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null) com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null)
if (!isAutoMeasuring) { if (!isAutoMeasuring) {
handler.post { android.widget.Toast.makeText(context, handler.post { android.widget.Toast.makeText(context,