diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV2.kt b/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV2.kt index 2eaabea..57a09f0 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV2.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV2.kt @@ -41,6 +41,8 @@ data class AdvisorState( val summary: AlignSummary?, val accumProgress: Int = 0, // 'accum' 일 때 (현재/총) val accumTarget: Int = 0, + /** 2026-07-06: CH3 signal 미약으로 상단 3채널 (CH0~CH2) 만으로 정렬 진행 중. */ + val relaxedMode: Boolean = false, ) data class AlignSummary( @@ -76,6 +78,22 @@ object AlignmentConstants { const val CV_THRESHOLD_LOOSE = 0.20 /** CENTER_OPTIMIZE 통과 요구 연속 commit 수 (hysteresis). */ const val CENTER_OPT_CONFIRM_STREAK = 3 + + // 2026-07-06: CH3 flicker 인체 대응 — sliding window majority + relaxed mode. + // 실측: Human VBT26050202 0CM 세션에서 CH3 hit pattern = Y Y N N Y Y N N (최대 2연속). + // 기존 "3연속 hit" 진입 조건이 절대 통과 못해 VERTICAL_CLIMB 무한 대기 → 사용자 UX 악화. + /** VERTICAL_CLIMB 진입 판정 sliding window (프레임). */ + const val VERTICAL_HIT_WINDOW = 6 + /** 위 window 내 CH3 hit 최소 횟수 (3/6 = 50%). */ + const val VERTICAL_HIT_MAJORITY = 3 + /** CH3_STABILIZE / CENTER_OPTIMIZE 의 CH3 lost 판정 sliding window. */ + const val STABILIZE_LOST_WINDOW = 4 + /** 위 window 내 lost 최소 횟수 — 진짜 lost 로 판정. */ + const val STABILIZE_LOST_MAJORITY = 3 + /** VERTICAL_CLIMB 무한 대기 감지 임계 (프레임, 약 5초). */ + const val VERTICAL_STUCK_THRESHOLD = 20 + /** VERTICAL_CLIMB 반복 안내 문구 완화 시점 (프레임, 약 3초). */ + const val VERTICAL_SOFT_HINT_AFTER = 12 } /** stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). */ @@ -207,12 +225,17 @@ class AlignGuide4Stage( val msgKey: Int? = null, /** Args for msgKey formatting. */ val msgArgs: List = emptyList(), + /** 2026-07-06: CH3 미약 → 상단 3채널 relaxed mode 진행 중. */ + val relaxedMode: Boolean = false, ) var phase: Phase4 = Phase4.INITIAL_ACCUM private set var bestCenterSet: Set = emptySet() private set + /** 2026-07-06: CH3 신호 미약으로 CH0~CH2 로만 정렬 진행하는 완화 모드. */ + var relaxedMode: Boolean = false + private set private var initialCount = 0 private var verticalHitStreak = 0 private var stabilizeCount = 0 @@ -223,9 +246,22 @@ class AlignGuide4Stage( private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis private var centerOptimizeStreak = 0 // CENTER_OPTIMIZE CV 통과 연속 카운터 + // 2026-07-06 CH3 flicker 대응 링버퍼 & stuck counter. + // history 는 true = hit / detected, false = miss / lost. + private val verticalHitHistory = ArrayDeque() + private val stabilizeLostHistory = ArrayDeque() // true = lost this frame + private val centerOptLostHistory = ArrayDeque() // true = lost this frame + private var verticalStuckCount = 0 // VERTICAL_CLIMB 체류 프레임 수 + + private fun pushHistory(dq: ArrayDeque, v: Boolean, window: Int) { + dq.addLast(v) + while (dq.size > window) dq.removeFirst() + } + fun reset() { phase = Phase4.INITIAL_ACCUM bestCenterSet = emptySet() + relaxedMode = false initialCount = 0 verticalHitStreak = 0 stabilizeCount = 0 @@ -235,6 +271,10 @@ class AlignGuide4Stage( finalConfirmCount = 0 lostStreak = 0 centerOptimizeStreak = 0 + verticalHitHistory.clear() + stabilizeLostHistory.clear() + centerOptLostHistory.clear() + verticalStuckCount = 0 } fun step(s: AlignSummary): Out { @@ -253,43 +293,74 @@ class AlignGuide4Stage( } // === Phase 2: VERTICAL_CLIMB === + // 2026-07-06: 연속 카운터 대신 sliding window majority (window=6, hit≥3). + // 실측 인체 데이터에서 CH3 이 YYNN 패턴으로 flick 하여 3연속을 절대 못 채우는 + // 무한 대기 케이스가 있어, 최근 6 프레임 중 3회 hit 이면 진입 허용. + // 또한 20 프레임 지나도 통과 못하면 RELAXED_MODE ON → 상단 3채널로 진행. if (phase == Phase4.VERTICAL_CLIMB) { - if (!s.ch3) { - verticalHitStreak = 0 // 연속 카운터 리셋 — false detection 거부 - return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요 (ch3 미검출)", - phase, 0, - msgKey = R.string.align_move_up) - } - verticalHitStreak++ - if (verticalHitStreak < verticalHitRequired) { - // ch3 일시 검출 — verticalHitRequired 회 연속 확인 전까지 사용자 정지. - // (현재 위치가 진짜 ch3 인지 평균이 만든 false echo 인지 확인 중) + pushHistory(verticalHitHistory, s.ch3, AlignmentConstants.VERTICAL_HIT_WINDOW) + verticalStuckCount++ + val hitCount = verticalHitHistory.count { it } + + // Stuck detection → Relaxed mode 진입 조건: + // (a) VERTICAL_CLIMB 체류 ≥ 20 프레임 + // (b) 최근 window 내 CH3 hit < majority (신호가 여전히 미약) + // (c) CH0+CH1+CH2 상단 3채널이 detected + if (!relaxedMode + && verticalStuckCount >= AlignmentConstants.VERTICAL_STUCK_THRESHOLD + && hitCount < AlignmentConstants.VERTICAL_HIT_MAJORITY + && (0..2).all { it < s.detected.size && s.detected[it] }) { + relaxedMode = true + phase = Phase4.CENTER_OPTIMIZE + centerOptimizeStreak = 0 + bestCenterSet = (0..2).toSet() return Out(AlignAction.STOP, - "✓ ch3 일시 검출 — 확인 중 (${verticalHitStreak}/${verticalHitRequired})", - phase, verticalHitStreak, - msgKey = R.string.align_hold_position, - msgArgs = listOf(verticalHitStreak, verticalHitRequired)) + "⚠ CH3 미약 — 상단 3채널 relaxed 정렬 진입", + phase, 0, + msgKey = R.string.align_ch3_relaxed_mode, + relaxedMode = true) + } + + if (hitCount >= AlignmentConstants.VERTICAL_HIT_MAJORITY) { + phase = Phase4.CH3_STABILIZE + stabilizeCount = 0 + bestCenterSet = curSet + verticalStuckCount = 0 + verticalHitHistory.clear() + } else { + // 진입 조건 미달 — MOVE_UP 힌트. 다만 오래 반복되면 문구를 부드럽게. + val useSoftHint = verticalStuckCount >= AlignmentConstants.VERTICAL_SOFT_HINT_AFTER + val msgKey = if (useSoftHint) R.string.align_ch3_soft_hint else R.string.align_move_up + val label = if (useSoftHint) + "· CH3 확인 중 · 유지/미세 조정 (hit ${hitCount}/${AlignmentConstants.VERTICAL_HIT_WINDOW})" + else + "↑ 위로 조금씩 올리세요 (hit ${hitCount}/${AlignmentConstants.VERTICAL_HIT_WINDOW})" + return Out(AlignAction.MOVE_UP, label, phase, hitCount, msgKey = msgKey) } - phase = Phase4.CH3_STABILIZE - stabilizeCount = 0 - bestCenterSet = curSet } // === Phase 3: CH3_STABILIZE === + // 2026-07-06: CH3 lost 즉시 회귀 대신 sliding window (window=4, lost≥3) 로 판정. + // 단발 flick 은 무시하여 stabilize 진행. 진짜 lost 는 원위치 (VERTICAL_CLIMB) 복귀. if (phase == Phase4.CH3_STABILIZE) { stabilizeCount++ if (curSet.size > bestCenterSet.size) bestCenterSet = curSet - if (!s.ch3) { - // ch3 잃었으면 → VERTICAL_CLIMB 회귀 + pushHistory(stabilizeLostHistory, !s.ch3, AlignmentConstants.STABILIZE_LOST_WINDOW) + val lostCount = stabilizeLostHistory.count { it } + if (lostCount >= AlignmentConstants.STABILIZE_LOST_MAJORITY) { + // 진짜 lost — VERTICAL_CLIMB 회귀 (기존 로직 유지) phase = Phase4.VERTICAL_CLIMB stabilizeCount = 0 - return Out(AlignAction.MOVE_UP, "↑ ch3 다시 잃음 — 위로", + stabilizeLostHistory.clear() + verticalHitHistory.clear() + verticalStuckCount = 0 + return Out(AlignAction.MOVE_UP, "↑ ch3 진짜 놓침 — 다시 위로", phase, 0, msgKey = R.string.align_recover_up) } if (stabilizeCount < stabilizeTarget) { return Out(AlignAction.STOP, - "✓ ch3 검출! 위치 유지 (${stabilizeCount}/${stabilizeTarget})", + "✓ ch3 검출 — 위치 유지 (${stabilizeCount}/${stabilizeTarget}, flick ${lostCount}/${AlignmentConstants.STABILIZE_LOST_WINDOW})", phase, stabilizeCount, msgKey = R.string.align_hold_position, msgArgs = listOf(stabilizeCount, stabilizeTarget)) @@ -297,51 +368,71 @@ class AlignGuide4Stage( // 2026-07-02: CH3_STABILIZE 통과 → CENTER_OPTIMIZE (CV 로 4채널 균등 확인) phase = Phase4.CENTER_OPTIMIZE centerOptimizeStreak = 0 + stabilizeLostHistory.clear() } - // === Phase 4a: CENTER_OPTIMIZE (신규) — CH0~CH3 chord CV 로 최적 vertical 정밀 조정 === - // 전제: CH3 는 이미 안정 검출됨. 4채널 chord CV 가 낮으면 (0.15 이하) 정렬 완료로 인정. - // CV 높으면: chord[3] > chord[0] 이면 probe 가 high → MOVE_DOWN, - // chord[0] > chord[3] 이면 probe 가 low → MOVE_UP. + // === Phase 4a: CENTER_OPTIMIZE — CH chord CV 로 최적 vertical 정밀 조정 === + // Normal mode: CH0~CH3 4채널 CV. CV ≤ 0.15 이면 정렬 완료로 인정. + // CV 높으면 chord[3] > chord[0] → MOVE_DOWN, chord[0] > chord[3] → MOVE_UP. + // Relaxed mode (2026-07-06): CH3 미약 시 CH0~CH2 3채널로 CV, 방향은 chord[2]:chord[0]. + // CH3 lost 감지는 sliding window (window=4, lost≥3) — 단발 flick 무시. if (phase == Phase4.CENTER_OPTIMIZE) { - val center4Detected = (0..3).all { it < s.detected.size && s.detected[it] } - if (!s.ch3) { - // 2026-07-06 fix: CH3 를 CENTER_OPTIMIZE 진입 후 잃음 = probe 가 위로 올라가 - // CH3 beam 이 방광 inferior edge 를 넘어감 → 방광이 probe 아래로 이동. - // → MOVE_DOWN 으로 CH3 회복. 이전 MOVE_UP 은 잃음 원인과 반대라 무한 상승. - phase = Phase4.CH3_STABILIZE - stabilizeCount = 0 - centerOptimizeStreak = 0 - return Out(AlignAction.MOVE_DOWN, "↓ ch3 재확인 필요", - phase, 0, msgKey = R.string.align_recover_down_short) + // Relaxed 모드가 아니면 CH3 flicker 검사. + if (!relaxedMode) { + pushHistory(centerOptLostHistory, !s.ch3, AlignmentConstants.STABILIZE_LOST_WINDOW) + val lostCount = centerOptLostHistory.count { it } + if (lostCount >= AlignmentConstants.STABILIZE_LOST_MAJORITY) { + // 진짜 lost — CH3_STABILIZE 회귀. 원인: probe 가 위로 올라가 CH3 beam 이 방광 + // inferior edge 를 넘어감. → 사용자에게 MOVE_DOWN 안내. + phase = Phase4.CH3_STABILIZE + stabilizeCount = 0 + centerOptimizeStreak = 0 + centerOptLostHistory.clear() + return Out(AlignAction.MOVE_DOWN, "↓ ch3 재확인 필요", + phase, 0, msgKey = R.string.align_recover_down_short) + } + if (!s.ch3) { + // 단발 flick — 위치 유지. streak 은 리셋해서 CV 통과 재검증 필요. + centerOptimizeStreak = 0 + return Out(AlignAction.STOP, + "· CH3 확인 중 · 위치 유지 (flick ${lostCount}/${AlignmentConstants.STABILIZE_LOST_WINDOW})", + phase, 0, msgKey = R.string.align_ch3_soft_hint) + } } - if (!center4Detected) { - // 2026-07-06 fix: 미검출 채널 위치로 probe 위치 판단. - // CH0 (+6.89° 위로 tilt, 상단 channel) 만 놓치고 CH3 검출 = probe 가 방광 + + // 검출 채널 범위 결정 — relaxed = CH0~CH2, normal = CH0~CH3. + val centerRange = if (relaxedMode) 0..2 else 0..3 + val centerAllDetected = centerRange.all { it < s.detected.size && s.detected[it] } + if (!centerAllDetected) { + // 미검출 채널 위치로 probe 위치 판단. + // CH0 (+6.89° up-tilt, 상단 channel) 만 놓치고 하단 detected = probe 가 방광 // dome 위에 있어 CH0 beam 이 위로 빠짐 → MOVE_DOWN 으로 방광 중심 재정렬. - // CH1/CH2 만 놓치고 CH0+CH3 검출 = 드물지만 부분적 dropout, default MOVE_UP. // 상단 (CH0 or CH0+CH1) 만 놓침 = probe too high → MOVE_DOWN. + // 그 외 (CH1 이하만 미검출) = default MOVE_UP. centerOptimizeStreak = 0 - val missing = (0..3).filter { !s.detected[it] } + val missing = centerRange.filter { !s.detected[it] } val missingLabel = missing.joinToString(",") { "ch$it" } - val topOnly = missing.isNotEmpty() && missing.all { it <= 1 } // {0} or {1} or {0,1} + val topOnly = missing.isNotEmpty() && missing.all { it <= 1 } val (direction, arrow, msgKey) = if (topOnly) Triple(AlignAction.MOVE_DOWN, "↓", R.string.align_recover_down_short) else Triple(AlignAction.MOVE_UP, "↑", R.string.align_move_up) return Out(direction, "$arrow 조금 ${if (topOnly) "아래" else "더 위"}로 ($missingLabel 미검출)", - phase, 0, msgKey = msgKey) + phase, 0, msgKey = msgKey, relaxedMode = relaxedMode) } - // 4채널 모두 검출됨 → chord CV 계산 (summarize 에서 angle-corrected mm 값 저장됨) - val chords = DoubleArray(4) { ch -> s.chords[ch] ?: 0.0 } + // 모두 검출됨 → chord CV 계산. + val chordN = centerRange.count() + val chords = DoubleArray(chordN) { i -> s.chords[centerRange.first + i] ?: 0.0 } val chordMean = chords.average() if (chordMean <= 0.1) { phase = Phase4.VERTICAL_CLIMB verticalHitStreak = 0 + verticalHitHistory.clear() + verticalStuckCount = 0 centerOptimizeStreak = 0 return Out(AlignAction.MOVE_UP, "↑ 재정렬 필요", phase, 0, - msgKey = R.string.align_move_up) + msgKey = R.string.align_move_up, relaxedMode = relaxedMode) } val variance = chords.map { (it - chordMean) * (it - chordMean) }.average() val chordStd = kotlin.math.sqrt(variance) @@ -350,33 +441,35 @@ class AlignGuide4Stage( if (cv <= AlignmentConstants.CV_THRESHOLD_STRICT) { centerOptimizeStreak++ if (centerOptimizeStreak >= AlignmentConstants.CENTER_OPT_CONFIRM_STREAK) { - bestCenterSet = (0..3).toSet() + bestCenterSet = centerRange.toSet() phase = Phase4.LR_BALANCE return Out(AlignAction.STOP, - "✓ 최적 vertical 확인 (CV=${"%.2f".format(cv)}) — 좌우 균형 확인", + "✓ 최적 vertical 확인 (CV=${"%.2f".format(cv)}${if (relaxedMode) " relaxed" else ""}) — 좌우 균형", phase, 0, msgKey = R.string.align_hold_position, - msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK)) + msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK), + relaxedMode = relaxedMode) } return Out(AlignAction.STOP, "✓ 최적 근접 (CV=${"%.2f".format(cv)}) — 확인 중 " + "(${centerOptimizeStreak}/${AlignmentConstants.CENTER_OPT_CONFIRM_STREAK})", phase, centerOptimizeStreak, msgKey = R.string.align_hold_position, - msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK)) + msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK), + relaxedMode = relaxedMode) } - // CV 너무 큼 → 방향 힌트 + // CV 너무 큼 → 방향 힌트 (relaxed 는 CH2 기준, normal 은 CH3 기준). centerOptimizeStreak = 0 - val ch0Chord = chords[0] - val ch3Chord = chords[3] - return if (ch3Chord > ch0Chord) { + val topChord = chords[0] // CH0 + val botChord = chords[chords.size - 1] // CH3 or CH2 + return if (botChord > topChord) { Out(AlignAction.MOVE_DOWN, - "↓ 조금 아래로 (CV=${"%.2f".format(cv)}, CH3 극대)", phase, 0, - msgKey = R.string.align_recover_down_short) + "↓ 조금 아래로 (CV=${"%.2f".format(cv)}, 하단 극대)", phase, 0, + msgKey = R.string.align_recover_down_short, relaxedMode = relaxedMode) } else { Out(AlignAction.MOVE_UP, "↑ 조금 위로 (CV=${"%.2f".format(cv)}, CH0 극대)", phase, 0, - msgKey = R.string.align_recover_up_short) + msgKey = R.string.align_recover_up_short, relaxedMode = relaxedMode) } } @@ -602,6 +695,7 @@ class RollingAligner( dets = dets, summary = summary, accumProgress = if (state == "accum") out.progress else effectiveTarget, accumTarget = effectiveTarget, + relaxedMode = out.relaxedMode, ) lastCommit = st return st diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ce2cbf9..f4f24e1 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -191,6 +191,8 @@ ← 왼쪽으로 살짝 → 오른쪽으로 살짝 ↔ 좌우로 천천히 움직여 보세요 + CH3 신호 확인 중 · 위치 유지 또는 미세 조정 + CH3 신호 미약 — 상단 3채널로 정렬 진행 앱 종료 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfa3977..b73fb40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -191,6 +191,8 @@ ← Slightly to the left → Slightly to the right ↔ Move slowly side to side + Verifying CH3 signal · hold or fine-tune + CH3 weak — aligning with upper 3 channels Exit App