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:
2026-04-29 16:56:51 +09:00
parent c9cd3cb3ce
commit 6614e915d6
6 changed files with 476 additions and 182 deletions
@@ -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)
)
}