feat: Method B TGC+FP필터, Placement UI 리디자인, 네비게이션 개선
Method B 알고리즘 (Python 동기화): - TGC 파이프라인: applyTgc (감쇠 보상, adaptive ratio) - 후면반사 제거: findBackReflectionIdx + suppressBackReflection - 멀티채널 합의: findBackReflectionMultichannel (median) - FP 필터: wallMin/lowMean < 1.15, score < 3000 - peakMin: max(lowMean+margin, threshold) - Otsu: separability 반환 + OTSU_RATIO(0.85) + fallback(sep<0.3) - urineLen: post-ant → post-ant-1 (Python 동기화) - ant fallback: span 시작 직전 (단조 감소 대응) Placement UI 리디자인: - 상반신 일러스트 (Canvas: 몸통+배꼽+치골+방광+센서+화살표) - Green Zone: 배경 animateColorAsState 전환 + 체크마크 - 가이드 순서: 치골→위로→CH0 안잡히면 아래로→좌우 대칭 - 디바운스 4초 유지, 힌트 텍스트 Green시 초록 - 좌우 힌트: CH4/CH5 비교 기반 방향 제시 - 설정 톱니바퀴 + floating 패널 (Method A/B/C + Otsu) - 채널 dot row 별도 배치 - Green score: CV 0.10, LR dev 0.25 (엄격화) 도넛차트 UI: - Max Vol 카드 제거 (4→3칸), InfoCard 높이 통일 - Void/Catheterize 버튼 (2줄, 56dp) - Quick Save: 실제 측정값 표시 + volume 0 리셋 - 기본 Method C 네비게이션: - Placement 뒤로가기 → 연결중이면 Monitoring (Home 안감) - Home Start → 연결중이면 바로 Monitoring (DeviceScan 스킵) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C }
|
||||
|
||||
object GreenZoneConstants {
|
||||
|
||||
var detectionMethod: DetectionMethod = DetectionMethod.METHOD_B
|
||||
var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 신호 범위
|
||||
|
||||
@@ -157,6 +157,32 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 다채널 TGC 파이프라인: denoise → TGC → findBackReflection → 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() } }
|
||||
val denoised = raws.map { denoise(it) }
|
||||
|
||||
// 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
|
||||
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)
|
||||
ChannelAnalysisResult(ch.channel, result, raw, cleaned)
|
||||
} catch (_: Exception) {
|
||||
ChannelAnalysisResult(ch.channel, null, raws[idx], denoised[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 다채널 분석 → 최종 용적 */
|
||||
fun analyzeMultiChannel(channelData: List<PiezoChannelData>): PiezoAnalysisResult {
|
||||
val results = channelData.map { ch ->
|
||||
@@ -313,38 +339,140 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
|
||||
var useAdaptiveThreshold: Boolean = true
|
||||
var lastOtsuThreshold: Double = 0.0; private set
|
||||
var lastOtsuSeparability: Double = 0.0; private set
|
||||
|
||||
/** 단일 1D 채널 → urine region 탐지 */
|
||||
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray): LowEchoResult? {
|
||||
// Otsu 관련 상수
|
||||
val otsuRatio: Double = 0.85
|
||||
val minOtsuSeparability: Double = 0.3
|
||||
val minScore: Double = 3000.0
|
||||
val wallLowMeanMinRatio: Double = 1.15
|
||||
|
||||
/** 단일 1D 채널 → urine region 탐지 (otsuSignal: suppress 전 원본 SG로 Otsu 계산) */
|
||||
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null): LowEchoResult? {
|
||||
val sg = denoised
|
||||
if (sg.size < 10) return null
|
||||
|
||||
val thr = if (useAdaptiveThreshold) {
|
||||
val limit = min(sg.size, postMaxIdx + 1)
|
||||
otsu1d(sg, limit)
|
||||
} else {
|
||||
lowEchoAmpDefault
|
||||
val effectivePostMax = dynamicPostMax ?: postMaxIdx
|
||||
|
||||
if (!useAdaptiveThreshold) {
|
||||
lastOtsuThreshold = lowEchoAmpDefault
|
||||
lastOtsuSeparability = 0.0
|
||||
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
lastOtsuThreshold = thr
|
||||
return detectLowEchoCore(sg = sg, threshold = thr)
|
||||
|
||||
val otsuInput = otsuSignal ?: sg
|
||||
val limit = min(otsuInput.size, effectivePostMax + 1)
|
||||
val (thr, sep) = otsu1dWithSeparability(otsuInput, limit)
|
||||
val adjustedThr = thr * otsuRatio
|
||||
lastOtsuSeparability = sep
|
||||
|
||||
if (sep >= minOtsuSeparability) {
|
||||
lastOtsuThreshold = adjustedThr
|
||||
return detectLowEchoCore(sg = sg, threshold = adjustedThr, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
|
||||
// Separability 낮음 → 고정 threshold fallback
|
||||
lastOtsuThreshold = lowEchoAmpDefault
|
||||
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
|
||||
// ── TGC (Time Gain Compensation) ──
|
||||
|
||||
fun applyTgc(signal: DoubleArray): DoubleArray {
|
||||
val n = signal.size
|
||||
if (n < 5) return signal.copyOf()
|
||||
|
||||
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] }
|
||||
val slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
|
||||
if (slope >= 0) return signal.copyOf() // 감쇠 없으면 보상 불필요
|
||||
|
||||
val absSlope = abs(slope)
|
||||
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
|
||||
val ratio = if (absSlope < slopeThresh) 1.0
|
||||
else max(ratioMin, 1.0 - (1.0 - ratioMin) * (absSlope - slopeThresh) / (slopeMax - slopeThresh))
|
||||
|
||||
val targetSlope = slope * ratio
|
||||
val compensation = DoubleArray(n) { (targetSlope - slope) * x[it] }
|
||||
return DoubleArray(n) { signal[it] + compensation[it] }
|
||||
}
|
||||
|
||||
// ── Back Reflection Detection & Suppression ──
|
||||
|
||||
fun findBackReflectionIdx(tgcSg: DoubleArray, postMaxIdx: Int, minSearchIdx: Int = 40, extend: Int = 20): Int {
|
||||
val n = tgcSg.size
|
||||
val searchEnd = min(n, postMaxIdx + extend)
|
||||
if (minSearchIdx >= searchEnd) return postMaxIdx
|
||||
|
||||
val seg = tgcSg.sliceArray(minSearchIdx until searchEnd)
|
||||
val peaks = findPeaks1D(seg)
|
||||
if (peaks.isEmpty()) return postMaxIdx
|
||||
|
||||
val globalPeaks = peaks.map { it + minSearchIdx }
|
||||
return globalPeaks.maxByOrNull { tgcSg[it] } ?: postMaxIdx
|
||||
}
|
||||
|
||||
fun suppressBackReflection(sg: DoubleArray, backRefIdx: Int, searchMargin: Int = 10): DoubleArray {
|
||||
val cleaned = sg.copyOf()
|
||||
val n = sg.size
|
||||
if (backRefIdx >= n) return cleaned
|
||||
|
||||
val lo = max(0, backRefIdx - min(searchMargin, 3))
|
||||
val hi = min(n, backRefIdx + searchMargin + 1)
|
||||
var actualPeak = lo
|
||||
for (i in lo until hi) if (sg[i] > sg[actualPeak]) actualPeak = i
|
||||
|
||||
// left valley
|
||||
val valleyLoStart = max(0, actualPeak - searchMargin)
|
||||
var leftValley = valleyLoStart
|
||||
for (i in valleyLoStart..actualPeak) if (sg[i] < sg[leftValley]) leftValley = i
|
||||
|
||||
// right valley
|
||||
val valleyHiEnd = min(n, actualPeak + searchMargin + 1)
|
||||
var rightValley = actualPeak
|
||||
for (i in actualPeak until valleyHiEnd) if (sg[i] < sg[rightValley]) rightValley = i
|
||||
|
||||
if (rightValley > leftValley) {
|
||||
val leftVal = sg[leftValley]
|
||||
val rightVal = sg[rightValley]
|
||||
val length = rightValley - leftValley
|
||||
for (i in 0..length) {
|
||||
cleaned[leftValley + i] = leftVal + (rightVal - leftVal) * i.toDouble() / length
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/** 다채널 후면 반사 위치 합의 (median) */
|
||||
fun findBackReflectionMultichannel(tgcSignals: List<DoubleArray>, postMaxIdx: Int): Int {
|
||||
val detected = mutableListOf<Int>()
|
||||
for (tgc in tgcSignals) {
|
||||
val idx = findBackReflectionIdx(tgc, postMaxIdx)
|
||||
if (idx < postMaxIdx) detected.add(idx)
|
||||
}
|
||||
if (detected.isEmpty()) return postMaxIdx
|
||||
detected.sort()
|
||||
return detected[detected.size / 2]
|
||||
}
|
||||
|
||||
/**
|
||||
* 1D Otsu threshold — 원은지 연구원 span_utils.py otsu_1d() 1:1 포팅
|
||||
*
|
||||
* 히스토그램에서 between-class variance를 최대화하는 임계값 반환.
|
||||
* 저진폭(urine/조직)과 고진폭(벽 echo) 두 모집단을 분리.
|
||||
* 1D Otsu threshold + separability
|
||||
*/
|
||||
fun otsu1d(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): Double {
|
||||
data class OtsuResult(val threshold: Double, val separability: Double)
|
||||
|
||||
fun otsu1dWithSeparability(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): OtsuResult {
|
||||
val n = min(values.size, limit)
|
||||
if (n == 0) return 0.0
|
||||
if (n == 0) return OtsuResult(0.0, 0.0)
|
||||
|
||||
var vMin = values[0]; var vMax = values[0]
|
||||
for (i in 1 until n) {
|
||||
if (values[i] < vMin) vMin = values[i]
|
||||
if (values[i] > vMax) vMax = values[i]
|
||||
}
|
||||
if (vMax == vMin) return vMin
|
||||
if (vMax == vMin) return OtsuResult(vMin, 0.0)
|
||||
|
||||
val binWidth = (vMax - vMin) / nBins
|
||||
val hist = IntArray(nBins)
|
||||
@@ -355,24 +483,21 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
|
||||
val centers = DoubleArray(nBins) { vMin + (it + 0.5) * binWidth }
|
||||
val total = n.toDouble()
|
||||
val p = DoubleArray(nBins) { hist[it] / total }
|
||||
|
||||
// cumulative probability & mean
|
||||
val cumP = DoubleArray(nBins)
|
||||
val cumMP = DoubleArray(nBins)
|
||||
cumP[0] = hist[0] / total
|
||||
cumMP[0] = cumP[0] * centers[0]
|
||||
cumP[0] = p[0]; cumMP[0] = p[0] * centers[0]
|
||||
for (i in 1 until nBins) {
|
||||
cumP[i] = cumP[i - 1] + hist[i] / total
|
||||
cumMP[i] = cumMP[i - 1] + (hist[i] / total) * centers[i]
|
||||
cumP[i] = cumP[i - 1] + p[i]
|
||||
cumMP[i] = cumMP[i - 1] + p[i] * centers[i]
|
||||
}
|
||||
val totalM = cumMP[nBins - 1]
|
||||
|
||||
// between-class variance 최대화
|
||||
var bestSigma = -1.0
|
||||
var bestIdx = 0
|
||||
for (t in 0 until nBins - 1) {
|
||||
val w0 = cumP[t]
|
||||
val w1 = 1.0 - w0
|
||||
val w0 = cumP[t]; val w1 = 1.0 - w0
|
||||
if (w0 < 1e-6 || w1 < 1e-6) continue
|
||||
val m0 = cumMP[t] / w0
|
||||
val m1 = (totalM - cumMP[t]) / w1
|
||||
@@ -382,18 +507,26 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
bestIdx = t
|
||||
}
|
||||
}
|
||||
return centers[bestIdx]
|
||||
// Separability: σ_b² / σ_total²
|
||||
var totalVar = 0.0
|
||||
for (i in 0 until nBins) totalVar += p[i] * (centers[i] - totalM) * (centers[i] - totalM)
|
||||
val separability = if (totalVar > 1e-12) bestSigma / totalVar else 0.0
|
||||
|
||||
return OtsuResult(centers[bestIdx], separability)
|
||||
}
|
||||
|
||||
fun otsu1d(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): Double {
|
||||
return otsu1dWithSeparability(values, limit, nBins).threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-echo 탐지 핵심 — prominence 기반 wall peak 선택
|
||||
* 1:1 port of low_echo_detection_method_b.py (2026-04-20 update)
|
||||
* Low-echo 탐지 핵심 — prominence 기반 wall peak 선택 + FP 필터
|
||||
*/
|
||||
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double): LowEchoResult? {
|
||||
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? {
|
||||
if (sg.size < 10) return null
|
||||
|
||||
// 1) low-echo span 추출 (postMaxIdx까지만 — 이후는 노이즈)
|
||||
val analysisLimit = min(sg.size, postMaxIdx + 1)
|
||||
// 1) low-echo span 추출 (effectivePostMax까지)
|
||||
val analysisLimit = min(sg.size, effectivePostMax + 1)
|
||||
val lowMask = BooleanArray(sg.size) { it < analysisLimit && sg[it] <= threshold }
|
||||
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
|
||||
|
||||
@@ -409,11 +542,14 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
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
|
||||
val peakMin = max(lowMean + minPeakMargin, threshold)
|
||||
|
||||
// 3) Prominence 기반 wall peak 선택
|
||||
val ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
|
||||
peakMin = peakMin, side = WallSide.ANT, otherEdge = e) ?: return null
|
||||
var ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
|
||||
peakMin = peakMin, side = WallSide.ANT, otherEdge = e)
|
||||
// ant 없으면 span 시작 직전 (단조 감소 대응)
|
||||
if (ant == null && s > 0) ant = s - 1
|
||||
if (ant == null) return null
|
||||
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
|
||||
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
|
||||
|
||||
@@ -433,10 +569,19 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
if (postH < threshold) return null
|
||||
|
||||
val lowDepth = ((antH + postH) / 2.0) - lowMean
|
||||
val urineLen = post - ant
|
||||
val urineLen = post - ant - 1
|
||||
|
||||
if (urineLen < minUrineLen) return null
|
||||
|
||||
// FP 필터: 벽/low 비율
|
||||
val wallMin = min(antH, postH)
|
||||
if (lowMean > 0 && (wallMin / lowMean) < wallLowMeanMinRatio) return null
|
||||
|
||||
val score = lowDepth * urineLen.toDouble()
|
||||
|
||||
// FP 필터: score 최소
|
||||
if (score < minScore) return null
|
||||
|
||||
return LowEchoResult(
|
||||
ant = ant,
|
||||
post = post,
|
||||
@@ -444,7 +589,7 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
lowEnd = e,
|
||||
lowMean = lowMean,
|
||||
urineLen = urineLen,
|
||||
score = lowDepth * urineLen.toDouble(),
|
||||
score = score,
|
||||
innerPeaks = findInnerPeaks(sg, left = ant, right = post)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user