Clinical: V4.1 wall detection으로 채널별 ant/post peak 그래프 오버레이

사내 임상 테스트 진행자가 실시간 측정 중 wall peak 위치를 눈으로 확인하고
싶다는 요청 반영. raw 데이터 저장 우선 정책은 그대로 유지하고, 시각화 측면에만
PiezoMonitoring에서 쓰는 V4.1(walldetect.V41Detector) 알고리즘을 사이클마다
호출하여 결과를 채널 그래프 위에 오버레이.

표시:
  - 각 채널 그래프 위 세로선 마커 — ant=green, post=orange (alpha 0.75)
  - 채널 헤더에 'a{antIdx} p{postIdx}' 텍스트 (두 줄 한 칸 차지)
  - V4.1 detection 실패 시(둘 중 하나 null) 마커/텍스트 모두 미표시

동작:
  - 매 mtb 사이클(piezoCollector.onMultiChannelComplete) 시점에 detect 호출
  - SweepInput probe profile은 PiezoMonitoring과 동일 (PiezoHW.presetName/degreeAll)
  - runCatching으로 감싸 detect 실패가 측정 자체에 영향 없도록

저장 데이터는 변경 없음. measurement.json/labdb 업로드 payload는 그대로 raw ADC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 11:13:40 +09:00
parent dadf80a778
commit ccd4dcbbc3
@@ -78,10 +78,38 @@ fun ClinicalLiveView(appState: AppState) {
// 한 사이클 = piezo 완료 + imu 완료. piezo가 먼저 오면 buffer에 보관, imu 도착 시 합쳐 push. // 한 사이클 = piezo 완료 + imu 완료. piezo가 먼저 오면 buffer에 보관, imu 도착 시 합쳐 push.
var pendingPiezo by remember { mutableStateOf<List<PiezoChannelData>?>(null) } var pendingPiezo by remember { mutableStateOf<List<PiezoChannelData>?>(null) }
// V4.1 wall detection — 채널별 ant/post peak 표시용 (사내 테스트 가시화 요청)
val v41 = remember { com.example.medilightv2android.walldetect.V41Detector() }
var channelWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
DisposableEffect(Unit) { DisposableEffect(Unit) {
bleManager.piezoCollector.onMultiChannelComplete = { channels -> bleManager.piezoCollector.onMultiChannelComplete = { channels ->
lastChannels = channels lastChannels = channels
pendingPiezo = channels pendingPiezo = channels
// V4.1로 채널별 ant/post 즉시 계산 (실패해도 측정 자체는 영향 없음)
runCatching {
val adcMatrix = (0 until 6).map { ch ->
channels.find { it.channel == ch }?.buffer?.map { it.toInt() } ?: List(100) { 0 }
}
val probe = com.example.medilightv2android.walldetect.dto.ProbeProfileDto(
name = com.example.medilightv2android.managers.PiezoHW.presetName,
rMm = 50.2, anteriorAnchorMm = 32.0,
fsHz = 537500, cMmPerUs = 1.54,
anglesDeg = com.example.medilightv2android.managers.PiezoHW.degreeAll.toList()
)
val sweepInput = com.example.medilightv2android.walldetect.dto.SweepInput(
requestId = "clinical_${System.currentTimeMillis()}",
timestampMs = System.currentTimeMillis(),
deviceId = bleManager.connectedDeviceName.value,
sweepSeq = 0, probe = probe, adc = adcMatrix
)
val result = v41.detect(sweepInput)
channelWalls = List(6) { ch ->
val r = result.perChannel.firstOrNull { it.ch == ch }
val a = r?.antIdx; val p = r?.postIdx
if (a != null && p != null) Pair(a, p) else null
}
}
} }
bleManager.imuCollector.onComplete = { imuSamples -> bleManager.imuCollector.onComplete = { imuSamples ->
lastImu = imuSamples lastImu = imuSamples
@@ -147,6 +175,7 @@ fun ClinicalLiveView(appState: AppState) {
// ── 6ch grid (2 rows × 3 cols) ── // ── 6ch grid (2 rows × 3 cols) ──
SixChannelGrid( SixChannelGrid(
channels = lastChannels, channels = lastChannels,
walls = channelWalls,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp) modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
) )
@@ -272,6 +301,7 @@ fun ClinicalLiveView(appState: AppState) {
@Composable @Composable
private fun SixChannelGrid( private fun SixChannelGrid(
channels: List<PiezoChannelData>, channels: List<PiezoChannelData>,
walls: List<Pair<Int, Int>?>,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val channelColors = listOf( val channelColors = listOf(
@@ -289,6 +319,7 @@ private fun SixChannelGrid(
channelIdx = ch, channelIdx = ch,
buffer = data?.buffer ?: emptyList(), buffer = data?.buffer ?: emptyList(),
color = channelColors[ch], color = channelColors[ch],
walls = walls.getOrNull(ch),
) )
} }
} }
@@ -297,13 +328,16 @@ private fun SixChannelGrid(
} }
} }
/** 한 채널 그래프 (raw ADC waveform) */ /** 한 채널 그래프 (raw ADC waveform + V4.1 ant/post 마커) */
@Composable @Composable
private fun ChannelChart( private fun ChannelChart(
channelIdx: Int, channelIdx: Int,
buffer: List<UShort>, buffer: List<UShort>,
color: Color, color: Color,
walls: Pair<Int, Int>?,
) { ) {
val antColor = Color(0xFF4CAF50) // green = anterior wall
val postColor = Color(0xFFFF9800) // orange = posterior wall
Card( Card(
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
@@ -320,6 +354,12 @@ private fun ChannelChart(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("CH$channelIdx", fontSize = 10.sp, fontWeight = FontWeight.Bold) Text("CH$channelIdx", fontSize = 10.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
if (walls != null) {
Text("a${walls.first}", fontSize = 9.sp, color = antColor, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(3.dp))
Text("p${walls.second}", fontSize = 9.sp, color = postColor, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(4.dp))
}
if (buffer.isNotEmpty()) { if (buffer.isNotEmpty()) {
val peak = buffer.maxOrNull()?.toInt() ?: 0 val peak = buffer.maxOrNull()?.toInt() ?: 0
Text("$peak", fontSize = 9.sp, color = MlSecondaryText) Text("$peak", fontSize = 9.sp, color = MlSecondaryText)
@@ -348,6 +388,19 @@ private fun ChannelChart(
) )
} }
// V4.1 ant/post 마커 (waveform 뒤에 그려서 가려지지 않게 alpha 조정)
if (walls != null) {
val (antIdx, postIdx) = walls
if (antIdx in 0 until n) {
val x = antIdx * xScale
drawLine(antColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 1.5f)
}
if (postIdx in 0 until n) {
val x = postIdx * xScale
drawLine(postColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 1.5f)
}
}
// waveform // waveform
val path = Path() val path = Path()
for (i in buffer.indices) { for (i in buffer.indices) {