feat: low echo detection + Placement 알고리즘 업데이트 (원은지님 공유)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,8 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
val minUrineLen: Int = 3
|
val minUrineLen: Int = 3
|
||||||
val postRefineTolRatio: Double = 0.05
|
val postRefineTolRatio: Double = 0.05
|
||||||
val maxRealisticVolumeMl: Double = 1500.0
|
val maxRealisticVolumeMl: Double = 1500.0
|
||||||
|
val valleyStopRise: Double = 50.0
|
||||||
|
val edgeDistDecay: Double = 0.12
|
||||||
|
|
||||||
// ── Public API ──
|
// ── Public API ──
|
||||||
|
|
||||||
@@ -399,20 +401,19 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
for (i in (peakIdx + 1) until min(n, peakIdx + maxDist)) {
|
for (i in (peakIdx + 1) until min(n, peakIdx + maxDist)) {
|
||||||
if (sg[i] < v) {
|
if (sg[i] < v) {
|
||||||
v = sg[i]
|
v = sg[i]
|
||||||
} else if (sg[i] > v + 10) {
|
} else if (sg[i] > v + valleyStopRise) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
/** peak 왼쪽에서 가장 가까운 valley의 sg 값 */
|
|
||||||
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||||||
var v = sg[peakIdx]
|
var v = sg[peakIdx]
|
||||||
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
|
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
|
||||||
if (sg[i] < v) {
|
if (sg[i] < v) {
|
||||||
v = sg[i]
|
v = sg[i]
|
||||||
} else if (sg[i] > v + 10) {
|
} else if (sg[i] > v + valleyStopRise) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,24 +447,28 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
rightHi = min(n - 1, edge + searchWin) // 바깥 (자유)
|
rightHi = min(n - 1, edge + searchWin) // 바깥 (자유)
|
||||||
}
|
}
|
||||||
|
|
||||||
// edge 왼쪽 peak 후보 (가까운 순)
|
// edge 왼쪽 peak 후보 — edge+1 포함 (inclusive boundary)
|
||||||
var leftCandidates = listOf<Int>()
|
var leftCandidates = listOf<Int>()
|
||||||
if (edge > leftLo) {
|
val leftEnd = min(edge + 1, n)
|
||||||
val seg = safeSlice(sg, from = leftLo, to = edge - 1)
|
if (leftEnd > leftLo) {
|
||||||
|
val seg = safeSlice(sg, from = leftLo, to = leftEnd - 1)
|
||||||
if (seg != null) {
|
if (seg != null) {
|
||||||
val pks = findPeaks1D(seg)
|
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)
|
leftCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// edge 오른쪽 peak 후보 (가까운 순)
|
// edge 오른쪽 peak 후보 — edge-1부터 시작 (inclusive boundary)
|
||||||
var rightCandidates = listOf<Int>()
|
var rightCandidates = listOf<Int>()
|
||||||
if (rightHi >= edge) {
|
val rightStart = max(edge - 1, 0)
|
||||||
val seg = safeSlice(sg, from = edge, to = rightHi)
|
if (rightHi >= rightStart) {
|
||||||
|
val seg = safeSlice(sg, from = rightStart, to = rightHi)
|
||||||
if (seg != null) {
|
if (seg != null) {
|
||||||
val pks = findPeaks1D(seg)
|
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)
|
rightCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,19 +476,21 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
val candidates = leftCandidates + rightCandidates
|
val candidates = leftCandidates + rightCandidates
|
||||||
if (candidates.isEmpty()) return null
|
if (candidates.isEmpty()) return null
|
||||||
|
|
||||||
// prominence 계산 (urine 방향 valley 기준)
|
// prominence + edge distance penalty (EDGE_DIST_DECAY=0.12)
|
||||||
var bestPeak: Int? = null
|
var bestPeak: Int? = null
|
||||||
var bestProm = -1.0
|
var bestScore = -1.0
|
||||||
|
|
||||||
for (p in candidates) {
|
for (p in candidates) {
|
||||||
val valley: Double = if (side == WallSide.ANT) {
|
val valley: Double = if (side == WallSide.ANT) {
|
||||||
findRightValley(sg, peakIdx = p) // urine 방향 = 오른쪽
|
findRightValley(sg, peakIdx = p)
|
||||||
} else {
|
} else {
|
||||||
findLeftValley(sg, peakIdx = p) // urine 방향 = 왼쪽
|
findLeftValley(sg, peakIdx = p)
|
||||||
}
|
}
|
||||||
val prom = sg[p] - valley
|
val prom = sg[p] - valley
|
||||||
if (prom > bestProm) {
|
val dist = abs(p - edge)
|
||||||
bestProm = prom
|
val score = prom / (1.0 + edgeDistDecay * dist)
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
bestPeak = p
|
bestPeak = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-54
@@ -37,6 +37,7 @@ import com.example.medilightv2android.AppState
|
|||||||
import com.example.medilightv2android.ble.BleManager
|
import com.example.medilightv2android.ble.BleManager
|
||||||
import com.example.medilightv2android.ble.PiezoChannelData
|
import com.example.medilightv2android.ble.PiezoChannelData
|
||||||
import com.example.medilightv2android.managers.PiezoEchoAnalyzer
|
import com.example.medilightv2android.managers.PiezoEchoAnalyzer
|
||||||
|
import com.example.medilightv2android.managers.PiezoHW
|
||||||
import com.example.medilightv2android.managers.UrinAI
|
import com.example.medilightv2android.managers.UrinAI
|
||||||
import com.example.medilightv2android.services.MeasurementLogService
|
import com.example.medilightv2android.services.MeasurementLogService
|
||||||
import com.example.medilightv2android.ui.theme.*
|
import com.example.medilightv2android.ui.theme.*
|
||||||
@@ -291,6 +292,7 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
|
|
||||||
val detected = MutableList(6) { false }
|
val detected = MutableList(6) { false }
|
||||||
val urineLens = MutableList(6) { 0 }
|
val urineLens = MutableList(6) { 0 }
|
||||||
|
val allWalls: MutableList<Pair<Int, Int>?> = MutableList(6) { null }
|
||||||
|
|
||||||
for (ch in channels) {
|
for (ch in channels) {
|
||||||
if (ch.buffer.isNotEmpty() && ch.channel < 6) {
|
if (ch.buffer.isNotEmpty() && ch.channel < 6) {
|
||||||
@@ -298,11 +300,13 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
if (analysis.isValid && analysis.result != null) {
|
if (analysis.isValid && analysis.result != null) {
|
||||||
detected[ch.channel] = true
|
detected[ch.channel] = true
|
||||||
urineLens[ch.channel] = analysis.result.urineLen
|
urineLens[ch.channel] = analysis.result.urineLen
|
||||||
|
allWalls[ch.channel] = Pair(analysis.result.ant, analysis.result.post)
|
||||||
} else {
|
} else {
|
||||||
val gate = UrinAI.detect(ch.buffer)
|
val gate = UrinAI.detect(ch.buffer)
|
||||||
if (gate.detected) {
|
if (gate.detected) {
|
||||||
detected[ch.channel] = true
|
detected[ch.channel] = true
|
||||||
urineLens[ch.channel] = gate.bw - gate.fw
|
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
|
lastChannelData = channels
|
||||||
|
|
||||||
val guideResult = computePlacementGuide(detected, urineLens)
|
val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount)
|
||||||
directionHint = guideResult.hint
|
directionHint = guideResult.hint
|
||||||
directionIcon = guideResult.icon
|
directionIcon = guideResult.icon
|
||||||
placementScore = guideResult.score
|
placementScore = guideResult.score
|
||||||
|
|
||||||
if (guideResult.score >= 70 || detected.count { it } >= 4) {
|
if (guideResult.isPass) {
|
||||||
isLocked = true
|
isLocked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,27 +604,46 @@ private fun SensorLayoutCanvas(
|
|||||||
data class PlacementGuideResult(
|
data class PlacementGuideResult(
|
||||||
val hint: String,
|
val hint: String,
|
||||||
val icon: 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:
|
* Placement guide — CV threshold 기반 (placement.py 포팅)
|
||||||
* 1단계: 잡힘/안잡힘 → 기본 방향 가이드
|
*
|
||||||
* 2단계: urineLen 기반 정밀 가이드 (대칭 + 균형 + 크기)
|
* 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<Boolean>, urineLens: List<Int>): PlacementGuideResult {
|
private fun computePlacementGuide(
|
||||||
val ch0 = detected[0]; val ch3 = detected[3]
|
detected: List<Boolean>,
|
||||||
val ch4 = if (detected.size > 4) detected[4] else true
|
urineLens: List<Int>,
|
||||||
val ch5 = if (detected.size > 5) detected[5] else true
|
walls: List<Pair<Int, Int>?> = emptyList(),
|
||||||
|
repeatCount: Int = 0
|
||||||
|
): PlacementGuideResult {
|
||||||
val detectedCount = detected.count { it }
|
val detectedCount = detected.count { it }
|
||||||
|
|
||||||
// 0 channels: no signal
|
|
||||||
if (detectedCount == 0) {
|
if (detectedCount == 0) {
|
||||||
return PlacementGuideResult("No bladder detected. Check sensor contact.", "exclamationmark.triangle", 0)
|
return PlacementGuideResult("No bladder detected. Check sensor contact.", "exclamationmark.triangle", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1단계: 이진 판정 (4채널 미만일 때)
|
// Center 채널 미달 — 이진 가이드
|
||||||
if (detectedCount < 4) {
|
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 {
|
val hint = when {
|
||||||
!ch3 && ch0 -> "Move sensor UP"
|
!ch3 && ch0 -> "Move sensor UP"
|
||||||
ch3 && !ch0 -> "Move sensor DOWN"
|
ch3 && !ch0 -> "Move sensor DOWN"
|
||||||
@@ -635,61 +658,125 @@ private fun computePlacementGuide(detected: List<Boolean>, urineLens: List<Int>)
|
|||||||
ch4 && !ch5 -> "arrow.right"
|
ch4 && !ch5 -> "arrow.right"
|
||||||
else -> "arrow.up.arrow.down"
|
else -> "arrow.up.arrow.down"
|
||||||
}
|
}
|
||||||
return PlacementGuideResult(hint, icon, detectedCount * 10)
|
return PlacementGuideResult(hint, icon, centerDetected * 15)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2단계: 4채널 이상 잡힘 → urineLen 기반 정밀 가이드
|
// CV 계산 — cos-corrected depth (mm)
|
||||||
val len0 = urineLens[0].toDouble() // CH0 (배꼽)
|
val hw = PiezoHW
|
||||||
val len3 = urineLens[3].toDouble() // CH3 (치골)
|
val dps = hw.distancePerSample
|
||||||
val len1 = urineLens[1].toDouble() // CH1
|
val offset = hw.delayOffsetMm
|
||||||
val len2 = urineLens[2].toDouble() // CH2
|
val degAll = hw.degreeAll
|
||||||
val len4 = urineLens.getOrElse(4) { 0 }.toDouble() // CH4 (왼)
|
val centerCh = hw.centerCh
|
||||||
val len5 = urineLens.getOrElse(5) { 0 }.toDouble() // CH5 (오)
|
|
||||||
|
|
||||||
// 세로 대칭: CH0과 CH3의 urineLen 비율
|
val depths = mutableListOf<Double>()
|
||||||
val vertMax = maxOf(len0, len3, 1.0)
|
val depthPerCh = DoubleArray(4) { Double.NaN }
|
||||||
val vertSymmetry = 1.0 - kotlin.math.abs(len0 - len3) / vertMax
|
|
||||||
|
|
||||||
// 가로 균형: CH4와 CH5의 urineLen 차이
|
for (localIdx in centerCh.indices) {
|
||||||
val latMax = maxOf(len4, len5, 1.0)
|
val ch = centerCh[localIdx]
|
||||||
val latBalance = if (len4 > 0 && len5 > 0) 1.0 - kotlin.math.abs(len4 - len5) / latMax else 0.5
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 중앙 채널 크기 (절대적 — 클수록 방광 중심에 가까움)
|
if (depths.size < 3) {
|
||||||
val centerAvg = (len1 + len2) / 2.0
|
return PlacementGuideResult("Insufficient data (${depths.size}/4 depths)", "exclamationmark.triangle", centerDetected * 15)
|
||||||
val centerScore = (centerAvg / 40.0).coerceAtMost(1.0) // 40칸이면 만점
|
}
|
||||||
|
|
||||||
// 종합 점수
|
val mean = depths.average()
|
||||||
val rawScore = vertSymmetry * 0.3 + latBalance * 0.3 + centerScore * 0.4
|
val std = kotlin.math.sqrt(depths.map { (it - mean) * (it - mean) }.average())
|
||||||
val score = (rawScore * 100).toInt().coerceIn(0, 100)
|
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 hint: String
|
||||||
val icon: String
|
val icon: String
|
||||||
|
|
||||||
if (score >= 80) {
|
if (isPass) {
|
||||||
hint = "Perfect position! Score: $score%"
|
hint = "Best Placement! CV=${"%.2f".format(cv)} (≤${"%.2f".format(cvThr)})"
|
||||||
icon = "checkmark.circle"
|
icon = "checkmark.circle"
|
||||||
} else if (len0 > 0 && len3 > 0 && len0 / maxOf(len3, 1.0) > 1.5) {
|
} else if (!lrPass) {
|
||||||
hint = "Move sensor UP (CH0 longer)"
|
hint = "$lrDirection — LR dev=${"%.2f".format(lrDev)}"
|
||||||
icon = "arrow.up"
|
icon = if (lrDirection == "MOVE RIGHT") "arrow.right" else "arrow.left"
|
||||||
} 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 {
|
} else {
|
||||||
hint = "Good position! Score: $score%"
|
hint = "$siDirection ($severity) — CV=${"%.2f".format(cv)}"
|
||||||
icon = "checkmark.circle"
|
icon = siIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
return PlacementGuideResult(hint, icon, score)
|
return PlacementGuideResult(hint, icon, score, cv, isPass, lrDev, lrPass, siDirection, severity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user