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:
@@ -3,6 +3,7 @@
|
|||||||
/local.properties
|
/local.properties
|
||||||
/.idea/
|
/.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/docs/
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ object GreenZoneConstants {
|
|||||||
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
|
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
|
||||||
* Python 원본: 1150 (low_echo_detection_method_b.py LOW_ECHO_AMP, 6ch 버전)
|
* Python 원본: 1150 (low_echo_detection_method_b.py LOW_ECHO_AMP, 6ch 버전)
|
||||||
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
|
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
|
||||||
var lowEchoAmp: Float = 1150f
|
var lowEchoAmp: Float = 1250f
|
||||||
|
|
||||||
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
|
/** 구조 이진화 임계값: 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> {
|
fun analyzeMultiChannelWithTgc(channelData: List<PiezoChannelData>): List<ChannelAnalysisResult> {
|
||||||
// 1) SG denoise all channels
|
// 1) SG denoise all channels
|
||||||
val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } }
|
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
|
// 2) TGC per channel
|
||||||
val tgcSignals = denoised.map { applyTgc(it) }
|
val tgcSignals = denoised.map { applyTgc(it) }
|
||||||
|
|
||||||
// 3) Multi-channel back reflection consensus
|
// 3) Per-channel: backReflection → suppress → detect (Python pipeline.py 동일)
|
||||||
val brIdx = findBackReflectionMultichannel(tgcSignals, postMaxIdx)
|
|
||||||
|
|
||||||
// 4) Per-channel: suppress → detect
|
|
||||||
return channelData.mapIndexed { idx, ch ->
|
return channelData.mapIndexed { idx, ch ->
|
||||||
try {
|
try {
|
||||||
val raw = raws[idx]
|
val raw = raws[idx]
|
||||||
val sg = denoised[idx]
|
val sg = denoised[idx]
|
||||||
val cleaned = suppressBackReflection(sg, brIdx)
|
val br = findBackReflectionIdx(tgcSignals[idx], postMaxIdx)
|
||||||
val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = brIdx)
|
val cleaned = suppressBackReflection(sg, br)
|
||||||
|
val result = detectLowEcho(raw = raw, denoised = cleaned, otsuSignal = sg, dynamicPostMax = br)
|
||||||
ChannelAnalysisResult(ch.channel, result, raw, cleaned)
|
ChannelAnalysisResult(ch.channel, result, raw, cleaned)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
ChannelAnalysisResult(ch.channel, null, raws[idx], denoised[idx])
|
ChannelAnalysisResult(ch.channel, null, raws[idx], denoised[idx])
|
||||||
@@ -361,14 +359,20 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val otsuInput = otsuSignal ?: sg
|
val otsuInput = otsuSignal ?: sg
|
||||||
val limit = min(otsuInput.size, effectivePostMax + 1)
|
val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size)
|
||||||
val (thr, sep) = otsu1dWithSeparability(otsuInput, limit)
|
|
||||||
val adjustedThr = thr * otsuRatio
|
val adjustedThr = thr * otsuRatio
|
||||||
lastOtsuSeparability = sep
|
lastOtsuSeparability = sep
|
||||||
|
|
||||||
if (sep >= minOtsuSeparability) {
|
if (sep >= minOtsuSeparability) {
|
||||||
lastOtsuThreshold = adjustedThr
|
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
|
// Separability 낮음 → 고정 threshold fallback
|
||||||
@@ -382,13 +386,14 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
val n = signal.size
|
val n = signal.size
|
||||||
if (n < 5) return signal.copyOf()
|
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() }
|
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
|
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)
|
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 absSlope = abs(slope)
|
||||||
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
|
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? {
|
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? {
|
||||||
if (sg.size < 10) return null
|
if (sg.size < 10) return null
|
||||||
|
|
||||||
// 1) low-echo span 추출 (effectivePostMax까지)
|
// 1) low-echo span 추출 (전체 신호에 마스킹, Python 동일)
|
||||||
val analysisLimit = min(sg.size, effectivePostMax + 1)
|
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold }
|
||||||
val lowMask = BooleanArray(sg.size) { it < analysisLimit && sg[it] <= threshold }
|
|
||||||
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
|
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
|
||||||
|
|
||||||
// 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음
|
// 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음
|
||||||
@@ -536,7 +540,7 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
val firstSpan = spans.firstOrNull() ?: return null
|
val firstSpan = spans.firstOrNull() ?: return null
|
||||||
|
|
||||||
val s = max(0, firstSpan.first)
|
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
|
if (s > e) return null
|
||||||
|
|
||||||
val lowSlice = safeSlice(sg, from = s, to = 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,
|
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
|
||||||
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
|
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
|
||||||
|
|
||||||
// 4) post가 분석 범위를 넘으면 재탐색
|
// 4) post > effectivePostMax 재탐색
|
||||||
if (post >= analysisLimit) {
|
if (post > effectivePostMax) {
|
||||||
val backHalfEdge = (s + e) / 2
|
val backHalfEdge = (s + e) / 2
|
||||||
post = selectWallByProminence(
|
post = selectWallByProminence(
|
||||||
sg = sg, edge = backHalfEdge,
|
sg = sg, edge = backHalfEdge,
|
||||||
searchWin = analysisLimit - 1 - backHalfEdge,
|
searchWin = effectivePostMax - backHalfEdge,
|
||||||
peakMin = peakMin, side = WallSide.POST, otherEdge = null
|
peakMin = peakMin, side = WallSide.POST, otherEdge = null
|
||||||
) ?: return null
|
) ?: return null
|
||||||
}
|
}
|
||||||
@@ -566,8 +570,6 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
val antH = sg[ant]
|
val antH = sg[ant]
|
||||||
val postH = sg[post]
|
val postH = sg[post]
|
||||||
|
|
||||||
if (postH < threshold) return null
|
|
||||||
|
|
||||||
val lowDepth = ((antH + postH) / 2.0) - lowMean
|
val lowDepth = ((antH + postH) / 2.0) - lowMean
|
||||||
val urineLen = post - ant - 1
|
val urineLen = post - ant - 1
|
||||||
|
|
||||||
@@ -766,9 +768,9 @@ class PiezoEchoAnalyzer private constructor() {
|
|||||||
val prevEnd = merged[merged.size - 1].second
|
val prevEnd = merged[merged.size - 1].second
|
||||||
val gap = s - prevEnd - 1
|
val gap = s - prevEnd - 1
|
||||||
if (gap <= maxGap) {
|
if (gap <= maxGap) {
|
||||||
// Check: gap 내부에 벽 후보 peak이 있는지
|
// gap < 3은 무조건 병합 (Python peak_check_min_gap=3)
|
||||||
var hasWallPeak = false
|
var hasWallPeak = false
|
||||||
if (signal != null && gapPeakThr > 0) {
|
if (gap >= 3 && signal != null && gapPeakThr > 0) {
|
||||||
val gapStart = prevEnd + 1
|
val gapStart = prevEnd + 1
|
||||||
val gapEnd = s - 1
|
val gapEnd = s - 1
|
||||||
if (gapStart <= gapEnd) {
|
if (gapStart <= gapEnd) {
|
||||||
|
|||||||
@@ -68,13 +68,9 @@ class PiezoEchoAnalyzerA private constructor() {
|
|||||||
if (L < 10) return null
|
if (L < 10) return null
|
||||||
val y = DoubleArray(L) { sg[it] + alpha * abs(d1[it]) + beta * abs(d2[it]) }
|
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 platScore = slidingScores1d(sg, scoreWin)
|
||||||
val fullSorted = platScore.sorted().toDoubleArray()
|
val scoreThr = npQuantile(platScore, plateauQ)
|
||||||
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 limit = min(platScore.size, postMaxIdx + 1)
|
val limit = min(platScore.size, postMaxIdx + 1)
|
||||||
|
|
||||||
// 3) Plateau spans + merge (postMaxIdx 이내만)
|
// 3) Plateau spans + merge (postMaxIdx 이내만)
|
||||||
@@ -186,8 +182,8 @@ class PiezoEchoAnalyzerA private constructor() {
|
|||||||
val mean = sum / w
|
val mean = sum / w
|
||||||
flat[i] = sqrt(max(0.0, sqSum / w - mean * mean))
|
flat[i] = sqrt(max(0.0, sqSum / w - mean * mean))
|
||||||
|
|
||||||
val sorted = ww.sorted().toDoubleArray()
|
val sorted = ww.sorted()
|
||||||
low[i] = sorted[(w * 0.2).toInt().coerceIn(0, w - 1)]
|
low[i] = npQuantile(sorted, 0.2)
|
||||||
|
|
||||||
var slopeNum = 0.0
|
var slopeNum = 0.0
|
||||||
for (j in 0 until w) {
|
for (j in 0 until w) {
|
||||||
@@ -283,6 +279,23 @@ class PiezoEchoAnalyzerA private constructor() {
|
|||||||
return corrected
|
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 ──
|
// ── Peak / Valley helpers ──
|
||||||
|
|
||||||
private fun findPeaksInRange(sg: DoubleArray, from: Int, to: Int): List<Int> {
|
private fun findPeaksInRange(sg: DoubleArray, from: Int, to: Int): List<Int> {
|
||||||
|
|||||||
Reference in New Issue
Block a user