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:
+54
-1
@@ -78,10 +78,38 @@ fun ClinicalLiveView(appState: AppState) {
|
||||
// 한 사이클 = piezo 완료 + imu 완료. piezo가 먼저 오면 buffer에 보관, imu 도착 시 합쳐 push.
|
||||
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) {
|
||||
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<PiezoChannelData>,
|
||||
walls: List<Pair<Int, Int>?>,
|
||||
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<UShort>,
|
||||
color: Color,
|
||||
walls: Pair<Int, Int>?,
|
||||
) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user