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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 신호 범위
|
||||
|
||||
@@ -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 {
|
||||
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<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 포팅
|
||||
*
|
||||
* 히스토그램에서 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)
|
||||
)
|
||||
}
|
||||
|
||||
+18
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+275
-131
@@ -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<List<PiezoChannelData>>(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
|
||||
|
||||
Reference in New Issue
Block a user