diff --git a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt new file mode 100644 index 0000000..29acd14 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt @@ -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?, + 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 { + 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 { + 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 { + 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 = 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>() + 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 get() = guide4.bestCenterSet + + fun reset() { + buf.clear() + guide4.reset() + } + + /** 단일 채널 길이 100 가정. raw6.size == 6 이어야 함. */ + fun push(raw6: List): 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): 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) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index 0b8f475..c500c26 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -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) // ═══════════════════════════════════════════════════════════ // 신호 범위 diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt index ab42788..bc9a08b 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt @@ -518,7 +518,8 @@ fun ClinicalHomeView(appState: AppState) { trueVolumeMl = trueVolText.toDoubleOrNull(), abdomenThicknessMm = abdomenText.toDoubleOrNull(), mode = com.example.medilightv2android.models.SessionMode.ALIGNMENT, - alignmentAlgo = "V1", // demo-final: V1 전용 + // 현재 dev 토글된 algo 그대로 — V1 (default) 또는 V2. + alignmentAlgo = com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo.name, ) ) appState.enterAlignmentSession() diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 9a6784f..771c578 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -98,6 +98,8 @@ fun PlacementGuideView(appState: AppState) { var debounceCount by remember { mutableIntStateOf(0) } var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } var lastHintChangeMs by remember { mutableStateOf(0L) } + // V2 alignment (alignment.py 포팅) — dev 토글로 활성화. + val v2Aligner = remember { com.example.medilightv2android.managers.RollingAligner() } // SWEEP 모드용 sphere-fit state val rTracker = remember { com.example.medilightv2android.managers.BladderSeekRTracker() } @@ -700,6 +702,44 @@ fun PlacementGuideView(appState: AppState) { bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") Log.w("PlacementGuide", "DETACHED mean=${ "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") + } else if (com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo == + com.example.medilightv2android.managers.AlignmentAlgo.V2) { + // ═══ V2 alignment (alignment.py 포팅, method_d 기반) ═══ + val raw6 = (0..5).map { ch -> + val chData = channels.find { it.channel == ch } + if (chData != null && chData.buffer.isNotEmpty()) + DoubleArray(chData.buffer.size) { i -> chData.buffer[i].toInt().toDouble() } + else DoubleArray(100) + } + val st = v2Aligner.push(raw6) + bleManager.debugLogger.info("ALIGN_V2 phase=${v2Aligner.phase} state=${st.state} action=${st.action} u4=${st.summary?.u4} u5=${st.summary?.u5} ch3=${st.summary?.ch3} bestSet=${v2Aligner.bestCenterSet}") + if (st.state == "accum") { + directionHint = st.msg + directionIcon = "" + placementScore = 5 + } else { + directionHint = st.msg + directionIcon = when (st.action) { + com.example.medilightv2android.managers.AlignAction.MOVE_UP -> "arrow.up" + com.example.medilightv2android.managers.AlignAction.MOVE_DOWN -> "arrow.down" + com.example.medilightv2android.managers.AlignAction.MOVE_LEFT -> "arrow.left" + com.example.medilightv2android.managers.AlignAction.MOVE_RIGHT -> "arrow.right" + com.example.medilightv2android.managers.AlignAction.PROBE_LR -> "arrow.left" + com.example.medilightv2android.managers.AlignAction.STOP -> "checkmark.circle" + null -> "" + } + placementScore = if (st.action == + com.example.medilightv2android.managers.AlignAction.STOP) 100 else 40 + if (st.action == com.example.medilightv2android.managers.AlignAction.STOP) { + if (!isLocked) greenLockedAtMs = System.currentTimeMillis() + isLocked = true + placementPhase = PlacementPhase.GREEN + if (lastLedMode != 6) { bleManager.sendLedMode(6); lastLedMode = 6 } + } else { + isLocked = false + if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 } + } + } } else { // Sphere-fit 계산 (SWEEP 모드에서 사용, 다른 모드에서는 무시됨) val fitResult = com.example.medilightv2android.managers.BladderSphereSeek @@ -850,12 +890,15 @@ fun PlacementGuideView(appState: AppState) { if (clinSess != null) { val rawADC = channels.sortedBy { it.channel }.map { it.buffer } com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null) - // Alignment session 진행 중이면 measurement.json 용 frame 누적 (V1 advisor 출력). + // Alignment session 진행 중이면 measurement.json 용 frame 누적. if (clinSess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT) { + val isV2 = com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo == + com.example.medilightv2android.managers.AlignmentAlgo.V2 com.example.medilightv2android.services.ClinicalSessionStore.addAlignmentFrame( piezo = channels, imu = lastImuSamples, - alignPhase = placementPhase.name, + // V2 활성 시 4-stage phase 이름 (INITIAL_ACCUM 등), 그 외 V1 placementPhase + alignPhase = if (isV2) v2Aligner.phase.name else placementPhase.name, alignScore = placementScore, alignDetected = (0 until 6).map { channelDetected[it] }, alignUrineLens = (0 until 6).map { channelUrineLen[it] }, @@ -1082,6 +1125,44 @@ fun PlacementGuideView(appState: AppState) { HorizontalDivider() + // Alignment 알고리즘 V1/V2 + var selectedAlgo by remember { + mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo) + } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Alignment", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.weight(1f)) + val algoModes = listOf( + com.example.medilightv2android.managers.AlignmentAlgo.V1 to "V1 (legacy)", + com.example.medilightv2android.managers.AlignmentAlgo.V2 to "V2 (new)", + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + algoModes.forEach { (algo, label) -> + val sel = selectedAlgo == algo + Button( + onClick = { + selectedAlgo = algo + com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo = algo + if (algo == com.example.medilightv2android.managers.AlignmentAlgo.V2) { + v2Aligner.reset() + } + }, + modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (sel) Color(0xFF7C3AED) else Color.Gray.copy(alpha = 0.15f) + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp) + ) { + Text(label, fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = if (sel) Color.White else MlSecondaryText) + } + } + } + } + + HorizontalDivider() + // Otsu toggle var otsuEnabled by remember { mutableStateOf(analyzer.useAdaptiveThreshold) } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/MethodDDetector.kt b/app/src/main/java/com/example/medilightv2android/walldetect/MethodDDetector.kt new file mode 100644 index 0000000..13d6321 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/MethodDDetector.kt @@ -0,0 +1,205 @@ +/* + * Method D detector — port of method_d/detector.py. + * + * 단일 채널 raw → ant/post wall index + subsample refine + diagnostics. + * + * Stage 1 : SG heavy / light denoise (호출부에서 미리 줘도 됨) + * Stage 2 : span (heavy primary, light fallback) + * Stage 3 : ant/post wall peak (find_wall_peak_local) + * Stage 3.5: wall-lumen ratio gate + 최대 2회 recovery (outward stronger peak 탐색) + * Stage 4 : TGC-FP gate (raw post ratio ≥ min_post_raw_ratio) + * Stage 5 : subsample refine (parabolic for peak / d2 fit for shoulder) + * + * 인터페이스는 WallDetector 와 무관 — alignment.py 가 channels[i].urine_len 만 + * 필요로 하므로 가벼운 단일-함수 API 로 유지. + */ +package com.example.medilightv2android.walldetect + +import com.example.medilightv2android.walldetect.algo.methodd.MethodDParams +import com.example.medilightv2android.walldetect.algo.methodd.MethodDPreprocessing +import com.example.medilightv2android.walldetect.algo.methodd.MethodDSpan +import com.example.medilightv2android.walldetect.algo.methodd.MethodDWallSelect +import com.example.medilightv2android.walldetect.algo.methodd.MethodDWallSelect.CType +import com.example.medilightv2android.walldetect.algo.methodd.MethodDWallSelect.Side +import kotlin.math.max + +data class MethodDResult( + val ant: Int, + val post: Int, + val antRefined: Double, + val postRefined: Double, + val lowStart: Int, + val lowEnd: Int, + val lowAmp: Double, + val inwardWalkAnt: Int, + val inwardWalkPost: Int, + val urineLen: Int, + val antProm: Double, + val postProm: Double, + val antType: CType, + val postType: CType, + val sgHeavy: DoubleArray, + val sgLight: DoubleArray, +) + +object MethodDDetector { + + private fun wallGateOk(antAmp: Double, postAmp: Double, lumenMin: Double, p: MethodDParams): Boolean { + val wallAmp = if (p.wallRatioUsePostOnly) postAmp else kotlin.math.min(antAmp, postAmp) + return wallAmp / max(lumenMin, 1.0) >= p.minWallLumenRatio + } + + fun detect( + raw: DoubleArray, + denoisedHeavy: DoubleArray? = null, + denoisedLight: DoubleArray? = null, + otsuRatioOverride: Double? = null, + params: MethodDParams = MethodDParams.DEFAULT, + ): MethodDResult? { + val sgHeavy = denoisedHeavy ?: MethodDPreprocessing.preprocessHeavy(raw, params) + val sgLight = denoisedLight ?: MethodDPreprocessing.preprocessLight(raw) + val ratio = otsuRatioOverride ?: params.otsuRatio + + // Stage 2: span + val spanRes = MethodDSpan.extractLowEchoSpanWithFallback(sgHeavy, sgLight, ratio, params) + ?: return null + val lowStart = spanRes.lowStart + val lowEnd = spanRes.lowEnd + val lowAmp = spanRes.lowAmp + val inwardAnt = spanRes.inwardWalkAnt + val inwardPost = spanRes.inwardWalkPost + + // lumen_min — Stage 3.5 wall_lumen_ratio gate 에서 사용 + var lumenMin = Double.POSITIVE_INFINITY + for (i in lowStart..lowEnd) if (sgHeavy[i] < lumenMin) lumenMin = sgHeavy[i] + if (!lumenMin.isFinite()) lumenMin = 1.0 + + // Stage 3: ant + post peak + val antRes = MethodDWallSelect.findWallPeakLocal( + sgLight, lowStart, lowEnd, Side.ANT, params, + dMaxOverride = params.antDMax, inwardWalk = inwardAnt, + ) + val postRes = MethodDWallSelect.findWallPeakLocal( + sgLight, lowStart, lowEnd, Side.POST, params, + dMaxOverride = params.dMax, inwardWalk = inwardPost, + ) + if (antRes.best == null || postRes.best == null) return null + + var antIdx = antRes.best.idx + var antProm = antRes.best.prom + var antType = antRes.best.type + var postIdx = postRes.best.idx + var postProm = postRes.best.prom + var postType = postRes.best.type + + if (postIdx <= antIdx) return null + var urineLen = postIdx - antIdx - 1 + if (urineLen < params.minUrineLen) return null + + var antAmp = sgLight[antIdx] + var postAmp = sgLight[postIdx] + + // Stage 3.5: recovery (최대 2회, 각 side 1회씩) + repeat(2) { + if (wallGateOk(antAmp, postAmp, lumenMin, params)) return@repeat + val side: Side + val curIdx: Int + val curAmp: Double + val otherAmp: Double + val walkKw: Int + val extDMax: Int + if (postAmp <= antAmp) { + side = Side.POST + curIdx = postIdx + curAmp = postAmp + otherAmp = antAmp + walkKw = inwardPost + extDMax = if (params.recoveryExtendOutward) + max(params.dMax, params.postMaxIdx - lowEnd) else params.dMax + } else { + side = Side.ANT + curIdx = antIdx + curAmp = antAmp + otherAmp = postAmp + walkKw = inwardAnt + extDMax = if (params.recoveryExtendOutward) + max(params.antDMax, lowStart) else params.antDMax + } + val ext = MethodDWallSelect.findWallPeakLocal( + sgLight, lowStart, lowEnd, side, params, + dMaxOverride = extDMax, inwardWalk = walkKw, + ) + + // outward stronger peak 만 후보. closest 우선 (post=오름차순/ant=내림차순). + val sorted = if (side == Side.POST) ext.candidates.sortedBy { it.idx } + else ext.candidates.sortedByDescending { it.idx } + var passing: MethodDWallSelect.Candidate? = null // ratio 통과시키는 closest + var fallback: MethodDWallSelect.Candidate? = null // 통과 못해도 stronger 한 첫 후보 + for (c in sorted) { + val outward = if (side == Side.POST) c.idx > curIdx else c.idx < curIdx + if (!outward) continue + val cAmp = sgLight[c.idx] + if (cAmp <= curAmp) continue + if (fallback == null) fallback = c + val minAmp = kotlin.math.min(cAmp, otherAmp) + if (minAmp / max(lumenMin, 1.0) >= params.minWallLumenRatio) { + passing = c + break + } + } + val recovered = passing ?: fallback ?: return null + if (side == Side.POST) { + postIdx = recovered.idx + postProm = recovered.prom + postType = recovered.type + postAmp = sgLight[postIdx] + } else { + antIdx = recovered.idx + antProm = recovered.prom + antType = recovered.type + antAmp = sgLight[antIdx] + } + } + + if (!wallGateOk(antAmp, postAmp, lumenMin, params)) return null + + // Stage 4: TGC-FP gate — raw post / raw lumen_min ≥ min_post_raw_ratio + val rawHeavy = MethodDPreprocessing.preprocessHeavy(raw, params) + val rawLight = MethodDPreprocessing.preprocessLight(raw) + var rawLumenMin = Double.POSITIVE_INFINITY + for (i in lowStart..lowEnd) if (rawHeavy[i] < rawLumenMin) rawLumenMin = rawHeavy[i] + if (!rawLumenMin.isFinite()) rawLumenMin = 1.0 + val rawPostRatio = rawLight[postIdx] / max(rawLumenMin, 1.0) + if (rawPostRatio < params.minPostRawRatio) return null + + urineLen = postIdx - antIdx - 1 + if (urineLen < params.minUrineLen) return null + + // Stage 5: subsample refine + val antRefined = if (antType == CType.PEAK) + MethodDWallSelect.refineParabolic(sgLight, antIdx, true) + else MethodDWallSelect.refineShoulder(sgLight, antIdx) + val postRefined = if (postType == CType.PEAK) + MethodDWallSelect.refineParabolic(sgLight, postIdx, true) + else MethodDWallSelect.refineShoulder(sgLight, postIdx) + + return MethodDResult( + ant = antIdx, + post = postIdx, + antRefined = antRefined, + postRefined = postRefined, + lowStart = lowStart, + lowEnd = lowEnd, + lowAmp = lowAmp, + inwardWalkAnt = inwardAnt, + inwardWalkPost = inwardPost, + urineLen = urineLen, + antProm = antProm, + postProm = postProm, + antType = antType, + postType = postType, + sgHeavy = sgHeavy, + sgLight = sgLight, + ) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/MethodDRunner.kt b/app/src/main/java/com/example/medilightv2android/walldetect/MethodDRunner.kt new file mode 100644 index 0000000..b1c2389 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/MethodDRunner.kt @@ -0,0 +1,57 @@ +/* + * Method D multichannel runner — port of library/runners.py method_d(). + * + * 입력: List (6채널 raw ADC, 길이 100 가정) + * 처리 (Python 1:1): + * 1) heavy = SG(7,3) + oscfar_median(win=5, iter=4) + * light = SG(7,3) + * 2) apply_tgc_pipeline(heavy / light) — center_ch=None → all channels + * 3) 채널별 otsu_ratio × cos(beam_angle) ← PiezoHW.degreeAll + * 4) MethodDDetector.detect() + * + * TGC 는 default ON (Python 과 동일). `applyTgc=false` 로 비활성화 가능. + * + * 출력 컨트랙트: alignment.py 가 `dets[i].urine_len` 만 의존 → + * MethodDResult.urineLen 또는 null 의 List 로 충분. + */ +package com.example.medilightv2android.walldetect + +import com.example.medilightv2android.managers.PiezoHW +import com.example.medilightv2android.walldetect.algo.methodd.MethodDParams +import com.example.medilightv2android.walldetect.algo.methodd.MethodDPreprocessing +import com.example.medilightv2android.walldetect.algo.methodd.MethodDTgc +import kotlin.math.cos + +object MethodDRunner { + + /** + * 6채널 (또는 N채널) raw 신호 → 채널별 MethodDResult? 리스트. + * raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립). + */ + fun detectMultichannel( + signals: List, + params: MethodDParams = MethodDParams.DEFAULT, + beamAnglesDeg: DoubleArray? = null, + applyTgc: Boolean = true, + ): List { + val angles = beamAnglesDeg ?: PiezoHW.degreeAll + // 1) per-channel SG denoise (heavy + light) + val heavyList = signals.map { MethodDPreprocessing.preprocessHeavy(it, params) } + val lightList = signals.map { MethodDPreprocessing.preprocessLight(it) } + // 2) TGC per channel (Python apply_tgc_pipeline default center_ch=None → all) + val heavyTgc = if (applyTgc) MethodDTgc.applyTgcPipeline(heavyList) else heavyList + val lightTgc = if (applyTgc) MethodDTgc.applyTgcPipeline(lightList) else lightList + // 3+4) per-channel cos-angle adjusted otsu_ratio + detect + return List(signals.size) { ch -> + val angleDeg = if (ch < angles.size) angles[ch] else 0.0 + val chRatio = params.otsuRatio * cos(Math.toRadians(angleDeg)) + MethodDDetector.detect( + raw = signals[ch], + denoisedHeavy = heavyTgc[ch], + denoisedLight = lightTgc[ch], + otsuRatioOverride = chRatio, + params = params, + ) + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/Otsu.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Otsu.kt index 7a6a157..608b04a 100644 --- a/app/src/main/java/com/example/medilightv2android/walldetect/algo/Otsu.kt +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Otsu.kt @@ -11,6 +11,61 @@ package com.example.medilightv2android.walldetect.algo object Otsu { + /** Otsu threshold + separability — Python `otsu_1d(values)` 의 (threshold, sep) tuple 호환. */ + data class OtsuResult(val threshold: Double, val separability: Double) + + fun otsu1dWithSeparability(values: DoubleArray, nBins: Int = 64): OtsuResult { + if (values.isEmpty()) return OtsuResult(0.0, 0.0) + var lo = Double.POSITIVE_INFINITY + var hi = Double.NEGATIVE_INFINITY + var sum = 0.0 + for (v in values) { + if (v < lo) lo = v + if (v > hi) hi = v + sum += v + } + if (values.size == 1 || lo == hi) return OtsuResult(sum / values.size, 0.0) + val hist = IntArray(nBins) + val width = (hi - lo) / nBins + for (v in values) { + var b = ((v - lo) / width).toInt() + if (b >= nBins) b = nBins - 1 + if (b < 0) b = 0 + hist[b]++ + } + val total = values.size + val centers = DoubleArray(nBins) { lo + (it + 0.5) * width } + val p = DoubleArray(nBins) { hist[it].toDouble() / total } + var muT = 0.0 + for (i in 0 until nBins) muT += p[i] * centers[i] + var sigmaT = 0.0 + for (i in 0 until nBins) { + val d = centers[i] - muT + sigmaT += p[i] * d * d + } + if (sigmaT <= 1e-12) return OtsuResult(muT, 0.0) + var cumP = 0.0 + var cumMP = 0.0 + var bestT = 0 + var bestSigmaB = Double.NEGATIVE_INFINITY + for (t in 0 until nBins - 1) { + cumP += p[t] + cumMP += p[t] * centers[t] + val w0 = cumP + val w1 = 1.0 - w0 + if (w0 <= 1e-6 || w1 <= 1e-6) continue + val m0 = cumMP / w0 + val m1 = (muT - cumMP) / w1 + val sigmaB = w0 * w1 * (m0 - m1) * (m0 - m1) + if (sigmaB > bestSigmaB) { + bestSigmaB = sigmaB + bestT = t + } + } + val sep = (bestSigmaB / sigmaT).coerceIn(0.0, 1.0) + return OtsuResult(centers[bestT], sep) + } + /** * 1-D Otsu threshold over `values`. Returns 0.0 for empty input, * `values.mean()` for single-value or constant input. n_bins default 64 diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/SgSmoothGeneric.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SgSmoothGeneric.kt new file mode 100644 index 0000000..ee8f213 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SgSmoothGeneric.kt @@ -0,0 +1,147 @@ +/* + * Generic Savitzky-Golay smoother — port of vesiscan_test/library/denoising.py:sg_smooth. + * + * 임의 (window, polyorder) 에 대해 Python 1:1 동작: + * 1) 내부 m..n-m: pinv(Vandermonde)[0] 커널로 컨볼루션 + * 2) 왼쪽 edge 0..m-1: 첫 window 샘플에 polynomial fit → t=i-m 위치 평가 + * 3) 오른쪽 edge n-m..n-1: 마지막 window 샘플에 polynomial fit → t=i-(n-m-1) 위치 평가 + * 4) n < window: 전체 신호 단일 polynomial fit + * + * config_6ch.py: SG_WIN=7, SG_POLY=3 ← method_d 기준 + * (V4.1 detector 는 별도 (5,2) 하드코딩 커널 사용 — walldetect/algo/Denoising.kt) + * + * 수치 검증: + * (7,3) 내부 커널 = [-2, 3, 6, 7, 6, 3, -2] / 21 (표준 SG 7-3 좌표) + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.abs + +object SgSmoothGeneric { + + /** + * SG smooth signal `x` with given (window, polyorder). + * window 은 odd, polyorder < window 이어야 함. + */ + fun smooth(x: DoubleArray, window: Int, polyorder: Int): DoubleArray { + require(window % 2 == 1) { "window must be odd, got $window" } + require(polyorder < window) { "polyorder($polyorder) must be < window($window)" } + val n = x.size + val m = (window - 1) / 2 + + if (n < window) { + // 짧은 신호: 전체 구간 단일 polynomial fit + val xs = DoubleArray(n) { it - (n - 1) / 2.0 } + val coef = polyfit(xs, x, polyorder) + return DoubleArray(n) { i -> evalPoly(coef, xs[i]) } + } + + val xsInner = DoubleArray(window) { it - m.toDouble() } + // 내부 커널 = pinv(A)[0, :] — 다항식 c0 (상수항) 의 LS 계수 + val kernel = innerKernel(xsInner, polyorder) + val out = DoubleArray(n) + for (i in m until n - m) { + var s = 0.0 + for (k in 0 until window) s += kernel[k] * x[i - m + k] + out[i] = s + } + + // 왼쪽 edge: 첫 window 샘플 polynomial fit + val leftCoef = polyfit(xsInner, sliceArray(x, 0, window), polyorder) + for (i in 0 until m) { + val t = (i - m).toDouble() // block center(index m) 기준 상대좌표 + out[i] = evalPoly(leftCoef, t) + } + // 오른쪽 edge: 마지막 window 샘플 polynomial fit + val rightCoef = polyfit(xsInner, sliceArray(x, n - window, n), polyorder) + for (i in n - m until n) { + val t = (i - (n - m - 1)).toDouble() // block center(index n-m-1) 기준 상대좌표 + out[i] = evalPoly(rightCoef, t) + } + return out + } + + private fun sliceArray(x: DoubleArray, from: Int, to: Int): DoubleArray = + DoubleArray(to - from) { x[from + it] } + + private fun evalPoly(coef: DoubleArray, t: Double): Double { + var v = 0.0 + var tk = 1.0 + for (k in coef.indices) { + v += coef[k] * tk + tk *= t + } + return v + } + + /** xs (length=window) 에 y (length=window) polynomial-fit → coefs [c0, c1, ..., c_p]. */ + private fun polyfit(xs: DoubleArray, ys: DoubleArray, polyorder: Int): DoubleArray { + val p = polyorder + 1 + val n = xs.size + val ata = Array(p) { DoubleArray(p) } + val aty = DoubleArray(p) + for (i in 0 until n) { + val powers = DoubleArray(p) + powers[0] = 1.0 + for (k in 1 until p) powers[k] = powers[k - 1] * xs[i] + for (j in 0 until p) { + aty[j] += powers[j] * ys[i] + for (k in 0 until p) ata[j][k] += powers[j] * powers[k] + } + } + return solveLinearSystem(ata, aty) + } + + /** 내부 SG 커널: pinv(A)[0, :] — c0 의 LS 계수. y → c0 = Σ kernel[i]·y[i]. */ + private fun innerKernel(xs: DoubleArray, polyorder: Int): DoubleArray { + val p = polyorder + 1 + val n = xs.size + val ata = Array(p) { DoubleArray(p) } + for (i in 0 until n) { + val powers = DoubleArray(p) + powers[0] = 1.0 + for (k in 1 until p) powers[k] = powers[k - 1] * xs[i] + for (j in 0 until p) for (k in 0 until p) ata[j][k] += powers[j] * powers[k] + } + val e0 = DoubleArray(p) + e0[0] = 1.0 + val nInvCol0 = solveLinearSystem(ata, e0) // N^-1 [:, 0] = 대칭으로 row 0 동등 + val kernel = DoubleArray(n) + for (i in 0 until n) { + var xik = 1.0 + for (k in 0 until p) { + kernel[i] += nInvCol0[k] * xik + xik *= xs[i] + } + } + return kernel + } + + /** Gaussian elimination with partial pivoting — 소형 PSD 정상 매트릭스용. */ + private fun solveLinearSystem(a: Array, b: DoubleArray): DoubleArray { + val n = b.size + val m = Array(n) { DoubleArray(n + 1) } + for (i in 0 until n) { + for (j in 0 until n) m[i][j] = a[i][j] + m[i][n] = b[i] + } + for (i in 0 until n) { + var pivot = i + for (k in i + 1 until n) if (abs(m[k][i]) > abs(m[pivot][i])) pivot = k + if (pivot != i) { val t = m[i]; m[i] = m[pivot]; m[pivot] = t } + val piv = m[i][i] + require(abs(piv) >= 1e-12) { "singular matrix at row $i" } + for (k in i + 1 until n) { + val f = m[k][i] / piv + for (j in i..n) m[k][j] -= f * m[i][j] + } + } + val x = DoubleArray(n) + for (i in n - 1 downTo 0) { + var s = m[i][n] + for (j in i + 1 until n) s -= m[i][j] * x[j] + x[i] = s / m[i][i] + } + return x + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDParams.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDParams.kt new file mode 100644 index 0000000..f027554 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDParams.kt @@ -0,0 +1,39 @@ +/* + * Method D config — port of vesiscan_test/library/method_d/config_d.py. + * + * 모든 디폴트값을 python 과 동일하게 유지. 튜닝 근거 주석은 원본 참조. + * 각도 보정(otsu_ratio × cos(angle))은 호출부(MethodDRunner)에서 곱함. + */ +package com.example.medilightv2android.walldetect.algo.methodd + +data class MethodDParams( + val otsuRatio: Double = 0.88, + val lowMinLen: Int = 3, + val mergeGapMax: Int = 3, + val dMax: Int = 10, + val antDMax: Int = 18, + val postMaxIdx: Int = 100, + val minUrineLen: Int = 10, + val distDecay: Double = 0.1, + val promGamma: Double = 1.5, + val shoulderDistDecay: Double = 0.3, + val shoulderPromGamma: Double = 1.0, + val minShoulderProm: Double = 100.0, + val minPeakProm: Double = 50.0, + val shoulderScoreHandicap: Double = 0.15, + val gapPeakMinProm: Double = 50.0, + val minWallLumenRatio: Double = 1.16, + val minPostRawRatio: Double = 1.08, + val inwardWalkWin: Int = 3, + val inwardWalkSlopeTol: Double = 10.0, + val oscfarWin: Int = 5, + val oscfarMaxIters: Int = 4, + val minLightSeparability: Double = 0.75, + val minSpanLen: Int = 5, + val recoveryExtendOutward: Boolean = false, + val wallRatioUsePostOnly: Boolean = true, +) { + companion object { + val DEFAULT = MethodDParams() + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDPreprocessing.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDPreprocessing.kt new file mode 100644 index 0000000..bff3c8e --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDPreprocessing.kt @@ -0,0 +1,26 @@ +/* + * Method D preprocessing — port of method_d/preprocessing.py. + * + * heavy = SG (7,3) + OS-CFAR iterative median (span 검출용; ringing/speckle 흡수) + * light = SG (7,3) only (wall peak / subsample refine 용) + * + * Python config_6ch.SG_WIN=7, SG_POLY=3 — V4.1 의 (5,2) 하드코딩 커널과 분리. + * 일반화된 SgSmoothGeneric 으로 호출 (any window, polyorder 지원). + */ +package com.example.medilightv2android.walldetect.algo.methodd + +import com.example.medilightv2android.walldetect.algo.MedianFilter +import com.example.medilightv2android.walldetect.algo.SgSmoothGeneric + +object MethodDPreprocessing { + const val SG_WIN = 7 // config_6ch.SG_WIN + const val SG_POLY = 3 // config_6ch.SG_POLY + + fun preprocessHeavy(raw: DoubleArray, params: MethodDParams = MethodDParams.DEFAULT): DoubleArray { + val sg = SgSmoothGeneric.smooth(raw, SG_WIN, SG_POLY) + return MedianFilter.runningMedianRoot(sg, params.oscfarWin, params.oscfarMaxIters) + } + + fun preprocessLight(raw: DoubleArray): DoubleArray = + SgSmoothGeneric.smooth(raw, SG_WIN, SG_POLY) +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDSpan.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDSpan.kt new file mode 100644 index 0000000..b126bf7 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDSpan.kt @@ -0,0 +1,107 @@ +/* + * Method D span detection — port of method_d/span.py. + * + * extract_low_echo_span: + * 1) Otsu threshold × ratio = low_amp + * 2) low_mask = sg ≤ low_amp → contiguous spans (len ≥ low_min_len) + * 3) merge_close_spans (gap_peak_min_prom 가드) + * 4) 첫 candidate (sig_end 제외, len ≥ min_span_len) 채택 + * 5) post 후위 검색 상한(post_max_idx) 초과 시 reject + * 6) walk_inward_to_valley 로 양쪽 valley plateau 시작점까지 shrink + * 7) walk 결과가 min_span_len 미만이면 reject + * + * extract_low_echo_span_with_fallback: + * heavy primary; heavy 가 fail 하거나 끝까지 흐르면 light 로 재시도. + * light 의 Otsu separability < min_light_separability 이면 fallback 거부. + */ +package com.example.medilightv2android.walldetect.algo.methodd + +import com.example.medilightv2android.walldetect.algo.Otsu +import com.example.medilightv2android.walldetect.algo.SpanUtils + +object MethodDSpan { + + data class SpanResult( + val lowStart: Int, + val lowEnd: Int, + val lowAmp: Double, + val inwardWalkAnt: Int, + val inwardWalkPost: Int, + ) + + private fun walkInwardToValley( + sg: DoubleArray, spanS: Int, spanE: Int, + win: Int, slopeTol: Double, + ): IntArray { + var s = spanS + var e = spanE + val threshold = slopeTol * win + while (s + win <= e && (sg[s] - sg[s + win]) >= threshold) s++ + while (e - win >= s && (sg[e] - sg[e - win]) >= threshold) e-- + return intArrayOf(s, e) + } + + fun extractLowEchoSpan( + sg: DoubleArray, + otsuRatio: Double, + params: MethodDParams = MethodDParams.DEFAULT, + ): SpanResult? { + val otsuRes = Otsu.otsu1dWithSeparability(sg) + val lowAmp = otsuRes.threshold * otsuRatio + + val mask = BooleanArray(sg.size) { sg[it] <= lowAmp } + val rawSpans = SpanUtils.contiguousTrueSpans(mask) + .filter { (it.end - it.start + 1) >= params.lowMinLen } + val spans = SpanUtils.mergeCloseSpans( + rawSpans, + maxGap = params.mergeGapMax, + sg = sg, + gapPeakThr = lowAmp + params.gapPeakMinProm, + ) + if (spans.isEmpty()) return null + + // 앞쪽 span 부터 순회: sig_end 제외 + 길이 ≥ min_span_len 인 첫 span. + val sigEnd = sg.size - 1 + var pickStart = -1 + var pickEnd = -1 + for (span in spans) { + if (span.end >= sigEnd) continue + if ((span.end - span.start + 1) < params.minSpanLen) continue + pickStart = span.start + pickEnd = span.end + break + } + if (pickStart < 0) return null + if (pickEnd >= params.postMaxIdx) return null + + val walked = walkInwardToValley( + sg, pickStart, pickEnd, + params.inwardWalkWin, params.inwardWalkSlopeTol, + ) + val s = walked[0] + val e = walked[1] + if ((e - s + 1) < params.minSpanLen) return null + + return SpanResult( + lowStart = s, + lowEnd = e, + lowAmp = lowAmp, + inwardWalkAnt = s - pickStart, + inwardWalkPost = pickEnd - e, + ) + } + + fun extractLowEchoSpanWithFallback( + sgHeavy: DoubleArray, + sgLight: DoubleArray, + otsuRatio: Double, + params: MethodDParams = MethodDParams.DEFAULT, + ): SpanResult? { + val primary = extractLowEchoSpan(sgHeavy, otsuRatio, params) + if (primary != null && primary.lowEnd < sgHeavy.size - 1) return primary + // Light fallback — unimodal 신호 차단. + val sep = Otsu.otsu1dWithSeparability(sgLight).separability + if (sep < params.minLightSeparability) return null + return extractLowEchoSpan(sgLight, otsuRatio, params) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDTgc.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDTgc.kt new file mode 100644 index 0000000..1500510 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDTgc.kt @@ -0,0 +1,87 @@ +/* + * TGC (Time Gain Compensation) — port of denoising.py:apply_tgc_pipeline. + * + * 깊이가 깊을수록 음향 신호가 감쇠하는 현상을 보정: + * 1) fit_attenuation_lines: per-channel linear LS fit (slope, intercept) on x=[0..N-1] + * 2) adaptive_tgc_ratio(slope, slope_thresh=3.0, slope_max=15.0, ratio_min=0.1): + * |slope| < 3.0 → ratio=1.0 (보정 없음, 가파르지 않은 채널) + * else ratio = 1.0 - (1-ratio_min) * (|slope|-slope_thresh) / (slope_max-slope_thresh) + * clipped to [ratio_min, 1.0] + * 3) target_slope = slope * ratio + * 4) compensation = (target_slope - slope) * x + * 5) compensated = original + compensation + * + * slope > 0 (양수, 깊을수록 밝아짐) 인 채널은 보정 스킵 — 비정상 케이스. + * + * 입력은 (n_ch, n_samples) 단일 scan. method_d 가 heavy/light 각각에 호출. + */ +package com.example.medilightv2android.walldetect.algo.methodd + +import kotlin.math.abs + +object MethodDTgc { + + /** Linear LS fit y = a + b*x on x=[0..n-1] → (slope=b, intercept=a). numpy.polyfit(x,y,1). */ + fun fitAttenuationLine(y: DoubleArray): Pair { + val n = y.size + if (n < 2) return 0.0 to (if (n == 1) y[0] else 0.0) + // x = 0..n-1 + val sumX = (n - 1).toDouble() * n / 2.0 // Σx + val sumX2 = (n - 1).toDouble() * n * (2 * n - 1) / 6.0 // Σx² + var sumY = 0.0 + var sumXY = 0.0 + for (i in 0 until n) { + sumY += y[i] + sumXY += i * y[i] + } + val meanX = sumX / n + val meanY = sumY / n + val varX = sumX2 - n * meanX * meanX + val covXY = sumXY - n * meanX * meanY + val slope = if (abs(varX) < 1e-12) 0.0 else covXY / varX + val intercept = meanY - slope * meanX + return slope to intercept + } + + /** adaptive_tgc_ratio(slope) — Python 그대로. slope_thresh=3.0, slope_max=15.0, ratio_min=0.1. */ + fun adaptiveTgcRatio( + slope: Double, + slopeThresh: Double = 3.0, + slopeMax: Double = 15.0, + ratioMin: Double = 0.1, + ): Double { + val absSlope = abs(slope) + if (absSlope < slopeThresh) return 1.0 + val ratio = 1.0 - (1.0 - ratioMin) * (absSlope - slopeThresh) / (slopeMax - slopeThresh) + return maxOf(ratio, ratioMin) + } + + /** + * Apply TGC to a single scan (n_ch × n_samples). 채널별 slope 계산 → ratio → + * compensation = (target_slope - slope) * x 가산. slope > 0 인 채널은 skip. + * + * Python apply_tgc_pipeline(df, n_ch, center_ch=None) with center_ch=None + * defaults to all channels — 우리는 입력 list 전체에 적용. + * + * @param channels (n_ch) 길이의 (n_samples) DoubleArray + * @param ratioMin Python default 0.1. 1차 비교에선 그대로. + * @param targetRatio override (Python `target_ratio` param). null 이면 adaptive. + */ + fun applyTgcPipeline( + channels: List, + ratioMin: Double = 0.1, + targetRatio: Double? = null, + ): List { + if (channels.isEmpty()) return channels + return channels.map { row -> + val (slope, _) = fitAttenuationLine(row) + if (slope >= 0.0) return@map row.copyOf() + val ratio = targetRatio ?: adaptiveTgcRatio(slope, ratioMin = ratioMin) + val targetSlope = slope * ratio + val delta = targetSlope - slope // 음수 slope → 음수 ratio 곱하면 더 작은 음수, + // delta = targetSlope - slope > 0 → 깊이 갈수록 보정+ + val out = DoubleArray(row.size) { i -> row[i] + delta * i } + out + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDWallSelect.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDWallSelect.kt new file mode 100644 index 0000000..e506f1e --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDWallSelect.kt @@ -0,0 +1,167 @@ +/* + * Method D wall selection — port of method_d/wall_select.py. + * + * span edge 근방 d_max 안에서 두 종류 후보를 수집: + * 1) local maxima (PeakDetection.findPeaks1D) → type='peak' + * 2) d2 local-min 이면서 음수 (어깨) → type='shoulder' + * ±1 sample 이내 peak 와 중복이면 제거. + * + * 스코어링: + * score = prom^gamma / (1 + dist_decay × dist) + * prom = sig[peak] - sig[adjacent_valley] (valley = peak ↔ edge 사이 최소점) + * dist = |peak - edge| + * + * shoulder 는 prom_gate(min_shoulder_prom) 더 strict, + * score 에 (1+shoulder_score_handicap) deadband 페널티 → peak 와 동률 토글 차단. + */ +package com.example.medilightv2android.walldetect.algo.methodd + +import com.example.medilightv2android.walldetect.algo.PeakDetection +import kotlin.math.abs +import kotlin.math.pow + +object MethodDWallSelect { + + enum class Side { ANT, POST } + enum class CType { PEAK, SHOULDER } + + data class Candidate( + val idx: Int, + val prom: Double, + val dist: Int, + val valleyIdx: Int, + val score: Double, + val type: CType, + ) + + data class WallResult( + val best: Candidate?, + val candidates: List, + ) + + private fun adjacentValley(sig: DoubleArray, peakIdx: Int, edgeIdx: Int): Int { + val step = if (edgeIdx > peakIdx) 1 else -1 + var i = peakIdx + while (true) { + val nxt = i + step + if ((step > 0 && nxt > edgeIdx) || (step < 0 && nxt < edgeIdx)) return edgeIdx + if (sig[nxt] <= sig[i]) i = nxt + else return i + } + } + + private fun findShouldersLocal(sub: DoubleArray): IntArray { + if (sub.size < 5) return IntArray(0) + val n = sub.size + val d2 = DoubleArray(n - 2) { i -> sub[i] - 2 * sub[i + 1] + sub[i + 2] } + val out = mutableListOf() + for (i in 1 until d2.size - 1) { + if (d2[i] < 0 && d2[i] < d2[i - 1] && d2[i] < d2[i + 1]) { + out += i + 1 // d2 index → sub index (+1 from central-diff offset) + } + } + return out.toIntArray() + } + + fun findWallPeakLocal( + sig: DoubleArray, + spanS: Int, + spanE: Int, + side: Side, + params: MethodDParams = MethodDParams.DEFAULT, + dMaxOverride: Int? = null, + inwardWalk: Int = 0, + ): WallResult { + val n = sig.size + val baseDMax = dMaxOverride ?: when (side) { + Side.ANT -> params.antDMax + Side.POST -> params.dMax + } + val effectiveDMax = baseDMax + inwardWalk + + val edge: Int + val lo: Int + val hi: Int + when (side) { + Side.ANT -> { + edge = spanS + lo = (edge - effectiveDMax).coerceAtLeast(0) + hi = edge + } + Side.POST -> { + edge = spanE + lo = edge + hi = minOf(n - 1, edge + effectiveDMax, params.postMaxIdx) + } + } + if (hi <= lo) return WallResult(null, emptyList()) + + val sub = DoubleArray(hi - lo + 1) { sig[lo + it] } + val relPeaks = PeakDetection.findPeaks1D(sub, 0, sub.size) + val relShoulders = findShouldersLocal(sub) + + val candIdxType = mutableListOf>() + for (p in relPeaks) candIdxType += Pair(lo + p, CType.PEAK) + for (s in relShoulders) { + val idx = lo + s + if (candIdxType.any { it.second == CType.PEAK && abs(it.first - idx) <= 1 }) continue + candIdxType += Pair(idx, CType.SHOULDER) + } + + val scored = mutableListOf() + for ((p, ctype) in candIdxType) { + val vIdx = adjacentValley(sig, p, edge) + val prom = sig[p] - sig[vIdx] + val gate = if (ctype == CType.PEAK) params.minPeakProm else params.minShoulderProm + if (prom <= 0 || prom < gate) continue + val dist = abs(p - edge) + val score = if (ctype == CType.SHOULDER) { + prom.pow(params.shoulderPromGamma) / + (1.0 + params.shoulderDistDecay * dist) / + (1.0 + params.shoulderScoreHandicap) + } else { + prom.pow(params.promGamma) / (1.0 + params.distDecay * dist) + } + scored += Candidate(p, prom, dist, vIdx, score, ctype) + } + if (scored.isEmpty()) return WallResult(null, emptyList()) + return WallResult(scored.maxBy { it.score }, scored) + } + + /** + * Parabolic sub-sample refine (Cespedes 1995) for `peak=true`. + * Returns the original idx as Double when refinement is invalid. + */ + fun refineParabolic(envelope: DoubleArray, idx: Int, peak: Boolean = true): Double { + val n = envelope.size + if (idx < 1 || idx > n - 2) return idx.toDouble() + val ym1 = envelope[idx - 1] + val y0 = envelope[idx] + val yp1 = envelope[idx + 1] + val denom = ym1 - 2 * y0 + yp1 + if (denom == 0.0 || denom.isNaN() || denom.isInfinite()) return idx.toDouble() + if (peak && denom > 0) return idx.toDouble() + if (!peak && denom < 0) return idx.toDouble() + val delta = 0.5 * (ym1 - yp1) / denom + if (delta.isNaN() || delta.isInfinite() || abs(delta) > 1.0) return idx.toDouble() + return idx + delta + } + + /** + * Shoulder (d2 local-min) sub-sample refine via parabolic fit on d2 itself. + * Needs envelope[idx-2 .. idx+2]. + */ + fun refineShoulder(envelope: DoubleArray, idx: Int): Double { + val n = envelope.size + if (idx < 2 || idx > n - 3) return idx.toDouble() + val e = envelope + val d2m1 = e[idx - 2] - 2 * e[idx - 1] + e[idx] + val d20 = e[idx - 1] - 2 * e[idx] + e[idx + 1] + val d2p1 = e[idx] - 2 * e[idx + 1] + e[idx + 2] + val denom = d2m1 - 2 * d20 + d2p1 + if (denom <= 0.0 || denom.isNaN() || denom.isInfinite()) return idx.toDouble() + val delta = 0.5 * (d2m1 - d2p1) / denom + if (delta.isNaN() || delta.isInfinite() || abs(delta) > 1.0) return idx.toDouble() + return idx + delta + } +}