fix: Python 대조 CRITICAL 10건 수정 (Method A/B 완전 동기화)
Method B: - #1 Otsu: limit 제거 → 전체 신호로 계산 - #2 detectLowEcho: 2단계 fallback (Otsu→None이면 고정1250 재시도) - #3 LOW_ECHO_AMP: 1150 → 1250 - #4 low mask: analysisLimit 제한 제거 → 전체 신호 마스킹 - #5 postH >= threshold 체크 제거 (Python에 없음) - #6 merge gap < 3: 무조건 병합 (peak_check_min_gap=3) - #7 TGC: raw fit → log 압축 후 fit (Python fit_attenuation_lines 동일) - #8 Back reflection: 다채널 consensus → 채널별 개별 (Python pipeline.py 동일) Method A: - #9 sliding_scores_1d low quantile: floor index → np.quantile 선형 보간 - #10 score_thr: 중간값 근사 → np.quantile(0.5) 선형 보간 - npQuantile() 헬퍼 함수 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,7 @@ object GreenZoneConstants {
|
||||
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
|
||||
* Python 원본: 1150 (low_echo_detection_method_b.py LOW_ECHO_AMP, 6ch 버전)
|
||||
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
|
||||
var lowEchoAmp: Float = 1150f
|
||||
var lowEchoAmp: Float = 1250f
|
||||
|
||||
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
|
||||
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
|
||||
|
||||
@@ -157,7 +157,7 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 다채널 TGC 파이프라인: denoise → TGC → findBackReflection → suppress → detect */
|
||||
/** 다채널 TGC 파이프라인: denoise → TGC → 채널별 backReflection → suppress → detect */
|
||||
fun analyzeMultiChannelWithTgc(channelData: List<PiezoChannelData>): List<ChannelAnalysisResult> {
|
||||
// 1) SG denoise all channels
|
||||
val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } }
|
||||
@@ -166,16 +166,14 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
// 2) TGC per channel
|
||||
val tgcSignals = denoised.map { applyTgc(it) }
|
||||
|
||||
// 3) Multi-channel back reflection consensus
|
||||
val brIdx = findBackReflectionMultichannel(tgcSignals, postMaxIdx)
|
||||
|
||||
// 4) Per-channel: suppress → detect
|
||||
// 3) Per-channel: backReflection → suppress → detect (Python pipeline.py 동일)
|
||||
return channelData.mapIndexed { idx, ch ->
|
||||
try {
|
||||
val raw = raws[idx]
|
||||
val sg = denoised[idx]
|
||||
val cleaned = suppressBackReflection(sg, brIdx)
|
||||
val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = brIdx)
|
||||
val br = findBackReflectionIdx(tgcSignals[idx], postMaxIdx)
|
||||
val cleaned = suppressBackReflection(sg, br)
|
||||
val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = br)
|
||||
ChannelAnalysisResult(ch.channel, result, raw, cleaned)
|
||||
} catch (_: Exception) {
|
||||
ChannelAnalysisResult(ch.channel, null, raws[idx], denoised[idx])
|
||||
@@ -361,14 +359,20 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
}
|
||||
|
||||
val otsuInput = otsuSignal ?: sg
|
||||
val limit = min(otsuInput.size, effectivePostMax + 1)
|
||||
val (thr, sep) = otsu1dWithSeparability(otsuInput, limit)
|
||||
val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size)
|
||||
val adjustedThr = thr * otsuRatio
|
||||
lastOtsuSeparability = sep
|
||||
|
||||
if (sep >= minOtsuSeparability) {
|
||||
lastOtsuThreshold = adjustedThr
|
||||
return detectLowEchoCore(sg = sg, threshold = adjustedThr, effectivePostMax = effectivePostMax)
|
||||
val result = detectLowEchoCore(sg = sg, threshold = adjustedThr, effectivePostMax = effectivePostMax)
|
||||
if (result != null) return result
|
||||
// Otsu 결과 None → 고정 threshold로 재시도 (|otsu - fixed| > 1.0일 때만)
|
||||
if (abs(adjustedThr - lowEchoAmpDefault) > 1.0) {
|
||||
lastOtsuThreshold = lowEchoAmpDefault
|
||||
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Separability 낮음 → 고정 threshold fallback
|
||||
@@ -382,13 +386,14 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
val n = signal.size
|
||||
if (n < 5) return signal.copyOf()
|
||||
|
||||
// Log 압축 후 linear fit (Python fit_attenuation_lines 동일)
|
||||
val logSignal = DoubleArray(n) { kotlin.math.ln(max(signal[it], 1.0)) }
|
||||
val x = DoubleArray(n) { it.toDouble() }
|
||||
// Linear fit: slope, intercept
|
||||
var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumX2 = 0.0
|
||||
for (i in 0 until n) { sumX += x[i]; sumY += signal[i]; sumXY += x[i] * signal[i]; sumX2 += x[i] * x[i] }
|
||||
for (i in 0 until n) { sumX += x[i]; sumY += logSignal[i]; sumXY += x[i] * logSignal[i]; sumX2 += x[i] * x[i] }
|
||||
val slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
|
||||
if (slope >= 0) return signal.copyOf() // 감쇠 없으면 보상 불필요
|
||||
if (slope >= 0) return signal.copyOf()
|
||||
|
||||
val absSlope = abs(slope)
|
||||
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
|
||||
@@ -525,9 +530,8 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? {
|
||||
if (sg.size < 10) return null
|
||||
|
||||
// 1) low-echo span 추출 (effectivePostMax까지)
|
||||
val analysisLimit = min(sg.size, effectivePostMax + 1)
|
||||
val lowMask = BooleanArray(sg.size) { it < analysisLimit && sg[it] <= threshold }
|
||||
// 1) low-echo span 추출 (전체 신호에 마스킹, Python 동일)
|
||||
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold }
|
||||
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
|
||||
|
||||
// 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음
|
||||
@@ -536,7 +540,7 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
val firstSpan = spans.firstOrNull() ?: return null
|
||||
|
||||
val s = max(0, firstSpan.first)
|
||||
val e = min(analysisLimit - 1, firstSpan.second)
|
||||
val e = min(sg.size - 1, firstSpan.second)
|
||||
if (s > e) return null
|
||||
|
||||
val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
|
||||
@@ -553,12 +557,12 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
|
||||
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
|
||||
|
||||
// 4) post가 분석 범위를 넘으면 재탐색
|
||||
if (post >= analysisLimit) {
|
||||
// 4) post > effectivePostMax 재탐색
|
||||
if (post > effectivePostMax) {
|
||||
val backHalfEdge = (s + e) / 2
|
||||
post = selectWallByProminence(
|
||||
sg = sg, edge = backHalfEdge,
|
||||
searchWin = analysisLimit - 1 - backHalfEdge,
|
||||
searchWin = effectivePostMax - backHalfEdge,
|
||||
peakMin = peakMin, side = WallSide.POST, otherEdge = null
|
||||
) ?: return null
|
||||
}
|
||||
@@ -566,8 +570,6 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
val antH = sg[ant]
|
||||
val postH = sg[post]
|
||||
|
||||
if (postH < threshold) return null
|
||||
|
||||
val lowDepth = ((antH + postH) / 2.0) - lowMean
|
||||
val urineLen = post - ant - 1
|
||||
|
||||
@@ -766,9 +768,9 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
val prevEnd = merged[merged.size - 1].second
|
||||
val gap = s - prevEnd - 1
|
||||
if (gap <= maxGap) {
|
||||
// Check: gap 내부에 벽 후보 peak이 있는지
|
||||
// gap < 3은 무조건 병합 (Python peak_check_min_gap=3)
|
||||
var hasWallPeak = false
|
||||
if (signal != null && gapPeakThr > 0) {
|
||||
if (gap >= 3 && signal != null && gapPeakThr > 0) {
|
||||
val gapStart = prevEnd + 1
|
||||
val gapEnd = s - 1
|
||||
if (gapStart <= gapEnd) {
|
||||
|
||||
@@ -68,13 +68,9 @@ class PiezoEchoAnalyzerA private constructor() {
|
||||
if (L < 10) return null
|
||||
val y = DoubleArray(L) { sg[it] + alpha * abs(d1[it]) + beta * abs(d2[it]) }
|
||||
|
||||
// 2) Plateau score — threshold from full array (Python 동일)
|
||||
// 2) Plateau score — threshold from full array (np.quantile 보간)
|
||||
val platScore = slidingScores1d(sg, scoreWin)
|
||||
val fullSorted = platScore.sorted().toDoubleArray()
|
||||
val qIdx = (fullSorted.size * plateauQ).toInt().coerceIn(0, fullSorted.size - 1)
|
||||
val scoreThr = if (qIdx > 0 && qIdx < fullSorted.size - 1) {
|
||||
(fullSorted[qIdx] + fullSorted[qIdx + 1]) / 2.0
|
||||
} else fullSorted[qIdx]
|
||||
val scoreThr = npQuantile(platScore, plateauQ)
|
||||
val limit = min(platScore.size, postMaxIdx + 1)
|
||||
|
||||
// 3) Plateau spans + merge (postMaxIdx 이내만)
|
||||
@@ -186,8 +182,8 @@ class PiezoEchoAnalyzerA private constructor() {
|
||||
val mean = sum / w
|
||||
flat[i] = sqrt(max(0.0, sqSum / w - mean * mean))
|
||||
|
||||
val sorted = ww.sorted().toDoubleArray()
|
||||
low[i] = sorted[(w * 0.2).toInt().coerceIn(0, w - 1)]
|
||||
val sorted = ww.sorted()
|
||||
low[i] = npQuantile(sorted, 0.2)
|
||||
|
||||
var slopeNum = 0.0
|
||||
for (j in 0 until w) {
|
||||
@@ -283,6 +279,23 @@ class PiezoEchoAnalyzerA private constructor() {
|
||||
return corrected
|
||||
}
|
||||
|
||||
// ── np.quantile 보간 (linear interpolation, numpy default) ──
|
||||
private fun npQuantile(data: DoubleArray, q: Double): Double {
|
||||
if (data.isEmpty()) return 0.0
|
||||
val sorted = data.sorted().toDoubleArray()
|
||||
return npQuantile(sorted.toList(), q)
|
||||
}
|
||||
|
||||
private fun npQuantile(sorted: List<Double>, q: Double): Double {
|
||||
if (sorted.isEmpty()) return 0.0
|
||||
val n = sorted.size
|
||||
val idx = q * (n - 1)
|
||||
val lo = idx.toInt().coerceIn(0, n - 1)
|
||||
val hi = (lo + 1).coerceAtMost(n - 1)
|
||||
val frac = idx - lo
|
||||
return sorted[lo] + frac * (sorted[hi] - sorted[lo])
|
||||
}
|
||||
|
||||
// ── Peak / Valley helpers ──
|
||||
|
||||
private fun findPeaksInRange(sg: DoubleArray, from: Int, to: Int): List<Int> {
|
||||
|
||||
Reference in New Issue
Block a user