fix: UX feedback + BV ellipse outlier (#22) + README v3 update

- #1 배터리 최적화 페이지 이동 제거 (ForegroundService+WakeLock만)
- #2 Auto 첫 측정 대기 시 "Measuring... (n/5)" loading 표시
- #3 Placement 멘트 정리 — 방향만 남기기 (CH 언급 제거)
- #4 CH0~CH5 dot row 개발자 모드 전용
- #5 Spot 재시도 로직 (최대 6회, 유효 3개 목표, 300ms 간격)
- LR 좌우 조건 완화: ±8mm, symmetry≥0.5, lrDev≤0.35
- |len4-len5|≤10 → LATERAL 건너뛰고 바로 GREEN
- GREEN 미달 시 CV만 간당간당하면 GREEN 유지 ("Almost there...")
- CV 기본 임계 0.08→0.12, 디바운스 3초→1.5초
- BV 타원 피팅 outlier 반복 제거 (appshare #22)
- README v3 업데이트 (3모드 placement, BV 파이프라인, 상태표)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:36:14 +09:00
parent 139ebcbbb1
commit b73921eb2b
5 changed files with 367 additions and 278 deletions
@@ -504,31 +504,69 @@ fun estimateBladderVolume(
val aCapS = DoubleArray(order.size) { aCap[order[it]] }
val sortedCh = order.map { validChannels[it] }
// 7) y-z 평면 타원 피팅 → cap 높이 추정 (#21 merge)
val yw = DoubleArray(2 * n) { i -> if (i < n) yWallAnt[i] else yWallPost[i - n] }
val zw = DoubleArray(2 * n) { i -> if (i < n) zWallAnt[i] else zWallPost[i - n] }
val nPts = yw.size
// 7) y-z 평면 타원 피팅 + 반복 outlier 제거 (#22 merge)
val allYw = DoubleArray(2 * n) { i -> if (i < n) yWallAnt[i] else yWallPost[i - n] }
val allZw = DoubleArray(2 * n) { i -> if (i < n) zWallAnt[i] else zWallPost[i - n] }
val nPts = allYw.size
var capKind = "fallback"
var z0Ellipse: Double? = null
var hCapBot = aCapS[0]
var hCapTop = aCapS[n - 1]
val ellipseCostThr = 0.5 // 점당 평균 잔차 임계
if (nPts >= 4) {
val ym = yw.average(); val ysStd = std(yw) + 1e-12
val zm = zw.average(); val zsStd = std(zw) + 1e-12
val yn = DoubleArray(nPts) { (yw[it] - ym) / ysStd }
val zn = DoubleArray(nPts) { (zw[it] - zm) / zsStd }
// 타원 피팅 helper: 성공 시 (y0, z0, bSi, aAp, residuals) 반환
fun fitEllipsePts(ywF: DoubleArray, zwF: DoubleArray): Array<Any>? {
val cnt = ywF.size
val ym = ywF.average(); val ysS = std(ywF) + 1e-12
val zm = zwF.average(); val zsS = std(zwF) + 1e-12
val yn = DoubleArray(cnt) { (ywF[it] - ym) / ysS }
val zn = DoubleArray(cnt) { (zwF[it] - zm) / zsS }
val sol = solveEllipseLSQ(yn, zn, cnt) ?: return null
if (sol[0] <= 1e-12 || sol[1] <= 1e-12) return null
val rr = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1])
if (rr <= 0) return null
val y0 = (-sol[2] / (2.0 * sol[0])) * ysS + ym
val z0 = (-sol[3] / (2.0 * sol[1])) * zsS + zm
val bSi = sqrt(rr / sol[0]) * ysS
val aAp = sqrt(rr / sol[1]) * zsS
val resid = DoubleArray(cnt) {
abs(((ywF[it] - y0) / bSi) * ((ywF[it] - y0) / bSi) +
((zwF[it] - z0) / aAp) * ((zwF[it] - z0) / aAp) - 1.0)
}
return arrayOf(y0, z0, bSi, aAp, resid)
}
val sol = solveEllipseLSQ(yn, zn, nPts)
if (sol != null && sol[0] > 1e-12 && sol[1] > 1e-12) {
val y0n = -sol[2] / (2.0 * sol[0])
val z0n = -sol[3] / (2.0 * sol[1])
val rVal = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1])
if (rVal > 0) {
val bSiN = sqrt(rVal / sol[0])
val y0 = y0n * ysStd + ym
z0Ellipse = z0n * zsStd + zm
val bSi = bSiN * ysStd
if (nPts >= 5) {
val keep = BooleanArray(nPts) { true }
var fit = fitEllipsePts(allYw, allZw)
if (fit != null) {
var y0 = fit[0] as Double; var z0 = fit[1] as Double
var bSi = fit[2] as Double; var aAp = fit[3] as Double
var resid = fit[4] as DoubleArray
var meanRes = resid.average()
// 반복 outlier 제거: worst 점 하나씩, 최소 5점 유지
while (meanRes > ellipseCostThr && keep.count { it } > 5) {
val worstLocal = resid.indices.maxByOrNull { resid[it] } ?: break
val activeIndices = keep.indices.filter { keep[it] }
keep[activeIndices[worstLocal]] = false
val keptY = keep.indices.filter { keep[it] }.map { allYw[it] }.toDoubleArray()
val keptZ = keep.indices.filter { keep[it] }.map { allZw[it] }.toDoubleArray()
val fit2 = fitEllipsePts(keptY, keptZ) ?: break
val meanRes2 = (fit2[4] as DoubleArray).average()
if (meanRes2 < meanRes) {
y0 = fit2[0] as Double; z0 = fit2[1] as Double
bSi = fit2[2] as Double; aAp = fit2[3] as Double
resid = fit2[4] as DoubleArray; meanRes = meanRes2
} else break
}
// 품질 판정: 평균 잔차 ≤ threshold
if (meanRes <= ellipseCostThr) {
z0Ellipse = z0
// b_si 상한: a_ap × 1.3 (해부학적 SI/AP 비율 제한)
if (bSi > aAp * 1.3) bSi = aAp * 1.3
hCapBot = max(0.0, yS[0] - (y0 - bSi))
hCapTop = max(0.0, (y0 + bSi) - yS[n - 1])
hCapBot = min(hCapBot, aCapS[0])