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)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 신호 범위
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+83
-2
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Method D multichannel runner — port of library/runners.py method_d().
|
||||
*
|
||||
* 입력: List<DoubleArray> (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<DoubleArray>,
|
||||
params: MethodDParams = MethodDParams.DEFAULT,
|
||||
beamAnglesDeg: DoubleArray? = null,
|
||||
applyTgc: Boolean = true,
|
||||
): List<MethodDResult?> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<DoubleArray>, 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
|
||||
}
|
||||
}
|
||||
+39
@@ -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()
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+107
@@ -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)
|
||||
}
|
||||
}
|
||||
+87
@@ -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<Double, Double> {
|
||||
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<DoubleArray>,
|
||||
ratioMin: Double = 0.1,
|
||||
targetRatio: Double? = null,
|
||||
): List<DoubleArray> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -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<Candidate>,
|
||||
)
|
||||
|
||||
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<Int>()
|
||||
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<Pair<Int, CType>>()
|
||||
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<Candidate>()
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user