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.
|
// 한 사이클 = 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user