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:
2026-06-17 14:03:34 +09:00
parent 429964571f
commit 718016af7a
13 changed files with 1306 additions and 4 deletions
@@ -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
}
}
@@ -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()
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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
}
}