From 886d40333d517f1965f8c97cce7142a187a5e151 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 30 Jun 2026 12:09:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(demo):=20BV=20METHOD=5FD=20toggle=20+=20al?= =?UTF-8?q?ignment=20graph=2010-cycle=20=ED=8F=89=EA=B7=A0=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자앱 변경 (e9b5d9a) 을 데모에도 적용 + clinical alignment 그래프 개선. ## BV METHOD_D toggle (사용자앱과 동기화) - BvMethod.METHOD_D 추가 (FRUSTUM/V41/METHOD_D) - PiezoMonitoringView: useMethodDBv 분기. METHOD_D 선택 시 MethodDRunner walls, 그 외엔 legacy analyzer walls. (계산 cost 회피 위해 toggle ON 일 때만 실행) - Dev panel BV toggle: 3-버튼 (Method D 보라색 0xFF7C3AED) - ANT_OFFSET verify 로그: 어느 wall source 인지 표시 ## Clinical alignment graph 10-cycle 평균 표시 이전: 매 cycle 마다 raw 단일 신호 그래프 갱신 → 알고리즘이 실제 보는 신호 (10-frame 평균) 와 시각 불일치. 수정: V2 활성 시 RollingAligner.lastAvg 노출 → PlacementWaveformChart 가 overrideRaw 로 평균 신호 표시. V1 일 땐 기존 raw 단일 cycle 유지. - AlignmentAdvisorV2: lastAvg: List? 노출 (push 마다 set, reset 시 null) - PlacementWaveformChart: overrideRaw 파라미터 (null=기존 raw, non-null=avg) - ChannelGraphItem: V2 일 때 v2Aligner.lastAvg?.getOrNull(ch) 전달 알고리즘 ↔ 그래프 일치. 임상 alignment 평가 시 정확한 신호 시각 가능. Co-Authored-By: Claude Opus 4.7 --- .../managers/AlignmentAdvisorV2.kt | 6 ++++ .../managers/GreenZoneConstants.kt | 8 ++++- .../views/monitoring/PiezoMonitoringView.kt | 29 ++++++++++++++----- .../ui/views/monitoring/PlacementGuideView.kt | 18 ++++++++---- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt index 38d4c43..4987570 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt @@ -341,8 +341,13 @@ class RollingAligner( fun reset() { buf.clear() guide4.reset() + lastAvg = null } + /** UI 그래프에서 N-cycle 평균 신호를 보여주기 위해 마지막 avg 노출. */ + var lastAvg: List? = null + private set + /** 단일 채널 길이 100 가정. raw6.size == 6 이어야 함. */ fun push(raw6: List): AdvisorState { // ring buffer — accumK 사이즈 sliding window. buf 가 덜 찼어도 항상 step 호출 @@ -359,6 +364,7 @@ class RollingAligner( sum / buf.size } } + lastAvg = avg val dets = MethodDRunner.detectMultichannel(avg, methodDParams) val summary = summarize(dets) val out = guide4.step(summary) diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index f373b96..93b3f62 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -14,7 +14,13 @@ package com.example.medilightv2android.managers */ enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C } enum class PlacementGuideMode { SIMPLE, BOUNDARY, SWEEP } -enum class BvMethod { FRUSTUM, V41 } +/** + * BV 계산 방법. + * - FRUSTUM : legacy analyzer walls + estimateBladderVolume6ch (Tanaka frustum) + * - V41 : Method C 의 SphereFit (methodCBvMl 직접 사용) + * - METHOD_D : Method D walls (cross-channel ant tie-break + OS-CFAR) + estimateBladderVolume6ch + */ +enum class BvMethod { FRUSTUM, V41, METHOD_D } /** Sensor alignment 알고리즘 — V1=기존 (computePlacementGuide), V2=신규 (alignment.py 포팅). */ enum class AlignmentAlgo { V1, V2 } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index b22c0f5..578fa5e 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -452,9 +452,22 @@ fun PiezoMonitoringView(appState: AppState) { (antOffsetMm.toDouble() / com.example.medilightv2android.managers.PiezoHW.distancePerSample).toInt() else 0 + + // 2026-06-30 (사용자앱 동기화): BvMethod.METHOD_D 선택 시 Method D walls 사용. + val useMethodDBv = bvMethodSetting == com.example.medilightv2android.managers.BvMethod.METHOD_D + val sourceAllWalls: List?> = if (useMethodDBv) { + val signals = (0..5).map { ch -> + val cd = channels.find { it.channel == ch } + cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0) + } + if (signals.all { it.isEmpty() }) allWalls + else com.example.medilightv2android.walldetect.MethodDRunner + .detectMultichannel(signals) + .map { r -> r?.let { Pair(it.ant, it.post) } } + } else allWalls val effectiveAllWalls: List?> = - if (antOffsetSamples == 0) allWalls - else allWalls.map { w -> + if (antOffsetSamples == 0) sourceAllWalls + else sourceAllWalls.map { w -> if (w == null) null else Pair(maxOf(0, w.first - antOffsetSamples), w.second) } @@ -476,11 +489,12 @@ fun PiezoMonitoringView(appState: AppState) { } else if (effectiveCenterWalls >= 4) { // 검증 로그 — ant offset 비활성(0)이 아닐 때 chord 변화 출력 if (antOffsetSamples != 0) { - val antOrig = (0..3).map { allWalls[it]?.first ?: -1 } + val antOrig = (0..3).map { sourceAllWalls[it]?.first ?: -1 } val antAdj = (0..3).map { effectiveAllWalls[it]?.first ?: -1 } - val chordOrig = (0..3).map { allWalls[it]?.let { w -> w.second - w.first } ?: -1 } + val chordOrig = (0..3).map { sourceAllWalls[it]?.let { w -> w.second - w.first } ?: -1 } val chordAdj = (0..3).map { effectiveAllWalls[it]?.let { w -> w.second - w.first } ?: -1 } - Log.d("PiezoMonitor", "ANT_OFFSET verify: mm=$antOffsetMm samples=$antOffsetSamples " + + val srcLabel = if (useMethodDBv) "Method D" else "legacy" + Log.d("PiezoMonitor", "ANT_OFFSET verify ($srcLabel walls): mm=$antOffsetMm samples=$antOffsetSamples " + "ant_orig=$antOrig ant_adj=$antAdj " + "chord_orig=$chordOrig chord_adj=$chordAdj " + "chord_delta(samples)=${chordAdj.mapIndexed { i, c -> if (c >= 0 && chordOrig[i] >= 0) c - chordOrig[i] else 0 }}") @@ -1846,9 +1860,10 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.weight(1f)) val bvMethods = listOf( com.example.medilightv2android.managers.BvMethod.FRUSTUM to "Frustum", - com.example.medilightv2android.managers.BvMethod.V41 to "V41" + com.example.medilightv2android.managers.BvMethod.V41 to "V41", + com.example.medilightv2android.managers.BvMethod.METHOD_D to "Method D" ) - val bvColors = listOf(MlPrimary, Color(0xFFFF5722)) + val bvColors = listOf(MlPrimary, Color(0xFFFF5722), Color(0xFF7C3AED)) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { bvMethods.forEachIndexed { idx, (m, label) -> val sel = selectedBvMethod == m diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 5d966c7..488cab5 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -740,13 +740,19 @@ fun PlacementGuideView(appState: AppState) { } } if (chData != null && chData.buffer.isNotEmpty()) { + // V2 활성 시 RollingAligner 의 10-cycle 평균 신호로 표시 → + // 알고리즘 입력 신호와 그래프 일치 (실제 측정 시 보는 화면). + val isV2 = com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo == + com.example.medilightv2android.managers.AlignmentAlgo.V2 + val avgRaw = if (isV2) v2Aligner.lastAvg?.getOrNull(ch) else null PlacementWaveformChart( buffer = chData.buffer, peakIndex = chData.peakIndex.toInt(), analyzer = analyzer, antIndex = showWalls?.ant, postIndex = showWalls?.post, - modifier = Modifier.fillMaxWidth().height(120.dp) + modifier = Modifier.fillMaxWidth().height(120.dp), + overrideRaw = avgRaw, ) } else { Box(modifier = Modifier.fillMaxWidth().height(120.dp).background(Color.Gray.copy(alpha = 0.05f), RoundedCornerShape(4.dp)), @@ -2138,12 +2144,14 @@ private fun PlacementWaveformChart( analyzer: PiezoEchoAnalyzer, antIndex: Int?, postIndex: Int?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + /** V2 활성 시 RollingAligner 의 N-cycle 평균 신호로 raw 덮어쓰기. + * null → 기존 single-cycle 표시. */ + overrideRaw: DoubleArray? = null, ) { - val count = buffer.size + val raw = overrideRaw ?: buffer.map { it.toDouble() }.toDoubleArray() + val count = raw.size if (count < 2) return - - val raw = buffer.map { it.toDouble() }.toDoubleArray() val denoised = analyzer.denoise(raw) val maxVal = 2500f val thrVal = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0)