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:
2026-04-24 10:22:05 +09:00
parent 98d3b11a46
commit 4cdd69ef3d
2 changed files with 165 additions and 71 deletions
@@ -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<Int>()
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<Int>()
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
}
}