feat: 채널별 빔 각도 보정 (Python PR#20 동기화)

Method B:
- 채널별 otsu_ratio = OTSU_RATIO × cos(θ) 보정
- 큰 각도 채널은 threshold 완화 → 약한 후위벽 감지 개선
- detectLowEcho에 otsuRatioOverride 파라미터 추가
- analyzeMultiChannelWithTgc: initial + rerun 모두 각도 보정 적용

Method A:
- 채널별 min_wall_contrast_ratio = 1.2 × cos(θ) 보정
- detectMethodA에 contrastRatioOverride 파라미터 추가
- analyzeChannel에서 PiezoHW.degreeAll 기반 자동 계산

Monitoring:
- Detachment LED: 상태 변경 시에만 전송 (wasDetached 추적)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 16:32:31 +09:00
parent 258bad9a26
commit d6e981e1d2
3 changed files with 31 additions and 13 deletions
@@ -167,11 +167,18 @@ class PiezoEchoAnalyzer private constructor() {
// TGC 전체 채널 통합 (Python preprocess_multichannel) // TGC 전체 채널 통합 (Python preprocess_multichannel)
val preprocessed = applyTgcMultichannel(denoised) val preprocessed = applyTgcMultichannel(denoised)
// 채널별 각도 보정 (Python _make_angle_kwargs_b)
val beamAngles = PiezoHW.degreeAll
fun channelOtsuRatio(chIdx: Int): Double {
if (chIdx >= beamAngles.size) return otsuRatio
return otsuRatio * kotlin.math.cos(beamAngles[chIdx] * Math.PI / 180.0)
}
// 1) Initial detect + BR candidate // 1) Initial detect + BR candidate
val initialResults = mutableListOf<LowEchoResult?>() val initialResults = mutableListOf<LowEchoResult?>()
val brCandidates = mutableListOf<Int?>() val brCandidates = mutableListOf<Int?>()
for (i in channelData.indices) { for (i in channelData.indices) {
val r = detectLowEcho(raw = raws[i], denoised = preprocessed[i]) val r = detectLowEcho(raw = raws[i], denoised = preprocessed[i], otsuRatioOverride = channelOtsuRatio(channelData[i].channel))
initialResults.add(r) initialResults.add(r)
brCandidates.add(if (r != null) BackReflection.buildCandidate(preprocessed[i], r.post, postMaxIdx) else null) brCandidates.add(if (r != null) BackReflection.buildCandidate(preprocessed[i], r.post, postMaxIdx) else null)
} }
@@ -201,7 +208,7 @@ class PiezoEchoAnalyzer private constructor() {
} }
val cleaned = BackReflection.suppress(preprocessed[i], suppressIdx) val cleaned = BackReflection.suppress(preprocessed[i], suppressIdx)
val r2 = detectLowEcho(raw = raws[i], denoised = cleaned, otsuSignal = preprocessed[i], dynamicPostMax = suppressIdx) val r2 = detectLowEcho(raw = raws[i], denoised = cleaned, otsuSignal = preprocessed[i], dynamicPostMax = suppressIdx, otsuRatioOverride = channelOtsuRatio(ch.channel))
val chosen = BackReflection.chooseResult(r, r2, brIdx = BackReflection.selectIdx(preprocessed[i], r?.post ?: 0, postMaxIdx, antIdx = r?.ant, sharedIdx = sharedBrIdx, channelCandidate = brCandidates[i]), suppressIdx = suppressIdx, minGap = 8) val chosen = BackReflection.chooseResult(r, r2, brIdx = BackReflection.selectIdx(preprocessed[i], r?.post ?: 0, postMaxIdx, antIdx = r?.ant, sharedIdx = sharedBrIdx, channelCandidate = brCandidates[i]), suppressIdx = suppressIdx, minGap = 8)
ChannelAnalysisResult(ch.channel, chosen, raws[i], cleaned) ChannelAnalysisResult(ch.channel, chosen, raws[i], cleaned)
} catch (_: Exception) { } catch (_: Exception) {
@@ -402,7 +409,7 @@ class PiezoEchoAnalyzer private constructor() {
val minScore: Double = 3000.0 val minScore: Double = 3000.0
/** 단일 1D 채널 → urine region 탐지 (Otsu-only, Python 동기화) */ /** 단일 1D 채널 → urine region 탐지 (Otsu-only, Python 동기화) */
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null, lowEchoAmpOverride: Double? = null): LowEchoResult? { fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null, lowEchoAmpOverride: Double? = null, otsuRatioOverride: Double? = null): LowEchoResult? {
val sg = denoised val sg = denoised
if (sg.size < 10) return null if (sg.size < 10) return null
val effectivePostMax = dynamicPostMax ?: postMaxIdx val effectivePostMax = dynamicPostMax ?: postMaxIdx
@@ -417,10 +424,11 @@ class PiezoEchoAnalyzer private constructor() {
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax) return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
} }
// Otsu-only: 단일 시도, fallback 없음 (Python detect_low_echo_method_b 동일) // Otsu-only: 단일 시도 (Python detect_low_echo_method_b 동일)
val effectiveRatio = otsuRatioOverride ?: otsuRatio
val otsuInput = otsuSignal ?: sg val otsuInput = otsuSignal ?: sg
val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size) val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size)
lastOtsuThreshold = thr * otsuRatio lastOtsuThreshold = thr * effectiveRatio
lastOtsuSeparability = sep lastOtsuSeparability = sep
return detectLowEchoCore(sg = sg, threshold = lastOtsuThreshold, effectivePostMax = effectivePostMax) return detectLowEchoCore(sg = sg, threshold = lastOtsuThreshold, effectivePostMax = effectivePostMax)
} }
@@ -51,14 +51,18 @@ class PiezoEchoAnalyzerA private constructor() {
} }
return try { return try {
val sg = PiezoEchoAnalyzer.shared.denoise(raw) val sg = PiezoEchoAnalyzer.shared.denoise(raw)
val result = detectMethodA(raw, sg) // 각도 보정: min_wall_contrast_ratio × cos(θ)
val beamAngles = PiezoHW.degreeAll
val angleFactor = if (channel < beamAngles.size) kotlin.math.cos(beamAngles[channel] * Math.PI / 180.0) else 1.0
val result = detectMethodA(raw, sg, contrastRatioOverride = minWallContrastRatio * angleFactor)
ChannelAnalysisResult(channel, result, raw, sg) ChannelAnalysisResult(channel, result, raw, sg)
} catch (_: Exception) { } catch (_: Exception) {
ChannelAnalysisResult(channel, null, raw, raw.copyOf()) ChannelAnalysisResult(channel, null, raw, raw.copyOf())
} }
} }
fun detectMethodA(raw: DoubleArray, sg: DoubleArray): LowEchoResult? { fun detectMethodA(raw: DoubleArray, sg: DoubleArray, contrastRatioOverride: Double? = null): LowEchoResult? {
val effectiveContrastRatio = contrastRatioOverride ?: minWallContrastRatio
if (sg.size < 10) return null if (sg.size < 10) return null
// 1) Composite signal y = sg + α|d1| + β|d2| // 1) Composite signal y = sg + α|d1| + β|d2|
@@ -107,7 +111,7 @@ class PiezoEchoAnalyzerA private constructor() {
for (i in leftLo..min(spanStart, sg.size - 1)) if (sg[i] > leftMax) leftMax = sg[i] for (i in leftLo..min(spanStart, sg.size - 1)) if (sg[i] > leftMax) leftMax = sg[i]
var rightMax = 0.0 var rightMax = 0.0
for (i in spanEnd until rightHi) if (sg[i] > rightMax) rightMax = sg[i] for (i in spanEnd until rightHi) if (sg[i] > rightMax) rightMax = sg[i]
if (leftMax / platMean < minWallContrastRatio || rightMax / platMean < minWallContrastRatio) continue if (leftMax / platMean < effectiveContrastRatio || rightMax / platMean < effectiveContrastRatio) continue
// Find peaks // Find peaks
val leftPks = findPeaksInRange(sg, leftLo, spanStart).filter { it < spanStart } val leftPks = findPeaksInRange(sg, leftLo, spanStart).filter { it < spanStart }
@@ -77,6 +77,7 @@ fun PiezoMonitoringView(appState: AppState) {
var measureRetryCount by remember { mutableIntStateOf(0) } var measureRetryCount by remember { mutableIntStateOf(0) }
val maxMeasureRetries = 2 val maxMeasureRetries = 2
var lastVolumeMl by remember { mutableStateOf(0.0) } var lastVolumeMl by remember { mutableStateOf(0.0) }
var wasDetached by remember { mutableStateOf(false) }
var maxVolumeMl by remember { mutableStateOf(0.0) } var maxVolumeMl by remember { mutableStateOf(0.0) }
var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred<Unit>() } var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred<Unit>() }
val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() } val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() }
@@ -129,12 +130,17 @@ fun PiezoMonitoringView(appState: AppState) {
val sortedBuffers = channels.sortedBy { it.channel }.map { it.buffer } val sortedBuffers = channels.sortedBy { it.channel }.map { it.buffer }
val detachResult = com.example.medilightv2android.managers.DetachmentDetection.detect(sortedBuffers) val detachResult = com.example.medilightv2android.managers.DetachmentDetection.detect(sortedBuffers)
if (detachResult.isDetached) { if (detachResult.isDetached) {
if (!wasDetached) {
Log.w("PiezoMonitor", "DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") Log.w("PiezoMonitor", "DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}")
bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}")
handler.post { bleManager.sendLedMode(4) } handler.post { bleManager.sendLedMode(4) }
wasDetached = true
}
} else { } else {
// 정상 복귀 → LED OFF if (wasDetached) {
handler.post { bleManager.sendLedMode(0) } handler.post { bleManager.sendLedMode(0) }
wasDetached = false
}
} }
} }