6889136783
detectLowEcho: 고정 1150 → adaptive threshold (기본 활성화) 1. ringSkip(3) 이후 신호에서 q25/q50/q75 계산 2. percentile 기반: q25 + iqr*0.3 ~ q50 - iqr*0.3 3. 고정값(1150) 범위 제한: ×0.7 ~ ×1.5 (805~1725) 4. 가중 평균: 고정 40% + adaptive 60% → 신호 레벨에 자동 적응하되 급격한 변동 방지 WaveformChart: threshold 점선도 adaptive 값으로 표시 useAdaptiveThreshold 플래그로 on/off 전환 가능 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
680 lines
24 KiB
Kotlin
680 lines
24 KiB
Kotlin
package com.example.medilightv2android.managers
|
||
|
||
import com.example.medilightv2android.ble.PiezoChannelData
|
||
import kotlin.math.abs
|
||
import kotlin.math.max
|
||
import kotlin.math.min
|
||
import kotlin.math.sqrt
|
||
|
||
// ── Result Types ──
|
||
|
||
/**
|
||
* Low-echo 기반 urine region 탐지 결과 (원은지 B방식 포팅)
|
||
*/
|
||
data class LowEchoResult(
|
||
val ant: Int, // 전벽 index
|
||
val post: Int, // 후벽 index
|
||
val lowStart: Int, // low-echo span 시작
|
||
val lowEnd: Int, // low-echo span 끝 (inclusive)
|
||
val lowMean: Double, // low-echo 구간 평균 진폭
|
||
val urineLen: Int, // post - ant - 1
|
||
val score: Double, // low_depth * urine_len
|
||
val innerPeaks: List<Int> // (ant, post) 내부 local peak indices
|
||
) {
|
||
/** 방광 직경 (mm) = urine_len × distancePerSample */
|
||
val diameterMm: Double
|
||
get() = urineLen.toDouble() * PiezoConstants.distancePerSample
|
||
|
||
/** 방광 용적 (ml) = K × (D_cm)³ (DrBench 공식, 팬텀 캘리브레이션) */
|
||
val volumeMl: Double
|
||
get() = PiezoConstants.volumeMl(diameterMm)
|
||
|
||
/** 전벽 깊이 (mm) */
|
||
val antDepthMm: Double
|
||
get() = PiezoConstants.distanceMm(ant)
|
||
|
||
/** 후벽 깊이 (mm) */
|
||
val postDepthMm: Double
|
||
get() = PiezoConstants.distanceMm(post)
|
||
}
|
||
|
||
/**
|
||
* 채널별 분석 결과
|
||
*/
|
||
data class ChannelAnalysisResult(
|
||
val channel: Int,
|
||
val result: LowEchoResult?,
|
||
val rawSignal: DoubleArray,
|
||
val denoisedSignal: DoubleArray
|
||
) {
|
||
val isValid: Boolean get() = result != null
|
||
|
||
override fun equals(other: Any?): Boolean {
|
||
if (this === other) return true
|
||
if (other !is ChannelAnalysisResult) return false
|
||
return channel == other.channel && result == other.result
|
||
}
|
||
|
||
override fun hashCode(): Int = 31 * channel + (result?.hashCode() ?: 0)
|
||
}
|
||
|
||
/**
|
||
* 전체 측정 분석 결과
|
||
*/
|
||
data class PiezoAnalysisResult(
|
||
val channels: List<ChannelAnalysisResult>,
|
||
val volumeMl: Double,
|
||
val confidence: Double, // 0~100
|
||
val validChannelCount: Int,
|
||
val method: String // "single", "multi_avg", "multi_weighted"
|
||
) {
|
||
val bestChannel: ChannelAnalysisResult?
|
||
get() = channels.filter { it.isValid }
|
||
.maxByOrNull { it.result?.score ?: 0.0 }
|
||
}
|
||
|
||
// ── PiezoConstants (referenced by LowEchoResult) ──
|
||
|
||
/**
|
||
* Physical constants for Piezo ultrasound echo measurement.
|
||
* Mirrors iOS PiezoConstants, delegates distancePerSample/delayOffsetMm to PiezoHW.
|
||
*/
|
||
object PiezoConstants {
|
||
const val adcMaxValue: Int = 4095
|
||
const val adcVrefMv: Double = 3300.0
|
||
|
||
const val sampleRateMhz: Double = 0.5375
|
||
const val sampleIntervalUs: Double = 1.86
|
||
const val soundSpeedMmUs: Double = 1.54
|
||
|
||
val distancePerSample: Double get() = PiezoHW.distancePerSample
|
||
val delayOffsetMm: Double get() = PiezoHW.delayOffsetMm
|
||
|
||
const val volumeK: Double = 1.76
|
||
|
||
fun distanceMm(index: Int): Double =
|
||
index.toDouble() * distancePerSample + delayOffsetMm
|
||
|
||
fun voltageMv(adcValue: Int): Double =
|
||
adcValue.toDouble() / adcMaxValue.toDouble() * adcVrefMv
|
||
|
||
fun volumeMl(diameterMm: Double): Double {
|
||
val dCm = diameterMm / 10.0
|
||
return volumeK * dCm * dCm * dCm
|
||
}
|
||
}
|
||
|
||
// ── Analyzer ──
|
||
|
||
/**
|
||
* Piezo 초음파 에코 분석기 — 원은지 연구원 B방식 알고리즘 포팅
|
||
* Pipeline: Raw ADC → TVD Denoise → SG Smooth → Low-Echo Detection → Volume
|
||
*/
|
||
class PiezoEchoAnalyzer private constructor() {
|
||
|
||
companion object {
|
||
val shared = PiezoEchoAnalyzer()
|
||
}
|
||
|
||
// ── Parameters (노트북 검증값) ──
|
||
|
||
// TVD
|
||
val tvdWeight: Double = 0.06
|
||
val tvdMaxIter: Int = 200
|
||
val tvdTol: Double = 2e-4
|
||
val tvdTau: Double = 0.25
|
||
|
||
// Savitzky-Golay (window=5, poly=2 → 고정 계수)
|
||
val sgCoeffs: DoubleArray = doubleArrayOf(-3.0/35.0, 12.0/35.0, 17.0/35.0, 12.0/35.0, -3.0/35.0)
|
||
|
||
// Low Echo Detection
|
||
val lowEchoAmpDefault: Double get() = GreenZoneConstants.lowEchoAmp.toDouble()
|
||
val lowMinLen: Int = 3
|
||
val mergeGapMax: Int = 3
|
||
val peakSearchWin: Int = 20
|
||
val postMaxIdx: Int = 80
|
||
val minPeakMargin: Double = 30.0
|
||
val minUrineLen: Int = 3
|
||
val postRefineTolRatio: Double = 0.05
|
||
val maxRealisticVolumeMl: Double = 1500.0
|
||
val valleyStopRise: Double = 50.0
|
||
val edgeDistDecay: Double = 0.12
|
||
|
||
// ── Public API ──
|
||
|
||
/** 단일 채널 raw ADC → 용적 분석 (crash-safe) */
|
||
fun analyzeChannel(rawADC: List<UShort>, channel: Int = 0): ChannelAnalysisResult {
|
||
val raw = DoubleArray(rawADC.size) { rawADC[it].toDouble() }
|
||
if (raw.size < 10) {
|
||
return ChannelAnalysisResult(channel = channel, result = null, rawSignal = raw, denoisedSignal = raw.copyOf())
|
||
}
|
||
return try {
|
||
val denoised = denoise(raw)
|
||
val result = detectLowEcho(raw = raw, denoised = denoised)
|
||
ChannelAnalysisResult(channel = channel, result = result, rawSignal = raw, denoisedSignal = denoised)
|
||
} catch (_: Exception) {
|
||
ChannelAnalysisResult(channel = channel, result = null, rawSignal = raw, denoisedSignal = raw.copyOf())
|
||
}
|
||
}
|
||
|
||
/** 다채널 분석 → 최종 용적 */
|
||
fun analyzeMultiChannel(channelData: List<PiezoChannelData>): PiezoAnalysisResult {
|
||
val results = channelData.map { ch ->
|
||
analyzeChannel(ch.buffer, ch.channel)
|
||
}
|
||
|
||
// Step 1: Get valid results (algorithm found urine region)
|
||
val validResults = results.filter { it.isValid }
|
||
|
||
// Step 2: Filter out unrealistic volumes (container wall artifacts)
|
||
val realisticResults = validResults.filter { (it.result?.volumeMl ?: 0.0) <= maxRealisticVolumeMl }
|
||
|
||
// Step 3: Outlier removal — if 3+ results, remove > 2× median
|
||
var filtered = realisticResults
|
||
if (filtered.size >= 3) {
|
||
val vols = filtered.mapNotNull { it.result?.volumeMl }.sorted()
|
||
val median = vols[vols.size / 2]
|
||
filtered = filtered.filter { cr ->
|
||
val v = cr.result?.volumeMl ?: return@filter false
|
||
v <= median * 2.5 && v >= median * 0.3
|
||
}
|
||
}
|
||
|
||
val usedCount = filtered.size
|
||
val volumeMl: Double
|
||
val method: String
|
||
val confidence: Double
|
||
|
||
if (usedCount == 0) {
|
||
volumeMl = 0.0
|
||
method = "none"
|
||
confidence = 0.0
|
||
} else if (usedCount == 1) {
|
||
volumeMl = filtered[0].result!!.volumeMl
|
||
method = "single"
|
||
confidence = min(100.0, filtered[0].result!!.score / 100.0)
|
||
} else {
|
||
// Median-based: use median volume (robust against remaining outliers)
|
||
val vols = filtered.mapNotNull { it.result?.volumeMl }.sorted()
|
||
val median = vols[vols.size / 2]
|
||
volumeMl = median
|
||
method = "median_${usedCount}ch"
|
||
confidence = min(100.0, usedCount.toDouble() / results.size.toDouble() * 100.0)
|
||
}
|
||
|
||
return PiezoAnalysisResult(
|
||
channels = results,
|
||
volumeMl = volumeMl,
|
||
confidence = confidence,
|
||
validChannelCount = usedCount,
|
||
method = method
|
||
)
|
||
}
|
||
|
||
// ── Denoising Pipeline ──
|
||
|
||
/** SG 디노이징 (6ch 알고리즘: TVD 제거, SG만 사용) */
|
||
fun denoise(signal: DoubleArray): DoubleArray {
|
||
return sgSmooth(signal)
|
||
}
|
||
|
||
/** 1D Total Variation Denoising — Chambolle (2004) dual projected gradient */
|
||
fun tvDenoiseChambolle1D(signal: DoubleArray): DoubleArray {
|
||
val n = signal.size
|
||
if (n < 2 || tvdWeight <= 0) return signal.copyOf()
|
||
// Skip if signal is flat (all same values)
|
||
val mn = signal.min()
|
||
val mx = signal.max()
|
||
if (mx - mn < 1.0) return signal.copyOf()
|
||
|
||
val p = DoubleArray(n - 1)
|
||
var uPrev = signal.copyOf()
|
||
val tauW = tvdTau / tvdWeight
|
||
|
||
for (iter in 0 until tvdMaxIter) {
|
||
// u = x + weight * div(p)
|
||
val divP = DoubleArray(n)
|
||
divP[0] = p[0]
|
||
for (i in 1 until (n - 1)) {
|
||
divP[i] = p[i] - p[i - 1]
|
||
}
|
||
divP[n - 1] = -p[n - 2]
|
||
|
||
val u = DoubleArray(n) { signal[it] + tvdWeight * divP[it] }
|
||
|
||
// grad(u) = diff(u) and dual update
|
||
var maxDiff = 0.0
|
||
for (i in 0 until (n - 1)) {
|
||
val gradU = u[i + 1] - u[i]
|
||
p[i] = (p[i] + tauW * gradU) / (1.0 + tauW * abs(gradU))
|
||
maxDiff = max(maxDiff, abs(u[i] - uPrev[i]))
|
||
}
|
||
maxDiff = max(maxDiff, abs(u[n - 1] - uPrev[n - 1]))
|
||
|
||
if (maxDiff < tvdTol) break
|
||
uPrev = u
|
||
}
|
||
|
||
// Final reconstruction
|
||
val divP = DoubleArray(n)
|
||
divP[0] = p[0]
|
||
for (i in 1 until (n - 1)) {
|
||
divP[i] = p[i] - p[i - 1]
|
||
}
|
||
divP[n - 1] = -p[n - 2]
|
||
|
||
return DoubleArray(n) { signal[it] + tvdWeight * divP[it] }
|
||
}
|
||
|
||
/** Savitzky-Golay 5-tap FIR smoothing (window=5, poly=2) */
|
||
fun sgSmooth(signal: DoubleArray): DoubleArray {
|
||
val n = signal.size
|
||
if (n < 5) return signal.copyOf()
|
||
|
||
val out = DoubleArray(n)
|
||
|
||
// Inner: convolution with fixed coefficients [-3, 12, 17, 12, -3]/35
|
||
for (i in 2 until (n - 2)) {
|
||
out[i] = sgCoeffs[0] * signal[i - 2] +
|
||
sgCoeffs[1] * signal[i - 1] +
|
||
sgCoeffs[2] * signal[i] +
|
||
sgCoeffs[3] * signal[i + 1] +
|
||
sgCoeffs[4] * signal[i + 2]
|
||
}
|
||
|
||
// Edge: polynomial fit for first/last 2 samples
|
||
// Left edge: fit poly2 to signal[0..4], evaluate at 0,1
|
||
val leftCoeffs = polyFit2(signal.sliceArray(0 until 5))
|
||
out[0] = evalPoly2(leftCoeffs, -2.0)
|
||
out[1] = evalPoly2(leftCoeffs, -1.0)
|
||
|
||
// Right edge: fit poly2 to signal[n-5..n-1], evaluate at n-2,n-1
|
||
val rightCoeffs = polyFit2(signal.sliceArray((n - 5) until n))
|
||
out[n - 2] = evalPoly2(rightCoeffs, 1.0)
|
||
out[n - 1] = evalPoly2(rightCoeffs, 2.0)
|
||
|
||
return out
|
||
}
|
||
|
||
// Quadratic LS fit to 5 points centered at 0: x = [-2,-1,0,1,2]
|
||
// Returns Triple(c0, c1, c2) where f(t) = c0 + c1*t + c2*t²
|
||
private fun polyFit2(y: DoubleArray): Triple<Double, Double, Double> {
|
||
val c0 = (-3*y[0] + 12*y[1] + 17*y[2] + 12*y[3] - 3*y[4]) / 35.0
|
||
val c1 = (-2*y[0] - y[1] + y[3] + 2*y[4]) / 10.0
|
||
val c2 = (2*y[0] - y[1] - 2*y[2] - y[3] + 2*y[4]) / 14.0
|
||
return Triple(c0, c1, c2)
|
||
}
|
||
|
||
private fun evalPoly2(c: Triple<Double, Double, Double>, t: Double): Double =
|
||
c.first + c.second * t + c.third * t * t
|
||
|
||
// ── Low Echo Detection ──
|
||
|
||
/** 적응형 low-echo 임계값: 신호 통계 기반 */
|
||
fun adaptiveThreshold(signal: DoubleArray): Double {
|
||
if (signal.size < 10) return lowEchoAmpDefault
|
||
val sorted = signal.sorted().toDoubleArray()
|
||
val q25 = sorted[sorted.size / 4]
|
||
val median = sorted[sorted.size / 2]
|
||
val q75 = sorted[3 * sorted.size / 4]
|
||
val iqr = q75 - q25
|
||
return max(q25, median - 0.5 * iqr)
|
||
}
|
||
|
||
var useAdaptiveThreshold: Boolean = true
|
||
|
||
/** 단일 1D 채널 → urine region 탐지 */
|
||
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray): LowEchoResult? {
|
||
val sg = denoised
|
||
if (sg.size < 10) return null
|
||
|
||
if (!useAdaptiveThreshold) {
|
||
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault)
|
||
}
|
||
|
||
// Adaptive threshold: percentile + depth 보상 조합
|
||
val thr = computeAdaptiveThreshold(sg)
|
||
return detectLowEchoCore(sg = sg, threshold = thr)
|
||
}
|
||
|
||
/**
|
||
* Adaptive threshold: 신호 통계 + depth attenuation 보상
|
||
*
|
||
* 1) Percentile 기반 베이스라인: 전체 신호의 q25~median 사이에서 결정
|
||
* 2) 초반 peak(피부 반사) 제외: ringSkip(3) 이후 사용
|
||
* 3) 고정 threshold와의 가중 평균으로 급격한 변동 방지
|
||
*/
|
||
fun computeAdaptiveThreshold(sg: DoubleArray): Double {
|
||
val skip = GreenZoneConstants.ringSkip
|
||
val usable = if (sg.size > skip + 10) sg.sliceArray(skip until sg.size) else sg
|
||
|
||
val sorted = usable.sorted()
|
||
val n = sorted.size
|
||
val q25 = sorted[n / 4]
|
||
val q50 = sorted[n / 2]
|
||
val q75 = sorted[3 * n / 4]
|
||
val iqr = q75 - q25
|
||
|
||
// Percentile 기반: 소변 영역은 보통 하위 25~50%
|
||
val percThr = max(q25 + iqr * 0.3, q50 - iqr * 0.3)
|
||
|
||
// 고정값과 adaptive의 가중 평균 (급격한 변동 방지)
|
||
val adaptive = percThr.coerceIn(lowEchoAmpDefault * 0.7, lowEchoAmpDefault * 1.5)
|
||
val blended = lowEchoAmpDefault * 0.4 + adaptive * 0.6
|
||
|
||
return blended
|
||
}
|
||
|
||
/**
|
||
* Low-echo 탐지 핵심 — prominence 기반 wall peak 선택
|
||
* 1:1 port of low_echo_detection_method_b.py (2026-04-20 update)
|
||
*/
|
||
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double): LowEchoResult? {
|
||
if (sg.size < 10) return null
|
||
|
||
// 1) low-echo span 추출
|
||
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold }
|
||
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
|
||
|
||
// 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음
|
||
val gapPeakThr = threshold + 5.0
|
||
val spans = mergeCloseSpansWithWallCheck(rawSpans, maxGap = mergeGapMax, signal = sg, gapPeakThr = gapPeakThr)
|
||
val firstSpan = spans.firstOrNull() ?: return null
|
||
|
||
val s = max(0, firstSpan.first)
|
||
val e = min(sg.size - 1, firstSpan.second)
|
||
if (s > e) return null
|
||
|
||
val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
|
||
if (lowSlice.isEmpty()) return null
|
||
val lowMean = lowSlice.sum() / lowSlice.size.toDouble()
|
||
val peakMin = lowMean + minPeakMargin
|
||
|
||
// 3) Prominence 기반 wall peak 선택
|
||
val ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
|
||
peakMin = peakMin, side = WallSide.ANT, otherEdge = e) ?: return null
|
||
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
|
||
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
|
||
|
||
// 4) post > POST_MAX_IDX: prominence 기반 재탐색
|
||
if (post > postMaxIdx) {
|
||
val backHalfEdge = (s + e) / 2
|
||
post = selectWallByProminence(
|
||
sg = sg, edge = backHalfEdge,
|
||
searchWin = postMaxIdx - backHalfEdge,
|
||
peakMin = peakMin, side = WallSide.POST, otherEdge = null
|
||
) ?: return null
|
||
}
|
||
|
||
val antH = sg[ant]
|
||
val postH = sg[post]
|
||
val lowDepth = ((antH + postH) / 2.0) - lowMean
|
||
val urineLen = post - ant - 1
|
||
|
||
if (urineLen < minUrineLen) return null
|
||
|
||
return LowEchoResult(
|
||
ant = ant,
|
||
post = post,
|
||
lowStart = s,
|
||
lowEnd = e,
|
||
lowMean = lowMean,
|
||
urineLen = urineLen,
|
||
score = lowDepth * urineLen.toDouble(),
|
||
innerPeaks = findInnerPeaks(sg, left = ant, right = post)
|
||
)
|
||
}
|
||
|
||
// ── Prominence-based Wall Selection ──
|
||
|
||
private val maxPeakCandidates = 3
|
||
|
||
private enum class WallSide { ANT, POST }
|
||
|
||
/** peak 오른쪽에서 가장 가까운 valley의 sg 값 */
|
||
private fun findRightValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||
val n = sg.size
|
||
var v = sg[peakIdx]
|
||
for (i in (peakIdx + 1) until min(n, peakIdx + maxDist)) {
|
||
if (sg[i] < v) {
|
||
v = sg[i]
|
||
} else if (sg[i] > v + valleyStopRise) {
|
||
break
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||
var v = sg[peakIdx]
|
||
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
|
||
if (sg[i] < v) {
|
||
v = sg[i]
|
||
} else if (sg[i] > v + valleyStopRise) {
|
||
break
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
/**
|
||
* edge 양쪽에서 prominence 최대인 wall peak 선택
|
||
* 1:1 port of _select_wall_by_prominence()
|
||
*/
|
||
private fun selectWallByProminence(
|
||
sg: DoubleArray, edge: Int, searchWin: Int,
|
||
peakMin: Double, side: WallSide, otherEdge: Int?
|
||
): Int? {
|
||
val n = sg.size
|
||
if (n <= 0 || edge < 0 || edge >= n) return null
|
||
|
||
// 검색 범위 결정
|
||
val leftLo: Int
|
||
val rightHi: Int
|
||
|
||
if (side == WallSide.ANT) {
|
||
leftLo = max(0, edge - searchWin) // 바깥 (자유)
|
||
var rh = min(n - 1, edge + searchWin) // 안쪽
|
||
if (otherEdge != null) rh = min(rh, otherEdge) // e를 넘지 않음
|
||
rightHi = rh
|
||
} else {
|
||
var ll = max(0, edge - searchWin) // 안쪽
|
||
if (otherEdge != null) ll = max(ll, otherEdge) // s를 넘지 않음
|
||
leftLo = ll
|
||
rightHi = min(n - 1, edge + searchWin) // 바깥 (자유)
|
||
}
|
||
|
||
// edge 왼쪽 peak 후보 — edge+1 포함 (inclusive boundary)
|
||
var leftCandidates = listOf<Int>()
|
||
val leftEnd = min(edge + 1, n)
|
||
if (leftEnd > leftLo) {
|
||
val seg = safeSlice(sg, from = leftLo, to = leftEnd - 1)
|
||
if (seg != null) {
|
||
val pks = findPeaks1D(seg)
|
||
val global = pks.map { it + leftLo }
|
||
.filter { it < edge && sg[it] >= peakMin }
|
||
leftCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||
}
|
||
}
|
||
|
||
// edge 오른쪽 peak 후보 — edge-1부터 시작 (inclusive boundary)
|
||
var rightCandidates = listOf<Int>()
|
||
val rightStart = max(edge - 1, 0)
|
||
if (rightHi >= rightStart) {
|
||
val seg = safeSlice(sg, from = rightStart, to = rightHi)
|
||
if (seg != null) {
|
||
val pks = findPeaks1D(seg)
|
||
val global = pks.map { it + rightStart }
|
||
.filter { it >= edge && sg[it] >= peakMin }
|
||
rightCandidates = global.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||
}
|
||
}
|
||
|
||
val candidates = leftCandidates + rightCandidates
|
||
if (candidates.isEmpty()) return null
|
||
|
||
// prominence + edge distance penalty (EDGE_DIST_DECAY=0.12)
|
||
var bestPeak: Int? = null
|
||
var bestScore = -1.0
|
||
|
||
for (p in candidates) {
|
||
val valley: Double = if (side == WallSide.ANT) {
|
||
findRightValley(sg, peakIdx = p)
|
||
} else {
|
||
findLeftValley(sg, peakIdx = p)
|
||
}
|
||
val prom = sg[p] - valley
|
||
val dist = abs(p - edge)
|
||
val score = prom / (1.0 + edgeDistDecay * dist)
|
||
if (score > bestScore) {
|
||
bestScore = score
|
||
bestPeak = p
|
||
}
|
||
}
|
||
|
||
return bestPeak
|
||
}
|
||
|
||
// ── Span Utilities ──
|
||
|
||
fun findPeaks1D(x: DoubleArray, height: Double? = null): List<Int> {
|
||
val n = x.size
|
||
if (n < 3) return emptyList()
|
||
|
||
val peaks = mutableListOf<Int>()
|
||
var i = 1
|
||
while (i < n - 1) {
|
||
if (x[i - 1] < x[i]) {
|
||
var j = i
|
||
while (j < n - 1 && x[j + 1] == x[j]) j++
|
||
if (j < n - 1 && x[j + 1] < x[j]) {
|
||
peaks.add((i + j) / 2)
|
||
}
|
||
i = j + 1
|
||
} else {
|
||
i++
|
||
}
|
||
}
|
||
|
||
return if (height != null) {
|
||
peaks.filter { x[it] >= height }
|
||
} else {
|
||
peaks
|
||
}
|
||
}
|
||
|
||
fun contiguousTrueSpans(mask: BooleanArray): List<Pair<Int, Int>> {
|
||
val spans = mutableListOf<Pair<Int, Int>>()
|
||
var start: Int? = null
|
||
for ((i, m) in mask.withIndex()) {
|
||
if (m && start == null) {
|
||
start = i
|
||
} else if (!m && start != null) {
|
||
spans.add(Pair(start, i - 1))
|
||
start = null
|
||
}
|
||
}
|
||
if (start != null) {
|
||
spans.add(Pair(start, mask.size - 1))
|
||
}
|
||
return spans
|
||
}
|
||
|
||
fun mergeCloseSpans(spans: List<Pair<Int, Int>>, maxGap: Int): List<Pair<Int, Int>> {
|
||
return mergeCloseSpansWithWallCheck(spans, maxGap = maxGap, signal = null, gapPeakThr = 0.0)
|
||
}
|
||
|
||
/** 병합 시 gap 내부에 벽 후보(gapPeakThr 초과 peak)가 있으면 병합하지 않음 */
|
||
fun mergeCloseSpansWithWallCheck(
|
||
spans: List<Pair<Int, Int>>, maxGap: Int,
|
||
signal: DoubleArray?, gapPeakThr: Double
|
||
): List<Pair<Int, Int>> {
|
||
if (spans.isEmpty()) return emptyList()
|
||
val ordered = spans.sortedBy { it.first }
|
||
val merged = mutableListOf(ordered[0])
|
||
for (i in 1 until ordered.size) {
|
||
val (s, e) = ordered[i]
|
||
val prevEnd = merged[merged.size - 1].second
|
||
val gap = s - prevEnd - 1
|
||
if (gap <= maxGap) {
|
||
// Check: gap 내부에 벽 후보 peak이 있는지
|
||
var hasWallPeak = false
|
||
if (signal != null && gapPeakThr > 0) {
|
||
val gapStart = prevEnd + 1
|
||
val gapEnd = s - 1
|
||
if (gapStart <= gapEnd) {
|
||
val gapSlice = safeSlice(signal, from = gapStart, to = gapEnd)
|
||
if (gapSlice != null) {
|
||
val peaks = findPeaks1D(gapSlice, height = gapPeakThr)
|
||
if (peaks.isNotEmpty()) hasWallPeak = true
|
||
// Also check endpoints
|
||
if (!hasWallPeak) {
|
||
if ((gapSlice.firstOrNull() ?: 0.0) > gapPeakThr) hasWallPeak = true
|
||
if ((gapSlice.lastOrNull() ?: 0.0) > gapPeakThr) hasWallPeak = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (hasWallPeak) {
|
||
// Wall candidate in gap — don't merge
|
||
merged.add(Pair(s, e))
|
||
} else {
|
||
merged[merged.size - 1] = Pair(merged[merged.size - 1].first, max(prevEnd, e))
|
||
}
|
||
} else {
|
||
merged.add(Pair(s, e))
|
||
}
|
||
}
|
||
return merged
|
||
}
|
||
|
||
fun nearestPeak(signal: DoubleArray, center: Int, left: Int, right: Int, minHeight: Double): Int? {
|
||
if (signal.isEmpty()) return null
|
||
val l = max(0, left)
|
||
val r = min(signal.size - 1, right)
|
||
val local = safeSlice(signal, from = l, to = r) ?: return null
|
||
val pkLocal = findPeaks1D(local, height = minHeight)
|
||
val candidates = (pkLocal.map { it + l }).toMutableSet()
|
||
|
||
// Endpoint correction
|
||
if (l == r) {
|
||
if (signal[l] >= minHeight) candidates.add(l)
|
||
} else {
|
||
if (signal[l] >= minHeight && signal[l] >= signal[l + 1]) candidates.add(l)
|
||
if (signal[r] >= minHeight && signal[r] >= signal[r - 1]) candidates.add(r)
|
||
}
|
||
|
||
if (candidates.isEmpty()) return null
|
||
|
||
val sorted = candidates.sorted()
|
||
// Find nearest to center, tie-break by amplitude
|
||
var bestIdx = sorted[0]
|
||
var bestDist = abs(sorted[0] - center)
|
||
for (c in sorted) {
|
||
val dist = abs(c - center)
|
||
if (dist < bestDist || (dist == bestDist && signal[c] > signal[bestIdx])) {
|
||
bestIdx = c
|
||
bestDist = dist
|
||
}
|
||
}
|
||
return bestIdx
|
||
}
|
||
|
||
fun findInnerPeaks(signal: DoubleArray, left: Int, right: Int): List<Int> {
|
||
val l = left + 1
|
||
val r = right - 1
|
||
val local = safeSlice(signal, from = l, to = r) ?: return emptyList()
|
||
return findPeaks1D(local).map { it + l }
|
||
}
|
||
|
||
// ── Safe Array Slicing ──
|
||
|
||
/** Safe inclusive range slice — returns null if bounds are invalid */
|
||
private fun safeSlice(arr: DoubleArray, from: Int, to: Int): DoubleArray? {
|
||
val l = max(0, from)
|
||
val r = min(arr.size - 1, to)
|
||
if (l > r || arr.isEmpty()) return null
|
||
return arr.sliceArray(l..r)
|
||
}
|
||
}
|