fix(align): CH3 flicker 무한 대기 방지 — sliding window majority + relaxed mode

인체 데이터 (data123/) 분석 결과 CH3 (-13.66° down-tilt) 검출률이
Phantom 95.2% → Human 31.8% 로 급락하며 YYNNYY flick 패턴 관측.
기존 "3연속 hit" 진입 조건이 무한 대기에 빠져 사용자에게
"↑ 위로 조금씩" 문구만 반복 노출되는 문제를 해결.

- VERTICAL_CLIMB 진입: 3연속 → 최근 6프레임 중 3회 (majority)
- CH3_STABILIZE / CENTER_OPTIMIZE lost 판정: 4프레임 중 3회 (majority)
- Stuck detection (20프레임 ≈ 5초 대기 시): 상단 3채널 relaxed mode
  진입 → CH3 없이도 정렬 완료 진행
- Soft hint (12프레임 ≈ 3초 후): "CH3 확인 중 · 위치 유지/미세 조정"
  으로 문구 완화 (사용자 답답함 완화)
- AdvisorState.relaxedMode 플래그 추가 (후속 저장 로직 대비)

Python replay (VBT26050202_0CM 22프레임) 로 검증:
- LEGACY: VERTICAL 무한 대기 → 정렬 실패
- NEW: soft-hint → threshold 도달 시 relaxed → 3프레임 만에 LR_BAL 도달

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 17:45:14 +09:00
parent 45f5efc278
commit e808b5bc57
3 changed files with 154 additions and 56 deletions
@@ -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<Any> = emptyList(),
/** 2026-07-06: CH3 미약 → 상단 3채널 relaxed mode 진행 중. */
val relaxedMode: Boolean = false,
)
var phase: Phase4 = Phase4.INITIAL_ACCUM
private set
var bestCenterSet: Set<Int> = 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<Boolean>()
private val stabilizeLostHistory = ArrayDeque<Boolean>() // true = lost this frame
private val centerOptLostHistory = ArrayDeque<Boolean>() // true = lost this frame
private var verticalStuckCount = 0 // VERTICAL_CLIMB 체류 프레임 수
private fun pushHistory(dq: ArrayDeque<Boolean>, 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
+2
View File
@@ -191,6 +191,8 @@
<string name="align_left">← 왼쪽으로 살짝</string>
<string name="align_right">→ 오른쪽으로 살짝</string>
<string name="align_probe_lr">↔ 좌우로 천천히 움직여 보세요</string>
<string name="align_ch3_soft_hint">CH3 신호 확인 중 · 위치 유지 또는 미세 조정</string>
<string name="align_ch3_relaxed_mode">CH3 신호 미약 — 상단 3채널로 정렬 진행</string>
<!-- Exit Dialog -->
<string name="exit_app_title">앱 종료</string>
+2
View File
@@ -191,6 +191,8 @@
<string name="align_left">← Slightly to the left</string>
<string name="align_right">→ Slightly to the right</string>
<string name="align_probe_lr">↔ Move slowly side to side</string>
<string name="align_ch3_soft_hint">Verifying CH3 signal · hold or fine-tune</string>
<string name="align_ch3_relaxed_mode">CH3 weak — aligning with upper 3 channels</string>
<!-- Exit Dialog -->
<string name="exit_app_title">Exit App</string>