feat: V2 alignment (Method D + alignment.py) demo-final 에 이식
feature/tab-navigation 의 V2 코드를 demo-final 로 가져와 임상 alignment session
에서 V1/V2 둘 다 선택 가능하도록.
복사된 파일 (feature/tab-navigation 그대로):
walldetect/algo/SgSmoothGeneric.kt
walldetect/algo/methodd/MethodDParams.kt
walldetect/algo/methodd/MethodDPreprocessing.kt
walldetect/algo/methodd/MethodDSpan.kt
walldetect/algo/methodd/MethodDWallSelect.kt
walldetect/algo/methodd/MethodDTgc.kt
walldetect/MethodDDetector.kt
walldetect/MethodDRunner.kt
managers/AlignmentAdvisorV2.kt
(RollingAligner + AlignGuide4Stage + AlignmentAdvice — alignment.py 1:1)
확장:
walldetect/algo/Otsu.kt
+ otsu1dWithSeparability(values) — Python otsu_1d 의 (threshold, sep) tuple 호환
method_d span fallback gate 에서 사용
managers/GreenZoneConstants.kt
+ enum AlignmentAlgo { V1, V2 }
+ @Volatile var alignmentAlgo: AlignmentAlgo = V1 (default = legacy)
ui/views/monitoring/PlacementGuideView.kt
+ val v2Aligner = remember { RollingAligner() }
+ V2 분기 (alignmentAlgo == V2 시): raw 6ch → push → state(accum/commit)
→ directionHint/directionIcon/placementScore/LED 설정
→ bleManager.debugLogger.info("ALIGN_V2 phase=... state=... bestSet=...")
+ ⚙ Settings overlay 에 V1 (legacy) / V2 (new) 토글 (보라색)
+ addAlignmentFrame 호출 시 V2 활성이면 v2Aligner.phase.name 사용 (4-stage)
ui/views/clinical/ClinicalHomeView.kt
+ ClinicalSession.alignmentAlgo 를 현재 GreenZoneConstants.alignmentAlgo 값으로 채움
(이전 demo-final 은 "V1" 하드코딩)
→ V2 토글 후 Sensor Alignment 진입 시 alignment_algo="V2" 가 json 에 기록
영향:
- 기존 V1 동작 그대로 (default 가 V1, 토글 안 하면 동일)
- V2 선택 시 demo-final 에서도 4-stage 진행 가능 + json 라벨 V2 명시
- 알고리즘 토글은 dev 모드 (⚙ 버튼) 에서만 가능 — 일반 사용자 노출 X
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* Alignment Advisor V2 — port of vesiscan_test/alignment.py.
|
||||
*
|
||||
* 새 알고리즘 (한 팀장 / 대표님 회의 결정사항):
|
||||
* ─ method_d 기반 urine_len 으로 정렬 판단
|
||||
* ─ 2-phase: V (CH3 검출 → 머리방향) → L (CH4/CH5 urine_len 균형)
|
||||
* ─ |u4 − u5| ≤ LAT_TOL(8 samples ≈ 16mm) 이면 STOP
|
||||
* ─ ulen 큰 쪽으로 이동 (= 큰 쪽이 방광 중심)
|
||||
* ─ 둘 다 미검출 → 좌우 PROBE 모드 (hill-climbing 으로 최대점 추적)
|
||||
*
|
||||
* 입력은 raw 6채널 (각 길이 100, UShort/Int 어느 쪽이든 toDouble() 가능).
|
||||
* RollingAligner 가 N=accumK(default 10) 프레임 평균 후 한 번씩 commit.
|
||||
*
|
||||
* UI 측에서는 `push(raw6)` 호출 → AdvisorState.action 확인 → directionIcon/Hint
|
||||
* 매핑. 위치 이동 직후엔 `reset()` 으로 누적 버퍼 비우기 권장.
|
||||
*
|
||||
* 기존 PlacementGuide computePlacementGuide(...) 와 병행 — V1/V2 토글로 분기.
|
||||
*/
|
||||
package com.example.medilightv2android.managers
|
||||
|
||||
import com.example.medilightv2android.walldetect.MethodDResult
|
||||
import com.example.medilightv2android.walldetect.MethodDRunner
|
||||
import com.example.medilightv2android.walldetect.algo.methodd.MethodDParams
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
/** action 상수. MOVE_DOWN 은 4-stage guide 의 set 회복 (잃은 center channel 다시 찾기) 용. */
|
||||
enum class AlignAction { MOVE_UP, MOVE_DOWN, MOVE_LEFT, MOVE_RIGHT, PROBE_LR, STOP }
|
||||
|
||||
/** RollingAligner.push() 결과. */
|
||||
data class AdvisorState(
|
||||
val state: String, // 'accum' 또는 'commit'
|
||||
val action: AlignAction?,
|
||||
val msg: String,
|
||||
val dets: List<MethodDResult?>?,
|
||||
val summary: AlignSummary?,
|
||||
val accumProgress: Int = 0, // 'accum' 일 때 (현재/총)
|
||||
val accumTarget: Int = 0,
|
||||
)
|
||||
|
||||
data class AlignSummary(
|
||||
val ch3: Boolean,
|
||||
val u4: Int?,
|
||||
val u5: Int?,
|
||||
val chN: Int,
|
||||
/** Per-channel detection (size 6). center=[0,1,2,3], lateral=[4,5]. */
|
||||
val detected: BooleanArray = BooleanArray(6),
|
||||
)
|
||||
|
||||
object AlignmentConstants {
|
||||
const val CH3 = 3 // 치골쪽 최하단 center 채널
|
||||
const val LAT_TOL = 8 // 좌우 균형 허용 |u4-u5| (samples, ≈16mm)
|
||||
val CENTER_CH = intArrayOf(0, 1, 2, 3)
|
||||
val LATERAL_CH = intArrayOf(4, 5)
|
||||
}
|
||||
|
||||
/** stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). */
|
||||
object AlignmentAdvice {
|
||||
fun advise(ch3: Boolean, u4: Int?, u5: Int?, latTol: Int = AlignmentConstants.LAT_TOL): Pair<AlignAction, String> {
|
||||
if (!ch3) return Pair(AlignAction.MOVE_UP, "↑ 머리방향으로 이동 (ch3 미검출)")
|
||||
if (u4 != null && u5 != null) {
|
||||
val d = abs(u4 - u5)
|
||||
if (d <= latTol) return Pair(AlignAction.STOP, "■ 정렬 완료 — 정지 (|Δ|=$d)")
|
||||
return if (u4 < u5)
|
||||
Pair(AlignAction.MOVE_RIGHT, "→ 우측으로 이동 (ch4 $u4 < ch5 $u5, |Δ|=$d)")
|
||||
else
|
||||
Pair(AlignAction.MOVE_LEFT, "← 좌측으로 이동 (ch5 $u5 < ch4 $u4, |Δ|=$d)")
|
||||
}
|
||||
if (u4 == null && u5 == null)
|
||||
return Pair(AlignAction.PROBE_LR, "↔ 좌우로 천천히 이동해 lateral 탐색 (ch4·ch5 미검출)")
|
||||
return if (u4 == null) Pair(AlignAction.MOVE_RIGHT, "→ 우측으로 이동 (ch4 미검출)")
|
||||
else Pair(AlignAction.MOVE_LEFT, "← 좌측으로 이동 (ch5 미검출)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-phase stateful guide — Python `AlignGuide`.
|
||||
* (RollingAligner 는 단일 commit 마다 stateless advice 만 사용하지만, 향후
|
||||
* imbal hill-climbing 모드에서 쓸 수 있도록 같이 포팅.)
|
||||
*/
|
||||
class AlignGuide(private val latTol: Int = AlignmentConstants.LAT_TOL) {
|
||||
private enum class Phase { V, L }
|
||||
private var phase = Phase.V
|
||||
private var imbal: Int? = null
|
||||
private var u: Int? = null
|
||||
private var dir: AlignAction? = null
|
||||
private var triedBoth = false
|
||||
|
||||
fun reset() {
|
||||
phase = Phase.V
|
||||
imbal = null; u = null; dir = null; triedBoth = false
|
||||
}
|
||||
|
||||
fun step(summary: AlignSummary): Pair<AlignAction, String> {
|
||||
if (phase == Phase.V) {
|
||||
if (!summary.ch3) return Pair(AlignAction.MOVE_UP, "ch3 미검출 — 머리방향 이동")
|
||||
phase = Phase.L
|
||||
}
|
||||
return lateral(summary)
|
||||
}
|
||||
|
||||
private fun lateral(s: AlignSummary): Pair<AlignAction, String> {
|
||||
val u4 = s.u4
|
||||
val u5 = s.u5
|
||||
if (u4 != null && u5 != null) {
|
||||
val curImbal = abs(u4 - u5)
|
||||
if (curImbal <= latTol) return Pair(AlignAction.STOP, "좌우 균형 |Δulen|=$curImbal<=$latTol")
|
||||
if (dir == null) {
|
||||
dir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
|
||||
} else if (imbal != null && curImbal > imbal!!) {
|
||||
// 악화 → 반대로
|
||||
dir = if (dir == AlignAction.MOVE_LEFT) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
|
||||
}
|
||||
imbal = curImbal
|
||||
return Pair(dir!!, "불균형 |Δulen|=$curImbal 감소 방향")
|
||||
}
|
||||
// 미검출 (probe 모드)
|
||||
val cur = max(u4 ?: 0, u5 ?: 0)
|
||||
if (dir == null) {
|
||||
dir = AlignAction.MOVE_LEFT
|
||||
u = cur
|
||||
return Pair(AlignAction.MOVE_LEFT, "lateral 미검출 — 좌측 probe")
|
||||
}
|
||||
if (cur > (u ?: 0)) {
|
||||
u = cur
|
||||
return Pair(dir!!, "ulen 증가($cur) — ${dir!!.name} 계속")
|
||||
}
|
||||
if (!triedBoth && dir == AlignAction.MOVE_LEFT) {
|
||||
dir = AlignAction.MOVE_RIGHT
|
||||
triedBoth = true
|
||||
u = cur
|
||||
return Pair(AlignAction.MOVE_RIGHT, "좌측 미개선 — 우측 probe")
|
||||
}
|
||||
return Pair(AlignAction.STOP, "ulen 최대(${u ?: 0}) 위치 정지")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4-stage stateful guide — 사용자 설계 (2026-06-16).
|
||||
*
|
||||
* 단계:
|
||||
* 1. INITIAL_ACCUM — 첫 10 cycle warm-up, 안내 X (msg 만)
|
||||
* 2. VERTICAL_CLIMB — ch3 검출까지 ↑
|
||||
* 3. CH3_STABILIZE — ch3 검출 후 10 cycle 또 누적 (자세 확정)
|
||||
* 4. LR_BALANCE — current center set 유지하며 좌우 균형
|
||||
* set 잃으면 회복 (lost ch3 → ↑, else → ↓)
|
||||
*
|
||||
* Stage 4 의 bestCenterSet 은 ever-best — set 이 한 번 ch3+ch2+ch1 까지 갔다가
|
||||
* ch1 사라져도, 목표는 여전히 ch3+ch2+ch1. 회복 후 좌우 계속.
|
||||
*/
|
||||
class AlignGuide4Stage(
|
||||
private val latTol: Int = AlignmentConstants.LAT_TOL,
|
||||
private val initialTarget: Int = 10,
|
||||
private val stabilizeTarget: Int = 10,
|
||||
) {
|
||||
enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE }
|
||||
|
||||
data class Out(val action: AlignAction?, val msg: String, val phase: Phase4, val progress: Int = 0)
|
||||
|
||||
var phase: Phase4 = Phase4.INITIAL_ACCUM
|
||||
private set
|
||||
var bestCenterSet: Set<Int> = emptySet()
|
||||
private set
|
||||
private var initialCount = 0
|
||||
private var stabilizeCount = 0
|
||||
private var lateralDir: AlignAction? = null
|
||||
private var lateralImbal: Int? = null
|
||||
|
||||
fun reset() {
|
||||
phase = Phase4.INITIAL_ACCUM
|
||||
bestCenterSet = emptySet()
|
||||
initialCount = 0
|
||||
stabilizeCount = 0
|
||||
lateralDir = null
|
||||
lateralImbal = null
|
||||
}
|
||||
|
||||
fun step(s: AlignSummary): Out {
|
||||
val curSet = (0..3).filter { it < s.detected.size && s.detected[it] }.toSet()
|
||||
|
||||
// === Phase 1: INITIAL_ACCUM ===
|
||||
if (phase == Phase4.INITIAL_ACCUM) {
|
||||
initialCount++
|
||||
if (initialCount < initialTarget) {
|
||||
return Out(null, "측정 준비 중... (${initialCount}/${initialTarget})",
|
||||
phase, initialCount)
|
||||
}
|
||||
phase = Phase4.VERTICAL_CLIMB
|
||||
}
|
||||
|
||||
// === Phase 2: VERTICAL_CLIMB ===
|
||||
if (phase == Phase4.VERTICAL_CLIMB) {
|
||||
if (!s.ch3) {
|
||||
return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요 (ch3 미검출)",
|
||||
phase, 0)
|
||||
}
|
||||
phase = Phase4.CH3_STABILIZE
|
||||
stabilizeCount = 0
|
||||
bestCenterSet = curSet
|
||||
}
|
||||
|
||||
// === Phase 3: CH3_STABILIZE ===
|
||||
if (phase == Phase4.CH3_STABILIZE) {
|
||||
stabilizeCount++
|
||||
if (curSet.size > bestCenterSet.size) bestCenterSet = curSet
|
||||
if (!s.ch3) {
|
||||
// ch3 잃었으면 → VERTICAL_CLIMB 회귀
|
||||
phase = Phase4.VERTICAL_CLIMB
|
||||
stabilizeCount = 0
|
||||
return Out(AlignAction.MOVE_UP, "↑ ch3 다시 잃음 — 위로",
|
||||
phase, 0)
|
||||
}
|
||||
if (stabilizeCount < stabilizeTarget) {
|
||||
return Out(AlignAction.STOP,
|
||||
"✓ ch3 검출! 위치 유지 (${stabilizeCount}/${stabilizeTarget})",
|
||||
phase, stabilizeCount)
|
||||
}
|
||||
phase = Phase4.LR_BALANCE
|
||||
}
|
||||
|
||||
// === Phase 4: LR_BALANCE ===
|
||||
if (curSet.size > bestCenterSet.size) bestCenterSet = curSet
|
||||
val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" }
|
||||
val lost = bestCenterSet - curSet
|
||||
if (lost.isNotEmpty()) {
|
||||
// 잃은 채널 있음 → 회복 우선. ch3 잃었으면 더 위로, 아니면 아래로.
|
||||
val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN
|
||||
val lostLabel = lost.sortedDescending().joinToString(",") { "ch$it" }
|
||||
val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓"
|
||||
return Out(recoveryDir, "$arrow $lostLabel 잃음 — 위치 회복", phase, 0)
|
||||
}
|
||||
|
||||
// full set 유지 중 — 좌우 균형
|
||||
val u4 = s.u4
|
||||
val u5 = s.u5
|
||||
if (u4 != null && u5 != null) {
|
||||
val imbal = kotlin.math.abs(u4 - u5)
|
||||
if (imbal <= latTol) {
|
||||
return Out(AlignAction.STOP,
|
||||
"■ 정렬 완료 ($setLabel 유지, |Δ|=$imbal)", phase, 0)
|
||||
}
|
||||
if (lateralDir == null) {
|
||||
lateralDir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
|
||||
} else if (lateralImbal != null && imbal > lateralImbal!!) {
|
||||
lateralDir = if (lateralDir == AlignAction.MOVE_LEFT) AlignAction.MOVE_RIGHT
|
||||
else AlignAction.MOVE_LEFT
|
||||
}
|
||||
lateralImbal = imbal
|
||||
val arrow = if (lateralDir == AlignAction.MOVE_LEFT) "←" else "→"
|
||||
return Out(lateralDir!!, "$arrow $setLabel 유지 + 좌우 균형 (|Δ|=$imbal)",
|
||||
phase, 0)
|
||||
}
|
||||
// u4/u5 미검출 → probe
|
||||
return Out(AlignAction.PROBE_LR, "↔ $setLabel 유지 + 좌우 천천히 (lateral 미검출)",
|
||||
phase, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* k-프레임 누적 + commit 한 번 — Python `RollingAligner`.
|
||||
*
|
||||
* push(raw6) 호출 시:
|
||||
* - buf.size < accumK 인 동안: state='accum'
|
||||
* - 충분히 모이면: 평균 → method_d → summarize → advice → state='commit'
|
||||
*
|
||||
* UI 는 commit 일 때만 화살표 갱신, accum 일 때는 "측정 중… N/K" 표시.
|
||||
*/
|
||||
class RollingAligner(
|
||||
private val latTol: Int = AlignmentConstants.LAT_TOL,
|
||||
private val accumK: Int = 10,
|
||||
private val methodDParams: MethodDParams = MethodDParams.DEFAULT,
|
||||
) {
|
||||
private val buf = ArrayDeque<List<DoubleArray>>()
|
||||
private val guide4 = AlignGuide4Stage(latTol = latTol,
|
||||
initialTarget = accumK, stabilizeTarget = accumK)
|
||||
/** 마지막 commit 결과. 외부 query 용. */
|
||||
var lastCommit: AdvisorState? = null
|
||||
private set
|
||||
/** 현재 4-stage phase — UI step indicator 용. */
|
||||
val phase: AlignGuide4Stage.Phase4 get() = guide4.phase
|
||||
/** 지금까지 검출된 최대 center channel set — Phase 4 의 lock 대상. */
|
||||
val bestCenterSet: Set<Int> get() = guide4.bestCenterSet
|
||||
|
||||
fun reset() {
|
||||
buf.clear()
|
||||
guide4.reset()
|
||||
}
|
||||
|
||||
/** 단일 채널 길이 100 가정. raw6.size == 6 이어야 함. */
|
||||
fun push(raw6: List<DoubleArray>): AdvisorState {
|
||||
// ring buffer — accumK 사이즈 sliding window. buf 가 덜 찼어도 항상 step 호출
|
||||
// (점진 평균 + 4-stage 가 INITIAL_ACCUM 으로 처리).
|
||||
buf.addLast(raw6)
|
||||
while (buf.size > accumK) buf.removeFirst()
|
||||
|
||||
val nCh = raw6.size
|
||||
val avg = List(nCh) { ch ->
|
||||
val len = buf.first()[ch].size
|
||||
DoubleArray(len) { i ->
|
||||
var sum = 0.0
|
||||
for (frame in buf) sum += frame[ch][i]
|
||||
sum / buf.size
|
||||
}
|
||||
}
|
||||
val dets = MethodDRunner.detectMultichannel(avg, methodDParams)
|
||||
val summary = summarize(dets)
|
||||
val out = guide4.step(summary)
|
||||
val state = if (out.phase == AlignGuide4Stage.Phase4.INITIAL_ACCUM) "accum" else "commit"
|
||||
val st = AdvisorState(
|
||||
state = state, action = out.action, msg = out.msg,
|
||||
dets = dets, summary = summary,
|
||||
accumProgress = if (state == "accum") out.progress else accumK,
|
||||
accumTarget = accumK,
|
||||
)
|
||||
lastCommit = st
|
||||
return st
|
||||
}
|
||||
|
||||
private fun summarize(dets: List<MethodDResult?>): AlignSummary {
|
||||
fun u(i: Int) = if (i < dets.size) dets[i]?.urineLen else null
|
||||
val ch3 = dets.size > AlignmentConstants.CH3 && dets[AlignmentConstants.CH3] != null
|
||||
val chN = AlignmentConstants.CENTER_CH.count { it < dets.size && dets[it] != null }
|
||||
val detected = BooleanArray(6) { i -> i < dets.size && dets[i] != null }
|
||||
return AlignSummary(ch3 = ch3, u4 = u(4), u5 = u(5), chN = chN, detected = detected)
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,17 @@ enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C }
|
||||
enum class PlacementGuideMode { SIMPLE, BOUNDARY, SWEEP }
|
||||
enum class BvMethod { FRUSTUM, V41 }
|
||||
|
||||
/** Sensor alignment 알고리즘 — V1=기존 (computePlacementGuide), V2=신규 (alignment.py 포팅). */
|
||||
enum class AlignmentAlgo { V1, V2 }
|
||||
|
||||
object GreenZoneConstants {
|
||||
|
||||
@Volatile var postMaxIdx: Int = 80 // 후벽 탐색 최대 sample index (이 이상 peak 무시)
|
||||
@Volatile var postMaxIdx: Int = 80
|
||||
|
||||
@Volatile var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C
|
||||
@Volatile var bvMethod: BvMethod = BvMethod.V41
|
||||
@Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
|
||||
@Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V1 // dev 토글 — default V1 (legacy)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 신호 범위
|
||||
|
||||
Reference in New Issue
Block a user