From 6614e915d6f676a67dd92ace54ed356c47d8a296 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 29 Apr 2026 16:56:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Method=20B=20TGC+FP=ED=95=84=ED=84=B0,?= =?UTF-8?q?=20Placement=20UI=20=EB=A6=AC=EB=94=94=EC=9E=90=EC=9D=B8,=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Method B 알고리즘 (Python 동기화): - TGC 파이프라인: applyTgc (감쇠 보상, adaptive ratio) - 후면반사 제거: findBackReflectionIdx + suppressBackReflection - 멀티채널 합의: findBackReflectionMultichannel (median) - FP 필터: wallMin/lowMean < 1.15, score < 3000 - peakMin: max(lowMean+margin, threshold) - Otsu: separability 반환 + OTSU_RATIO(0.85) + fallback(sep<0.3) - urineLen: post-ant → post-ant-1 (Python 동기화) - ant fallback: span 시작 직전 (단조 감소 대응) Placement UI 리디자인: - 상반신 일러스트 (Canvas: 몸통+배꼽+치골+방광+센서+화살표) - Green Zone: 배경 animateColorAsState 전환 + 체크마크 - 가이드 순서: 치골→위로→CH0 안잡히면 아래로→좌우 대칭 - 디바운스 4초 유지, 힌트 텍스트 Green시 초록 - 좌우 힌트: CH4/CH5 비교 기반 방향 제시 - 설정 톱니바퀴 + floating 패널 (Method A/B/C + Otsu) - 채널 dot row 별도 배치 - Green score: CV 0.10, LR dev 0.25 (엄격화) 도넛차트 UI: - Max Vol 카드 제거 (4→3칸), InfoCard 높이 통일 - Void/Catheterize 버튼 (2줄, 56dp) - Quick Save: 실제 측정값 표시 + volume 0 리셋 - 기본 Method C 네비게이션: - Placement 뒤로가기 → 연결중이면 Monitoring (Home 안감) - Home Start → 연결중이면 바로 Monitoring (DeviceScan 스킵) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/medilightv2android/AppState.kt | 2 +- .../medilightv2android/MainActivity.kt | 2 +- .../managers/GreenZoneConstants.kt | 2 +- .../managers/PiezoEchoAnalyzer.kt | 215 ++++++++-- .../views/monitoring/PiezoMonitoringView.kt | 31 +- .../ui/views/monitoring/PlacementGuideView.kt | 406 ++++++++++++------ 6 files changed, 476 insertions(+), 182 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 35faa95..71e57fd 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -95,7 +95,7 @@ class AppState(private val context: Context) { fun startFromHome() { sensorMode = SensorMode.PIEZO storage.sensorMode = SensorMode.PIEZO - currentScreen = AppScreen.DEVICE_SCAN + currentScreen = if (isDeviceConnected) AppScreen.PIEZO_MONITORING else AppScreen.DEVICE_SCAN } fun selectSensor(mode: SensorMode) { diff --git a/app/src/main/java/com/example/medilightv2android/MainActivity.kt b/app/src/main/java/com/example/medilightv2android/MainActivity.kt index 39a4d24..933c434 100644 --- a/app/src/main/java/com/example/medilightv2android/MainActivity.kt +++ b/app/src/main/java/com/example/medilightv2android/MainActivity.kt @@ -68,7 +68,7 @@ fun MedilightApp(modifier: Modifier = Modifier) { // 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN - AppScreen.PLACEMENT_GUIDE -> appState.currentScreen = AppScreen.PIEZO_PERSONALIZATION + AppScreen.PLACEMENT_GUIDE -> appState.currentScreen = if (appState.isDeviceConnected) AppScreen.PIEZO_MONITORING else AppScreen.HOME AppScreen.PIEZO_MONITORING -> appState.currentScreen = AppScreen.PLACEMENT_GUIDE // 모니터링 하위 화면 → 모니터링 AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index ea46b5e..1709650 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -16,7 +16,7 @@ enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C } object GreenZoneConstants { - var detectionMethod: DetectionMethod = DetectionMethod.METHOD_B + var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C // ═══════════════════════════════════════════════════════════ // 신호 범위 diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt index 505e123..fba5f2e 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt @@ -157,6 +157,32 @@ class PiezoEchoAnalyzer private constructor() { } } + /** 다채널 TGC 파이프라인: denoise → TGC → findBackReflection → suppress → detect */ + fun analyzeMultiChannelWithTgc(channelData: List): List { + // 1) SG denoise all channels + val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } } + val denoised = raws.map { denoise(it) } + + // 2) TGC per channel + val tgcSignals = denoised.map { applyTgc(it) } + + // 3) Multi-channel back reflection consensus + val brIdx = findBackReflectionMultichannel(tgcSignals, postMaxIdx) + + // 4) Per-channel: suppress → detect + return channelData.mapIndexed { idx, ch -> + try { + val raw = raws[idx] + val sg = denoised[idx] + val cleaned = suppressBackReflection(sg, brIdx) + val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = brIdx) + ChannelAnalysisResult(ch.channel, result, raw, cleaned) + } catch (_: Exception) { + ChannelAnalysisResult(ch.channel, null, raws[idx], denoised[idx]) + } + } + } + /** 다채널 분석 → 최종 용적 */ fun analyzeMultiChannel(channelData: List): PiezoAnalysisResult { val results = channelData.map { ch -> @@ -313,38 +339,140 @@ class PiezoEchoAnalyzer private constructor() { var useAdaptiveThreshold: Boolean = true var lastOtsuThreshold: Double = 0.0; private set + var lastOtsuSeparability: Double = 0.0; private set - /** 단일 1D 채널 → urine region 탐지 */ - fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray): LowEchoResult? { + // Otsu 관련 상수 + val otsuRatio: Double = 0.85 + val minOtsuSeparability: Double = 0.3 + val minScore: Double = 3000.0 + val wallLowMeanMinRatio: Double = 1.15 + + /** 단일 1D 채널 → urine region 탐지 (otsuSignal: suppress 전 원본 SG로 Otsu 계산) */ + fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null): LowEchoResult? { val sg = denoised if (sg.size < 10) return null - val thr = if (useAdaptiveThreshold) { - val limit = min(sg.size, postMaxIdx + 1) - otsu1d(sg, limit) - } else { - lowEchoAmpDefault + val effectivePostMax = dynamicPostMax ?: postMaxIdx + + if (!useAdaptiveThreshold) { + lastOtsuThreshold = lowEchoAmpDefault + lastOtsuSeparability = 0.0 + return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax) } - lastOtsuThreshold = thr - return detectLowEchoCore(sg = sg, threshold = thr) + + val otsuInput = otsuSignal ?: sg + val limit = min(otsuInput.size, effectivePostMax + 1) + val (thr, sep) = otsu1dWithSeparability(otsuInput, limit) + val adjustedThr = thr * otsuRatio + lastOtsuSeparability = sep + + if (sep >= minOtsuSeparability) { + lastOtsuThreshold = adjustedThr + return detectLowEchoCore(sg = sg, threshold = adjustedThr, effectivePostMax = effectivePostMax) + } + + // Separability 낮음 → 고정 threshold fallback + lastOtsuThreshold = lowEchoAmpDefault + return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax) + } + + // ── TGC (Time Gain Compensation) ── + + fun applyTgc(signal: DoubleArray): DoubleArray { + val n = signal.size + if (n < 5) return signal.copyOf() + + val x = DoubleArray(n) { it.toDouble() } + // Linear fit: slope, intercept + var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumX2 = 0.0 + for (i in 0 until n) { sumX += x[i]; sumY += signal[i]; sumXY += x[i] * signal[i]; sumX2 += x[i] * x[i] } + val slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX) + + if (slope >= 0) return signal.copyOf() // 감쇠 없으면 보상 불필요 + + val absSlope = abs(slope) + val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1 + val ratio = if (absSlope < slopeThresh) 1.0 + else max(ratioMin, 1.0 - (1.0 - ratioMin) * (absSlope - slopeThresh) / (slopeMax - slopeThresh)) + + val targetSlope = slope * ratio + val compensation = DoubleArray(n) { (targetSlope - slope) * x[it] } + return DoubleArray(n) { signal[it] + compensation[it] } + } + + // ── Back Reflection Detection & Suppression ── + + fun findBackReflectionIdx(tgcSg: DoubleArray, postMaxIdx: Int, minSearchIdx: Int = 40, extend: Int = 20): Int { + val n = tgcSg.size + val searchEnd = min(n, postMaxIdx + extend) + if (minSearchIdx >= searchEnd) return postMaxIdx + + val seg = tgcSg.sliceArray(minSearchIdx until searchEnd) + val peaks = findPeaks1D(seg) + if (peaks.isEmpty()) return postMaxIdx + + val globalPeaks = peaks.map { it + minSearchIdx } + return globalPeaks.maxByOrNull { tgcSg[it] } ?: postMaxIdx + } + + fun suppressBackReflection(sg: DoubleArray, backRefIdx: Int, searchMargin: Int = 10): DoubleArray { + val cleaned = sg.copyOf() + val n = sg.size + if (backRefIdx >= n) return cleaned + + val lo = max(0, backRefIdx - min(searchMargin, 3)) + val hi = min(n, backRefIdx + searchMargin + 1) + var actualPeak = lo + for (i in lo until hi) if (sg[i] > sg[actualPeak]) actualPeak = i + + // left valley + val valleyLoStart = max(0, actualPeak - searchMargin) + var leftValley = valleyLoStart + for (i in valleyLoStart..actualPeak) if (sg[i] < sg[leftValley]) leftValley = i + + // right valley + val valleyHiEnd = min(n, actualPeak + searchMargin + 1) + var rightValley = actualPeak + for (i in actualPeak until valleyHiEnd) if (sg[i] < sg[rightValley]) rightValley = i + + if (rightValley > leftValley) { + val leftVal = sg[leftValley] + val rightVal = sg[rightValley] + val length = rightValley - leftValley + for (i in 0..length) { + cleaned[leftValley + i] = leftVal + (rightVal - leftVal) * i.toDouble() / length + } + } + return cleaned + } + + /** 다채널 후면 반사 위치 합의 (median) */ + fun findBackReflectionMultichannel(tgcSignals: List, postMaxIdx: Int): Int { + val detected = mutableListOf() + for (tgc in tgcSignals) { + val idx = findBackReflectionIdx(tgc, postMaxIdx) + if (idx < postMaxIdx) detected.add(idx) + } + if (detected.isEmpty()) return postMaxIdx + detected.sort() + return detected[detected.size / 2] } /** - * 1D Otsu threshold — 원은지 연구원 span_utils.py otsu_1d() 1:1 포팅 - * - * 히스토그램에서 between-class variance를 최대화하는 임계값 반환. - * 저진폭(urine/조직)과 고진폭(벽 echo) 두 모집단을 분리. + * 1D Otsu threshold + separability */ - fun otsu1d(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): Double { + data class OtsuResult(val threshold: Double, val separability: Double) + + fun otsu1dWithSeparability(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): OtsuResult { val n = min(values.size, limit) - if (n == 0) return 0.0 + if (n == 0) return OtsuResult(0.0, 0.0) var vMin = values[0]; var vMax = values[0] for (i in 1 until n) { if (values[i] < vMin) vMin = values[i] if (values[i] > vMax) vMax = values[i] } - if (vMax == vMin) return vMin + if (vMax == vMin) return OtsuResult(vMin, 0.0) val binWidth = (vMax - vMin) / nBins val hist = IntArray(nBins) @@ -355,24 +483,21 @@ class PiezoEchoAnalyzer private constructor() { val centers = DoubleArray(nBins) { vMin + (it + 0.5) * binWidth } val total = n.toDouble() + val p = DoubleArray(nBins) { hist[it] / total } - // cumulative probability & mean val cumP = DoubleArray(nBins) val cumMP = DoubleArray(nBins) - cumP[0] = hist[0] / total - cumMP[0] = cumP[0] * centers[0] + cumP[0] = p[0]; cumMP[0] = p[0] * centers[0] for (i in 1 until nBins) { - cumP[i] = cumP[i - 1] + hist[i] / total - cumMP[i] = cumMP[i - 1] + (hist[i] / total) * centers[i] + cumP[i] = cumP[i - 1] + p[i] + cumMP[i] = cumMP[i - 1] + p[i] * centers[i] } val totalM = cumMP[nBins - 1] - // between-class variance 최대화 var bestSigma = -1.0 var bestIdx = 0 for (t in 0 until nBins - 1) { - val w0 = cumP[t] - val w1 = 1.0 - w0 + val w0 = cumP[t]; val w1 = 1.0 - w0 if (w0 < 1e-6 || w1 < 1e-6) continue val m0 = cumMP[t] / w0 val m1 = (totalM - cumMP[t]) / w1 @@ -382,18 +507,26 @@ class PiezoEchoAnalyzer private constructor() { bestIdx = t } } - return centers[bestIdx] + // Separability: σ_b² / σ_total² + var totalVar = 0.0 + for (i in 0 until nBins) totalVar += p[i] * (centers[i] - totalM) * (centers[i] - totalM) + val separability = if (totalVar > 1e-12) bestSigma / totalVar else 0.0 + + return OtsuResult(centers[bestIdx], separability) + } + + fun otsu1d(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): Double { + return otsu1dWithSeparability(values, limit, nBins).threshold } /** - * Low-echo 탐지 핵심 — prominence 기반 wall peak 선택 - * 1:1 port of low_echo_detection_method_b.py (2026-04-20 update) + * Low-echo 탐지 핵심 — prominence 기반 wall peak 선택 + FP 필터 */ - private fun detectLowEchoCore(sg: DoubleArray, threshold: Double): LowEchoResult? { + private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? { if (sg.size < 10) return null - // 1) low-echo span 추출 (postMaxIdx까지만 — 이후는 노이즈) - val analysisLimit = min(sg.size, postMaxIdx + 1) + // 1) low-echo span 추출 (effectivePostMax까지) + val analysisLimit = min(sg.size, effectivePostMax + 1) val lowMask = BooleanArray(sg.size) { it < analysisLimit && sg[it] <= threshold } val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen } @@ -409,11 +542,14 @@ class PiezoEchoAnalyzer private constructor() { val lowSlice = safeSlice(sg, from = s, to = e) ?: return null if (lowSlice.isEmpty()) return null val lowMean = lowSlice.sum() / lowSlice.size.toDouble() - val peakMin = lowMean + minPeakMargin + val peakMin = max(lowMean + minPeakMargin, threshold) // 3) Prominence 기반 wall peak 선택 - val ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin, - peakMin = peakMin, side = WallSide.ANT, otherEdge = e) ?: return null + var ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin, + peakMin = peakMin, side = WallSide.ANT, otherEdge = e) + // ant 없으면 span 시작 직전 (단조 감소 대응) + if (ant == null && s > 0) ant = s - 1 + if (ant == null) return null var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin, peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null @@ -433,10 +569,19 @@ class PiezoEchoAnalyzer private constructor() { if (postH < threshold) return null val lowDepth = ((antH + postH) / 2.0) - lowMean - val urineLen = post - ant + val urineLen = post - ant - 1 if (urineLen < minUrineLen) return null + // FP 필터: 벽/low 비율 + val wallMin = min(antH, postH) + if (lowMean > 0 && (wallMin / lowMean) < wallLowMeanMinRatio) return null + + val score = lowDepth * urineLen.toDouble() + + // FP 필터: score 최소 + if (score < minScore) return null + return LowEchoResult( ant = ant, post = post, @@ -444,7 +589,7 @@ class PiezoEchoAnalyzer private constructor() { lowEnd = e, lowMean = lowMean, urineLen = urineLen, - score = lowDepth * urineLen.toDouble(), + score = score, innerPeaks = findInnerPeaks(sg, left = ant, right = post) ) } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 0efe07f..45412b9 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -167,12 +167,12 @@ fun PiezoMonitoringView(appState: AppState) { } else -> { methodLabel = "B" - val channelResults = validChannels.map { analyzer.analyzeChannel(it.buffer, it.channel) } + val channelResults = analyzer.analyzeMultiChannelWithTgc(validChannels) for (ch in channelResults) { if (ch.channel < 6 && ch.result != null) allWalls[ch.channel] = Pair(ch.result.ant, ch.result.post) } for (ch in channelResults) { - if (ch.isValid && ch.result != null) Log.d("PiezoMonitor", " CH${ch.channel}[B]: ant=${ch.result.ant} post=${ch.result.post} urine=${ch.result.urineLen}") + if (ch.isValid && ch.result != null) Log.d("PiezoMonitor", " CH${ch.channel}[B]: ant=${ch.result.ant} post=${ch.result.post} urine=${ch.result.urineLen} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)} sep=${"%.2f".format(analyzer.lastOtsuSeparability)}") } } } @@ -449,15 +449,18 @@ fun PiezoMonitoringView(appState: AppState) { Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText) } } - // Quick Save (iOS bolt.fill 스타일) + // Quick Save — 현재 측정값으로 저장 + volume 0 리셋 + val quickVol = if (lastVolumeMl > 0) lastVolumeMl.toInt() else appState.estimatedVolume Surface( onClick = { com.example.medilightv2android.measure.VoidingRecordStore.add( context, com.example.medilightv2android.measure.VoidingRecord( - volume = appState.estimatedVolume, method = "catheterize" + volume = quickVol, method = "catheterize" ) ) appState.resetLevel() + lastVolumeMl = 0.0 + maxVolumeMl = 0.0 resetMeasurement() android.widget.Toast.makeText(context, context.getString(R.string.registered_diary), android.widget.Toast.LENGTH_SHORT).show() showCatheterizeSheet = false @@ -472,7 +475,7 @@ fun PiezoMonitoringView(appState: AppState) { Icon(Icons.Default.FlashOn, contentDescription = null, tint = Color(0xFF4CAF50)) Spacer(modifier = Modifier.width(12.dp)) Text("빠른 저장", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("${appState.estimatedVolume} ml", color = MlSecondaryText, fontSize = 14.sp) + Text("$quickVol ml", color = MlSecondaryText, fontSize = 14.sp) Spacer(modifier = Modifier.width(4.dp)) Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText) } @@ -699,21 +702,23 @@ fun PiezoMonitoringView(appState: AppState) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { val displayVol = if (lastVolumeMl > 0) "%.0f".format(lastVolumeMl) else "${appState.estimatedVolume}" InfoCard("Volume", displayVol, "ml", urgency.accentColor, Modifier.weight(1f)) - InfoCard("Max Vol", if (maxVolumeMl > 0) "%.0f".format(maxVolumeMl) else "—", "ml", Color(0xFFFF9500), Modifier.weight(1f)) InfoCard("Fill", String.format("%.0f", appState.fillPercentage), "%", urgency.accentColor, Modifier.weight(1f)) InfoCard("Level", "${appState.bladderLevel.currentLevel}/${BladderLevel.MAX_LEVEL}", "", urgency.accentColor, Modifier.weight(1f)) } - // Control buttons: Catheterize / Auto / Spot + // Control buttons: Void/Catheterize / Auto / Spot Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - // Catheterize + // Void / Catheterize Button( onClick = { showCatheterizeSheet = true }, - modifier = Modifier.weight(1.2f).height(52.dp), + modifier = Modifier.weight(1.3f).height(56.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f)) ) { - Text("Catheterize", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Void /", fontSize = 11.sp, color = MlPrimary) + Text("Catheterize", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary) + } } // Auto @@ -721,7 +726,7 @@ fun PiezoMonitoringView(appState: AppState) { onClick = { if (isAutoMeasuring) stopAutoMeasure() else startAutoMeasure() }, - modifier = Modifier.weight(1f).height(52.dp), + modifier = Modifier.weight(1f).height(56.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors( containerColor = if (isAutoMeasuring) Color.Red else Color(0xFFFF9800) @@ -738,7 +743,7 @@ fun PiezoMonitoringView(appState: AppState) { Button( onClick = { if (!isMeasuring && !isAutoMeasuring) measure() }, enabled = !isMeasuring && !isAutoMeasuring, - modifier = Modifier.weight(1f).height(52.dp), + modifier = Modifier.weight(1f).height(56.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors( containerColor = urgency.gradientColors.first() @@ -1394,7 +1399,7 @@ private fun InfoCard(title: String, value: String, unit: String, color: Color, m verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text(value, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1C1B1F)) - if (unit.isNotEmpty()) Text(unit, fontSize = 11.sp, color = MlSecondaryText) + Text(unit.ifEmpty { " " }, fontSize = 11.sp, color = MlSecondaryText) Text(title, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) } } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 0290101..1da8602 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -62,7 +62,7 @@ fun PlacementGuideView(appState: AppState) { var isContinuousMode 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 directionHint by remember { mutableStateOf("치골 위에 센서를 올려놓으세요") } var directionIcon by remember { mutableStateOf("") } var placementScore by remember { mutableIntStateOf(0) } var scanCount by remember { mutableIntStateOf(0) } @@ -70,6 +70,7 @@ fun PlacementGuideView(appState: AppState) { var lastChannelData by remember { mutableStateOf>(emptyList()) } var debounceCount by remember { mutableIntStateOf(0) } var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } + var lastHintChangeMs by remember { mutableStateOf(0L) } var showPlacementSettings by remember { mutableStateOf(false) } // 2단계 순차 탐색: VERTICAL → LATERAL → GREEN @@ -81,17 +82,23 @@ fun PlacementGuideView(appState: AppState) { isLocked = false scanCount = 0 placementScore = 0 - directionHint = "센서를 아래에서 위로 천천히 이동하세요" + directionHint = "치골 위에 센서를 올려놓으세요" directionIcon = "" for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 } } val detectedCount = channelDetected.count { it } + val isGreenZone = isLocked || placementScore >= 70 + val bgColor by androidx.compose.animation.animateColorAsState( + targetValue = if (isGreenZone) Color(0xFFE8F5E9) else MlBackground, + animationSpec = androidx.compose.animation.core.tween(600), label = "bg" + ) + Column( modifier = Modifier .fillMaxSize() - .background(MlBackground) + .background(bgColor) ) { // Top bar Row( @@ -100,7 +107,7 @@ fun PlacementGuideView(appState: AppState) { .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = { appState.goHome() }) { + IconButton(onClick = { appState.currentScreen = if (appState.isDeviceConnected) com.example.medilightv2android.AppScreen.PIEZO_MONITORING else com.example.medilightv2android.AppScreen.HOME }) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", @@ -131,108 +138,223 @@ fun PlacementGuideView(appState: AppState) { .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Instructions - Text( - text = when (placementPhase) { - PlacementPhase.VERTICAL -> "Step 1/3: 센서를 아래에서 위로 천천히 이동하세요" - PlacementPhase.LATERAL -> "Step 2/3: 센서를 좌우로 천천히 이동하세요" - PlacementPhase.GREEN -> "Step 3/3: 최종 위치 확인 중..." - }, - fontSize = 14.sp, - color = MlSecondaryText, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 8.dp) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Direction guide - if (directionHint.isNotEmpty() && !isLocked) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - Color(0xFFFFF3E0), - RoundedCornerShape(14.dp) - ) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - val icon = when { - directionIcon.contains("up") -> Icons.Filled.KeyboardArrowUp - directionIcon.contains("down") -> Icons.Filled.KeyboardArrowDown - directionIcon.contains("left") -> Icons.Filled.ArrowBack - directionIcon.contains("right") -> Icons.Filled.ArrowForward - directionIcon.contains("check") -> Icons.Filled.CheckCircle - else -> Icons.Filled.Warning - } - Icon( - icon, - contentDescription = null, - tint = Color(0xFFFF9800), - modifier = Modifier.size(24.dp) - ) - Text( - text = directionHint, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = Color(0xFFFF9800) - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Status summary - channel dots row - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + // ── 상반신 일러스트 + 센서 + 방향 가이드 ── + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(top = 8.dp), + contentAlignment = Alignment.Center ) { - for (i in 0 until 6) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background( - if (channelDetected[i]) Color(0xFF4CAF50) - else Color.Red.copy(alpha = 0.4f) - ) - ) - Text("CH$i", fontSize = 9.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) - if (channelUrineLen[i] > 0) { - Text("${channelUrineLen[i]}", fontSize = 8.sp, color = Color.Cyan) + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + val cx = w / 2f + val greenZone = isGreenZone + + // ── 상반신 실루엣 ── + val skinColor = Color(0xFFFFDDB5) + val outlineColor = Color(0xFFD4A574) + + // 몸통 (위: 가슴 아래, 아래: 치골) + val bodyTop = h * 0.05f + val bodyBot = h * 0.88f + val bodyW = w * 0.38f + + // 몸통 타원형 + val bodyPath = Path().apply { + moveTo(cx - bodyW, bodyTop) + // 왼쪽 어깨~허리 곡선 + cubicTo(cx - bodyW * 1.15f, bodyTop + h * 0.15f, + cx - bodyW * 0.85f, bodyBot - h * 0.1f, + cx - bodyW * 0.7f, bodyBot) + // 아래쪽 (치골) + lineTo(cx + bodyW * 0.7f, bodyBot) + // 오른쪽 허리~어깨 + cubicTo(cx + bodyW * 0.85f, bodyBot - h * 0.1f, + cx + bodyW * 1.15f, bodyTop + h * 0.15f, + cx + bodyW, bodyTop) + close() + } + drawPath(bodyPath, skinColor) + drawPath(bodyPath, outlineColor, style = Stroke(width = 2f)) + + // 배꼽 + val navelY = h * 0.40f + drawCircle(outlineColor.copy(alpha = 0.5f), radius = 4f, center = Offset(cx, navelY)) + + // 치골 라인 + val pubisY = h * 0.82f + drawLine(outlineColor.copy(alpha = 0.4f), + Offset(cx - bodyW * 0.5f, pubisY), + Offset(cx + bodyW * 0.5f, pubisY), + strokeWidth = 1.5f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 4f))) + + // 방광 (점선 타원, 배꼽과 치골 사이) + val bladderCy = (navelY + pubisY) / 2f + val bladderRx = bodyW * 0.35f + val bladderRy = (pubisY - navelY) * 0.35f + drawOval( + color = if (greenZone) Color(0xFF4CAF50).copy(alpha = 0.15f) else Color.Cyan.copy(alpha = 0.12f), + topLeft = Offset(cx - bladderRx, bladderCy - bladderRy), + size = Size(bladderRx * 2, bladderRy * 2) + ) + drawOval( + color = if (greenZone) Color(0xFF4CAF50).copy(alpha = 0.4f) else Color.Cyan.copy(alpha = 0.35f), + topLeft = Offset(cx - bladderRx, bladderCy - bladderRy), + size = Size(bladderRx * 2, bladderRy * 2), + style = Stroke(width = 1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f))) + ) + + // ── 센서 기기 (둥근 사각형) ── + val sensorW = 50f + val sensorH = 70f + val sensorY = bladderCy - sensorH / 2 + val sensorColor = if (greenZone) Color(0xFF4CAF50) else MlPrimary + drawRoundRect( + color = sensorColor.copy(alpha = 0.2f), + topLeft = Offset(cx - sensorW / 2, sensorY), + size = Size(sensorW, sensorH), + cornerRadius = CornerRadius(12f) + ) + drawRoundRect( + color = sensorColor, + topLeft = Offset(cx - sensorW / 2, sensorY), + size = Size(sensorW, sensorH), + cornerRadius = CornerRadius(12f), + style = Stroke(width = 3f) + ) + + // ── 방향 화살표 ── + if (!greenZone) { + val arrowColor = Color(0xFFFF9800) + val arrowLen = 40f + val arrowHead = 12f + + when { + directionIcon.contains("up") -> { + val ax = cx; val ay = sensorY - 20f + drawLine(arrowColor, Offset(ax, ay + arrowLen), Offset(ax, ay), strokeWidth = 4f) + drawLine(arrowColor, Offset(ax - arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = 4f) + drawLine(arrowColor, Offset(ax + arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = 4f) + } + directionIcon.contains("down") -> { + val ax = cx; val ay = sensorY + sensorH + 20f + drawLine(arrowColor, Offset(ax, ay - arrowLen), Offset(ax, ay), strokeWidth = 4f) + drawLine(arrowColor, Offset(ax - arrowHead, ay - arrowHead), Offset(ax, ay), strokeWidth = 4f) + drawLine(arrowColor, Offset(ax + arrowHead, ay - arrowHead), Offset(ax, ay), strokeWidth = 4f) + } + directionIcon.contains("left") -> { + val ax = cx - sensorW / 2 - 25f; val ay = sensorY + sensorH / 2 + drawLine(arrowColor, Offset(ax + arrowLen, ay), Offset(ax, ay), strokeWidth = 4f) + drawLine(arrowColor, Offset(ax + arrowHead, ay - arrowHead), Offset(ax, ay), strokeWidth = 4f) + drawLine(arrowColor, Offset(ax + arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = 4f) + } + directionIcon.contains("right") -> { + val ax = cx + sensorW / 2 + 25f; val ay = sensorY + sensorH / 2 + drawLine(arrowColor, Offset(ax - arrowLen, ay), Offset(ax, ay), strokeWidth = 4f) + drawLine(arrowColor, Offset(ax - arrowHead, ay - arrowHead), Offset(ax, ay), strokeWidth = 4f) + drawLine(arrowColor, Offset(ax - arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = 4f) + } } } + + // Green 체크마크 + if (greenZone) { + drawCircle(Color(0xFF4CAF50).copy(alpha = 0.2f), radius = 35f, center = Offset(cx, sensorY - 30f)) + // ✓ 모양 + val checkPath = Path().apply { + moveTo(cx - 15f, sensorY - 30f) + lineTo(cx - 5f, sensorY - 20f) + lineTo(cx + 15f, sensorY - 42f) + } + drawPath(checkPath, Color(0xFF4CAF50), style = Stroke(width = 4f)) + } } - Spacer(modifier = Modifier.weight(1f)) - Column(horizontalAlignment = Alignment.End) { - Text( - text = "$detectedCount/6", - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800) - ) + + // Score + 감지수 (우측 상단) + Column( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp), + horizontalAlignment = Alignment.End + ) { + 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, + Text("$placementScore%", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = when { placementScore >= 80 -> Color(0xFF4CAF50) placementScore >= 50 -> Color(0xFFFF9800) else -> Color.Red - } - ) + }) + } + } + + // 라벨 (좌측) + Column( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 4.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + Text("가슴", fontSize = 9.sp, color = MlSecondaryText) + Spacer(modifier = Modifier.height(60.dp)) + Text("배꼽", fontSize = 9.sp, color = MlSecondaryText) + Spacer(modifier = Modifier.height(60.dp)) + Text("치골", fontSize = 9.sp, color = MlSecondaryText) + } + } + + // ── 가이드 텍스트 (일러스트 아래) ── + 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) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 채널 dot row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + for (i in 0 until 6) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background( + if (channelDetected[i]) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.35f) + )) + Text("CH$i", fontSize = 8.sp, color = MlSecondaryText) } } } + Spacer(modifier = Modifier.height(4.dp)) + + // Step indicator + Text( + text = when (placementPhase) { + PlacementPhase.VERTICAL -> "Step 1/3" + PlacementPhase.LATERAL -> "Step 2/3" + PlacementPhase.GREEN -> "Step 3/3" + }, + fontSize = 11.sp, color = MlSecondaryText + ) + // Per-channel echo graphs (after scan) if (lastChannelData.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) @@ -380,7 +502,7 @@ fun PlacementGuideView(appState: AppState) { } else { val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase) - // 디바운스: 같은 phase가 2회 연속일 때만 힌트 변경 + // 디바운스: 같은 phase 2회 연속 + 최소 3초 유지 if (guideResult.nextPhase == lastGuidePhase) { debounceCount++ } else { @@ -388,11 +510,14 @@ fun PlacementGuideView(appState: AppState) { lastGuidePhase = guideResult.nextPhase } - if (debounceCount >= 2 || guideResult.isPass) { + val now = System.currentTimeMillis() + val elapsed = now - lastHintChangeMs + if ((debounceCount >= 2 && elapsed >= 4000) || guideResult.isPass) { directionHint = guideResult.hint directionIcon = guideResult.icon placementScore = guideResult.score placementPhase = guideResult.nextPhase + lastHintChangeMs = now } if (guideResult.isPass) { @@ -777,8 +902,8 @@ data class PlacementGuideResult( val nextPhase: PlacementPhase = PlacementPhase.VERTICAL ) -private const val DEFAULT_CV_THR = 0.15 -private const val DEFAULT_LR_DEV_THR = 0.30 +private const val DEFAULT_CV_THR = 0.10 +private const val DEFAULT_LR_DEV_THR = 0.25 /** * 2단계 순차 Placement Guide (대표님 요구사항) @@ -804,94 +929,113 @@ private fun computePlacementGuide( val detectedCount = detected.count { it } if (detectedCount == 0) { - return PlacementGuideResult("No bladder detected. Check sensor contact.", "exclamationmark.triangle", 0, + return PlacementGuideResult("방광이 감지되지 않습니다. 센서를 위로 천천히 올려보세요 ↑", "arrow.up", 0, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } // Center 채널 정보 val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } } val centerDetected = (0..3).count { detected.getOrElse(it) { false } } + val ch0 = detected.getOrElse(0) { false } + val ch1 = detected.getOrElse(1) { false } + val ch2 = detected.getOrElse(2) { false } + val ch3 = detected.getOrElse(3) { false } val len0 = centerLens[0]; val len1 = centerLens[1] val len2 = centerLens[2]; val len3 = centerLens[3] - // ═══ 1단계: 상하 탐색 ═══ + // ═══ 1단계: 상하 탐색 (치골→위로→CH0 안잡히면 아래로) ═══ if (currentPhase == PlacementPhase.VERTICAL) { - // 조건: 3개 이상 center에서 low echo 검출 if (centerDetected < 3) { - val ch0 = detected.getOrElse(0) { false } - val ch3 = detected.getOrElse(3) { false } - val hint = when { - ch3 && !ch0 -> "센서를 아래로 이동하세요 ↓" - !ch3 && ch0 -> "센서를 위로 이동하세요 ↑" - else -> "센서를 천천히 위아래로 이동하세요" - } - val icon = when { - ch3 && !ch0 -> "arrow.down" - !ch3 && ch0 -> "arrow.up" - else -> "arrow.up.arrow.down" + val hint: String + val icon: String + when { + // 위쪽만 잡힘 → 아래로 내리세요 + (ch3 || ch2) && !ch0 && !ch1 -> { + hint = "센서를 아래로 천천히 내리세요 ↓" + icon = "arrow.down" + } + // 아래쪽만 잡힘 → 위로 올리세요 + (ch0 || ch1) && !ch3 && !ch2 -> { + hint = "센서를 위로 천천히 올리세요 ↑" + icon = "arrow.up" + } + // 아무것도 또는 1개만 → 위로 올리세요 (치골에서 시작 기준) + else -> { + hint = "센서를 위로 천천히 올리세요 ↑" + icon = "arrow.up" + } } return PlacementGuideResult(hint, icon, centerDetected * 10, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } - // 3개 이상 검출 — 크기 패턴 확인 (3≥2≥1≥0 또는 유사) + // 3개 이상 검출 — CH0 유무 확인 + if (!ch0 && ch3) { + return PlacementGuideResult("CH0이 약합니다. 센서를 아래로 조금 내리세요 ↓", "arrow.down", 25, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) + } + + // 크기 패턴 확인 val validLens = (0..3).filter { detected.getOrElse(it) { false } }.map { centerLens[it] } val maxLen = validLens.maxOrNull() ?: 0 val minLen = validLens.minOrNull() ?: 0 val lenVariation = if (maxLen > 0) (maxLen - minLen).toDouble() / maxLen else 1.0 - // 크기가 유사(변동 < 50%) 또는 3≥2≥1≥0 패턴 - val isOrdered = len3 >= len2 - 5 && len2 >= len1 - 5 && (len0 == 0 || len1 >= len0 - 5) - val isSimilar = lenVariation < 0.5 - - if (isSimilar || isOrdered) { - // 상하 PASS → 2단계 좌우로 + if (lenVariation < 0.5) { + val l4 = urineLens.getOrElse(4) { 0 } + val l5 = urineLens.getOrElse(5) { 0 } + val lrHint = when { + l4 > 0 && l5 > 0 && l4 > l5 -> "오른쪽으로 이동하세요 →" + l4 > 0 && l5 > 0 && l5 > l4 -> "왼쪽으로 이동하세요 ←" + else -> "좌우로 천천히 이동하세요 ↔" + } + val lrIcon = when { + l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right" + l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left" + else -> "arrow.left.arrow.right" + } return PlacementGuideResult( - "상하 위치 OK! 좌우로 천천히 이동하세요 ↔", - "arrow.left.arrow.right", 40, + "상하 위치 OK! $lrHint", + lrIcon, 40, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) } else { - // 크기 편차 큼 → 상하 더 조절 return if (len3 > len0 * 2) { - PlacementGuideResult("센서를 아래로 더 이동하세요 ↓ (CH0 약함)", "arrow.down", 25, + PlacementGuideResult("센서를 아래로 조금 내리세요 ↓", "arrow.down", 25, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } else { - PlacementGuideResult("센서를 위로 더 이동하세요 ↑ (CH3 약함)", "arrow.up", 25, + PlacementGuideResult("센서를 위로 조금 올리세요 ↑", "arrow.up", 25, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } } } - // ═══ 2단계: 좌우 탐색 ═══ + // ═══ 2단계: 좌우 대칭 맞추기 ═══ if (currentPhase == PlacementPhase.LATERAL) { val len4 = urineLens.getOrElse(4) { 0 } val len5 = urineLens.getOrElse(5) { 0 } val ch4 = detected.getOrElse(4) { false } val ch5 = detected.getOrElse(5) { false } - // 먼저 상하가 여전히 유효한지 확인 if (centerDetected < 3) { - return PlacementGuideResult("상하 위치 벗어남 — 다시 위아래 조절", "arrow.up.arrow.down", 20, + return PlacementGuideResult("상하 위치 벗어남 — 위아래 다시 조절", "arrow.up.arrow.down", 20, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL) } if (!ch4 && !ch5) { - return PlacementGuideResult("좌우로 천천히 이동하세요 ↔", "arrow.left.arrow.right", 40, + return PlacementGuideResult("좌우 센서가 감지되지 않습니다. 좌우로 이동하세요 ↔", "arrow.left.arrow.right", 40, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) } - // 좌우 대칭성 판정 val lrMax = maxOf(len4, len5, 1) val lrSymmetry = 1.0 - kotlin.math.abs(len4 - len5).toDouble() / lrMax if (lrSymmetry >= 0.7 || (ch4 && ch5)) { - // 좌우 PASS → 3단계 CV 확인 return PlacementGuideResult( "좌우 대칭 OK! 최종 확인 중...", "checkmark.circle", 60, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN) } else { - val hint = if (len4 > len5) "센서를 오른쪽으로 이동 → (CH4 더 김)" else "센서를 왼쪽으로 이동 ← (CH5 더 김)" + val hint = if (len4 > len5) "센서를 오른쪽으로 이동 →" else "센서를 왼쪽으로 이동 ←" val icon = if (len4 > len5) "arrow.right" else "arrow.left" return PlacementGuideResult(hint, icon, 45, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) @@ -926,7 +1070,7 @@ private fun computePlacementGuide( val std = kotlin.math.sqrt(depths.map { (it - mean) * (it - mean) }.average()) val cv = if (mean > 0) std / mean else Double.MAX_VALUE - val relaxStep = (repeatCount / 5).coerceAtMost(3) + val relaxStep = (repeatCount / 8).coerceAtMost(2) val cvThr = DEFAULT_CV_THR + relaxStep * 0.05 // LR deviation