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 summary: AlignSummary?,
|
||||||
val accumProgress: Int = 0, // 'accum' 일 때 (현재/총)
|
val accumProgress: Int = 0, // 'accum' 일 때 (현재/총)
|
||||||
val accumTarget: Int = 0,
|
val accumTarget: Int = 0,
|
||||||
|
/** 2026-07-06: CH3 signal 미약으로 상단 3채널 (CH0~CH2) 만으로 정렬 진행 중. */
|
||||||
|
val relaxedMode: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AlignSummary(
|
data class AlignSummary(
|
||||||
@@ -76,6 +78,22 @@ object AlignmentConstants {
|
|||||||
const val CV_THRESHOLD_LOOSE = 0.20
|
const val CV_THRESHOLD_LOOSE = 0.20
|
||||||
/** CENTER_OPTIMIZE 통과 요구 연속 commit 수 (hysteresis). */
|
/** CENTER_OPTIMIZE 통과 요구 연속 commit 수 (hysteresis). */
|
||||||
const val CENTER_OPT_CONFIRM_STREAK = 3
|
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(). */
|
/** stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). */
|
||||||
@@ -207,12 +225,17 @@ class AlignGuide4Stage(
|
|||||||
val msgKey: Int? = null,
|
val msgKey: Int? = null,
|
||||||
/** Args for msgKey formatting. */
|
/** Args for msgKey formatting. */
|
||||||
val msgArgs: List<Any> = emptyList(),
|
val msgArgs: List<Any> = emptyList(),
|
||||||
|
/** 2026-07-06: CH3 미약 → 상단 3채널 relaxed mode 진행 중. */
|
||||||
|
val relaxedMode: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
var phase: Phase4 = Phase4.INITIAL_ACCUM
|
var phase: Phase4 = Phase4.INITIAL_ACCUM
|
||||||
private set
|
private set
|
||||||
var bestCenterSet: Set<Int> = emptySet()
|
var bestCenterSet: Set<Int> = emptySet()
|
||||||
private set
|
private set
|
||||||
|
/** 2026-07-06: CH3 신호 미약으로 CH0~CH2 로만 정렬 진행하는 완화 모드. */
|
||||||
|
var relaxedMode: Boolean = false
|
||||||
|
private set
|
||||||
private var initialCount = 0
|
private var initialCount = 0
|
||||||
private var verticalHitStreak = 0
|
private var verticalHitStreak = 0
|
||||||
private var stabilizeCount = 0
|
private var stabilizeCount = 0
|
||||||
@@ -223,9 +246,22 @@ class AlignGuide4Stage(
|
|||||||
private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis
|
private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis
|
||||||
private var centerOptimizeStreak = 0 // CENTER_OPTIMIZE CV 통과 연속 카운터
|
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() {
|
fun reset() {
|
||||||
phase = Phase4.INITIAL_ACCUM
|
phase = Phase4.INITIAL_ACCUM
|
||||||
bestCenterSet = emptySet()
|
bestCenterSet = emptySet()
|
||||||
|
relaxedMode = false
|
||||||
initialCount = 0
|
initialCount = 0
|
||||||
verticalHitStreak = 0
|
verticalHitStreak = 0
|
||||||
stabilizeCount = 0
|
stabilizeCount = 0
|
||||||
@@ -235,6 +271,10 @@ class AlignGuide4Stage(
|
|||||||
finalConfirmCount = 0
|
finalConfirmCount = 0
|
||||||
lostStreak = 0
|
lostStreak = 0
|
||||||
centerOptimizeStreak = 0
|
centerOptimizeStreak = 0
|
||||||
|
verticalHitHistory.clear()
|
||||||
|
stabilizeLostHistory.clear()
|
||||||
|
centerOptLostHistory.clear()
|
||||||
|
verticalStuckCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun step(s: AlignSummary): Out {
|
fun step(s: AlignSummary): Out {
|
||||||
@@ -253,43 +293,74 @@ class AlignGuide4Stage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Phase 2: VERTICAL_CLIMB ===
|
// === 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 (phase == Phase4.VERTICAL_CLIMB) {
|
||||||
if (!s.ch3) {
|
pushHistory(verticalHitHistory, s.ch3, AlignmentConstants.VERTICAL_HIT_WINDOW)
|
||||||
verticalHitStreak = 0 // 연속 카운터 리셋 — false detection 거부
|
verticalStuckCount++
|
||||||
return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요 (ch3 미검출)",
|
val hitCount = verticalHitHistory.count { it }
|
||||||
phase, 0,
|
|
||||||
msgKey = R.string.align_move_up)
|
// Stuck detection → Relaxed mode 진입 조건:
|
||||||
}
|
// (a) VERTICAL_CLIMB 체류 ≥ 20 프레임
|
||||||
verticalHitStreak++
|
// (b) 최근 window 내 CH3 hit < majority (신호가 여전히 미약)
|
||||||
if (verticalHitStreak < verticalHitRequired) {
|
// (c) CH0+CH1+CH2 상단 3채널이 detected
|
||||||
// ch3 일시 검출 — verticalHitRequired 회 연속 확인 전까지 사용자 정지.
|
if (!relaxedMode
|
||||||
// (현재 위치가 진짜 ch3 인지 평균이 만든 false echo 인지 확인 중)
|
&& 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,
|
return Out(AlignAction.STOP,
|
||||||
"✓ ch3 일시 검출 — 확인 중 (${verticalHitStreak}/${verticalHitRequired})",
|
"⚠ CH3 미약 — 상단 3채널 relaxed 정렬 진입",
|
||||||
phase, verticalHitStreak,
|
phase, 0,
|
||||||
msgKey = R.string.align_hold_position,
|
msgKey = R.string.align_ch3_relaxed_mode,
|
||||||
msgArgs = listOf(verticalHitStreak, verticalHitRequired))
|
relaxedMode = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hitCount >= AlignmentConstants.VERTICAL_HIT_MAJORITY) {
|
||||||
phase = Phase4.CH3_STABILIZE
|
phase = Phase4.CH3_STABILIZE
|
||||||
stabilizeCount = 0
|
stabilizeCount = 0
|
||||||
bestCenterSet = curSet
|
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 3: CH3_STABILIZE ===
|
// === 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) {
|
if (phase == Phase4.CH3_STABILIZE) {
|
||||||
stabilizeCount++
|
stabilizeCount++
|
||||||
if (curSet.size > bestCenterSet.size) bestCenterSet = curSet
|
if (curSet.size > bestCenterSet.size) bestCenterSet = curSet
|
||||||
if (!s.ch3) {
|
pushHistory(stabilizeLostHistory, !s.ch3, AlignmentConstants.STABILIZE_LOST_WINDOW)
|
||||||
// ch3 잃었으면 → VERTICAL_CLIMB 회귀
|
val lostCount = stabilizeLostHistory.count { it }
|
||||||
|
if (lostCount >= AlignmentConstants.STABILIZE_LOST_MAJORITY) {
|
||||||
|
// 진짜 lost — VERTICAL_CLIMB 회귀 (기존 로직 유지)
|
||||||
phase = Phase4.VERTICAL_CLIMB
|
phase = Phase4.VERTICAL_CLIMB
|
||||||
stabilizeCount = 0
|
stabilizeCount = 0
|
||||||
return Out(AlignAction.MOVE_UP, "↑ ch3 다시 잃음 — 위로",
|
stabilizeLostHistory.clear()
|
||||||
|
verticalHitHistory.clear()
|
||||||
|
verticalStuckCount = 0
|
||||||
|
return Out(AlignAction.MOVE_UP, "↑ ch3 진짜 놓침 — 다시 위로",
|
||||||
phase, 0,
|
phase, 0,
|
||||||
msgKey = R.string.align_recover_up)
|
msgKey = R.string.align_recover_up)
|
||||||
}
|
}
|
||||||
if (stabilizeCount < stabilizeTarget) {
|
if (stabilizeCount < stabilizeTarget) {
|
||||||
return Out(AlignAction.STOP,
|
return Out(AlignAction.STOP,
|
||||||
"✓ ch3 검출! 위치 유지 (${stabilizeCount}/${stabilizeTarget})",
|
"✓ ch3 검출 — 위치 유지 (${stabilizeCount}/${stabilizeTarget}, flick ${lostCount}/${AlignmentConstants.STABILIZE_LOST_WINDOW})",
|
||||||
phase, stabilizeCount,
|
phase, stabilizeCount,
|
||||||
msgKey = R.string.align_hold_position,
|
msgKey = R.string.align_hold_position,
|
||||||
msgArgs = listOf(stabilizeCount, stabilizeTarget))
|
msgArgs = listOf(stabilizeCount, stabilizeTarget))
|
||||||
@@ -297,51 +368,71 @@ class AlignGuide4Stage(
|
|||||||
// 2026-07-02: CH3_STABILIZE 통과 → CENTER_OPTIMIZE (CV 로 4채널 균등 확인)
|
// 2026-07-02: CH3_STABILIZE 통과 → CENTER_OPTIMIZE (CV 로 4채널 균등 확인)
|
||||||
phase = Phase4.CENTER_OPTIMIZE
|
phase = Phase4.CENTER_OPTIMIZE
|
||||||
centerOptimizeStreak = 0
|
centerOptimizeStreak = 0
|
||||||
|
stabilizeLostHistory.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Phase 4a: CENTER_OPTIMIZE (신규) — CH0~CH3 chord CV 로 최적 vertical 정밀 조정 ===
|
// === Phase 4a: CENTER_OPTIMIZE — CH chord CV 로 최적 vertical 정밀 조정 ===
|
||||||
// 전제: CH3 는 이미 안정 검출됨. 4채널 chord CV 가 낮으면 (0.15 이하) 정렬 완료로 인정.
|
// Normal mode: CH0~CH3 4채널 CV. CV ≤ 0.15 이면 정렬 완료로 인정.
|
||||||
// CV 높으면: chord[3] > chord[0] 이면 probe 가 high → MOVE_DOWN,
|
// CV 높으면 chord[3] > chord[0] → MOVE_DOWN, chord[0] > chord[3] → MOVE_UP.
|
||||||
// chord[0] > chord[3] 이면 probe 가 low → 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) {
|
if (phase == Phase4.CENTER_OPTIMIZE) {
|
||||||
val center4Detected = (0..3).all { it < s.detected.size && s.detected[it] }
|
// Relaxed 모드가 아니면 CH3 flicker 검사.
|
||||||
if (!s.ch3) {
|
if (!relaxedMode) {
|
||||||
// 2026-07-06 fix: CH3 를 CENTER_OPTIMIZE 진입 후 잃음 = probe 가 위로 올라가
|
pushHistory(centerOptLostHistory, !s.ch3, AlignmentConstants.STABILIZE_LOST_WINDOW)
|
||||||
// CH3 beam 이 방광 inferior edge 를 넘어감 → 방광이 probe 아래로 이동.
|
val lostCount = centerOptLostHistory.count { it }
|
||||||
// → MOVE_DOWN 으로 CH3 회복. 이전 MOVE_UP 은 잃음 원인과 반대라 무한 상승.
|
if (lostCount >= AlignmentConstants.STABILIZE_LOST_MAJORITY) {
|
||||||
|
// 진짜 lost — CH3_STABILIZE 회귀. 원인: probe 가 위로 올라가 CH3 beam 이 방광
|
||||||
|
// inferior edge 를 넘어감. → 사용자에게 MOVE_DOWN 안내.
|
||||||
phase = Phase4.CH3_STABILIZE
|
phase = Phase4.CH3_STABILIZE
|
||||||
stabilizeCount = 0
|
stabilizeCount = 0
|
||||||
centerOptimizeStreak = 0
|
centerOptimizeStreak = 0
|
||||||
|
centerOptLostHistory.clear()
|
||||||
return Out(AlignAction.MOVE_DOWN, "↓ ch3 재확인 필요",
|
return Out(AlignAction.MOVE_DOWN, "↓ ch3 재확인 필요",
|
||||||
phase, 0, msgKey = R.string.align_recover_down_short)
|
phase, 0, msgKey = R.string.align_recover_down_short)
|
||||||
}
|
}
|
||||||
if (!center4Detected) {
|
if (!s.ch3) {
|
||||||
// 2026-07-06 fix: 미검출 채널 위치로 probe 위치 판단.
|
// 단발 flick — 위치 유지. streak 은 리셋해서 CV 통과 재검증 필요.
|
||||||
// CH0 (+6.89° 위로 tilt, 상단 channel) 만 놓치고 CH3 검출 = probe 가 방광
|
|
||||||
// dome 위에 있어 CH0 beam 이 위로 빠짐 → MOVE_DOWN 으로 방광 중심 재정렬.
|
|
||||||
// CH1/CH2 만 놓치고 CH0+CH3 검출 = 드물지만 부분적 dropout, default MOVE_UP.
|
|
||||||
// 상단 (CH0 or CH0+CH1) 만 놓침 = probe too high → MOVE_DOWN.
|
|
||||||
centerOptimizeStreak = 0
|
centerOptimizeStreak = 0
|
||||||
val missing = (0..3).filter { !s.detected[it] }
|
return Out(AlignAction.STOP,
|
||||||
|
"· CH3 확인 중 · 위치 유지 (flick ${lostCount}/${AlignmentConstants.STABILIZE_LOST_WINDOW})",
|
||||||
|
phase, 0, msgKey = R.string.align_ch3_soft_hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검출 채널 범위 결정 — 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 으로 방광 중심 재정렬.
|
||||||
|
// 상단 (CH0 or CH0+CH1) 만 놓침 = probe too high → MOVE_DOWN.
|
||||||
|
// 그 외 (CH1 이하만 미검출) = default MOVE_UP.
|
||||||
|
centerOptimizeStreak = 0
|
||||||
|
val missing = centerRange.filter { !s.detected[it] }
|
||||||
val missingLabel = missing.joinToString(",") { "ch$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)
|
val (direction, arrow, msgKey) = if (topOnly)
|
||||||
Triple(AlignAction.MOVE_DOWN, "↓", R.string.align_recover_down_short)
|
Triple(AlignAction.MOVE_DOWN, "↓", R.string.align_recover_down_short)
|
||||||
else
|
else
|
||||||
Triple(AlignAction.MOVE_UP, "↑", R.string.align_move_up)
|
Triple(AlignAction.MOVE_UP, "↑", R.string.align_move_up)
|
||||||
return Out(direction,
|
return Out(direction,
|
||||||
"$arrow 조금 ${if (topOnly) "아래" else "더 위"}로 ($missingLabel 미검출)",
|
"$arrow 조금 ${if (topOnly) "아래" else "더 위"}로 ($missingLabel 미검출)",
|
||||||
phase, 0, msgKey = msgKey)
|
phase, 0, msgKey = msgKey, relaxedMode = relaxedMode)
|
||||||
}
|
}
|
||||||
// 4채널 모두 검출됨 → chord CV 계산 (summarize 에서 angle-corrected mm 값 저장됨)
|
// 모두 검출됨 → chord CV 계산.
|
||||||
val chords = DoubleArray(4) { ch -> s.chords[ch] ?: 0.0 }
|
val chordN = centerRange.count()
|
||||||
|
val chords = DoubleArray(chordN) { i -> s.chords[centerRange.first + i] ?: 0.0 }
|
||||||
val chordMean = chords.average()
|
val chordMean = chords.average()
|
||||||
if (chordMean <= 0.1) {
|
if (chordMean <= 0.1) {
|
||||||
phase = Phase4.VERTICAL_CLIMB
|
phase = Phase4.VERTICAL_CLIMB
|
||||||
verticalHitStreak = 0
|
verticalHitStreak = 0
|
||||||
|
verticalHitHistory.clear()
|
||||||
|
verticalStuckCount = 0
|
||||||
centerOptimizeStreak = 0
|
centerOptimizeStreak = 0
|
||||||
return Out(AlignAction.MOVE_UP, "↑ 재정렬 필요", phase, 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 variance = chords.map { (it - chordMean) * (it - chordMean) }.average()
|
||||||
val chordStd = kotlin.math.sqrt(variance)
|
val chordStd = kotlin.math.sqrt(variance)
|
||||||
@@ -350,33 +441,35 @@ class AlignGuide4Stage(
|
|||||||
if (cv <= AlignmentConstants.CV_THRESHOLD_STRICT) {
|
if (cv <= AlignmentConstants.CV_THRESHOLD_STRICT) {
|
||||||
centerOptimizeStreak++
|
centerOptimizeStreak++
|
||||||
if (centerOptimizeStreak >= AlignmentConstants.CENTER_OPT_CONFIRM_STREAK) {
|
if (centerOptimizeStreak >= AlignmentConstants.CENTER_OPT_CONFIRM_STREAK) {
|
||||||
bestCenterSet = (0..3).toSet()
|
bestCenterSet = centerRange.toSet()
|
||||||
phase = Phase4.LR_BALANCE
|
phase = Phase4.LR_BALANCE
|
||||||
return Out(AlignAction.STOP,
|
return Out(AlignAction.STOP,
|
||||||
"✓ 최적 vertical 확인 (CV=${"%.2f".format(cv)}) — 좌우 균형 확인",
|
"✓ 최적 vertical 확인 (CV=${"%.2f".format(cv)}${if (relaxedMode) " relaxed" else ""}) — 좌우 균형",
|
||||||
phase, 0,
|
phase, 0,
|
||||||
msgKey = R.string.align_hold_position,
|
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,
|
return Out(AlignAction.STOP,
|
||||||
"✓ 최적 근접 (CV=${"%.2f".format(cv)}) — 확인 중 " +
|
"✓ 최적 근접 (CV=${"%.2f".format(cv)}) — 확인 중 " +
|
||||||
"(${centerOptimizeStreak}/${AlignmentConstants.CENTER_OPT_CONFIRM_STREAK})",
|
"(${centerOptimizeStreak}/${AlignmentConstants.CENTER_OPT_CONFIRM_STREAK})",
|
||||||
phase, centerOptimizeStreak,
|
phase, centerOptimizeStreak,
|
||||||
msgKey = R.string.align_hold_position,
|
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
|
centerOptimizeStreak = 0
|
||||||
val ch0Chord = chords[0]
|
val topChord = chords[0] // CH0
|
||||||
val ch3Chord = chords[3]
|
val botChord = chords[chords.size - 1] // CH3 or CH2
|
||||||
return if (ch3Chord > ch0Chord) {
|
return if (botChord > topChord) {
|
||||||
Out(AlignAction.MOVE_DOWN,
|
Out(AlignAction.MOVE_DOWN,
|
||||||
"↓ 조금 아래로 (CV=${"%.2f".format(cv)}, CH3 극대)", phase, 0,
|
"↓ 조금 아래로 (CV=${"%.2f".format(cv)}, 하단 극대)", phase, 0,
|
||||||
msgKey = R.string.align_recover_down_short)
|
msgKey = R.string.align_recover_down_short, relaxedMode = relaxedMode)
|
||||||
} else {
|
} else {
|
||||||
Out(AlignAction.MOVE_UP,
|
Out(AlignAction.MOVE_UP,
|
||||||
"↑ 조금 위로 (CV=${"%.2f".format(cv)}, CH0 극대)", phase, 0,
|
"↑ 조금 위로 (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,
|
dets = dets, summary = summary,
|
||||||
accumProgress = if (state == "accum") out.progress else effectiveTarget,
|
accumProgress = if (state == "accum") out.progress else effectiveTarget,
|
||||||
accumTarget = effectiveTarget,
|
accumTarget = effectiveTarget,
|
||||||
|
relaxedMode = out.relaxedMode,
|
||||||
)
|
)
|
||||||
lastCommit = st
|
lastCommit = st
|
||||||
return st
|
return st
|
||||||
|
|||||||
@@ -191,6 +191,8 @@
|
|||||||
<string name="align_left">← 왼쪽으로 살짝</string>
|
<string name="align_left">← 왼쪽으로 살짝</string>
|
||||||
<string name="align_right">→ 오른쪽으로 살짝</string>
|
<string name="align_right">→ 오른쪽으로 살짝</string>
|
||||||
<string name="align_probe_lr">↔ 좌우로 천천히 움직여 보세요</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 -->
|
<!-- Exit Dialog -->
|
||||||
<string name="exit_app_title">앱 종료</string>
|
<string name="exit_app_title">앱 종료</string>
|
||||||
|
|||||||
@@ -191,6 +191,8 @@
|
|||||||
<string name="align_left">← Slightly to the left</string>
|
<string name="align_left">← Slightly to the left</string>
|
||||||
<string name="align_right">→ Slightly to the right</string>
|
<string name="align_right">→ Slightly to the right</string>
|
||||||
<string name="align_probe_lr">↔ Move slowly side to side</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 -->
|
<!-- Exit Dialog -->
|
||||||
<string name="exit_app_title">Exit App</string>
|
<string name="exit_app_title">Exit App</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user