fix: cross-channel outlier 제거 + placement 강화 + UI 수정

- Cross-channel urine length outlier: median의 1.35x 초과 또는 0.5x 미만 → 제거
  (CH3 바닥반사 urine=50 vs 정상 ~35 → 480ml↔223ml 편차 해결)
- Placement CV: V0=0.07, V1/V2=0.10 (기기별 분기)
- Placement 기준 강화: LR dev 0.20, gradient 0.3, lenVariation 0.35
- "방향OK→무조건GREEN" 제거, CV 미달시 "Fine-tune position"
- GREEN CV가이드/Hold steady 제거, ch별 len 분석 방향 안내
- 가이드 텍스트 상자 항상 표시
- 센서 이미지 topview 교체 (240×150dp)
- 앱 버전 VBTAND0101
- Plot/Log allWalls 통일 (선택 Method 기준)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 15:36:38 +09:00
parent 6dd0ec5058
commit 0e0e04b6f5
6 changed files with 116 additions and 56 deletions
@@ -70,6 +70,11 @@ fun HomeView(appState: AppState) {
Text("Smart Bladder Monitoring", fontSize = 16.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(4.dp))
val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
Text(versionName ?: "", fontSize = 12.sp, color = MlSecondaryText.copy(alpha = 0.5f))
Spacer(modifier = Modifier.weight(1f))
Button(
@@ -85,6 +85,7 @@ fun PiezoMonitoringView(appState: AppState) {
val spotVolumes = remember { mutableListOf<Double>() }
var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred<Unit>() }
val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() }
var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
val analyzer = remember { PiezoEchoAnalyzer.shared }
val screenWidth = LocalConfiguration.current.screenWidthDp
@@ -156,6 +157,7 @@ fun PiezoMonitoringView(appState: AppState) {
var rawVolumeMl: Double? = null
var rawLrRatio = 1.0
var totalWallCount = 0
val allWalls: MutableList<Pair<Int, Int>?> = MutableList(6) { null }
val validChannels = channels.filter { it.isValid }
val allChannelsReceived = channels.filter { it.buffer.size >= 20 }.size >= 6
@@ -163,7 +165,6 @@ fun PiezoMonitoringView(appState: AppState) {
val method = com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod
val analyzerA = com.example.medilightv2android.managers.PiezoEchoAnalyzerA.shared
val allWalls: MutableList<Pair<Int, Int>?> = MutableList(6) { null }
var methodCBvMl: Double? = null
val methodLabel: String
@@ -244,6 +245,21 @@ fun PiezoMonitoringView(appState: AppState) {
}
val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
// Cross-channel urine length outlier 제거
// median의 1.4배 초과 또는 0.6배 미만 채널은 outlier → null 처리
val urineLensAll = (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }
val validLensAll = urineLensAll.filter { it > 0 }
if (validLensAll.size >= 3) {
val medianLen = validLensAll.sorted()[validLensAll.size / 2]
for (ch in 0..5) {
val len = urineLensAll[ch]
if (len > 0 && (len > medianLen * 1.35 || len < medianLen * 0.5)) {
Log.w("PiezoMonitor", "CH$ch urine=$len OUTLIER (median=$medianLen) → removed")
allWalls[ch] = null
}
}
}
val bvMethodSetting = com.example.medilightv2android.managers.GreenZoneConstants.bvMethod
val useV41Bv = method == com.example.medilightv2android.managers.DetectionMethod.METHOD_C
&& bvMethodSetting == com.example.medilightv2android.managers.BvMethod.V41
@@ -281,6 +297,8 @@ fun PiezoMonitoringView(appState: AppState) {
com.example.medilightv2android.services.AdcCsvLogger.log(failADC, null)
}
// allWalls를 UI state에 저장 (ChannelPanel에서 사용)
handler.post { lastAllWalls = allWalls.toList() }
val finalRawVol = rawVolumeMl
val finalTotalWalls = totalWallCount
val analysisMs = System.currentTimeMillis() - analysisStart
@@ -788,11 +806,11 @@ fun PiezoMonitoringView(appState: AppState) {
size = donutSize
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Bladdy 크기: 일반 모드에서 더 크게
// Bladdy 크기: 일반 모드에서 크게
val bladdySize = if (appState.isDevMode) {
if (isCompact) 80.dp else 110.dp
} else {
if (isCompact) 120.dp else 150.dp // 원 지름의 ~1/2
if (isCompact) 180.dp else 220.dp
}
BladdyRiveView(urgency = urgency, size = bladdySize)
Spacer(modifier = Modifier.height(4.dp))
@@ -927,6 +945,7 @@ fun PiezoMonitoringView(appState: AppState) {
ChannelPanel(
lastMultiChannelResult = lastMultiChannelResult,
analyzer = analyzer,
allWalls = lastAllWalls,
isPlacementTesting = isPlacementTesting,
onMeasure = {
if (!isPlacementTesting && !isMeasuring) {
@@ -1173,6 +1192,7 @@ fun PiezoMonitoringView(appState: AppState) {
@Composable
private fun ChannelPanel(
lastMultiChannelResult: List<PiezoChannelData>,
allWalls: List<Pair<Int, Int>?> = List(6) { null },
analyzer: PiezoEchoAnalyzer,
isPlacementTesting: Boolean,
onMeasure: () -> Unit
@@ -1272,7 +1292,9 @@ private fun ChannelPanel(
.background(if (isValid) Color.Green else Color.Red.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
val label = chAnalysis?.result?.let { "u=${it.urineLen}" } ?: "—"
// allWalls의 실제 BV 계산값 표시 (post-ant)
val wallLen = allWalls.getOrNull(ch)?.let { it.second - it.first }
val label = wallLen?.let { "u=$it" } ?: "—"
Text(label, fontSize = 7.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
Text("CH$ch", fontSize = 8.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
@@ -1287,33 +1309,39 @@ private fun ChannelPanel(
else -> analyzer.analyzeChannel(chData.buffer, chData.channel)
}
// 실제 BV 계산에 사용된 allWalls 값으로 표시
val wall = allWalls.getOrNull(chData.channel)
val antIdx = wall?.first ?: chAnalysis.result?.ant
val postIdx = wall?.second ?: chAnalysis.result?.post
val wallLen = if (wall != null) wall.second - wall.first else null
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
// Channel header
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (chAnalysis.isValid) Color.Green else Color.Gray.copy(alpha = 0.4f))
.background(if (wall != null) Color.Green else Color.Gray.copy(alpha = 0.4f))
)
Spacer(modifier = Modifier.width(4.dp))
Text("CH${chData.channel}", fontSize = 12.sp, fontWeight = FontWeight.Bold)
if (chAnalysis.result != null) {
val r = chAnalysis.result
if (antIdx != null && postIdx != null) {
Spacer(modifier = Modifier.width(6.dp))
Text(
"urine idx ${r.ant}–${r.post}",
"urine idx ${antIdx}–${postIdx}",
fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = Color.Cyan
)
Spacer(modifier = Modifier.width(4.dp))
val dps = com.example.medilightv2android.managers.PiezoHW.distancePerSample
val offset = com.example.medilightv2android.managers.PiezoHW.delayOffsetMm
Text(
"(${"%.0f".format(r.antDepthMm)}–${"%.0f".format(r.postDepthMm)}mm)",
"(${"%.0f".format(offset + antIdx * dps)}–${"%.0f".format(offset + postIdx * dps)}mm)",
fontSize = 9.sp, color = MlSecondaryText
)
Spacer(modifier = Modifier.weight(1f))
Text(
"len=${r.urineLen}",
"len=${wallLen ?: (postIdx - antIdx)}",
fontSize = 9.sp, fontWeight = FontWeight.SemiBold, color = Color.Cyan
)
} else {
@@ -1322,13 +1350,12 @@ private fun ChannelPanel(
}
}
// Waveform chart
WaveformChart(
buffer = chData.buffer,
peakIndex = chData.peakIndex.toInt(),
analyzer = analyzer,
antIndex = chAnalysis.result?.ant,
postIndex = chAnalysis.result?.post,
antIndex = antIdx,
postIndex = postIdx,
lowStart = chAnalysis.result?.lowStart,
lowEnd = chAnalysis.result?.lowEnd,
modifier = Modifier.fillMaxWidth().height(120.dp)
@@ -167,6 +167,7 @@ fun PlacementGuideView(appState: AppState) {
Column(
modifier = Modifier
.fillMaxSize()
.then(if (appState.isDevMode) Modifier.verticalScroll(rememberScrollState()) else Modifier)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -201,7 +202,7 @@ fun PlacementGuideView(appState: AppState) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
.then(if (appState.isDevMode) Modifier.height(400.dp) else Modifier.weight(1f)),
contentAlignment = Alignment.Center
) {
// 하복부 실루엣 (Canvas — CKLaw body silhouette)
@@ -354,9 +355,9 @@ fun PlacementGuideView(appState: AppState) {
painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.sensor_device),
contentDescription = "Sensor",
modifier = Modifier
.size(width = 200.dp, height = 125.dp)
.size(width = 240.dp, height = 150.dp)
.align(Alignment.Center)
.offset(x = 5.dp, y = 115.dp),
.offset(x = 0.dp, y = 80.dp),
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
alpha = if (isGreenZone) 0.7f else 0.95f
)
@@ -402,23 +403,21 @@ fun PlacementGuideView(appState: AppState) {
}
// ── 가이드 텍스트 (일러스트 아래) ──
if (directionHint.isNotEmpty()) {
val hintGreen = isGreenZone
val hintBg = if (hintGreen) Color(0xFF4CAF50).copy(alpha = 0.12f) else Color(0xFFFFF3E0)
val hintFg = if (hintGreen) Color(0xFF4CAF50) else Color(0xFFFF9800)
Text(
text = directionHint,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = hintFg,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.background(hintBg, RoundedCornerShape(12.dp))
.padding(12.dp)
)
}
// ── 가이드 텍스트 (일러스트 아래, 항상 표시) ──
val hintGreen = isGreenZone
val hintBg = if (hintGreen) Color(0xFF4CAF50).copy(alpha = 0.12f) else Color(0xFFFFF3E0)
val hintFg = if (hintGreen) Color(0xFF4CAF50) else Color(0xFFFF9800)
Text(
text = directionHint.ifEmpty { " " },
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = hintFg,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.background(hintBg, RoundedCornerShape(12.dp))
.padding(12.dp)
)
Spacer(modifier = Modifier.height(8.dp))
@@ -1027,8 +1026,15 @@ data class PlacementGuideResult(
val nextPhase: PlacementPhase = PlacementPhase.VERTICAL
)
private const val DEFAULT_CV_THR = 0.12
private const val DEFAULT_LR_DEV_THR = 0.35
// CV threshold는 기기 preset에 따라 다름
private val DEFAULT_CV_THR: Double
get() = when (com.example.medilightv2android.managers.PiezoHW.activePreset) {
com.example.medilightv2android.managers.PiezoHW.DevicePreset.V0 -> 0.07
com.example.medilightv2android.managers.PiezoHW.DevicePreset.V1,
com.example.medilightv2android.managers.PiezoHW.DevicePreset.V2 -> 0.10
else -> 0.08
}
private const val DEFAULT_LR_DEV_THR = 0.20
/**
* Gradient 기반 Placement Guide
@@ -1130,7 +1136,7 @@ private fun computePlacementGuide(
val ch4ok = detected.getOrElse(4) { false }
val ch5ok = detected.getOrElse(5) { false }
// 좌우도 이미 균등하면 바로 GREEN
if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 10) {
if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 5) {
PlacementGuideResult("Position OK! Final check...",
"checkmark.circle", 90,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN)
@@ -1205,7 +1211,7 @@ private fun computePlacementGuide(
val gradient = weightedCenter - 1.5 // 양수 = 아래쪽 무거움, 음수 = 위쪽 무거움
// gradient threshold: 균등하다고 볼 수 있는 범위
val gradientThr = 0.4 // |gradient| < 0.4 → 상하 균등
val gradientThr = 0.3 // |gradient| < 0.3 → 상하 균등
// 추가 조건: variation (max-min)/max 도 체크
val validLens = detectedLens.map { it.second }
@@ -1217,7 +1223,7 @@ private fun computePlacementGuide(
"VERTICAL gradient=%.2f (center=%.2f) variation=%.2f detect=$centerDetected lens=$centerLens"
.format(gradient, weightedCenter, lenVariation))
if (kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.5) {
if (kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.35) {
// 상하 OK → 좌우 확인
val l4 = urineLens.getOrElse(4) { 0 }
val l5 = urineLens.getOrElse(5) { 0 }
@@ -1225,7 +1231,7 @@ private fun computePlacementGuide(
val ch5ok = detected.getOrElse(5) { false }
// 좌우도 이미 균등하면 LATERAL 건너뛰고 바로 GREEN
if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 10) {
if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 5) {
return PlacementGuideResult("Position OK! Final check...",
"checkmark.circle", 65,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN)
@@ -1395,7 +1401,7 @@ private fun computePlacementGuide(
if (isPass) {
val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70)
return PlacementGuideResult(
"Green! Optimal position (CV=${"%.2f".format(cv)})",
"Optimal position!",
"checkmark.circle", score, cv, true, lrDev, lrPass,
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN)
} else {
@@ -1406,16 +1412,7 @@ private fun computePlacementGuide(
val weightedCenter = detectedLens.sumOf { (ch, len) -> ch.toDouble() * len } / totalLen
val gradient = weightedCenter - 1.5
// 방향이 다 맞는데 CV만 미달 → 그냥 GREEN 통과 (위치는 맞음)
if (lrPass && kotlin.math.abs(gradient) <= 0.3) {
val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(60)
return PlacementGuideResult(
"Green! Optimal position",
"checkmark.circle", score, cv, true, lrDev, lrPass,
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN)
}
// 방향이 틀리면 VERTICAL로 복귀
// 방향이 틀리면 해당 방향으로 안내, 방향 OK면 GREEN에 머물며 재시도
val adjustHint: String; val adjustIcon: String
when {
!lrPass && lrDev > DEFAULT_LR_DEV_THR -> {
@@ -1429,12 +1426,33 @@ private fun computePlacementGuide(
gradient > 0.3 -> {
adjustHint = "Lower ↓"; adjustIcon = "arrow.down"
}
else -> {
gradient < -0.3 -> {
adjustHint = "Raise ↑"; adjustIcon = "arrow.up"
}
else -> {
// 방향 애매 + CV 미달 → ch별 len 분석으로 방향 제시
val lens = (0..3).map { urineLens.getOrElse(it) { 0 } }
val maxCh = lens.withIndex().maxByOrNull { it.value }?.index ?: 0
val minCh = lens.withIndex().filter { it.value > 0 }.minByOrNull { it.value }?.index
if (minCh != null && lens[maxCh] > 0 && lens[minCh].toDouble() < lens[maxCh] * 0.6) {
// 최소 채널이 최대의 60% 미만 → 약한 쪽으로 안내
if (minCh <= 1) {
// ch0/ch1 약함 → 위로
adjustHint = "Raise slightly ↑"; adjustIcon = "arrow.up"
} else {
// ch2/ch3 약함 → 아래로
adjustHint = "Lower slightly ↓"; adjustIcon = "arrow.down"
}
} else {
adjustHint = "Fine-tune position"; adjustIcon = "checkmark.circle"
}
}
}
// 구체적 방향(↑↓←→)이면 VERTICAL 복귀, 아니면 GREEN 유지
val stayGreen = adjustIcon == "checkmark.circle"
val nextPh = if (stayGreen) PlacementPhase.GREEN else PlacementPhase.VERTICAL
return PlacementGuideResult(adjustHint, adjustIcon, 50, cv, false, lrDev, lrPass,
phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL)
phase = PlacementPhase.GREEN, nextPhase = nextPh)
}
}