From 4cdd69ef3decd7ad8bd47dfe2d04da4cc7eca8aa Mon Sep 17 00:00:00 2001 From: jjangddu Date: Fri, 24 Apr 2026 10:22:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20low=20echo=20detection=20+=20Placement?= =?UTF-8?q?=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(=EC=9B=90=EC=9D=80=EC=A7=80=EB=8B=98=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Low Echo Detection 업데이트: - VALLEY_STOP_RISE: 10→50 (valley 스캔 early stop 임계) - EDGE_DIST_DECAY: 0.12 (신규, edge 거리 penalty) - Edge boundary 확장: left [leftLo:edge+1], right [edge-1:rightHi+1] - Peak scoring: prominence → prominence / (1 + 0.12 × dist) (low echo에 가까운 peak이 더 높은 점수) Placement 알고리즘 전면 교체 (CV threshold 방식): - 기존 urineLen score → cos-corrected depth의 CV(변동계수) - CV ≤ 0.15 → PASS (Best Placement) - CV > 0.15 → FAIL → weakest channel 기반 방향 안내 - CH0/CH1(top) weakest → MOVE DOWN - CH2/CH3(bottom) weakest → MOVE UP - severity: slight(<10%) / moderate(10~25%) / severe(≥25%) - LR deviation: CH4/CH5 cos-corrected depth 비교 - |lr_dev| ≤ 0.15 → PASS - lr_dev > 0 → MOVE RIGHT, < 0 → MOVE LEFT - 반복 시 CV threshold 완화 (+0.02/회) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../managers/PiezoEchoAnalyzer.kt | 41 ++-- .../ui/views/monitoring/PlacementGuideView.kt | 195 +++++++++++++----- 2 files changed, 165 insertions(+), 71 deletions(-) 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 8037ef1..155aa46 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt @@ -137,6 +137,8 @@ class PiezoEchoAnalyzer private constructor() { val minUrineLen: Int = 3 val postRefineTolRatio: Double = 0.05 val maxRealisticVolumeMl: Double = 1500.0 + val valleyStopRise: Double = 50.0 + val edgeDistDecay: Double = 0.12 // ── Public API ── @@ -399,20 +401,19 @@ class PiezoEchoAnalyzer private constructor() { for (i in (peakIdx + 1) until min(n, peakIdx + maxDist)) { if (sg[i] < v) { v = sg[i] - } else if (sg[i] > v + 10) { + } else if (sg[i] > v + valleyStopRise) { break } } return v } - /** peak 왼쪽에서 가장 가까운 valley의 sg 값 */ private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double { var v = sg[peakIdx] for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) { if (sg[i] < v) { v = sg[i] - } else if (sg[i] > v + 10) { + } else if (sg[i] > v + valleyStopRise) { break } } @@ -446,24 +447,28 @@ class PiezoEchoAnalyzer private constructor() { rightHi = min(n - 1, edge + searchWin) // 바깥 (자유) } - // edge 왼쪽 peak 후보 (가까운 순) + // edge 왼쪽 peak 후보 — edge+1 포함 (inclusive boundary) var leftCandidates = listOf() - if (edge > leftLo) { - val seg = safeSlice(sg, from = leftLo, to = edge - 1) + val leftEnd = min(edge + 1, n) + if (leftEnd > leftLo) { + val seg = safeSlice(sg, from = leftLo, to = leftEnd - 1) if (seg != null) { val pks = findPeaks1D(seg) - val global = pks.map { it + leftLo }.filter { sg[it] >= peakMin } + val global = pks.map { it + leftLo } + .filter { it < edge && sg[it] >= peakMin } leftCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates) } } - // edge 오른쪽 peak 후보 (가까운 순) + // edge 오른쪽 peak 후보 — edge-1부터 시작 (inclusive boundary) var rightCandidates = listOf() - if (rightHi >= edge) { - val seg = safeSlice(sg, from = edge, to = rightHi) + val rightStart = max(edge - 1, 0) + if (rightHi >= rightStart) { + val seg = safeSlice(sg, from = rightStart, to = rightHi) if (seg != null) { val pks = findPeaks1D(seg) - val global = pks.map { it + edge }.filter { sg[it] >= peakMin } + val global = pks.map { it + rightStart } + .filter { it >= edge && sg[it] >= peakMin } rightCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates) } } @@ -471,19 +476,21 @@ class PiezoEchoAnalyzer private constructor() { val candidates = leftCandidates + rightCandidates if (candidates.isEmpty()) return null - // prominence 계산 (urine 방향 valley 기준) + // prominence + edge distance penalty (EDGE_DIST_DECAY=0.12) var bestPeak: Int? = null - var bestProm = -1.0 + var bestScore = -1.0 for (p in candidates) { val valley: Double = if (side == WallSide.ANT) { - findRightValley(sg, peakIdx = p) // urine 방향 = 오른쪽 + findRightValley(sg, peakIdx = p) } else { - findLeftValley(sg, peakIdx = p) // urine 방향 = 왼쪽 + findLeftValley(sg, peakIdx = p) } val prom = sg[p] - valley - if (prom > bestProm) { - bestProm = prom + val dist = abs(p - edge) + val score = prom / (1.0 + edgeDistDecay * dist) + if (score > bestScore) { + bestScore = score bestPeak = p } } 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 555a230..5e2d14a 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 @@ -37,6 +37,7 @@ import com.example.medilightv2android.AppState import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.ble.PiezoChannelData import com.example.medilightv2android.managers.PiezoEchoAnalyzer +import com.example.medilightv2android.managers.PiezoHW import com.example.medilightv2android.managers.UrinAI import com.example.medilightv2android.services.MeasurementLogService import com.example.medilightv2android.ui.theme.* @@ -291,6 +292,7 @@ fun PlacementGuideView(appState: AppState) { val detected = MutableList(6) { false } val urineLens = MutableList(6) { 0 } + val allWalls: MutableList?> = MutableList(6) { null } for (ch in channels) { if (ch.buffer.isNotEmpty() && ch.channel < 6) { @@ -298,11 +300,13 @@ fun PlacementGuideView(appState: AppState) { if (analysis.isValid && analysis.result != null) { detected[ch.channel] = true urineLens[ch.channel] = analysis.result.urineLen + allWalls[ch.channel] = Pair(analysis.result.ant, analysis.result.post) } else { val gate = UrinAI.detect(ch.buffer) if (gate.detected) { detected[ch.channel] = true urineLens[ch.channel] = gate.bw - gate.fw + allWalls[ch.channel] = Pair(gate.fw, gate.bw) } } } @@ -314,12 +318,12 @@ fun PlacementGuideView(appState: AppState) { } lastChannelData = channels - val guideResult = computePlacementGuide(detected, urineLens) + val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount) directionHint = guideResult.hint directionIcon = guideResult.icon placementScore = guideResult.score - if (guideResult.score >= 70 || detected.count { it } >= 4) { + if (guideResult.isPass) { isLocked = true } @@ -600,27 +604,46 @@ private fun SensorLayoutCanvas( data class PlacementGuideResult( val hint: String, val icon: String, - val score: Int + val score: Int, + val cv: Double = Double.NaN, + val isPass: Boolean = false, + val lrDev: Double = Double.NaN, + val lrPass: Boolean = true, + val direction: String = "", + val severity: String = "" ) +private const val DEFAULT_CV_THR = 0.15 +private const val DEFAULT_LR_DEV_THR = 0.15 + /** - * 2단계 placement guide: - * 1단계: 잡힘/안잡힘 → 기본 방향 가이드 - * 2단계: urineLen 기반 정밀 가이드 (대칭 + 균형 + 크기) + * Placement guide — CV threshold 기반 (placement.py 포팅) + * + * Center CH0~CH3의 cos-corrected depth의 CV(변동계수)로 판정: + * CV ≤ threshold → PASS (Best Placement) + * CV > threshold → FAIL → weakest channel 기반 방향 안내 + * + * n회 반복 시 threshold를 완화할 수 있음 (repeatCount 기반) */ -private fun computePlacementGuide(detected: List, urineLens: List): PlacementGuideResult { - val ch0 = detected[0]; val ch3 = detected[3] - val ch4 = if (detected.size > 4) detected[4] else true - val ch5 = if (detected.size > 5) detected[5] else true +private fun computePlacementGuide( + detected: List, + urineLens: List, + walls: List?> = emptyList(), + repeatCount: Int = 0 +): PlacementGuideResult { val detectedCount = detected.count { it } - // 0 channels: no signal if (detectedCount == 0) { return PlacementGuideResult("No bladder detected. Check sensor contact.", "exclamationmark.triangle", 0) } - // 1단계: 이진 판정 (4채널 미만일 때) - if (detectedCount < 4) { + // Center 채널 미달 — 이진 가이드 + val centerDetected = (0..3).count { detected.getOrElse(it) { false } } + if (centerDetected < 3) { + val ch0 = detected.getOrElse(0) { false } + val ch3 = detected.getOrElse(3) { false } + val ch4 = detected.getOrElse(4) { true } + val ch5 = detected.getOrElse(5) { true } val hint = when { !ch3 && ch0 -> "Move sensor UP" ch3 && !ch0 -> "Move sensor DOWN" @@ -635,61 +658,125 @@ private fun computePlacementGuide(detected: List, urineLens: List) ch4 && !ch5 -> "arrow.right" else -> "arrow.up.arrow.down" } - return PlacementGuideResult(hint, icon, detectedCount * 10) + return PlacementGuideResult(hint, icon, centerDetected * 15) } - // 2단계: 4채널 이상 잡힘 → urineLen 기반 정밀 가이드 - val len0 = urineLens[0].toDouble() // CH0 (배꼽) - val len3 = urineLens[3].toDouble() // CH3 (치골) - val len1 = urineLens[1].toDouble() // CH1 - val len2 = urineLens[2].toDouble() // CH2 - val len4 = urineLens.getOrElse(4) { 0 }.toDouble() // CH4 (왼) - val len5 = urineLens.getOrElse(5) { 0 }.toDouble() // CH5 (오) + // CV 계산 — cos-corrected depth (mm) + val hw = PiezoHW + val dps = hw.distancePerSample + val offset = hw.delayOffsetMm + val degAll = hw.degreeAll + val centerCh = hw.centerCh - // 세로 대칭: CH0과 CH3의 urineLen 비율 - val vertMax = maxOf(len0, len3, 1.0) - val vertSymmetry = 1.0 - kotlin.math.abs(len0 - len3) / vertMax + val depths = mutableListOf() + val depthPerCh = DoubleArray(4) { Double.NaN } - // 가로 균형: CH4와 CH5의 urineLen 차이 - val latMax = maxOf(len4, len5, 1.0) - val latBalance = if (len4 > 0 && len5 > 0) 1.0 - kotlin.math.abs(len4 - len5) / latMax else 0.5 + for (localIdx in centerCh.indices) { + val ch = centerCh[localIdx] + val w = if (ch < walls.size) walls[ch] else null + if (w != null) { + val antMm = offset + w.first * dps + val postMm = offset + w.second * dps + val rawDepth = postMm - antMm + val theta = degAll[ch] * Math.PI / 180.0 + val corrected = rawDepth * kotlin.math.cos(theta) + if (corrected > 0) { + depths.add(corrected) + depthPerCh[localIdx] = corrected + } + } + } - // 중앙 채널 크기 (절대적 — 클수록 방광 중심에 가까움) - val centerAvg = (len1 + len2) / 2.0 - val centerScore = (centerAvg / 40.0).coerceAtMost(1.0) // 40칸이면 만점 + if (depths.size < 3) { + return PlacementGuideResult("Insufficient data (${depths.size}/4 depths)", "exclamationmark.triangle", centerDetected * 15) + } - // 종합 점수 - val rawScore = vertSymmetry * 0.3 + latBalance * 0.3 + centerScore * 0.4 - val score = (rawScore * 100).toInt().coerceIn(0, 100) + val mean = depths.average() + val std = kotlin.math.sqrt(depths.map { (it - mean) * (it - mean) }.average()) + val cv = if (mean > 0) std / mean else Double.MAX_VALUE + + // CV threshold (반복 시 완화) + val cvThr = DEFAULT_CV_THR + repeatCount * 0.02 + + // SI 방향 가이드 (deviation 기반) + val deviations = DoubleArray(4) { if (depthPerCh[it].isNaN()) 0.0 else (depthPerCh[it] - mean) / mean } + val validDevs = (0..3).filter { !depthPerCh[it].isNaN() } + val weakestIdx = validDevs.maxByOrNull { kotlin.math.abs(deviations[it]) } ?: 0 + val weakestDev = deviations[weakestIdx] + + val siDirection: String + val siIcon: String + val severity: String + + val absWeak = kotlin.math.abs(weakestDev) + severity = when { + absWeak < 0.10 -> "slight" + absWeak < 0.25 -> "moderate" + else -> "severe" + } + + if (cv <= cvThr) { + siDirection = "PASS" + siIcon = "checkmark.circle" + } else { + // CH0/CH1 = top(배꼽) → weakest면 DOWN + // CH2/CH3 = bottom(치골) → weakest면 UP + if (weakestIdx <= 1) { + siDirection = "MOVE DOWN" + siIcon = "arrow.down" + } else { + siDirection = "MOVE UP" + siIcon = "arrow.up" + } + } + + // LR deviation (CH4/CH5) + var lrDev = Double.NaN + var lrDirection = "UNKNOWN" + var lrPass = true + + val w4 = if (4 < walls.size) walls[4] else null + val w5 = if (5 < walls.size) walls[5] else null + if (w4 != null && w5 != null) { + val d4raw = (offset + w4.second * dps) - (offset + w4.first * dps) + val d5raw = (offset + w5.second * dps) - (offset + w5.first * dps) + val t4 = degAll[4] * Math.PI / 180.0 + val t5 = degAll[5] * Math.PI / 180.0 + val tLR4 = hw.degreeLRAll[4] * Math.PI / 180.0 + val tLR5 = hw.degreeLRAll[5] * Math.PI / 180.0 + val depth4 = d4raw * kotlin.math.cos(t4) * kotlin.math.cos(tLR4) + val depth5 = d5raw * kotlin.math.cos(t5) * kotlin.math.cos(tLR5) + val lrMean = (depth4 + depth5) / 2.0 + if (lrMean > 0) { + lrDev = (depth4 - depth5) / lrMean + lrPass = kotlin.math.abs(lrDev) <= DEFAULT_LR_DEV_THR + lrDirection = when { + lrPass -> "PASS" + lrDev > 0 -> "MOVE RIGHT" // left longer → move right + else -> "MOVE LEFT" + } + } + } + + val isPass = cv <= cvThr && lrPass + val cvPct = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt() + val score = if (isPass) maxOf(cvPct, 70) else cvPct.coerceIn(0, 69) - // 정밀 방향 가이드 val hint: String val icon: String - if (score >= 80) { - hint = "Perfect position! Score: $score%" + if (isPass) { + hint = "Best Placement! CV=${"%.2f".format(cv)} (≤${"%.2f".format(cvThr)})" icon = "checkmark.circle" - } else if (len0 > 0 && len3 > 0 && len0 / maxOf(len3, 1.0) > 1.5) { - hint = "Move sensor UP (CH0 longer)" - icon = "arrow.up" - } else if (len0 > 0 && len3 > 0 && len3 / maxOf(len0, 1.0) > 1.5) { - hint = "Move sensor DOWN (CH3 longer)" - icon = "arrow.down" - } else if (len4 > 0 && len5 > 0 && (len4 - len5) > 5) { - hint = "Move sensor RIGHT (CH4 longer)" - icon = "arrow.right" - } else if (len4 > 0 && len5 > 0 && (len5 - len4) > 5) { - hint = "Move sensor LEFT (CH5 longer)" - icon = "arrow.left" - } else if (centerAvg < 10) { - hint = "Adjust position — weak center signal" - icon = "arrow.up.arrow.down" + } else if (!lrPass) { + hint = "$lrDirection — LR dev=${"%.2f".format(lrDev)}" + icon = if (lrDirection == "MOVE RIGHT") "arrow.right" else "arrow.left" } else { - hint = "Good position! Score: $score%" - icon = "checkmark.circle" + hint = "$siDirection ($severity) — CV=${"%.2f".format(cv)}" + icon = siIcon } - return PlacementGuideResult(hint, icon, score) + return PlacementGuideResult(hint, icon, score, cv, isPass, lrDev, lrPass, siDirection, severity) } @Composable