feat(demo): BV METHOD_D toggle + alignment graph 10-cycle 평균 표시

사용자앱 변경 (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<DoubleArray>? 노출 (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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 12:09:39 +09:00
parent 9d1e0e62ad
commit 886d40333d
4 changed files with 48 additions and 13 deletions
@@ -341,8 +341,13 @@ class RollingAligner(
fun reset() {
buf.clear()
guide4.reset()
lastAvg = null
}
/** UI 그래프에서 N-cycle 평균 신호를 보여주기 위해 마지막 avg 노출. */
var lastAvg: List<DoubleArray>? = null
private set
/** 단일 채널 길이 100 가정. raw6.size == 6 이어야 함. */
fun push(raw6: List<DoubleArray>): 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)
@@ -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 }
@@ -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<Pair<Int, Int>?> = 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<Pair<Int, Int>?> =
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
@@ -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)