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,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