feat: Method B TGC+FP필터, Placement UI 리디자인, 네비게이션 개선

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 16:56:51 +09:00
parent c9cd3cb3ce
commit 6614e915d6
6 changed files with 476 additions and 182 deletions
@@ -95,7 +95,7 @@ class AppState(private val context: Context) {
fun startFromHome() { fun startFromHome() {
sensorMode = SensorMode.PIEZO sensorMode = SensorMode.PIEZO
storage.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) { fun selectSensor(mode: SensorMode) {
@@ -68,7 +68,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
// 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring // 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring
AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME
AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN 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.PIEZO_MONITORING -> appState.currentScreen = AppScreen.PLACEMENT_GUIDE
// 모니터링 하위 화면 → 모니터링 // 모니터링 하위 화면 → 모니터링
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
@@ -16,7 +16,7 @@ enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C }
object GreenZoneConstants { object GreenZoneConstants {
var detectionMethod: DetectionMethod = DetectionMethod.METHOD_B var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 신호 범위 // 신호 범위
@@ -157,6 +157,32 @@ class PiezoEchoAnalyzer private constructor() {
} }
} }
/** 다채널 TGC 파이프라인: denoise → TGC → findBackReflection → suppress → detect */
fun analyzeMultiChannelWithTgc(channelData: List<PiezoChannelData>): List<ChannelAnalysisResult> {
// 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<PiezoChannelData>): PiezoAnalysisResult { fun analyzeMultiChannel(channelData: List<PiezoChannelData>): PiezoAnalysisResult {
val results = channelData.map { ch -> val results = channelData.map { ch ->
@@ -313,38 +339,140 @@ class PiezoEchoAnalyzer private constructor() {
var useAdaptiveThreshold: Boolean = true var useAdaptiveThreshold: Boolean = true
var lastOtsuThreshold: Double = 0.0; private set var lastOtsuThreshold: Double = 0.0; private set
var lastOtsuSeparability: Double = 0.0; private set
/** 단일 1D 채널 → urine region 탐지 */ // Otsu 관련 상수
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray): LowEchoResult? { 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 val sg = denoised
if (sg.size < 10) return null if (sg.size < 10) return null
val thr = if (useAdaptiveThreshold) { val effectivePostMax = dynamicPostMax ?: postMaxIdx
val limit = min(sg.size, postMaxIdx + 1)
otsu1d(sg, limit) if (!useAdaptiveThreshold) {
} else { lastOtsuThreshold = lowEchoAmpDefault
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<DoubleArray>, postMaxIdx: Int): Int {
val detected = mutableListOf<Int>()
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 포팅 * 1D Otsu threshold + separability
*
* 히스토그램에서 between-class variance를 최대화하는 임계값 반환.
* 저진폭(urine/조직)과 고진폭(벽 echo) 두 모집단을 분리.
*/ */
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) 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] var vMin = values[0]; var vMax = values[0]
for (i in 1 until n) { for (i in 1 until n) {
if (values[i] < vMin) vMin = values[i] if (values[i] < vMin) vMin = values[i]
if (values[i] > vMax) vMax = 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 binWidth = (vMax - vMin) / nBins
val hist = IntArray(nBins) val hist = IntArray(nBins)
@@ -355,24 +483,21 @@ class PiezoEchoAnalyzer private constructor() {
val centers = DoubleArray(nBins) { vMin + (it + 0.5) * binWidth } val centers = DoubleArray(nBins) { vMin + (it + 0.5) * binWidth }
val total = n.toDouble() val total = n.toDouble()
val p = DoubleArray(nBins) { hist[it] / total }
// cumulative probability & mean
val cumP = DoubleArray(nBins) val cumP = DoubleArray(nBins)
val cumMP = DoubleArray(nBins) val cumMP = DoubleArray(nBins)
cumP[0] = hist[0] / total cumP[0] = p[0]; cumMP[0] = p[0] * centers[0]
cumMP[0] = cumP[0] * centers[0]
for (i in 1 until nBins) { for (i in 1 until nBins) {
cumP[i] = cumP[i - 1] + hist[i] / total cumP[i] = cumP[i - 1] + p[i]
cumMP[i] = cumMP[i - 1] + (hist[i] / total) * centers[i] cumMP[i] = cumMP[i - 1] + p[i] * centers[i]
} }
val totalM = cumMP[nBins - 1] val totalM = cumMP[nBins - 1]
// between-class variance 최대화
var bestSigma = -1.0 var bestSigma = -1.0
var bestIdx = 0 var bestIdx = 0
for (t in 0 until nBins - 1) { for (t in 0 until nBins - 1) {
val w0 = cumP[t] val w0 = cumP[t]; val w1 = 1.0 - w0
val w1 = 1.0 - w0
if (w0 < 1e-6 || w1 < 1e-6) continue if (w0 < 1e-6 || w1 < 1e-6) continue
val m0 = cumMP[t] / w0 val m0 = cumMP[t] / w0
val m1 = (totalM - cumMP[t]) / w1 val m1 = (totalM - cumMP[t]) / w1
@@ -382,18 +507,26 @@ class PiezoEchoAnalyzer private constructor() {
bestIdx = t 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 선택 * Low-echo 탐지 핵심 — prominence 기반 wall peak 선택 + FP 필터
* 1:1 port of low_echo_detection_method_b.py (2026-04-20 update)
*/ */
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 if (sg.size < 10) return null
// 1) low-echo span 추출 (postMaxIdx까지만 — 이후는 노이즈) // 1) low-echo span 추출 (effectivePostMax까지)
val analysisLimit = min(sg.size, postMaxIdx + 1) val analysisLimit = min(sg.size, effectivePostMax + 1)
val lowMask = BooleanArray(sg.size) { it < analysisLimit && sg[it] <= threshold } val lowMask = BooleanArray(sg.size) { it < analysisLimit && sg[it] <= threshold }
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen } 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 val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
if (lowSlice.isEmpty()) return null if (lowSlice.isEmpty()) return null
val lowMean = lowSlice.sum() / lowSlice.size.toDouble() val lowMean = lowSlice.sum() / lowSlice.size.toDouble()
val peakMin = lowMean + minPeakMargin val peakMin = max(lowMean + minPeakMargin, threshold)
// 3) Prominence 기반 wall peak 선택 // 3) Prominence 기반 wall peak 선택
val ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin, var ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.ANT, otherEdge = e) ?: return null 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, var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
@@ -433,10 +569,19 @@ class PiezoEchoAnalyzer private constructor() {
if (postH < threshold) return null if (postH < threshold) return null
val lowDepth = ((antH + postH) / 2.0) - lowMean val lowDepth = ((antH + postH) / 2.0) - lowMean
val urineLen = post - ant val urineLen = post - ant - 1
if (urineLen < minUrineLen) return null 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( return LowEchoResult(
ant = ant, ant = ant,
post = post, post = post,
@@ -444,7 +589,7 @@ class PiezoEchoAnalyzer private constructor() {
lowEnd = e, lowEnd = e,
lowMean = lowMean, lowMean = lowMean,
urineLen = urineLen, urineLen = urineLen,
score = lowDepth * urineLen.toDouble(), score = score,
innerPeaks = findInnerPeaks(sg, left = ant, right = post) innerPeaks = findInnerPeaks(sg, left = ant, right = post)
) )
} }
@@ -167,12 +167,12 @@ fun PiezoMonitoringView(appState: AppState) {
} }
else -> { else -> {
methodLabel = "B" methodLabel = "B"
val channelResults = validChannels.map { analyzer.analyzeChannel(it.buffer, it.channel) } val channelResults = analyzer.analyzeMultiChannelWithTgc(validChannels)
for (ch in channelResults) { for (ch in channelResults) {
if (ch.channel < 6 && ch.result != null) allWalls[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)
} }
for (ch in channelResults) { 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) 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( Surface(
onClick = { onClick = {
com.example.medilightv2android.measure.VoidingRecordStore.add( com.example.medilightv2android.measure.VoidingRecordStore.add(
context, com.example.medilightv2android.measure.VoidingRecord( context, com.example.medilightv2android.measure.VoidingRecord(
volume = appState.estimatedVolume, method = "catheterize" volume = quickVol, method = "catheterize"
) )
) )
appState.resetLevel() appState.resetLevel()
lastVolumeMl = 0.0
maxVolumeMl = 0.0
resetMeasurement() resetMeasurement()
android.widget.Toast.makeText(context, context.getString(R.string.registered_diary), android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(context, context.getString(R.string.registered_diary), android.widget.Toast.LENGTH_SHORT).show()
showCatheterizeSheet = false showCatheterizeSheet = false
@@ -472,7 +475,7 @@ fun PiezoMonitoringView(appState: AppState) {
Icon(Icons.Default.FlashOn, contentDescription = null, tint = Color(0xFF4CAF50)) Icon(Icons.Default.FlashOn, contentDescription = null, tint = Color(0xFF4CAF50))
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text("빠른 저장", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) 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)) Spacer(modifier = Modifier.width(4.dp))
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText) Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
} }
@@ -699,21 +702,23 @@ fun PiezoMonitoringView(appState: AppState) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val displayVol = if (lastVolumeMl > 0) "%.0f".format(lastVolumeMl) else "${appState.estimatedVolume}" val displayVol = if (lastVolumeMl > 0) "%.0f".format(lastVolumeMl) else "${appState.estimatedVolume}"
InfoCard("Volume", displayVol, "ml", urgency.accentColor, Modifier.weight(1f)) 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("Fill", String.format("%.0f", appState.fillPercentage), "%", urgency.accentColor, Modifier.weight(1f))
InfoCard("Level", "${appState.bladderLevel.currentLevel}/${BladderLevel.MAX_LEVEL}", "", 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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Catheterize // Void / Catheterize
Button( Button(
onClick = { showCatheterizeSheet = true }, onClick = { showCatheterizeSheet = true },
modifier = Modifier.weight(1.2f).height(52.dp), modifier = Modifier.weight(1.3f).height(56.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f)) 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 // Auto
@@ -721,7 +726,7 @@ fun PiezoMonitoringView(appState: AppState) {
onClick = { onClick = {
if (isAutoMeasuring) stopAutoMeasure() else startAutoMeasure() if (isAutoMeasuring) stopAutoMeasure() else startAutoMeasure()
}, },
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier.weight(1f).height(56.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = if (isAutoMeasuring) Color.Red else Color(0xFFFF9800) containerColor = if (isAutoMeasuring) Color.Red else Color(0xFFFF9800)
@@ -738,7 +743,7 @@ fun PiezoMonitoringView(appState: AppState) {
Button( Button(
onClick = { if (!isMeasuring && !isAutoMeasuring) measure() }, onClick = { if (!isMeasuring && !isAutoMeasuring) measure() },
enabled = !isMeasuring && !isAutoMeasuring, enabled = !isMeasuring && !isAutoMeasuring,
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier.weight(1f).height(56.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = urgency.gradientColors.first() 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) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text(value, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1C1B1F)) 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) Text(title, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
} }
} }
@@ -62,7 +62,7 @@ fun PlacementGuideView(appState: AppState) {
var isContinuousMode by remember { mutableStateOf(false) } var isContinuousMode by remember { mutableStateOf(false) }
val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) } val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) }
val channelUrineLen = remember { mutableStateListOf(0, 0, 0, 0, 0, 0) } 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 directionIcon by remember { mutableStateOf("") }
var placementScore by remember { mutableIntStateOf(0) } var placementScore by remember { mutableIntStateOf(0) }
var scanCount by remember { mutableIntStateOf(0) } var scanCount by remember { mutableIntStateOf(0) }
@@ -70,6 +70,7 @@ fun PlacementGuideView(appState: AppState) {
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) } var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
var debounceCount by remember { mutableIntStateOf(0) } var debounceCount by remember { mutableIntStateOf(0) }
var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) }
var lastHintChangeMs by remember { mutableStateOf(0L) }
var showPlacementSettings by remember { mutableStateOf(false) } var showPlacementSettings by remember { mutableStateOf(false) }
// 2단계 순차 탐색: VERTICAL → LATERAL → GREEN // 2단계 순차 탐색: VERTICAL → LATERAL → GREEN
@@ -81,17 +82,23 @@ fun PlacementGuideView(appState: AppState) {
isLocked = false isLocked = false
scanCount = 0 scanCount = 0
placementScore = 0 placementScore = 0
directionHint = "센서를 아래에서 위로 천천히 이동하세요" directionHint = "치골 위에 센서를 올려놓으세요"
directionIcon = "" directionIcon = ""
for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 } for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 }
} }
val detectedCount = channelDetected.count { it } 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MlBackground) .background(bgColor)
) { ) {
// Top bar // Top bar
Row( Row(
@@ -100,7 +107,7 @@ fun PlacementGuideView(appState: AppState) {
.padding(horizontal = 24.dp, vertical = 16.dp), .padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically 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( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
@@ -131,108 +138,223 @@ fun PlacementGuideView(appState: AppState) {
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Instructions // ── 상반신 일러스트 + 센서 + 방향 가이드 ──
Text( Box(
text = when (placementPhase) { modifier = Modifier
PlacementPhase.VERTICAL -> "Step 1/3: 센서를 아래에서 위로 천천히 이동하세요" .fillMaxWidth()
PlacementPhase.LATERAL -> "Step 2/3: 센서를 좌우로 천천히 이동하세요" .height(300.dp)
PlacementPhase.GREEN -> "Step 3/3: 최종 위치 확인 중..." .padding(top = 8.dp),
}, contentAlignment = Alignment.Center
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)
) { ) {
for (i in 0 until 6) { Canvas(modifier = Modifier.fillMaxSize()) {
Column( val w = size.width
horizontalAlignment = Alignment.CenterHorizontally, val h = size.height
verticalArrangement = Arrangement.spacedBy(2.dp) val cx = w / 2f
) { val greenZone = isGreenZone
Box(
modifier = Modifier // ── 상반신 실루엣 ──
.size(12.dp) val skinColor = Color(0xFFFFDDB5)
.clip(CircleShape) val outlineColor = Color(0xFFD4A574)
.background(
if (channelDetected[i]) Color(0xFF4CAF50) // 몸통 (위: 가슴 아래, 아래: 치골)
else Color.Red.copy(alpha = 0.4f) val bodyTop = h * 0.05f
) val bodyBot = h * 0.88f
) val bodyW = w * 0.38f
Text("CH$i", fontSize = 9.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
if (channelUrineLen[i] > 0) { // 몸통 타원형
Text("${channelUrineLen[i]}", fontSize = 8.sp, color = Color.Cyan) 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) { // Score + 감지수 (우측 상단)
Text( Column(
text = "$detectedCount/6", modifier = Modifier
fontSize = 16.sp, .align(Alignment.TopEnd)
fontWeight = FontWeight.Bold, .padding(4.dp),
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800) horizontalAlignment = Alignment.End
) ) {
Text("$detectedCount/6", fontSize = 16.sp, fontWeight = FontWeight.Bold,
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800))
if (placementScore > 0) { if (placementScore > 0) {
Text( Text("$placementScore%", fontSize = 13.sp, fontWeight = FontWeight.Bold,
text = "Score: $placementScore%",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = when { color = when {
placementScore >= 80 -> Color(0xFF4CAF50) placementScore >= 80 -> Color(0xFF4CAF50)
placementScore >= 50 -> Color(0xFFFF9800) placementScore >= 50 -> Color(0xFFFF9800)
else -> Color.Red 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) // Per-channel echo graphs (after scan)
if (lastChannelData.isNotEmpty()) { if (lastChannelData.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -380,7 +502,7 @@ fun PlacementGuideView(appState: AppState) {
} else { } else {
val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase) val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount, placementPhase)
// 디바운스: 같은 phase가 2회 연속일 때만 힌트 변경 // 디바운스: 같은 phase 2회 연속 + 최소 3초 유지
if (guideResult.nextPhase == lastGuidePhase) { if (guideResult.nextPhase == lastGuidePhase) {
debounceCount++ debounceCount++
} else { } else {
@@ -388,11 +510,14 @@ fun PlacementGuideView(appState: AppState) {
lastGuidePhase = guideResult.nextPhase 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 directionHint = guideResult.hint
directionIcon = guideResult.icon directionIcon = guideResult.icon
placementScore = guideResult.score placementScore = guideResult.score
placementPhase = guideResult.nextPhase placementPhase = guideResult.nextPhase
lastHintChangeMs = now
} }
if (guideResult.isPass) { if (guideResult.isPass) {
@@ -777,8 +902,8 @@ data class PlacementGuideResult(
val nextPhase: PlacementPhase = PlacementPhase.VERTICAL val nextPhase: PlacementPhase = PlacementPhase.VERTICAL
) )
private const val DEFAULT_CV_THR = 0.15 private const val DEFAULT_CV_THR = 0.10
private const val DEFAULT_LR_DEV_THR = 0.30 private const val DEFAULT_LR_DEV_THR = 0.25
/** /**
* 2단계 순차 Placement Guide (대표님 요구사항) * 2단계 순차 Placement Guide (대표님 요구사항)
@@ -804,94 +929,113 @@ private fun computePlacementGuide(
val detectedCount = detected.count { it } val detectedCount = detected.count { it }
if (detectedCount == 0) { 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) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} }
// Center 채널 정보 // Center 채널 정보
val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } } val centerLens = (0..3).map { urineLens.getOrElse(it) { 0 } }
val centerDetected = (0..3).count { detected.getOrElse(it) { false } } 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 len0 = centerLens[0]; val len1 = centerLens[1]
val len2 = centerLens[2]; val len3 = centerLens[3] val len2 = centerLens[2]; val len3 = centerLens[3]
// ═══ 1단계: 상하 탐색 ═══ // ═══ 1단계: 상하 탐색 (치골→위로→CH0 안잡히면 아래로) ═══
if (currentPhase == PlacementPhase.VERTICAL) { if (currentPhase == PlacementPhase.VERTICAL) {
// 조건: 3개 이상 center에서 low echo 검출
if (centerDetected < 3) { if (centerDetected < 3) {
val ch0 = detected.getOrElse(0) { false } val hint: String
val ch3 = detected.getOrElse(3) { false } val icon: String
val hint = when { when {
ch3 && !ch0 -> "센서를 아래로 이동하세요 ↓" // 위쪽만 잡힘 → 아래로 내리세요
!ch3 && ch0 -> "센서를 위로 이동하세요 ↑" (ch3 || ch2) && !ch0 && !ch1 -> {
else -> "센서를 천천히 위아래로 이동하세요" hint = "센서를 아래로 천천히 내리세요 ↓"
} icon = "arrow.down"
val icon = when { }
ch3 && !ch0 -> "arrow.down" // 아래쪽만 잡힘 → 위로 올리세요
!ch3 && ch0 -> "arrow.up" (ch0 || ch1) && !ch3 && !ch2 -> {
else -> "arrow.up.arrow.down" hint = "센서를 위로 천천히 올리세요 ↑"
icon = "arrow.up"
}
// 아무것도 또는 1개만 → 위로 올리세요 (치골에서 시작 기준)
else -> {
hint = "센서를 위로 천천히 올리세요 ↑"
icon = "arrow.up"
}
} }
return PlacementGuideResult(hint, icon, centerDetected * 10, return PlacementGuideResult(hint, icon, centerDetected * 10,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) 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 validLens = (0..3).filter { detected.getOrElse(it) { false } }.map { centerLens[it] }
val maxLen = validLens.maxOrNull() ?: 0 val maxLen = validLens.maxOrNull() ?: 0
val minLen = validLens.minOrNull() ?: 0 val minLen = validLens.minOrNull() ?: 0
val lenVariation = if (maxLen > 0) (maxLen - minLen).toDouble() / maxLen else 1.0 val lenVariation = if (maxLen > 0) (maxLen - minLen).toDouble() / maxLen else 1.0
// 크기가 유사(변동 < 50%) 또는 3≥2≥1≥0 패턴 if (lenVariation < 0.5) {
val isOrdered = len3 >= len2 - 5 && len2 >= len1 - 5 && (len0 == 0 || len1 >= len0 - 5) val l4 = urineLens.getOrElse(4) { 0 }
val isSimilar = lenVariation < 0.5 val l5 = urineLens.getOrElse(5) { 0 }
val lrHint = when {
if (isSimilar || isOrdered) { l4 > 0 && l5 > 0 && l4 > l5 -> "오른쪽으로 이동하세요 →"
// 상하 PASS → 2단계 좌우로 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( return PlacementGuideResult(
"상하 위치 OK! 좌우로 천천히 이동하세요 ↔", "상하 위치 OK! $lrHint",
"arrow.left.arrow.right", 40, lrIcon, 40,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL)
} else { } else {
// 크기 편차 큼 → 상하 더 조절
return if (len3 > len0 * 2) { return if (len3 > len0 * 2) {
PlacementGuideResult("센서를 아래로 더 이동하세요 ↓ (CH0 약함)", "arrow.down", 25, PlacementGuideResult("센서를 아래로 조금 내리세요 ↓", "arrow.down", 25,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} else { } else {
PlacementGuideResult("센서를 위로 더 이동하세요 ↑ (CH3 약함)", "arrow.up", 25, PlacementGuideResult("센서를 위로 조금 올리세요 ↑", "arrow.up", 25,
phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL)
} }
} }
} }
// ═══ 2단계: 좌우 탐색 ═══ // ═══ 2단계: 좌우 대칭 맞추기 ═══
if (currentPhase == PlacementPhase.LATERAL) { if (currentPhase == PlacementPhase.LATERAL) {
val len4 = urineLens.getOrElse(4) { 0 } val len4 = urineLens.getOrElse(4) { 0 }
val len5 = urineLens.getOrElse(5) { 0 } val len5 = urineLens.getOrElse(5) { 0 }
val ch4 = detected.getOrElse(4) { false } val ch4 = detected.getOrElse(4) { false }
val ch5 = detected.getOrElse(5) { false } val ch5 = detected.getOrElse(5) { false }
// 먼저 상하가 여전히 유효한지 확인
if (centerDetected < 3) { if (centerDetected < 3) {
return PlacementGuideResult("상하 위치 벗어남 — 다시 위아래 조절", "arrow.up.arrow.down", 20, return PlacementGuideResult("상하 위치 벗어남 — 위아래 다시 조절", "arrow.up.arrow.down", 20,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL) phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL)
} }
if (!ch4 && !ch5) { if (!ch4 && !ch5) {
return PlacementGuideResult("좌우로 천천히 이동하세요 ↔", "arrow.left.arrow.right", 40, return PlacementGuideResult("좌우 센서가 감지되지 않습니다. 좌우로 이동하세요 ↔", "arrow.left.arrow.right", 40,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
} }
// 좌우 대칭성 판정
val lrMax = maxOf(len4, len5, 1) val lrMax = maxOf(len4, len5, 1)
val lrSymmetry = 1.0 - kotlin.math.abs(len4 - len5).toDouble() / lrMax val lrSymmetry = 1.0 - kotlin.math.abs(len4 - len5).toDouble() / lrMax
if (lrSymmetry >= 0.7 || (ch4 && ch5)) { if (lrSymmetry >= 0.7 || (ch4 && ch5)) {
// 좌우 PASS → 3단계 CV 확인
return PlacementGuideResult( return PlacementGuideResult(
"좌우 대칭 OK! 최종 확인 중...", "좌우 대칭 OK! 최종 확인 중...",
"checkmark.circle", 60, "checkmark.circle", 60,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN) phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN)
} else { } 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" val icon = if (len4 > len5) "arrow.right" else "arrow.left"
return PlacementGuideResult(hint, icon, 45, return PlacementGuideResult(hint, icon, 45,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) 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 std = kotlin.math.sqrt(depths.map { (it - mean) * (it - mean) }.average())
val cv = if (mean > 0) std / mean else Double.MAX_VALUE 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 val cvThr = DEFAULT_CV_THR + relaxStep * 0.05
// LR deviation // LR deviation