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:
@@ -4,7 +4,17 @@
|
||||
"Bash(git push -u origin master)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(./gradlew assembleDebug)",
|
||||
"Bash(xargs grep:*)"
|
||||
"Bash(xargs grep:*)",
|
||||
"Bash(./gradlew compileDebugKotlin)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(git commit -m ':*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(pip install:*)",
|
||||
"Read(//c/Users/장동우/Downloads/**)",
|
||||
"Bash(python3 -c ':*)",
|
||||
"Bash(python -c ':*)",
|
||||
"Bash(./gradlew assembleDebug --rerun-tasks)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.example.medilightv2android"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 2
|
||||
versionName = "VBTAND0101"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+41
-14
@@ -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)
|
||||
|
||||
+44
-26
@@ -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,13 +403,12 @@ 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,
|
||||
text = directionHint.ifEmpty { " " },
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = hintFg,
|
||||
@@ -418,7 +418,6 @@ fun PlacementGuideView(appState: AppState) {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 2.1 MiB |
Reference in New Issue
Block a user