feat: 6ch 알고리즘 포팅 + urineLen 기반 정밀 Placement Guide

6ch 알고리즘 (config_6ch.py / bv_estimation.py 포팅):
- PiezoHW: V0/V1/V2 프리셋 (Snell's law 굴절 각도)
- computeLrRatio(): CH4/CH5 lateral에서 타원 단면 비율 동적 계산
- estimateBladderVolume6ch(): center 4ch + lateral lr_ratio
- denoise(): TVD 제거, SG 필터만 사용
- LOW_ECHO_AMP: 1600→1150, distancePerSample: 1.771

Placement Guide 2단계:
- 1단계: 이진 판정 (잡힘/안잡힘) → 기본 방향
- 2단계: urineLen 기반 정밀 가이드 (대칭+균형+크기)
  - 세로: CH0/CH3 비율 1.5배 이상이면 방향 제시
  - 가로: CH4/CH5 차이 5칸 이상이면 방향 제시
- Placement Score = 세로대칭(30%) + 가로균형(30%) + 중앙크기(40%)
- 각 채널에 urineLen 숫자 표시

VesiScan_Android_Pipeline_Summary.md 문서 작성

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 10:22:57 +09:00
parent a95815c42d
commit c114592ab1
6 changed files with 969 additions and 155 deletions
@@ -38,7 +38,7 @@ object GreenZoneConstants {
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
* Python 원본: 1600 (low_echo_detection_method_b.py LOW_ECHO_AMP)
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
const val lowEchoAmp: Float = 1600f
const val lowEchoAmp: Float = 1150f
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
@@ -20,106 +20,186 @@ import kotlin.math.sqrt
* Probe geometry — probe model별 고정
*/
object PiezoHW {
const val nCh = 5 // BV estimation에 사용할 수직 채널 수
// ═══════════════════════════════════════════════════════════
// Device Presets — 기기별 센서 배치
// Device Presets — 6ch 기기 3가지 버전 (Snell's law 굴절 보정 적용)
// config_6ch.py 1:1 포팅
// ═══════════════════════════════════════════════════════════
enum class DevicePreset {
/** 기존 5ch 수직 배열 (placeholder 각도) */
LEGACY_5CH,
/** 전채널 0° (BLE 테스트용, 각도 보정 없음) */
FLAT,
/** 새 기기 Case A: 0° / 10° / 20° / 30° + 좌우 ±5° */
NEW_CASE1,
/** 새 기기 Case B: -10° / 0° / 10° / 20° + 좌우 ±5° */
NEW_CASE2
V0, // All 0° (BLE 테스트용)
V1, // Max 20° housing → Snell's law refraction
V2 // Max 30° housing → Snell's law refraction
}
/** 현재 활성 preset — 기기 연결 시 자동 감지, 수동 변경도 가능 */
var activePreset: DevicePreset = DevicePreset.FLAT
var activePreset: DevicePreset = DevicePreset.V0
/**
* BLE 기기 이름으로 preset 자동 감지.
* VBT26040x0y: x=0 → FLAT, x=2 → NEW_CASE2, x=3 → NEW_CASE1
*/
fun autoDetectPreset(deviceName: String) {
if (deviceName.startsWith("VBT2604") && deviceName.length >= 9) {
when (deviceName[7]) {
'0' -> activePreset = DevicePreset.FLAT
'2' -> activePreset = DevicePreset.NEW_CASE2
'3' -> activePreset = DevicePreset.NEW_CASE1
'0' -> activePreset = DevicePreset.V0
'2' -> activePreset = DevicePreset.V1
'3' -> activePreset = DevicePreset.V2
}
} else if (deviceName.startsWith("2025MEDIP")) {
activePreset = DevicePreset.LEGACY_5CH
}
}
// BV용 수직 채널 (CH0~CH3)
val sensorZMm: DoubleArray
val centerCh = intArrayOf(0, 1, 2, 3)
val lateralCh = intArrayOf(4, 5)
val lateralNeighbors = intArrayOf(1, 2)
// 6채널 전체 z 좌표 (CH0~CH5)
val sensorZMmAll: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0)
DevicePreset.FLAT -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
DevicePreset.NEW_CASE1 -> doubleArrayOf(22.0, 16.5, 11.0, 5.5)
DevicePreset.NEW_CASE2 -> doubleArrayOf(22.0, 16.5, 11.0, 5.5)
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
DevicePreset.V0 -> doubleArrayOf(21.0, 14.0, 7.0, 0.0, 10.5, 10.5)
DevicePreset.V1 -> doubleArrayOf(20.1, 12.8, 7.0, 0.0, 9.9, 9.9)
DevicePreset.V2 -> doubleArrayOf(19.3, 13.0, 6.7, 0.0, 9.85, 9.85)
}
val sensorXMm: DoubleArray
// 6채널 전체 x 좌표
val sensorXMmAll: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
else -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -10.0, 10.0)
}
/** SI beam angle (degrees) — BV 계산용 */
val degree: DoubleArray
// 6채널 전체 SI 빔 각도 (Snell's law 굴절 후)
val degreeAll: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8)
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 10.0, 20.0, 30.0)
DevicePreset.NEW_CASE2 -> doubleArrayOf(-10.0, 0.0, 10.0, 20.0)
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8, 0.0)
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.V1 -> doubleArrayOf(6.89, 0.0, -6.89, -13.66, -6.87, -6.87)
DevicePreset.V2 -> doubleArrayOf(0.0, -6.89, -13.66, -20.20, -6.87, -6.87)
}
val degreeLR: DoubleArray
// 6채널 전체 LR 빔 각도 (Snell's law 굴절 후)
val degreeLRAll: DoubleArray
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
DevicePreset.NEW_CASE2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
DevicePreset.V1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
DevicePreset.V2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
}
/** 좌우 날개 채널 각도 (Placement Guide + lr_ratio 계산용)
* CH4: 왼쪽, CH5: 오른쪽 */
const val lateralDegreeSI: Double = 10.0 // SI 방향 (아래쪽을 봄)
const val lateralDegreeLR: Double = 5.0 // LR 방향 (바깥쪽)
// BV용 center 채널만 (CH0~CH3)
val sensorZMm: DoubleArray get() = centerCh.map { sensorZMmAll[it] }.toDoubleArray()
val sensorXMm: DoubleArray get() = centerCh.map { sensorXMmAll[it] }.toDoubleArray()
val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray()
val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray()
/** Acoustic calibration */
const val distancePerSample: Double = 1.974 // mm/sample (390 ksps, 1540 m/s)
const val delayOffsetMm: Double = 6.85 // mm (first ADC sample 이전 고정 전파 지연)
const val distancePerSample: Double = 1.771 // mm/sample
const val delayOffsetMm: Double = 6.85
/** Volume model */
val areaK: Double = Math.PI / 4.0 // S = areaK · D² · lr_ratio
const val defaultLrRatio: Double = 1.0 // 원형 단면 가정
val areaK: Double = Math.PI / 4.0
const val defaultLrRatio: Double = 1.0
/** Phantom presets */
fun lrRatio(phantom: String): Double {
if (phantom.contains("bp1_70")) return 1.56
if (phantom.contains("bp2_500")) return 1.0
return defaultLrRatio
}
// lr_ratio fallbacks
const val lrRatioNoDetection: Double = 1.0
const val lrRatioInvalid: Double = 1.2
const val lrPrior: Double = 1.2
/** 현재 preset 이름 (로그용) */
val presetName: String
get() = when (activePreset) {
DevicePreset.LEGACY_5CH -> "Legacy 5ch"
DevicePreset.FLAT -> "Flat (all 0°)"
DevicePreset.NEW_CASE1 -> "Case1 (0/10/20/30)"
DevicePreset.NEW_CASE2 -> "Case2 (-10/0/10/20)"
DevicePreset.V0 -> "V0 (all 0°)"
DevicePreset.V1 -> "V1 (max 20°, Snell)"
DevicePreset.V2 -> "V2 (max 30°, Snell)"
}
}
// ── LR Ratio Computation (from lateral CH4/CH5) ──
fun computeLrRatio(
centerWalls: List<Pair<Int, Int>?>,
lateralWalls: List<Pair<Int, Int>?>,
maxRatio: Double = 3.0
): Double {
val hw = PiezoHW
val neighbors = hw.lateralNeighbors
// Center AP chord from neighbors
val centerDs = mutableListOf<Double>()
for (ni in neighbors) {
if (ni < centerWalls.size) {
val w = centerWalls[ni] ?: continue
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
val theta = hw.degreeAll[hw.centerCh[ni]] * Math.PI / 180.0
centerDs.add((dFar - dNear) * cos(theta))
}
}
if (centerDs.isEmpty()) return hw.lrRatioNoDetection
val dCenter = centerDs.average()
if (dCenter <= 0) return hw.lrRatioInvalid
// Lateral chords
data class LatResult(val ratio: Double, val yMid: Double)
val latResults = mutableListOf<LatResult>()
for ((li, lCh) in hw.lateralCh.withIndex()) {
if (li >= lateralWalls.size) continue
val w = lateralWalls[li] ?: continue
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
val alpha = hw.degreeAll[lCh] * Math.PI / 180.0
val beta = hw.degreeLRAll[lCh] * Math.PI / 180.0
val sx = hw.sensorXMmAll[lCh]
val pFactor = cos(alpha) * cos(beta)
val qFactor = cos(alpha) * sin(beta)
val dLateral = (dFar - dNear) * pFactor
val dMid = (dNear + dFar) / 2.0
val yMid = sx + dMid * qFactor
val ratio = dLateral / dCenter
if (ratio <= 0 || ratio >= maxRatio) continue
if (abs(yMid) < 1e-3) continue
latResults.add(LatResult(ratio, yMid))
}
if (latResults.isEmpty()) return hw.lrRatioNoDetection
val lrRaw: Double
if (latResults.size >= 2) {
// Two laterals: solve ellipse
val yL = latResults[0].yMid
val rL = latResults[0].ratio
val yR = latResults[1].yMid
val rR = latResults[1].ratio
val denom = yR * (rL * rL - 1.0) - yL * (rR * rR - 1.0)
if (abs(denom) < 1e-12) return hw.lrRatioInvalid
val u = yL * yR * (yR - yL) / denom
if (u <= 0) return hw.lrRatioInvalid
val d = -(u * (rL * rL - 1.0) + yL * yL) / (2.0 * yL)
val bSq = u + d * d
if (bSq <= 0) return hw.lrRatioInvalid
lrRaw = 2.0 * sqrt(u) / dCenter
} else {
// Single lateral: assume center
val r = latResults[0].ratio
val yMid = latResults[0].yMid
if (r >= 1.0) return hw.lrRatioInvalid
val bEst = abs(yMid) / sqrt(1.0 - r * r)
val aEst = dCenter / 2.0
lrRaw = bEst / aEst
}
if (lrRaw <= 0 || lrRaw >= maxRatio) return hw.lrRatioInvalid
// Shrinkage toward prior
val avgRatio = latResults.map { it.ratio }.average()
val confidence = ((1.0 - avgRatio) / 0.10).coerceIn(0.0, 1.0)
return hw.lrPrior + (lrRaw - hw.lrPrior) * confidence
}
// ── Result ──
data class BVResult(
@@ -315,8 +395,26 @@ private fun capVolume(
* Traditional frustum BV 추정 (cos 보정 + SI 축 기반)
* 1:1 port of _bv_core(mode="traditional")
*/
/**
* 6ch BV 추정 — center 4채널(CH0~CH3) + lateral 2채널(CH4/CH5)에서 lr_ratio 계산
* config_6ch.py / bv_estimation.py estimate_bladder_volume_6ch 1:1 포팅
*/
fun estimateBladderVolume6ch(
allWalls: List<Pair<Int, Int>?>, // 6채널 전체 (ant, post), null = invalid
): BVResult? {
val centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
val lateralWalls = PiezoHW.lateralCh.map { if (it < allWalls.size) allWalls[it] else null }
val lrRatio = computeLrRatio(centerWalls, lateralWalls)
return estimateBladderVolume(
walls = centerWalls,
lrRatio = lrRatio,
edgeCh = 3,
edgeRefChannels = listOf(1, 2)
)
}
fun estimateBladderVolume(
walls: List<Pair<Int, Int>?>, // 5채널 (ant, post), null = invalid
walls: List<Pair<Int, Int>?>,
distancePerSample: Double = PiezoHW.distancePerSample,
delayOffsetMm: Double = PiezoHW.delayOffsetMm,
sensorZMm: DoubleArray = PiezoHW.sensorZMm,
@@ -324,28 +422,26 @@ fun estimateBladderVolume(
areaK: Double = PiezoHW.areaK,
lrRatio: Double = PiezoHW.defaultLrRatio,
applyAngleCorrection: Boolean = true,
ch4MinRatio: Double? = 0.9,
ch4RefChannels: List<Int> = listOf(2, 3)
edgeCh: Int = walls.size - 1,
edgeRefChannels: List<Int> = listOf(walls.size - 2, walls.size - 3)
): BVResult? {
// 1) CH4 filter — cap 방식 결정용 플래그
var ch4IsShort = false
if (ch4MinRatio != null && walls.size >= 5) {
val w4 = walls[4]
if (w4 != null) {
val l4 = w4.second - w4.first
// 1) Edge channel filter — cap 방식 결정용 플래그
var edgeIsShort = false
if (edgeCh < walls.size) {
val wEdge = walls[edgeCh]
if (wEdge != null) {
val lEdge = wEdge.second - wEdge.first
val refLens = mutableListOf<Int>()
for (ci in ch4RefChannels) {
if (ci < walls.size) {
for (ci in edgeRefChannels) {
if (ci in walls.indices) {
val w = walls[ci]
if (w != null) {
refLens.add(w.second - w.first)
}
if (w != null) refLens.add(w.second - w.first)
}
}
val minRef = refLens.minOrNull()
if (minRef != null && l4.toDouble() < ch4MinRatio * minRef.toDouble()) {
ch4IsShort = true
if (minRef != null && lEdge.toDouble() < 0.9 * minRef.toDouble()) {
edgeIsShort = true
}
}
}
@@ -412,7 +508,7 @@ fun estimateBladderVolume(
capMode = capMode, shape = "sphere")
// Top: cone (정상) / sphere (CH4 short)
val topCap = if (ch4IsShort) {
val topCap = if (edgeIsShort) {
capVolume(
sEdge = sS[n - 1], aCapEdge = aCapS[n - 1], bEff = bEff, sMax = sMax,
capMode = "fallback", shape = "sphere")
@@ -211,10 +211,9 @@ class PiezoEchoAnalyzer private constructor() {
// ── Denoising Pipeline ──
/** TVD → SG 디노이징 */
/** SG 디노이징 (6ch 알고리즘: TVD 제거, SG만 사용) */
fun denoise(signal: DoubleArray): DoubleArray {
val tvd = tvDenoiseChambolle1D(signal)
return sgSmooth(tvd)
return sgSmooth(signal)
}
/** 1D Total Variation Denoising — Chambolle (2004) dual projected gradient */
@@ -41,7 +41,7 @@ import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.ble.PiezoChannelData
import com.example.medilightv2android.managers.PiezoEchoAnalyzer
import com.example.medilightv2android.managers.PiezoConstants
import com.example.medilightv2android.managers.estimateBladderVolume
import com.example.medilightv2android.managers.estimateBladderVolume6ch
import com.example.medilightv2android.models.BladderLevel
import com.example.medilightv2android.models.UrgencyLevel
import com.example.medilightv2android.ui.components.BladdyRiveView
@@ -111,20 +111,21 @@ fun PiezoMonitoringView(appState: AppState) {
if (validChannels.isNotEmpty()) {
val analysisResult = analyzer.analyzeMultiChannel(validChannels)
// Build walls list for BV estimation (CH0~CH3, 세로 4채널만)
val walls: MutableList<Pair<Int, Int>?> = MutableList(4) { null }
// Build walls list for 6ch BV estimation
val allWalls: MutableList<Pair<Int, Int>?> = MutableList(6) { null }
for (ch in analysisResult.channels) {
if (ch.channel < 4 && ch.result != null) {
walls[ch.channel] = Pair(ch.result.ant, ch.result.post)
if (ch.channel < 6 && ch.result != null) {
allWalls[ch.channel] = Pair(ch.result.ant, ch.result.post)
}
}
// Frustum BV estimation
val bvResult = estimateBladderVolume(walls = walls)
// 6ch BV estimation (center 4ch + lateral lr_ratio)
val bvResult = estimateBladderVolume6ch(allWalls)
if (bvResult != null) {
lastVolumeMl = bvResult.volumeMl
maxVolumeMl = maxOf(maxVolumeMl, bvResult.volumeMl)
Log.d("PiezoMonitor", "BV: ${"%.1f".format(bvResult.volumeMl)}ml (frustum, ${bvResult.validChannels.size}ch)")
Log.d("PiezoMonitor", "BV: ${"%.1f".format(bvResult.volumeMl)}ml (6ch, lr=${
"%.2f".format(bvResult.lrRatio)}, ${bvResult.validChannels.size}ch)")
} else {
Log.d("PiezoMonitor", "BV: estimation failed (< 2 valid channels)")
}
@@ -58,8 +58,10 @@ fun PlacementGuideView(appState: AppState) {
var isScanning by remember { mutableStateOf(false) }
val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) }
val channelUrineLen = remember { mutableStateListOf(0, 0, 0, 0, 0, 0) }
var directionHint by remember { mutableStateOf("") }
var directionIcon by remember { mutableStateOf("") }
var placementScore by remember { mutableIntStateOf(0) }
var scanCount by remember { mutableIntStateOf(0) }
var isLocked by remember { mutableStateOf(false) }
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
@@ -186,7 +188,7 @@ fun PlacementGuideView(appState: AppState) {
for (i in 0 until 6) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Box(
modifier = Modifier
@@ -197,21 +199,33 @@ fun PlacementGuideView(appState: AppState) {
else Color.Red.copy(alpha = 0.4f)
)
)
Text(
text = "CH$i",
fontSize = 9.sp,
fontWeight = FontWeight.SemiBold,
color = MlSecondaryText
)
Text("CH$i", fontSize = 9.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
if (channelUrineLen[i] > 0) {
Text("${channelUrineLen[i]}", fontSize = 8.sp, color = Color.Cyan)
}
}
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "$detectedCount/6",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800)
)
Column(horizontalAlignment = Alignment.End) {
Text(
text = "$detectedCount/6",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800)
)
if (placementScore > 0) {
Text(
text = "Score: $placementScore%",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = when {
placementScore >= 80 -> Color(0xFF4CAF50)
placementScore >= 50 -> Color(0xFFFF9800)
else -> Color.Red
}
)
}
}
}
// Per-channel echo graphs (after scan)
@@ -275,21 +289,20 @@ fun PlacementGuideView(appState: AppState) {
isScanning = false
scanCount++
// Run UrinAI on each channel
val detected = MutableList(6) { false }
val urineLens = MutableList(6) { 0 }
for (ch in channels) {
if (ch.buffer.isNotEmpty() && ch.channel < 6) {
val gate = UrinAI.detect(ch.buffer)
detected[ch.channel] = gate.detected
// Echo analysis as backup
if (!gate.detected) {
val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel)
if (analysis.isValid) {
val r = analysis.result
if (r != null && r.urineLen >= 10) {
detected[ch.channel] = true
}
val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel)
if (analysis.isValid && analysis.result != null) {
detected[ch.channel] = true
urineLens[ch.channel] = analysis.result.urineLen
} else {
val gate = UrinAI.detect(ch.buffer)
if (gate.detected) {
detected[ch.channel] = true
urineLens[ch.channel] = gate.bw - gate.fw
}
}
}
@@ -297,16 +310,18 @@ fun PlacementGuideView(appState: AppState) {
for (i in 0 until 6) {
channelDetected[i] = detected[i]
channelUrineLen[i] = urineLens[i]
}
lastChannelData = channels
// Direction guidance
val hints = updateDirectionGuide(detected)
directionHint = hints.first
directionIcon = hints.second
// Direction guidance (urineLen-aware)
val guideResult = computePlacementGuide(detected, urineLens)
directionHint = guideResult.hint
directionIcon = guideResult.icon
placementScore = guideResult.score
// Lock if 4+ detected
if (detected.count { it } >= 4) {
// Lock if score >= 70 or 4+ detected
if (guideResult.score >= 70 || detected.count { it } >= 4) {
isLocked = true
}
@@ -558,48 +573,99 @@ private fun SensorLayoutCanvas(
}
}
/**
* Compute direction guidance based on which channels are detected.
* Returns (hint text, icon identifier).
*/
private fun updateDirectionGuide(detected: List<Boolean>): Pair<String, String> {
val topDetected = detected[3] // CH3 = top
val bottomDetected = detected[0] // CH0 = bottom
val leftDetected = if (detected.size > 4) detected[4] else true // CH4
val rightDetected = if (detected.size > 5) detected[5] else true // CH5
data class PlacementGuideResult(
val hint: String,
val icon: String,
val score: Int
)
/**
* 2단계 placement guide:
* 1단계: 잡힘/안잡힘 → 기본 방향 가이드
* 2단계: urineLen 기반 정밀 가이드 (대칭 + 균형 + 크기)
*/
private fun computePlacementGuide(detected: List<Boolean>, urineLens: List<Int>): PlacementGuideResult {
val ch0 = detected[0]; val ch3 = detected[3]
val ch4 = if (detected.size > 4) detected[4] else true
val ch5 = if (detected.size > 5) detected[5] else true
val detectedCount = detected.count { it }
if (detectedCount >= 4) {
return "Good position! Ready to measure." to "checkmark.circle"
}
// Vertical guidance
// CH0=위(배꼽), CH3=아래(치골)
// CH0✅ CH3❌ → 방광이 센서보다 위 → 센서를 위로
// CH0❌ CH3✅ → 방광이 센서보다 아래 → 센서를 아래로
if (!topDetected && bottomDetected) {
return "Move sensor UP" to "arrow.up"
}
if (topDetected && !bottomDetected) {
return "Move sensor DOWN" to "arrow.down"
}
// Horizontal guidance
if (!leftDetected && rightDetected) {
return "Move sensor LEFT" to "arrow.left"
}
if (leftDetected && !rightDetected) {
return "Move sensor RIGHT" to "arrow.right"
}
// No signal
// 0 channels: no signal
if (detectedCount == 0) {
return "No bladder detected. Check sensor contact." to "exclamationmark.triangle"
return PlacementGuideResult("No bladder detected. Check sensor contact.", "exclamationmark.triangle", 0)
}
// Partial
return "Adjust sensor position slowly" to "arrow.up.arrow.down"
// 1단계: 이진 판정 (4채널 미만일 때)
if (detectedCount < 4) {
val hint = when {
!ch3 && ch0 -> "Move sensor UP"
ch3 && !ch0 -> "Move sensor DOWN"
!ch4 && ch5 -> "Move sensor LEFT"
ch4 && !ch5 -> "Move sensor RIGHT"
else -> "Adjust sensor position slowly"
}
val icon = when {
!ch3 && ch0 -> "arrow.up"
ch3 && !ch0 -> "arrow.down"
!ch4 && ch5 -> "arrow.left"
ch4 && !ch5 -> "arrow.right"
else -> "arrow.up.arrow.down"
}
return PlacementGuideResult(hint, icon, detectedCount * 10)
}
// 2단계: 4채널 이상 잡힘 → urineLen 기반 정밀 가이드
val len0 = urineLens[0].toDouble() // CH0 (배꼽)
val len3 = urineLens[3].toDouble() // CH3 (치골)
val len1 = urineLens[1].toDouble() // CH1
val len2 = urineLens[2].toDouble() // CH2
val len4 = urineLens.getOrElse(4) { 0 }.toDouble() // CH4 (왼)
val len5 = urineLens.getOrElse(5) { 0 }.toDouble() // CH5 (오)
// 세로 대칭: CH0과 CH3의 urineLen 비율
val vertMax = maxOf(len0, len3, 1.0)
val vertSymmetry = 1.0 - kotlin.math.abs(len0 - len3) / vertMax
// 가로 균형: CH4와 CH5의 urineLen 차이
val latMax = maxOf(len4, len5, 1.0)
val latBalance = if (len4 > 0 && len5 > 0) 1.0 - kotlin.math.abs(len4 - len5) / latMax else 0.5
// 중앙 채널 크기 (절대적 — 클수록 방광 중심에 가까움)
val centerAvg = (len1 + len2) / 2.0
val centerScore = (centerAvg / 40.0).coerceAtMost(1.0) // 40칸이면 만점
// 종합 점수
val rawScore = vertSymmetry * 0.3 + latBalance * 0.3 + centerScore * 0.4
val score = (rawScore * 100).toInt().coerceIn(0, 100)
// 정밀 방향 가이드
val hint: String
val icon: String
if (score >= 80) {
hint = "Perfect position! Score: $score%"
icon = "checkmark.circle"
} else if (len0 > 0 && len3 > 0 && len0 / maxOf(len3, 1.0) > 1.5) {
hint = "Move sensor UP (CH0 longer)"
icon = "arrow.up"
} else if (len0 > 0 && len3 > 0 && len3 / maxOf(len0, 1.0) > 1.5) {
hint = "Move sensor DOWN (CH3 longer)"
icon = "arrow.down"
} else if (len4 > 0 && len5 > 0 && (len4 - len5) > 5) {
hint = "Move sensor RIGHT (CH4 longer)"
icon = "arrow.right"
} else if (len4 > 0 && len5 > 0 && (len5 - len4) > 5) {
hint = "Move sensor LEFT (CH5 longer)"
icon = "arrow.left"
} else if (centerAvg < 10) {
hint = "Adjust position — weak center signal"
icon = "arrow.up.arrow.down"
} else {
hint = "Good position! Score: $score%"
icon = "checkmark.circle"
}
return PlacementGuideResult(hint, icon, score)
}
@Composable