From ccd4dcbbc39c670d4c7032349a36f52170d4d453 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 2 Jun 2026 11:13:40 +0900 Subject: [PATCH] =?UTF-8?q?Clinical:=20V4.1=20wall=20detection=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B1=84=EB=84=90=EB=B3=84=20ant/post=20peak=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사내 임상 테스트 진행자가 실시간 측정 중 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) --- .../ui/views/clinical/ClinicalLiveView.kt | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt index dc3d2a0..7061a40 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalLiveView.kt @@ -78,10 +78,38 @@ fun ClinicalLiveView(appState: AppState) { // 한 사이클 = piezo 완료 + imu 완료. piezo가 먼저 오면 buffer에 보관, imu 도착 시 합쳐 push. var pendingPiezo by remember { mutableStateOf?>(null) } + // V4.1 wall detection — 채널별 ant/post peak 표시용 (사내 테스트 가시화 요청) + val v41 = remember { com.example.medilightv2android.walldetect.V41Detector() } + var channelWalls by remember { mutableStateOf?>>(List(6) { null }) } + DisposableEffect(Unit) { bleManager.piezoCollector.onMultiChannelComplete = { channels -> lastChannels = 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 -> lastImu = imuSamples @@ -147,6 +175,7 @@ fun ClinicalLiveView(appState: AppState) { // ── 6ch grid (2 rows × 3 cols) ── SixChannelGrid( channels = lastChannels, + walls = channelWalls, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp) ) @@ -272,6 +301,7 @@ fun ClinicalLiveView(appState: AppState) { @Composable private fun SixChannelGrid( channels: List, + walls: List?>, modifier: Modifier = Modifier ) { val channelColors = listOf( @@ -289,6 +319,7 @@ private fun SixChannelGrid( channelIdx = ch, buffer = data?.buffer ?: emptyList(), 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 private fun ChannelChart( channelIdx: Int, buffer: List, color: Color, + walls: Pair?, ) { + val antColor = Color(0xFF4CAF50) // green = anterior wall + val postColor = Color(0xFFFF9800) // orange = posterior wall Card( shape = RoundedCornerShape(6.dp), colors = CardDefaults.cardColors(containerColor = Color.White), @@ -320,6 +354,12 @@ private fun ChannelChart( Spacer(modifier = Modifier.width(4.dp)) Text("CH$channelIdx", fontSize = 10.sp, fontWeight = FontWeight.Bold) 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()) { val peak = buffer.maxOrNull()?.toInt() ?: 0 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 val path = Path() for (i in buffer.indices) {