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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user