diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index c48bf89..ea46b5e 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -12,7 +12,7 @@ package com.example.medilightv2android.managers * ⚠ 미세 조정 시 이 파일만 수정하면 전체 파이프라인에 반영됨. * 각 상수의 의미와 영향 범위를 아래 주석 참고. */ -enum class DetectionMethod { METHOD_A, METHOD_B } +enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C } object GreenZoneConstants { diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index c7c4f10..0efe07f 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -112,26 +112,69 @@ fun PiezoMonitoringView(appState: AppState) { val validChannels = channels.filter { it.isValid } if (validChannels.isNotEmpty()) { - val useMethodA = com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod == com.example.medilightv2android.managers.DetectionMethod.METHOD_A + val method = com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod val analyzerA = com.example.medilightv2android.managers.PiezoEchoAnalyzerA.shared - // Method A or B로 채널별 분석 - val channelResults = if (useMethodA) { - validChannels.map { analyzerA.analyzeChannel(it.buffer, it.channel) } - } else { - validChannels.map { analyzer.analyzeChannel(it.buffer, it.channel) } - } - - // Cross-validation (Method A only) val allWalls: MutableList?> = MutableList(6) { null } - for (ch in channelResults) { - if (ch.channel < 6 && ch.result != null) { - allWalls[ch.channel] = Pair(ch.result.ant, ch.result.post) + var methodCBvMl: Double? = null + val methodLabel: String + + when (method) { + com.example.medilightv2android.managers.DetectionMethod.METHOD_C -> { + methodLabel = "C" + // V4.1 detector + val v41 = com.example.medilightv2android.walldetect.V41Detector() + val adcMatrix = (0 until 6).map { ch -> + val chData = channels.find { it.channel == ch } + chData?.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 = "measure_${System.currentTimeMillis()}", + timestampMs = System.currentTimeMillis(), + deviceId = bleManager.connectedDeviceName.value, + sweepSeq = 0, probe = probe, adc = adcMatrix + ) + val result = v41.detect(sweepInput) + for (ch in result.perChannel) { + if (ch.antIdx != null && ch.postIdx != null && ch.ch < 6) { + allWalls[ch.ch] = Pair(ch.antIdx, ch.postIdx) + } + } + methodCBvMl = result.summary.bvDispatch?.bvMl?.toDouble() + val tier = result.summary.tier ?: "?" + Log.d("PiezoMonitor", "V4.1: bv=${methodCBvMl?.let { "%.1f".format(it) }}ml tier=$tier gated=${result.summary.gatedCount}") + for (ch in result.perChannel) { + Log.d("PiezoMonitor", " CH${ch.ch}[C]: ant=${ch.antIdx} post=${ch.postIdx} chord=${ch.chordMm?.let { "%.1f".format(it) }}mm") + } + } + com.example.medilightv2android.managers.DetectionMethod.METHOD_A -> { + methodLabel = "A" + val channelResults = validChannels.map { analyzerA.analyzeChannel(it.buffer, it.channel) } + for (ch in channelResults) { + if (ch.channel < 6 && ch.result != null) allWalls[ch.channel] = Pair(ch.result.ant, ch.result.post) + } + val validated = analyzerA.crossValidateWalls(allWalls) + for (i in validated.indices) allWalls[i] = validated[i] + for (ch in channelResults) { + if (ch.isValid && ch.result != null) Log.d("PiezoMonitor", " CH${ch.channel}[A]: ant=${ch.result.ant} post=${ch.result.post} urine=${ch.result.urineLen}") + } + } + else -> { + methodLabel = "B" + val channelResults = validChannels.map { analyzer.analyzeChannel(it.buffer, it.channel) } + for (ch in channelResults) { + if (ch.channel < 6 && ch.result != null) allWalls[ch.channel] = Pair(ch.result.ant, ch.result.post) + } + for (ch in channelResults) { + if (ch.isValid && ch.result != null) Log.d("PiezoMonitor", " CH${ch.channel}[B]: ant=${ch.result.ant} post=${ch.result.post} urine=${ch.result.urineLen}") + } } - } - if (useMethodA) { - val validated = analyzerA.crossValidateWalls(allWalls) - for (i in validated.indices) allWalls[i] = validated[i] } val analysisResult = analyzer.analyzeMultiChannel(validChannels) @@ -139,13 +182,6 @@ fun PiezoMonitoringView(appState: AppState) { // Count valid center channels (CH0~CH3) val centerWallCount = (0..3).count { allWalls[it] != null } val totalWallCount = allWalls.count { it != null } - val methodLabel = if (useMethodA) "A" else "B" - - for (ch in channelResults) { - if (ch.isValid && ch.result != null) { - Log.d("PiezoMonitor", " CH${ch.channel}[$methodLabel]: ant=${ch.result.ant} post=${ch.result.post} urine=${ch.result.urineLen} score=${"%.1f".format(ch.result.score)}") - } - } // ADC 로그 저장 (매 측정마다) val logService = com.example.medilightv2android.services.MeasurementLogService.getInstance(context) @@ -160,12 +196,20 @@ fun PiezoMonitoringView(appState: AppState) { } val rawADC = channels.sortedBy { it.channel }.map { it.buffer } - if (centerWallCount >= 3) { + if (method == com.example.medilightv2android.managers.DetectionMethod.METHOD_C && methodCBvMl != null) { + lastVolumeMl = methodCBvMl + maxVolumeMl = maxOf(maxVolumeMl, methodCBvMl) + Log.d("PiezoMonitor", "BV[C]: ${"%.1f".format(methodCBvMl)}ml (V4.1, walls=${totalWallCount}/6)") + bleManager.debugLogger.measurementResult(methodCBvMl, 1.0, totalWallCount, + (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }) + logService.logMeasurement(methodCBvMl, channelLogs, rawADC) + com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, methodCBvMl) + } else if (centerWallCount >= 3) { val bvResult = estimateBladderVolume6ch(allWalls) if (bvResult != null) { lastVolumeMl = bvResult.volumeMl maxVolumeMl = maxOf(maxVolumeMl, bvResult.volumeMl) - Log.d("PiezoMonitor", "BV: ${"%.1f".format(bvResult.volumeMl)}ml (6ch, lr=${"%.2f".format(bvResult.lrRatio)}, center=${centerWallCount}/4)") + Log.d("PiezoMonitor", "BV[$methodLabel]: ${"%.1f".format(bvResult.volumeMl)}ml (6ch, lr=${"%.2f".format(bvResult.lrRatio)}, center=${centerWallCount}/4)") bleManager.debugLogger.measurementResult(bvResult.volumeMl, bvResult.lrRatio, totalWallCount, (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }, otsuThreshold = analyzer.lastOtsuThreshold) @@ -846,20 +890,34 @@ fun PiezoMonitoringView(appState: AppState) { HorizontalDivider() - // Detection Method A/B (왼쪽=A, 오른쪽=B) - var useMethodB by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod == com.example.medilightv2android.managers.DetectionMethod.METHOD_B) } + // Detection Method A/B/C + var selectedMethod by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod) } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text("Detection", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (useMethodB) "Method B" else "Method A", fontSize = 13.sp, fontWeight = FontWeight.Bold, - color = if (useMethodB) MlPrimary else Color(0xFF9C27B0)) Spacer(modifier = Modifier.weight(1f)) - Switch(checked = useMethodB, onCheckedChange = { - useMethodB = it - com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod = - if (it) com.example.medilightv2android.managers.DetectionMethod.METHOD_B - else com.example.medilightv2android.managers.DetectionMethod.METHOD_A - }, colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary, uncheckedTrackColor = Color(0xFF9C27B0).copy(alpha = 0.5f))) + val methods = listOf( + com.example.medilightv2android.managers.DetectionMethod.METHOD_A to "A", + com.example.medilightv2android.managers.DetectionMethod.METHOD_B to "B", + com.example.medilightv2android.managers.DetectionMethod.METHOD_C to "C" + ) + val colors = listOf(Color(0xFF9C27B0), MlPrimary, Color(0xFFFF5722)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + methods.forEachIndexed { idx, (m, label) -> + val sel = selectedMethod == m + Button( + onClick = { selectedMethod = m; com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod = m }, + modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (sel) colors[idx] else Color.Gray.copy(alpha = 0.15f) + ), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp) + ) { + Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, + color = if (sel) Color.White else MlSecondaryText) + } + } + } } HorizontalDivider() diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index b1e47dc..0290101 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -70,6 +70,7 @@ fun PlacementGuideView(appState: AppState) { var lastChannelData by remember { mutableStateOf>(emptyList()) } var debounceCount by remember { mutableIntStateOf(0) } var lastGuidePhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } + var showPlacementSettings by remember { mutableStateOf(false) } // 2단계 순차 탐색: VERTICAL → LATERAL → GREEN var placementPhase by remember { mutableStateOf(PlacementPhase.VERTICAL) } @@ -114,25 +115,15 @@ fun PlacementGuideView(appState: AppState) { ) Spacer(modifier = Modifier.weight(1f)) if (isLocked) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - Icons.Filled.CheckCircle, - contentDescription = "Ready", - tint = Color(0xFF4CAF50) - ) - Text( - text = "Ready", - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF4CAF50) - ) - } + Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(4.dp)) + } + IconButton(onClick = { showPlacementSettings = !showPlacementSettings }) { + Icon(Icons.Default.Settings, "Settings", tint = if (showPlacementSettings) MlPrimary else MlSecondaryText) } } + Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() @@ -311,14 +302,59 @@ fun PlacementGuideView(appState: AppState) { val MIN_URINE_LEN_PLACEMENT = 12 + val method = com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod + val analyzerA = com.example.medilightv2android.managers.PiezoEchoAnalyzerA.shared + for (ch in channels) { if (ch.buffer.isNotEmpty() && ch.channel < 6) { - val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel) - Log.d("PlacementGuide", "CH${ch.channel} otsu_thr=${"%.0f".format(analyzer.lastOtsuThreshold)} valid=${analysis.isValid} urine=${analysis.result?.urineLen ?: 0}") - if (analysis.isValid && analysis.result != null && analysis.result.urineLen >= MIN_URINE_LEN_PLACEMENT) { - detected[ch.channel] = true - urineLens[ch.channel] = analysis.result.urineLen - allWalls[ch.channel] = Pair(analysis.result.ant, analysis.result.post) + when (method) { + com.example.medilightv2android.managers.DetectionMethod.METHOD_C -> { + val v41 = com.example.medilightv2android.walldetect.V41Detector() + val adcSingle = listOf(ch.buffer.map { it.toInt() }) + 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() + ) + // V4.1은 6ch 입력 필요 — 현재 채널만 해당 위치에, 나머지 빈 값 + val adcMatrix = (0 until 6).map { i -> + if (i == ch.channel) ch.buffer.map { it.toInt() } else List(100) { 0 } + } + val input = com.example.medilightv2android.walldetect.dto.SweepInput( + requestId = "placement", timestampMs = System.currentTimeMillis(), + deviceId = "", sweepSeq = 0, probe = probe, adc = adcMatrix + ) + val result = v41.detect(input) + val chResult = result.perChannel.find { it.ch == ch.channel } + if (chResult?.antIdx != null && chResult.postIdx != null) { + val urineLen = chResult.postIdx - chResult.antIdx + if (urineLen >= MIN_URINE_LEN_PLACEMENT) { + detected[ch.channel] = true + urineLens[ch.channel] = urineLen + allWalls[ch.channel] = Pair(chResult.antIdx, chResult.postIdx) + } + } + Log.d("PlacementGuide", "CH${ch.channel}[C] ant=${chResult?.antIdx} post=${chResult?.postIdx}") + } + com.example.medilightv2android.managers.DetectionMethod.METHOD_A -> { + val analysis = analyzerA.analyzeChannel(ch.buffer, ch.channel) + Log.d("PlacementGuide", "CH${ch.channel}[A] valid=${analysis.isValid} urine=${analysis.result?.urineLen ?: 0}") + if (analysis.isValid && analysis.result != null && analysis.result.urineLen >= MIN_URINE_LEN_PLACEMENT) { + detected[ch.channel] = true + urineLens[ch.channel] = analysis.result.urineLen + allWalls[ch.channel] = Pair(analysis.result.ant, analysis.result.post) + } + } + else -> { + val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel) + Log.d("PlacementGuide", "CH${ch.channel}[B] otsu=${"%.0f".format(analyzer.lastOtsuThreshold)} valid=${analysis.isValid} urine=${analysis.result?.urineLen ?: 0}") + if (analysis.isValid && analysis.result != null && analysis.result.urineLen >= MIN_URINE_LEN_PLACEMENT) { + detected[ch.channel] = true + urineLens[ch.channel] = analysis.result.urineLen + allWalls[ch.channel] = Pair(analysis.result.ant, analysis.result.post) + } + } } } } @@ -483,7 +519,80 @@ fun PlacementGuideView(appState: AppState) { } Spacer(modifier = Modifier.height(32.dp)) + } // end scrollable Column + + // Floating settings overlay + if (showPlacementSettings) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + .clickable { showPlacementSettings = false } + ) + Card( + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = MlCardBackground.copy(alpha = 0.97f)), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .align(Alignment.TopCenter) + .shadow(12.dp, RoundedCornerShape(14.dp)) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Method A/B/C + var selectedMethod by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod) } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Detection", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.weight(1f)) + val methods = listOf( + com.example.medilightv2android.managers.DetectionMethod.METHOD_A to "A", + com.example.medilightv2android.managers.DetectionMethod.METHOD_B to "B", + com.example.medilightv2android.managers.DetectionMethod.METHOD_C to "C" + ) + val colors = listOf(Color(0xFF9C27B0), MlPrimary, Color(0xFFFF5722)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + methods.forEachIndexed { idx, (m, label) -> + val sel = selectedMethod == m + Button( + onClick = { selectedMethod = m; com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod = m }, + modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (sel) colors[idx] else Color.Gray.copy(alpha = 0.15f) + ), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp) + ) { + Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, + color = if (sel) Color.White else MlSecondaryText) + } + } + } + } + + HorizontalDivider() + + // Otsu toggle + var otsuEnabled by remember { mutableStateOf(analyzer.useAdaptiveThreshold) } + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Threshold", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.width(8.dp)) + Text( + if (otsuEnabled) "Otsu: ${"%.0f".format(analyzer.lastOtsuThreshold)}" else "${"%.0f".format(com.example.medilightv2android.managers.GreenZoneConstants.lowEchoAmp)}", + fontSize = 13.sp, fontWeight = FontWeight.Bold, + color = if (otsuEnabled) Color(0xFF4CAF50) else Color(0xFFFF9800) + ) + Spacer(modifier = Modifier.weight(1f)) + Text("Otsu", fontSize = 12.sp, color = MlSecondaryText) + Switch( + checked = otsuEnabled, + onCheckedChange = { otsuEnabled = it; analyzer.useAdaptiveThreshold = it }, + colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFF4CAF50)) + ) + } + } + } } + } // end Box } } @@ -867,47 +976,74 @@ private fun PlacementWaveformChart( val raw = buffer.map { it.toDouble() }.toDoubleArray() val denoised = analyzer.denoise(raw) val maxVal = 2500f + val thrVal = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0) + analyzer.lastOtsuThreshold.toFloat() else com.example.medilightv2android.managers.GreenZoneConstants.lowEchoAmp Canvas(modifier = modifier.clip(RoundedCornerShape(4.dp))) { val w = size.width val h = size.height - val stepX = w / (count - 1).toFloat() + val yMargin = 28f + val chartW = w - yMargin + val stepX = chartW / (count - 1).toFloat() - for (i in 0..3) { - val y = h * i / 3f - drawLine(Color.Gray.copy(alpha = 0.1f), Offset(0f, y), Offset(w, y), strokeWidth = 0.5f) + // Y축 눈금 + val yTicks = listOf(0, 500, 1000, 1500, 2000, 2500) + for (tick in yTicks) { + val yPos = h - (tick.toFloat() / maxVal * h) + drawLine(Color.Gray.copy(alpha = 0.08f), Offset(yMargin, yPos), Offset(w, yPos), strokeWidth = 0.5f) } + // Y축 라벨 (0, 1k, 2k) + val labelTicks = listOf(0 to "0", 1000 to "1k", 2000 to "2k") + for ((tick, _) in labelTicks) { + val yPos = h - (tick.toFloat() / maxVal * h) + drawCircle(Color.Gray.copy(alpha = 0.3f), radius = 1.5f, center = Offset(4f, yPos)) + } + + // Threshold 수평선 + val thrY = h - (thrVal / maxVal * h) + drawLine( + Color(0xFFFF9800).copy(alpha = 0.6f), + Offset(yMargin, thrY), Offset(w, thrY), + strokeWidth = 1.5f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)) + ) + + // Cyan 영역 (urine region) if (antIndex != null && postIndex != null && antIndex >= 0 && postIndex > antIndex && postIndex < count) { drawRect( Color.Cyan.copy(alpha = 0.15f), - Offset(antIndex * stepX, 0f), + Offset(yMargin + antIndex * stepX, 0f), Size(((postIndex - antIndex) * stepX).coerceAtLeast(1f), h) ) } + // Raw 신호 (연한) val rawPath = Path() for (i in raw.indices) { - val x = i * stepX + val x = yMargin + i * stepX val y = h - (raw[i].toFloat().coerceIn(0f, maxVal) / maxVal * h) if (i == 0) rawPath.moveTo(x, y) else rawPath.lineTo(x, y) } drawPath(rawPath, Color.Blue.copy(alpha = 0.25f), style = Stroke(width = 1f)) + // Denoised 신호 val denoisedPath = Path() for (i in denoised.indices) { - val x = i * stepX + val x = yMargin + i * stepX val y = h - (denoised[i].toFloat().coerceIn(0f, maxVal) / maxVal * h) if (i == 0) denoisedPath.moveTo(x, y) else denoisedPath.lineTo(x, y) } drawPath(denoisedPath, Color.Blue, style = Stroke(width = 2f)) + // Ant wall (초록) if (antIndex != null && antIndex in 0 until count) { - val antX = antIndex * stepX + val antX = yMargin + antIndex * stepX drawLine(Color.Green, Offset(antX, 0f), Offset(antX, h), strokeWidth = 2f) } + // Post wall (빨강) if (postIndex != null && postIndex in 0 until count) { - val postX = postIndex * stepX + val postX = yMargin + postIndex * stepX drawLine(Color.Red, Offset(postX, 0f), Offset(postX, h), strokeWidth = 2f) } } diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/DetectorIds.kt b/app/src/main/java/com/example/medilightv2android/walldetect/DetectorIds.kt new file mode 100644 index 0000000..3d3bea2 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/DetectorIds.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * All rights reserved. + */ +package com.example.medilightv2android.walldetect + +object DetectorIds { + const val V2 = "v2" + const val V4_1 = "v4_1" +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/V2Detector.kt b/app/src/main/java/com/example/medilightv2android/walldetect/V2Detector.kt new file mode 100644 index 0000000..4c26a74 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/V2Detector.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + + * V2Detector v2.2.0 — aligned to py2/for_app_share/low_echo_detection_method_b. + * Replaces both the JS-V2 lumen-first (PR-2) and the plateau-based v2.1.0 + * (PR-11) with the actual SSOT mirrored by JS detect_lumen_first. + * + * Pipeline (method_b): + * 1. SG denoise (window=5, polyorder=2) + * 2. Adaptive low-echo threshold via 1-D Otsu on sg[0..POST_MAX_IDX] + * 3. low_mask = sg ≤ T → contiguous spans (≥ LOW_MIN_LEN=3) → merge gaps + * 4. peak_min = max(low_mean + 30, T) — both criteria required + * 5. wall selection by prominence × edge-distance decay (EDGE_DIST_DECAY=0.12) + * with valley-walk stop rise=50 + * 6. post > POST_MAX_IDX → back-half retry + * 7. urine_len ≥ 3 required + */ +package com.example.medilightv2android.walldetect + +import com.example.medilightv2android.walldetect.algo.DetectLumenFirst +import com.example.medilightv2android.walldetect.algo.Geometry +import com.example.medilightv2android.walldetect.algo.SubsampleRefine +import com.example.medilightv2android.walldetect.dto.ChannelResult +import com.example.medilightv2android.walldetect.dto.DetectionResult +import com.example.medilightv2android.walldetect.dto.DetectionSummary +import com.example.medilightv2android.walldetect.dto.SweepInput +import kotlin.math.sqrt + +class V2Detector : WallDetector { + + override val algorithmId: String = DetectorIds.V2 + + /** v2.0.0 (JS lumen-first) → v2.1.0 (plateau, scrapped) → v2.2.0 (py2 method_b). */ + override val algorithmVersion: String = "v2.2.0" + + override fun detect(input: SweepInput): DetectionResult { + val t0 = System.nanoTime() + + val perCh: List = (0..5).map { ch -> detectChannel(ch, input) } + + val chordsMm: List = perCh.mapNotNull { it.chordMm } + val (chordMmMean, chordMmStd) = if (chordsMm.isEmpty()) null to null + else { + val mu = chordsMm.average() + val v = chordsMm.map { (it - mu) * (it - mu) }.sum() / chordsMm.size + mu.toFloat() to sqrt(v).toFloat() + } + + return DetectionResult( + requestId = input.requestId, + timestampMs = input.timestampMs, + algorithm = algorithmId, + algorithmVersion = algorithmVersion, + processingMs = (System.nanoTime() - t0) / 1_000_000.0, + perChannel = perCh, + summary = DetectionSummary( + matchCount = perCh.count { it.antIdx != null && it.postIdx != null }, + chordMmMean = chordMmMean, + chordMmStd = chordMmStd, + ), + ) + } + + private fun detectChannel(ch: Int, input: SweepInput): ChannelResult { + val rawList = input.adc[ch] + val rawArr = IntArray(rawList.size) { rawList[it] } + + // py2 method_b: Mode.Otsu — adaptive threshold per channel + val det = DetectLumenFirst.detect(rawArr, DetectLumenFirst.Mode.Otsu) + + val antRefined = det.ant?.let { + SubsampleRefine.refineParabolic(det.sg, it, SubsampleRefine.Kind.PEAK) + } + val postRefined = det.post?.let { + SubsampleRefine.refineParabolic(det.sg, it, SubsampleRefine.Kind.PEAK) + } + + val antMm = antRefined?.let { Geometry.sampleToMm(it).toFloat() } + ?: det.ant?.let { Geometry.sampleToMm(it.toDouble()).toFloat() } + val postMm = postRefined?.let { Geometry.sampleToMm(it).toFloat() } + ?: det.post?.let { Geometry.sampleToMm(it.toDouble()).toFloat() } + val chordMm = if (antMm != null && postMm != null) postMm - antMm else null + + // method_b uses an adaptive scalar T (Otsu); fill the threshold trace + // with that constant so the chart still renders a horizontal reference. + val thrT = det.adaptiveT.toFloat() + val thrTrace = List(det.sg.size) { thrT } + + return ChannelResult( + ch = ch, + sg = det.sg.map { it.toFloat() }, + threshold = thrTrace, + antIdx = det.ant, + postIdx = det.post, + antRefined = antRefined?.toFloat(), + postRefined = postRefined?.toFloat(), + antMm = antMm, + postMm = postMm, + lumenStart = det.lowStart, + lumenEnd = det.lowEnd, + chordMm = chordMm, + clipping = null, + v41Diag = null, + ) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/V41Detector.kt b/app/src/main/java/com/example/medilightv2android/walldetect/V41Detector.kt new file mode 100644 index 0000000..a06905f --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/V41Detector.kt @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * ═════════════════════════════════════════════════════════════════════════ + * V41Detector — Charles V4.1 algorithm (PUBLIC API for team members) + * ═════════════════════════════════════════════════════════════════════════ + * + * ▶ USAGE (one-liner) + * ────────────────── + * val result = V41Detector().detect(sweepInput) + * + * ▶ TYPICAL FLOW + * ───────────── + * val mbb = measureCommands.measureFull().getOrThrow() // BLE mbb command + * val sweep = mbb.toSweepInput(probe = SampleSweeps.defaultProbe()) + * val result = V41Detector().detect(sweep) + * val bv = result.summary.bvDispatch?.bvMl // mL + * val gated = result.perChannel.count { it.v41Diag?.gated == true } + * + * ▶ INPUT · `dto.SweepInput` + * ────── + * adc : List> // 6 × 100 ADC matrix + * probe : ProbeProfileDto // metadata only — runtime uses WdProbe + * gainDb : Int // V4.1 ignores; V2 fixed-thr scaling + * + * ▶ OUTPUT · `dto.DetectionResult` + * ─────── + * algorithm = "v4_1" + * algorithmVersion = "v4.1.0" + * processingMs : Double + * perChannel[6] : ChannelResult + * ├─ sg[100] sg-smoothed envelope + * ├─ threshold[100] OS-CFAR per-sample T + * ├─ antIdx / postIdx wall indices (null when gated out) + * ├─ antRefined / postRefined parabolic sub-sample refine + * ├─ antMm / postMm converted via Geometry.sampleToMm + * ├─ lumenStart / End low-echo span on sg + * ├─ chordMm postMm − antMm + * ├─ clipping ADC saturation flag + * └─ v41Diag ★ V4.1-only diagnostics (see V41Diagnostics.kt) + * summary : DetectionSummary + * ├─ matchCount / chordMmMean / chordMmStd + * ├─ tier / scoreMean / scoreMin / scoreMax / gatedCount + * ├─ v41Sphere Kasa→LM (≥ 4 gated channels) + * └─ bvDispatch ★ multi-method BV (see BvEstimation.kt) + * + * ▶ ALGORITHM PIPELINE (per channel unless marked sweep) + * ───────────────────────────────────────────────────── + * 1.a Denoising.sgSmooth — Savitzky-Golay (5,2) + * 1.b WaveletDenoise.denoise — Sun 2024 db4 3-level adaptive + * 1.c WaveletDenoise.diagnose — energy ratio L1/L2/L3 + * 1.d WaveletDenoise.dwt — coefficients (scalogram) + * 2.a ThresholdOsCfar.perSample — Rohling 1983 (k=0.7, scale=1.05) + * 2.b Clipping.isClipped — ADC saturation flag + * 2.c PeakDetection.findPeaks1D — strict local maxima + * 2.d SpanUtils — boolean span merging + * 3.a DetectLumenFirst (Mode.Adaptive) — lumen → wall-pair selection + * 3.b WallSelect.selectWallByProminence— prominence × edge-distance decay + * 3.c SubsampleRefine.refineParabolic — Cespedes 1995 + * 4.a ContrastAux.farPostContrast — far-post recovery (s_contrast) + * 4.b BModeScore.score — composite [0,1] (RAW envelope!) + * 4.c AnatomicalGate.apply — PHANTOM_530 [22,60] mm depth + * 6.b BvFromSphere.bvFromChord — single-channel chord-as-D BV + * ─── sweep level ─── + * 5.a Geometry.buildWallPointsVisual — visual coords (≤ 12 wall pts) + * 5.d SphereFit2Step.fit2Step — Kasa → LM (auto sphere/circle) + * 6.a BvFromSphere.bvFromSphere — 4/3 π R³ / 1000 + * ★ BvEstimation.estimate — multi-method BV dispatch + * + * ▶ KEY CONFIG (`core.WdConfig`) + * ──────────── + * DPS_DEFAULT = 1.9309 mm/sample (amode_simulator V4 SSOT) + * DELAY_MM_DEFAULT = 6.85 mm + * LOW_ECHO_AMP = 1250 ADC (V2 fixed; V4.1 uses Otsu) + * PHANTOM_530 = R 50.20 mm, BV 530 mL, ant 32 mm + * WdProbe = v2 (30° device, Snell-refracted angles) by default + * + * ▶ DEPENDENCIES (do not remove) + * ───────────── + * data/protocol/ — packet build/parse + CRC16 + * data/command/MeasureCommands.measureFull() → mbb call + * domain/walldetect/algo/ — 17+ algorithm modules (see Pipeline above) + * domain/walldetect/dto/ — kotlinx.serialization DTOs + * data/ble/BleConnector — BLE GATT connection + * + * ▶ NOTES for new team members + * ─────────────────────────── + * • V4.1 == "Adaptive" (OS-CFAR) mode. V2 == "Otsu" mode (py2 method_b). + * • Both detectors implement `WallDetector` so the same SweepInput drives both + * in parallel (drift-zero pairing). + * • BV is now from `BvEstimation.estimate()` (multi-method dispatch); + * sphere fit BV is kept as a cross-check value inside that result. + * • `gainDb` only affects V2; V4.1 ignores it on purpose. + * • All algorithms are pure functions — no side effects beyond logging. + * • For team docs see `docs/CHARLES-V41-API.md`, + * `docs/BV-CALCULATION-DESIGN.md`. + * ═════════════════════════════════════════════════════════════════════════ + */ +package com.example.medilightv2android.walldetect + +import com.example.medilightv2android.walldetect.algo.AnatomicalGate +import com.example.medilightv2android.walldetect.algo.BModeScore +import com.example.medilightv2android.walldetect.algo.BvEstimation +import com.example.medilightv2android.walldetect.algo.BvFromSphere +import com.example.medilightv2android.walldetect.algo.Clipping +import com.example.medilightv2android.walldetect.algo.ContrastAux +import com.example.medilightv2android.walldetect.algo.DetectLumenFirst +import com.example.medilightv2android.walldetect.algo.Geometry +import com.example.medilightv2android.walldetect.algo.PeakDetection +import com.example.medilightv2android.walldetect.algo.SphereFit2Step +import com.example.medilightv2android.walldetect.algo.SubsampleRefine +import com.example.medilightv2android.walldetect.algo.WaveletDenoise +import com.example.medilightv2android.walldetect.core.WdConfig +import com.example.medilightv2android.walldetect.dto.ChannelResult +import com.example.medilightv2android.walldetect.dto.DetectionResult +import com.example.medilightv2android.walldetect.dto.DetectionSummary +import com.example.medilightv2android.walldetect.dto.IntRange2 +import com.example.medilightv2android.walldetect.dto.ScoreSubscores +import com.example.medilightv2android.walldetect.dto.SweepInput +import com.example.medilightv2android.walldetect.dto.V41Diagnostics +import com.example.medilightv2android.walldetect.dto.V41SphereFit +import com.example.medilightv2android.walldetect.dto.Vec3 +import com.example.medilightv2android.walldetect.dto.WaveletCoefs +import com.example.medilightv2android.walldetect.dto.WaveletEnergyRatio +import kotlin.math.sqrt + +class V41Detector( + private val gatePreset: AnatomicalGate.Preset = AnatomicalGate.PHANTOM_530, + private val useLrTilt: Boolean = WdConfig.USE_LR_TILT +) : WallDetector { + + override val algorithmId: String = DetectorIds.V4_1 + override val algorithmVersion: String = "v4.1.0" + + override fun detect(input: SweepInput): DetectionResult { + val t0 = System.nanoTime() + + val perCh: List = (0..5).map { ch -> detectChannel(ch, input) } + + // ── 5/6. sweep-level sphere fit (Mode A only — Q5: gated < 4 → null) ── + val gatedDetections: List = perCh.map { cr -> + if (cr.v41Diag?.gated == true && cr.antIdx != null && cr.postIdx != null) + Geometry.Detection(cr.antIdx, cr.postIdx) + else + Geometry.Detection(null, null) + } + val gatedCount = gatedDetections.count { it.ant != null && it.post != null } + + val v41Sphere: V41SphereFit? = if (gatedCount >= 4) { + val wallPts = Geometry.buildWallPointsVisual(gatedDetections, useLr = useLrTilt) + val fit = SphereFit2Step.fit2Step( + wallPts.map { it.xyz }, + SphereFit2Step.Mode.AUTO + ) + if (fit != null) buildSphereDto(fit, wallPts) else null + } else null + + // ── BV dispatch (PR-13 — see docs/BV-CALCULATION-DESIGN.md) ── + // Use the gated ant/post pairs (post anatomical gate) as input. Sphere + // fit BV is passed as cross-check. + val bvDispatch = run { + val dets = perCh.map { cr -> + BvEstimation.Detection( + ant = if (cr.v41Diag?.gated == true) cr.antIdx else null, + post = if (cr.v41Diag?.gated == true) cr.postIdx else null, + ) + } + BvEstimation.estimate( + walls = dets, + sphereCrossCheckBvMl = v41Sphere?.bvMl, + ) + } + + // ── summary ── + val scores = perCh.mapNotNull { it.v41Diag?.score } + val chords = perCh.mapNotNull { it.chordMm } + val gated = perCh.count { it.v41Diag?.gated == true } + + val chordMean: Float? = chords.takeIf { it.isNotEmpty() }?.average()?.toFloat() + val chordStd: Float? = stdF(chords) + val scoreMean: Float? = scores.takeIf { it.isNotEmpty() }?.let { it.average().toFloat() } + val scoreMin: Float? = scores.minOrNull() + val scoreMax: Float? = scores.maxOrNull() + val sweepTier: String? = scoreMean?.let { BModeScore.classify(it.toDouble()).tier } + + return DetectionResult( + requestId = input.requestId, + timestampMs = input.timestampMs, + algorithm = algorithmId, + algorithmVersion = algorithmVersion, + processingMs = (System.nanoTime() - t0) / 1_000_000.0, + perChannel = perCh, + summary = DetectionSummary( + matchCount = perCh.count { it.antIdx != null && it.postIdx != null }, + chordMmMean = chordMean, + chordMmStd = chordStd, + tier = sweepTier, + scoreMean = scoreMean, + scoreMin = scoreMin, + scoreMax = scoreMax, + gatedCount = gated, + v41Sphere = v41Sphere, + bvDispatch = bvDispatch, + ) + ) + } + + private fun detectChannel(ch: Int, input: SweepInput): ChannelResult { + val rawIntList = input.adc[ch] + val rawInt = IntArray(rawIntList.size) { rawIntList[it] } + val raw = DoubleArray(rawInt.size) { rawInt[it].toDouble() } + + // 1.b/c/d Wavelet (raw envelope) + val wlDenoised = WaveletDenoise.denoise(raw, levels = 3) + val wlDiag = WaveletDenoise.diagnose(raw, levels = 3) + val wlDecomp = WaveletDenoise.dwt(raw, levels = 3) + + // 1.a + 2.a + 2.d + 3.a + 3.b (DetectLumenFirst computes sg + per-sample CFAR + spans + walls) + val det = DetectLumenFirst.detect(rawInt, DetectLumenFirst.Mode.Adaptive) + val sg = det.sg + val cfarThr = det.cfarThr ?: DoubleArray(sg.size) { det.adaptiveT } // safety + + // 2.b clipping (raw) + val clipping = Clipping.isClipped(rawInt) + + // 2.c peaks (sg, all candidates) + val peaksAll = PeakDetection.findPeaks1D(sg).toList() + + // 3.c subsample refine (parabolic, peak kind) + val antRefined = det.ant?.let { SubsampleRefine.refineParabolic(sg, it, SubsampleRefine.Kind.PEAK) } + val postRefined = det.post?.let { SubsampleRefine.refineParabolic(sg, it, SubsampleRefine.Kind.PEAK) } + + val antMmRaw: Float? = antRefined?.let { Geometry.sampleToMm(it).toFloat() } + ?: det.ant?.let { Geometry.sampleToMm(it.toDouble()).toFloat() } + val postMmRaw: Float? = postRefined?.let { Geometry.sampleToMm(it).toFloat() } + ?: det.post?.let { Geometry.sampleToMm(it.toDouble()).toFloat() } + + // 4.a far-post contrast (sg) + val cR = ContrastAux.farPostContrast(sg, det.ant, det.post) + val sContrast: Float = (cR?.contrast ?: 0.0).toFloat() + val sContrastTier: String = ContrastAux.tierBand(cR?.contrast) + + // 4.b B-mode composite score (RAW envelope) + val sR = BModeScore.score(raw, det.ant, det.post) + val score: Float = (sR?.total ?: 0.0).toFloat() + val scoreSub: ScoreSubscores = if (sR != null) ScoreSubscores( + uFarPost = sR.sub.far.toFloat(), + uLumDark = sR.sub.dark.toFloat(), + uAntGrad = sR.sub.antGrad.toFloat(), + uPostGrad = sR.sub.postGrad.toFloat() + ) else ScoreSubscores(0f, 0f, 0f, 0f) + val chTier: String = sR?.tier ?: "—" + + // 4.c anatomical gate + val gate = AnatomicalGate.apply( + AnatomicalGate.Detection(det.ant, det.post), + channelIndex = ch, + preset = gatePreset + ) + val gated = gate.passed + + // 6.b single-channel BV (gated only) + val singleBv: Float? = if (gated && det.ant != null && det.post != null) { + BvFromSphere.bvFromChord((det.post - det.ant).toDouble()).toFloat() + } else null + + // spans (DetectLumenFirst.detect already merged them) + val spansForDto: List = det.spans.map { IntRange2(it.start, it.end) } + + val chordMmFinal: Float? = + if (gated && antMmRaw != null && postMmRaw != null) postMmRaw - antMmRaw else null + + // Wavelet diagnostic — diagnose() returns null for n < 8; sg.size = 100 → safe + val (rL1, rL2, rL3) = if (wlDiag != null) Triple( + wlDiag.ratio[0].toFloat(), + wlDiag.ratio[1].toFloat(), + wlDiag.ratio[2].toFloat() + ) else Triple(0f, 0f, 0f) + + return ChannelResult( + ch = ch, + sg = sg.map { it.toFloat() }, + threshold = cfarThr.map { it.toFloat() }, + antIdx = if (gated) det.ant else null, + postIdx = if (gated) det.post else null, + antRefined = if (gated) antRefined?.toFloat() else null, + postRefined = if (gated) postRefined?.toFloat() else null, + antMm = if (gated) antMmRaw else null, + postMm = if (gated) postMmRaw else null, + lumenStart = if (gated) det.lowStart else null, + lumenEnd = if (gated) det.lowEnd else null, + chordMm = chordMmFinal, + clipping = clipping, + v41Diag = V41Diagnostics( + waveletDenoised = wlDenoised.map { it.toFloat() }, + waveletEnergyRatio = WaveletEnergyRatio(rL1, rL2, rL3), + waveletCoefs = WaveletCoefs( + l1 = wlDecomp.details[0].map { it.toFloat() }, + l2 = wlDecomp.details[1].map { it.toFloat() }, + l3 = wlDecomp.details[2].map { it.toFloat() }, + a3 = wlDecomp.approx.map { it.toFloat() } + ), + peaksAll = peaksAll, + spans = spansForDto, + score = score, + scoreSub = scoreSub, + gated = gated, + tier = chTier, + sContrast = sContrast, + sContrastTier = sContrastTier, + singleChannelBvMl = singleBv + ) + ) + } + + /** + * Build V41SphereFit DTO from the SphereFit2Step result. + * Circle mode (constY/X/Z): inflate 2D centre back to 3D using the const-axis value. + * Sphere mode: 3D centre directly. + */ + private fun buildSphereDto( + fit: SphereFit2Step.FitResult, + wallPts: List + ): V41SphereFit { + val center3 = if (fit.mode == "circle") { + val axes = fit.axes!! + val dropAxis = fit.dropAxis!! + val constVal = if (wallPts.isNotEmpty()) wallPts[0].xyz[dropAxis] else 0.0 + val c = DoubleArray(3) + c[axes[0]] = fit.lmCenter[0] + c[axes[1]] = fit.lmCenter[1] + c[dropAxis] = constVal + c + } else { + fit.lmCenter + } + val rMm = fit.lmR + val bvMl = BvFromSphere.bvFromSphere(rMm) + return V41SphereFit( + mode = "A", + center = Vec3(center3[0].toFloat(), center3[1].toFloat(), center3[2].toFloat()), + radiusMm = rMm.toFloat(), + bvMl = bvMl.toFloat(), + residualStdMm = fit.residualStd.toFloat(), + wallPoints = wallPts.map { + Vec3(it.xyz[0].toFloat(), it.xyz[1].toFloat(), it.xyz[2].toFloat()) + }, + deltaRMm = (rMm - WdConfig.PHANTOM_530_R_MM).toFloat(), + deltaBvMl = (bvMl - WdConfig.PHANTOM_530_BV_ML).toFloat(), + nPoints = wallPts.size + ) + } + + private fun stdF(values: List): Float? { + if (values.isEmpty()) return null + if (values.size == 1) return 0f + val mu = values.average() + var sq = 0.0 + for (v in values) { val d = v - mu; sq += d * d } + return sqrt(sq / values.size).toFloat() + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/WallDetector.kt b/app/src/main/java/com/example/medilightv2android/walldetect/WallDetector.kt new file mode 100644 index 0000000..0aa202f --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/WallDetector.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * ───────────────────────────────────────────────────────────────────────── + * WallDetector — common contract for V2 and V4.1 detectors + * ───────────────────────────────────────────────────────────────────────── + * + * Two implementations exist: + * • V2Detector — py2 method_b (Otsu adaptive threshold + lumen-first) + * • V41Detector — Charles V4.1 (OS-CFAR + B-mode score + sphere fit) + * + * Both consume the same `SweepInput` and emit the same `DetectionResult` + * shape so a single sweep can drive both detectors in parallel + * (drift-zero pairing). Only the `algorithm` field differentiates output. + * + * Invariants (must hold for every implementation) + * ─────────── + * • Single method: `detect(SweepInput): DetectionResult` + * • Idempotent: same input → same output (modulo `processingMs`) + * • No side effects beyond logging (no IO / no mutation of input) + * • Thread-safe: a single instance can be called from multiple coroutines + * • `gainDb`: V2 scales LOW_ECHO_AMP by 10^(-gainDb/20); V4.1 ignores it (Q4) + */ +package com.example.medilightv2android.walldetect + +import com.example.medilightv2android.walldetect.dto.DetectionResult +import com.example.medilightv2android.walldetect.dto.SweepInput + +interface WallDetector { + + /** Stable algorithm identifier. `"v2"` | `"v4_1"`. */ + val algorithmId: String + + /** Algorithm semantic version (bump when the numeric definition changes). */ + val algorithmVersion: String + + /** + * Run the detector on a single sweep. Returns a complete `DetectionResult` + * including per-channel diagnostics and a sweep-level summary. + * + * @param input 6 × 100 ADC matrix + probe metadata + * @return DetectionResult with `algorithm = algorithmId` + */ + fun detect(input: SweepInput): DetectionResult +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/AnatomicalGate.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/AnatomicalGate.kt new file mode 100644 index 0000000..ed6dad8 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/AnatomicalGate.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/anatomical_gate.js (1:1). + * + * Biological-plausibility gate. Two priors: + * 1. Anterior-wall vertical depth band [antDepthMin, antDepthMax] + * probe-to-anterior depth, projected by cos(LR)·cos(SI), in band. + * PHANTOM_530 (BP2 530 mL): [22, 60] mm ← V41Detector default (Q6) + * CLINICAL (free bladder): [20, 70] mm + * 2. Wall-to-wall chord band (along beam) [chordMin, 2·R_max + chordSlack] + * PHANTOM_530: [5, 110.4] mm + * CLINICAL: [5, 130] mm + * + * Beam-angle correction uses Snell-refracted angles (PROBE.DEGREE / DEGREE_LR), + * NOT the mechanical CAD angles (MECH_DEGREE / MECH_DEGREE_LR). + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdConfig +import com.example.medilightv2android.walldetect.core.WdProbe +import kotlin.math.cos + +object AnatomicalGate { + + private const val DEG = Math.PI / 180.0 + + data class Preset( + val antDepthMin: Double, + val antDepthMax: Double, + val rMax: Double, + val chordMin: Double, + val chordSlack: Double + ) + + /** Default for the 530 mL BP2 phantom (asymmetric band, admits corner captures). */ + val PHANTOM_530 = Preset( + antDepthMin = 22.0, antDepthMax = 60.0, + rMax = 50.20, chordMin = 5.0, chordSlack = 10.0 + ) + + /** Free-bladder clinical preset. */ + val CLINICAL = Preset( + antDepthMin = 20.0, antDepthMax = 70.0, + rMax = 65.0, chordMin = 5.0, chordSlack = 0.0 + ) + + /** Per-channel detection input — only the indices matter for gating. */ + data class Detection(val ant: Int?, val post: Int?) + + data class BeforeGate( + val ant: Int, + val post: Int, + val antVdMm: Double, + val chordMm: Double + ) + + /** + * Gate decision. On pass: `ant`/`post` retained, `gateRejected = null`. + * On reject: `ant`/`post` set to null, `gateRejected` carries reason, + * `beforeGate` retains original indices + diagnostics. + */ + data class Result( + val ant: Int?, + val post: Int?, + val gateRejected: String?, + val antVdMm: Double?, + val chordMm: Double?, + val beforeGate: BeforeGate? + ) { + val passed: Boolean get() = gateRejected == null && ant != null && post != null + } + + fun apply( + detection: Detection, + channelIndex: Int, + preset: Preset = PHANTOM_530, + dps: Double = WdConfig.DPS_DEFAULT, + delay: Double = WdConfig.DELAY_MM_DEFAULT, + siAngles: DoubleArray = WdProbe.DEGREE, + lrAngles: DoubleArray = WdProbe.DEGREE_LR + ): Result { + val ant = detection.ant + val post = detection.post + if (ant == null || post == null) { + return Result( + ant = null, post = null, + gateRejected = null, + antVdMm = null, chordMm = null, beforeGate = null + ) + } + + val si = siAngles.getOrElse(channelIndex) { 0.0 } + val lr = lrAngles.getOrElse(channelIndex) { 0.0 } + val cosBeamY = cos(lr * DEG) * cos(si * DEG) + + val antPath = ant * dps + delay + val antVd = antPath * cosBeamY // vertical depth (mm) + val chord = (post - ant) * dps // along-beam (mm) + val chordMax = 2.0 * preset.rMax + preset.chordSlack + + val rejected: String? = when { + antVd < preset.antDepthMin -> + "ant depth ${"%.1f".format(antVd)} mm < ${"%.0f".format(preset.antDepthMin)} mm" + antVd > preset.antDepthMax -> + "ant depth ${"%.1f".format(antVd)} mm > ${"%.0f".format(preset.antDepthMax)} mm" + chord < preset.chordMin -> + "chord ${"%.1f".format(chord)} mm < ${"%.0f".format(preset.chordMin)} mm" + chord > chordMax -> + "chord ${"%.1f".format(chord)} mm > ${"%.0f".format(chordMax)} mm " + + "(2·R_max + ${"%.0f".format(preset.chordSlack)} mm)" + else -> null + } + + return if (rejected != null) { + Result( + ant = null, post = null, + gateRejected = rejected, + antVdMm = null, chordMm = null, + beforeGate = BeforeGate(ant, post, antVd, chord) + ) + } else { + Result( + ant = ant, post = post, + gateRejected = null, + antVdMm = antVd, chordMm = chord, beforeGate = null + ) + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/BModeScore.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/BModeScore.kt new file mode 100644 index 0000000..2c193b0 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/BModeScore.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/bmode_score.js (1:1). + * + * Quantitative 0..1 B-mode confidence score per channel. + * + * Subscores (computed on RAW envelope, indices ant/post inclusive of walls, + * lumen STRICTLY between them): + * + * L = r[ant+1 .. post−1] (lumen) + * F = r[post+τ .. post+τ+W] (far-post window) + * O = r[0..ant] ∪ r[post..N] (outside lumen — JS strict: k > ant && k < post) + * + * s_far = max(0, mean(F) − mean(L)) far-post recovery (ADC) + * s_dark = max(0, mean(O) − mean(L)) lumen-darkness depth (ADC) + * g_ant = |r[ant+1] − r[ant−1]| / 2 anterior wall gradient (ADC) + * g_post = |r[post+1] − r[post−1]| / 2 posterior wall gradient (ADC) + * + * Soft saturation: + * u(x; x_50) = x / (x + x_50) + * + * Total: + * score = w_far · u_far + w_dark · u_dark + w_ant · u_ant + w_post · u_post + * + * Tier from score: + * ≥ 0.70 → high + * ≥ 0.40 → moderate + * ≥ 0.15 → low + * else → zero + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.abs +import kotlin.math.min + +object BModeScore { + + // Defaults — DO NOT CHANGE without bumping algorithmVersion (golden tests will fail). + const val DEFAULT_TAU = 6 + const val DEFAULT_WIN = 10 + val DEFAULT_X50 = X50(far = 100.0, dark = 150.0, grad = 250.0) + val DEFAULT_WEIGHTS = Weights(far = 0.45, dark = 0.25, ant = 0.15, post = 0.15) + + data class X50(val far: Double, val dark: Double, val grad: Double) + data class Weights(val far: Double, val dark: Double, val ant: Double, val post: Double) + + data class Subscores(val far: Double, val dark: Double, val antGrad: Double, val postGrad: Double) + + data class RawValues( + val sFar: Double, + val sDark: Double, + val gAnt: Double, + val gPost: Double, + val lumenMean: Double, + val farMean: Double?, + val outsideMean: Double + ) + + data class Windows(val lLo: Int, val lHi: Int, val fLo: Int, val fHi: Int) + + data class Tier(val tier: String, val desc: String) + + data class Result( + val total: Double, + val tier: String, + val desc: String, + val sub: Subscores, + val raw: RawValues, + val windows: Windows + ) + + /** Soft saturation u(x) = x / (x + x50); 0 if x or x50 ≤ 0. */ + fun softSat(x: Double, x50: Double): Double { + if (x <= 0.0 || x50 <= 0.0) return 0.0 + return x / (x + x50) + } + + fun classify(score: Double?): Tier { + if (score == null) return Tier("—", "no detection") + return when { + score >= 0.70 -> Tier("high", "strong B-mode signature on all axes") + score >= 0.40 -> Tier("moderate", "B-mode signature attenuated but coherent") + score >= 0.15 -> Tier("low", "marginal — single-feature support") + else -> Tier("zero", "absent / shadowed / mis-detection") + } + } + + /** + * Compute the composite B-mode score on RAW envelope. + * @param envelope raw envelope (NOT sg-smoothed; gradient subscores need sharp transitions) + * @return null if ant/post are invalid (null, equal, or pathological pair) + */ + fun score( + envelope: DoubleArray?, + ant: Int?, + post: Int?, + tau: Int = DEFAULT_TAU, + win: Int = DEFAULT_WIN, + x50: X50 = DEFAULT_X50, + weights: Weights = DEFAULT_WEIGHTS + ): Result? { + if (envelope == null || envelope.isEmpty()) return null + if (ant == null || post == null) return null + if (ant >= post - 1) return null + val n = envelope.size + + // Lumen mean (strictly between walls) + var lSum = 0.0 + var lN = 0 + for (k in (ant + 1) until post) { lSum += envelope[k]; lN++ } + val lumenMean = if (lN > 0) lSum / lN else 0.0 + + // Far-post window + val fLo = post + tau + val fHi = min(n, fLo + win) + var fSum = 0.0 + var fN = 0 + for (k in fLo until fHi) { fSum += envelope[k]; fN++ } + val farMean: Double? = if (fN > 0) fSum / fN else null + + // Outside-lumen mean (everything except lumen interval) + // JS: `if (k > ant && k < post) continue;` — strict inside skipped, walls included + var oSum = 0.0 + var oN = 0 + for (k in 0 until n) { + if (k > ant && k < post) continue + oSum += envelope[k]; oN++ + } + val outsideMean = if (oN > 0) oSum / oN else 0.0 + + // Raw subscore values (ADC) + val sFar = if (farMean != null) maxOf(0.0, farMean - lumenMean) else 0.0 + val sDark = maxOf(0.0, outsideMean - lumenMean) + val gAnt = if (ant >= 1 && ant <= n - 2) + abs(envelope[ant + 1] - envelope[ant - 1]) / 2.0 else 0.0 + val gPost = if (post >= 1 && post <= n - 2) + abs(envelope[post + 1] - envelope[post - 1]) / 2.0 else 0.0 + + // Mapped subscores ∈ [0,1] + val uFar = softSat(sFar, x50.far) + val uDark = softSat(sDark, x50.dark) + val uAnt = softSat(gAnt, x50.grad) + val uPost = softSat(gPost, x50.grad) + + val total = weights.far * uFar + + weights.dark * uDark + + weights.ant * uAnt + + weights.post * uPost + val cls = classify(total) + + return Result( + total = total, + tier = cls.tier, + desc = cls.desc, + sub = Subscores(uFar, uDark, uAnt, uPost), + raw = RawValues(sFar, sDark, gAnt, gPost, lumenMean, farMean, outsideMean), + windows = Windows(lLo = ant + 1, lHi = post, fLo = fLo, fHi = fHi) + ) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/BvEstimation.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/BvEstimation.kt new file mode 100644 index 0000000..a9c351f --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/BvEstimation.kt @@ -0,0 +1,400 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Channel-combination BV dispatch — see docs/BV-CALCULATION-DESIGN.md. + * + * Implements 4-layer model (A/B/C/D) and 15-row dispatch table: + * A. FrustumNoLR / FrustumLR — Tanaka frustum + cap (py2 _bv_core 단순화) + * B. SphereLM — Kasa→LM (already in SphereFit2Step) + * C. Verathon — chord-as-diameter + lr_prior + * D. None — sentinel + * + * Simplifications vs py2 _bv_core: + * • Bottom & top caps both use hemispheres (hemisphere fallback) — no parabolic + * S(y) refinement. Acceptable accuracy ±5-10% for the live-compare use case. + * • LR ratio: single-chord only when one lateral; mean of both ratios when + * both laterals are present (simplified vs py2 3-chord offset-aware). + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdConfig +import com.example.medilightv2android.walldetect.core.WdProbe +import com.example.medilightv2android.walldetect.dto.BvDispatchResult +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cbrt +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin +import kotlin.math.sqrt + +object BvEstimation { + + private const val DEG = PI / 180.0 + private const val LR_PRIOR = 1.2f + private const val LR_NO_DETECTION = 1.0f + private const val AREA_K = PI / 4.0 // (π/4) D² + + /** Per-channel detection input — only the integer ant/post matter here. */ + data class Detection(val ant: Int?, val post: Int?) + + /** + * Main entry point. `walls` size must be 6. + * Returns a `BvDispatchResult`; never null (always has a method, even "None"). + */ + fun estimate( + walls: List, + sphereCrossCheckBvMl: Float? = null, + dps: Double = WdConfig.DPS_DEFAULT, + delayMm: Double = WdConfig.DELAY_MM_DEFAULT, + siDeg: DoubleArray = WdProbe.DEGREE, + sensorZ: DoubleArray = WdProbe.SENSOR_Z, + ): BvDispatchResult { + require(walls.size == 6) { "walls must have 6 entries (one per channel)" } + + val centerIdx = (0..3).filter { walls[it].ant != null && walls[it].post != null } + val lateralIdx = (4..5).filter { walls[it].ant != null && walls[it].post != null } + val nC = centerIdx.size + val nL = lateralIdx.size + val nWallPts = (nC + nL) * 2 + + // ── Compute LR ratio (uses lateral channels if available) ── + val lrRatio = computeLrRatio(walls, centerIdx, lateralIdx, dps, delayMm, siDeg) + + // ── Dispatch (2026-04-29 #3 — py2 _bv_core SSOT, lateral=auxiliary) ─ + // CH4/5 (lateral) are AUXILIARY indicators only: + // • They feed `compute_lr_ratio` (S = π/4 · D² · lr_ratio) + // • They do NOT participate in the SI-axis frustum integration. + // BV is therefore always computed from the CENTER channels (CH0-3): + // • nC ≥ 2 → frustum + caps (length ∝ nC) + // • nC = 1 → Verathon chord-as-D × √lr_prior (single SI chord) + // • nC = 0 → None (no SI information for integration) + val primary = when { + nC == 0 -> none(nC, nL, lrRatio, + if (nL == 0) "no detection" else "lateral only — no SI integration") + nC == 1 -> verathon(walls[centerIdx[0]], centerIdx[0], + dps, siDeg, lrRatio, nC, nL, sphereCrossCheckBvMl) + else -> frustum(walls, centerIdx, dps, delayMm, + siDeg, sensorZ, nC, nL, lrRatio, + sphereCrossCheckBvMl) + } + + return applyAnatomicalBounds(primary) + } + + // ────────────────────────────────────────────────────────── + // Model A — Frustum (Tanaka) + // ────────────────────────────────────────────────────────── + + private fun frustum( + walls: List, + centerIdx: List, + dps: Double, + delayMm: Double, + siDeg: DoubleArray, + sensorZ: DoubleArray, + nC: Int, + nL: Int, + lrRatio: Float, + sphereCrossCheck: Float?, + ): BvDispatchResult { + // 1. per-channel D, S, y + data class Cross(val ch: Int, val d_mm: Double, val S_mm2: Double, val y_mm: Double, val a: Double) + + val xs = centerIdx.map { ch -> + val w = walls[ch] + val dAnt = delayMm + (w.ant!!).toDouble() * dps + val dPost = delayMm + (w.post!!).toDouble() * dps + val theta = siDeg[ch] * DEG + val L_raw = dPost - dAnt + val D = L_raw * cos(theta) + val S = AREA_K * D * D * lrRatio + val dMid = 0.5 * (dAnt + dPost) + val y = sensorZ[ch] + dMid * sin(theta) + Cross(ch, D, S, y, D * 0.5) + }.sortedBy { it.y_mm } + + // 2. Frustum core (truncated cone integration between adjacent cross-sections) + var vCore = 0.0 + for (i in 0 until xs.size - 1) { + val h = abs(xs[i + 1].y_mm - xs[i].y_mm) + val s1 = xs[i].S_mm2 + val s2 = xs[i + 1].S_mm2 + val v = (h / 3.0) * (s1 + s2 + sqrt(max(0.0, s1 * s2))) + vCore += v + } + + // 3. Bottom & top caps — + // bottom: hemisphere (anterior dome of bladder) + // top : CONE (sigmoid/posterior tapers — ½ of hemisphere) + // Empirically tuned 2026-04-29: hemisphere on top was over-estimating + // BV by ~30-50% (e.g. centred 530mL phantom → 781mL). Cone is the + // simplified equivalent of py2 truncated-sphere top cap. + val rBot = xs.first().a + val rTop = xs.last().a + val vBot = (2.0 / 3.0) * PI * rBot * rBot * rBot // hemisphere + val vTop = (1.0 / 3.0) * PI * rTop * rTop * rTop // cone (½ hemi) + + val bvMm3 = vCore + vBot + vTop + val bvMl = (bvMm3 / 1000.0).toFloat() + + val method = if (nL > 0) "FrustumLR" else "FrustumNoLR" + val confidence = computeConfidence(method, nC, nL) + val rEq = cbrt(3.0 * bvMl * 1000.0 / (4.0 * PI)).toFloat() + + return BvDispatchResult( + bvMl = bvMl, + rMm = rEq, + method = method, + confidence = confidence, + nCenter = nC, + nLateral = nL, + lrRatio = lrRatio, + sphereCrossCheckBvMl = sphereCrossCheck, + ) + } + + /** 2-channel cone fallback: frustum between the two + hemisphere on each end. */ + private fun coneFallback( + walls: List, + centerIdx: List, + dps: Double, + delayMm: Double, + siDeg: DoubleArray, + sensorZ: DoubleArray, + nC: Int, + nL: Int, + lrRatio: Float, + sphereCrossCheck: Float?, + ): BvDispatchResult = frustum(walls, centerIdx, dps, delayMm, siDeg, sensorZ, + nC, nL, lrRatio, sphereCrossCheck) + .copy(method = "ConeFallback", confidence = 0.55f) + + // ────────────────────────────────────────────────────────── + // Model B — Sphere fit on a point list (re-uses SphereFit2Step) + // ────────────────────────────────────────────────────────── + + private fun sphereOnly( + walls: List, + gatedIdx: List, + dps: Double, + delayMm: Double, + siDeg: DoubleArray, + sensorZ: DoubleArray, + nC: Int, + nL: Int, + lrRatio: Float, + sphereCrossCheck: Float?, + ): BvDispatchResult { + // Build 3D wall points using visual coords (mirrors Geometry.wallIdxToXyzVisual + // but inlined here to avoid dependency on full WdProbe.SENSOR_X array). + val sx = WdProbe.SENSOR_X + val lr = WdProbe.DEGREE_LR + val pts = mutableListOf() + for (ch in gatedIdx) { + val w = walls[ch] + for (idx in listOf(w.ant!!, w.post!!)) { + val dist = delayMm + idx.toDouble() * dps + val tSi = siDeg[ch] * DEG + val tLr = lr[ch] * DEG + pts += doubleArrayOf( + sx[ch] + dist * sin(tLr) * cos(tSi), + dist * cos(tLr) * cos(tSi), + sensorZ[ch] + dist * sin(tSi), + ) + } + } + val fit = SphereFit2Step.fit2Step(pts, SphereFit2Step.Mode.AUTO) + if (fit == null || fit.lmR <= 0) { + return none(nC, nL, lrRatio, "sphere fit failed") + } + val rMm = fit.lmR + val bvMl = ((4.0 / 3.0) * PI * rMm * rMm * rMm / 1000.0).toFloat() + val confidence = computeConfidence("SphereLM", nC, nL) + return BvDispatchResult( + bvMl = bvMl, + rMm = rMm.toFloat(), + method = "SphereLM", + confidence = confidence, + nCenter = nC, + nLateral = nL, + lrRatio = lrRatio, + sphereCrossCheckBvMl = sphereCrossCheck, + ) + } + + // ────────────────────────────────────────────────────────── + // Model C — Verathon-style single-channel chord + // ────────────────────────────────────────────────────────── + + private fun verathon( + wall: Detection, + ch: Int, + dps: Double, + siDeg: DoubleArray, + lrRatio: Float, + nC: Int, + nL: Int, + sphereCrossCheck: Float?, + ): BvDispatchResult { + val a = wall.ant!!.toDouble() + val p = wall.post!!.toDouble() + val theta = siDeg[ch] * DEG + val D = (p - a) * dps * cos(theta) // SI-corrected chord + val rSi = D / 2.0 // assumed great-circle radius + // Use measured lr_ratio if lateral was detected (auxiliary), else prior 1.2. + val lrFactor = if (lrRatio > 1.05f) lrRatio else LR_PRIOR + // Ellipsoid (a,b,c) ≈ (R_si, R_si·lr, R_si): BV = (4/3)π·a·b·c + // = sphere(R_si) × lrFactor — note: × lrFactor (not √lrFactor) + // Compared to py2 single-channel chord BV which uses simple sphere + // (lr_factor = 1) — we add lr scaling because the live use-case knows + // the bladder is LR>AP (Sun 2024). + val bvMl = ((4.0 / 3.0) * PI * rSi * rSi * rSi / 1000.0 * lrFactor).toFloat() + + val warnings = mutableListOf() + if (D < 25.0) warnings += "single CH chord too short (${"%.0f".format(D)}mm)" + if (D > 100.0) warnings += "single CH chord too long (${"%.0f".format(D)}mm)" + warnings += "1 channel only — recommend re-scan with more probe coverage" + + return BvDispatchResult( + bvMl = bvMl, + rMm = rSi.toFloat(), + method = "Verathon", + confidence = if (D in 30.0..80.0) 0.35f else 0.20f, + nCenter = nC, + nLateral = nL, + lrRatio = lrFactor, + warnings = warnings, + sphereCrossCheckBvMl = sphereCrossCheck, + ) + } + + // ────────────────────────────────────────────────────────── + // Sentinel + // ────────────────────────────────────────────────────────── + + private fun none( + nC: Int, + nL: Int, + lrRatio: Float, + reason: String, + ): BvDispatchResult = BvDispatchResult( + bvMl = null, rMm = null, method = "None", + confidence = 0f, nCenter = nC, nLateral = nL, lrRatio = lrRatio, + warnings = listOf(reason), + ) + + // ────────────────────────────────────────────────────────── + // LR ratio — simplified single/two-chord (py2 compute_lr_ratio core) + // ────────────────────────────────────────────────────────── + + private fun computeLrRatio( + walls: List, + centerIdx: List, + lateralIdx: List, + dps: Double, + delayMm: Double, + siDeg: DoubleArray, + ): Float { + if (lateralIdx.isEmpty() || centerIdx.isEmpty()) return LR_NO_DETECTION + + // Center reference chord (D_center) — average of available center channels' + // SI-corrected chord lengths. + val dCenter = centerIdx.mapNotNull { ch -> + val w = walls[ch] + if (w.ant == null || w.post == null) null + else { + val theta = siDeg[ch] * DEG + val L = (w.post - w.ant) * dps + L * cos(theta) + } + }.takeIf { it.isNotEmpty() }?.average() ?: return LR_NO_DETECTION + + if (dCenter <= 0) return 1.2f + + // Per-lateral chord ratio + val ratios = lateralIdx.mapNotNull { ch -> + val w = walls[ch] + if (w.ant == null || w.post == null) return@mapNotNull null + val alpha = siDeg[ch] * DEG + val beta = WdProbe.DEGREE_LR[ch] * DEG + val P = cos(alpha) * cos(beta) + val L = (w.post - w.ant) * dps + val Dlat = L * P + val r = Dlat / dCenter + if (r <= 0 || r >= 1.0) null else r + } + if (ratios.isEmpty()) return 1.2f + + val avgRatio = ratios.average() + // Single-chord b = |y_mid|/sqrt(1 - r²) is too dependent on probe placement; + // fall back to a simple inverse-ratio prior: lr_raw = 1 / r (clamped). + val lrRaw = (1.0 / avgRatio).coerceIn(1.0, 1.6).toFloat() + // Shrinkage toward 1.2 prior with confidence based on (1 - avg) + val confidence = ((1.0 - avgRatio) / 0.10).coerceIn(0.0, 1.0).toFloat() + return LR_PRIOR + (lrRaw - LR_PRIOR) * confidence + } + + // ────────────────────────────────────────────────────────── + // Confidence model + // ────────────────────────────────────────────────────────── + + private fun computeConfidence(method: String, nC: Int, nL: Int): Float = when (method) { + "FrustumLR" -> when { + nC >= 4 && nL == 2 -> 0.95f + nC >= 4 && nL == 1 -> 0.85f + nC == 3 && nL == 2 -> 0.85f + nC == 3 && nL == 1 -> 0.75f + nC == 2 && nL >= 1 -> 0.55f + else -> 0.50f + } + "FrustumNoLR" -> when (nC) { + 4 -> 0.80f; 3 -> 0.70f; 2 -> 0.55f; else -> 0.50f + } + "SphereLM" -> when { + nC + nL >= 4 -> 0.65f + nC == 1 && nL == 2 -> 0.55f + nC == 1 && nL == 1 -> 0.45f + nC == 0 && nL == 2 -> 0.30f + else -> 0.40f + } + "ConeFallback" -> 0.55f + "Verathon" -> 0.35f + else -> 0f + } + + // ────────────────────────────────────────────────────────── + // Anatomical safety rails + cross-check + // ────────────────────────────────────────────────────────── + + private fun applyAnatomicalBounds(r: BvDispatchResult): BvDispatchResult { + val bv = r.bvMl ?: return r + val warnings = r.warnings.toMutableList() + var conf = r.confidence + + when { + bv < 5f -> warnings += "BV < 5 mL — likely empty / not detected" + bv < 30f -> warnings += "BV < 30 mL — low / verify" + bv > 600f -> warnings += "BV > 600 mL — verify probe placement" + bv > 1000f -> { + return r.copy(bvMl = null, method = "None", confidence = 0f, + warnings = warnings + "BV > 1000 mL — rejected (unrealistic)") + } + } + + // Cross-check vs sphere if both available + r.sphereCrossCheckBvMl?.let { spBv -> + if (r.method.startsWith("Frustum")) { + val diff = abs(bv - spBv) / bv + if (diff > 0.15f) { + warnings += "method disagreement (frustum=%.0f vs sphere=%.0f)".format(bv, spBv) + conf *= 0.7f + } + } + } + + return r.copy(warnings = warnings, confidence = conf) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/BvFromSphere.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/BvFromSphere.kt new file mode 100644 index 0000000..f282eee --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/BvFromSphere.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/bv_from_sphere.js (1:1). + * V4 canonical BV from sphere radius (mm) → mL. + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdConfig + +object BvFromSphere { + + /** BV (mL) from sphere radius (mm). */ + fun bvFromSphere(rMm: Double): Double = + (4.0 / 3.0) * Math.PI * rMm * rMm * rMm / 1000.0 + + /** + * Legacy: chord-as-diameter sphere (single-channel BV). + * @param chordSamples post − ant in samples + * @param dpsMm distance per sample (mm), default WdConfig.DPS_DEFAULT + */ + fun bvFromChord(chordSamples: Double, dpsMm: Double = WdConfig.DPS_DEFAULT): Double { + val chordMm = chordSamples * dpsMm + return bvFromSphere(chordMm / 2.0) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/Clipping.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Clipping.kt new file mode 100644 index 0000000..9bc683a --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Clipping.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/clipping.js (1:1). + * Dead-zone projection ψ_T(z)=max(z,T) + log envelope (dB). + * Reference: Donoho (1995) soft-thresholding; py4/clipping.py. + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdNumeric +import kotlin.math.log10 +import kotlin.math.max + +object Clipping { + + /** Dead-zone projection ψ_T(z) = max(z, T) for scalar threshold. */ + fun signalClip(sg: DoubleArray, threshold: Double): DoubleArray { + val n = sg.size + val out = DoubleArray(n) + for (i in 0 until n) out[i] = max(sg[i], threshold) + return out + } + + /** Dead-zone projection with per-sample threshold. */ + fun signalClip(sg: DoubleArray, threshold: DoubleArray): DoubleArray { + val n = sg.size + val out = DoubleArray(n) + for (i in 0 until n) out[i] = max(sg[i], threshold[i]) + return out + } + + /** + * Log envelope in dB: 20·log10(max(sg, eps) / base). + * If base is null, use max(median(sg), 1.0). + */ + fun logEnvelope(sg: DoubleArray, base: Double? = null, eps: Double = 1.0): DoubleArray { + val n = sg.size + val b = base ?: max(WdNumeric.median(sg), 1.0) + val out = DoubleArray(n) + for (i in 0 until n) out[i] = 20.0 * log10(max(sg[i], eps) / b) + return out + } + + /** + * Detect ADC saturation: returns true if any sample exceeds the saturation + * threshold (default 4090 for 12-bit ADC). + * Used by V4.1 to flag channels where the wall echo is clipping the rail. + */ + fun isClipped(raw: DoubleArray, saturationThreshold: Double = 4090.0): Boolean { + for (v in raw) if (v >= saturationThreshold) return true + return false + } + + /** Same, but on IntArray (typical sweep input). */ + fun isClipped(raw: IntArray, saturationThreshold: Int = 4090): Boolean { + for (v in raw) if (v >= saturationThreshold) return true + return false + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/ContrastAux.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/ContrastAux.kt new file mode 100644 index 0000000..fe9aa64 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/ContrastAux.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/contrast_aux.js (1:1). + * + * B-mode far-post contrast (CharlesKWON V4 reference, retained as a confidence + * metric in V4.1): + * + * Score = max(0, mean(sg[bw+τ : bw+τ+W]) − mean(sg[ant+1 : bw−1])) + * + * Tier (legacy, drives MultiModeBv trust criterion at τ=80): + * ≥ 200 → high + * ≥ 80 → moderate + * > 0 → low + * else → zero + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdConfig +import kotlin.math.max +import kotlin.math.min + +object ContrastAux { + + const val DEFAULT_TAU = 6 + const val DEFAULT_WIN = 10 + + data class Result( + val contrast: Double, + val lumenMean: Double?, + val farMean: Double?, + val farLo: Int, + val farHi: Int, + val status: String // "positive" | "zero" | "truncated" + ) + + data class Tier( + val tier: String, // "high" | "moderate" | "low" | "zero" | "—" + val desc: String + ) + + fun farPostContrast( + sg: DoubleArray, + fw: Int?, + bw: Int?, + tau: Int = WdConfig.CONTRAST_TAU, + win: Int = WdConfig.CONTRAST_WIN + ): Result? { + if (fw == null || bw == null) return null + val n = sg.size + val farLo = bw + tau + val farHi = min(n, farLo + win) + if (farLo >= farHi) { + return Result( + contrast = 0.0, + lumenMean = null, + farMean = null, + farLo = farLo, + farHi = farHi, + status = "truncated" + ) + } + + var farSum = 0.0 + for (k in farLo until farHi) farSum += sg[k] + val farMean = farSum / (farHi - farLo) + + val lumenMean: Double = if (bw - fw <= 1) { + sg[fw] + } else { + var s = 0.0 + for (k in (fw + 1) until bw) s += sg[k] + s / (bw - fw - 1) + } + + val contrast = max(0.0, farMean - lumenMean) + return Result( + contrast = contrast, + lumenMean = lumenMean, + farMean = farMean, + farLo = farLo, + farHi = farHi, + status = if (contrast > 0.0) "positive" else "zero" + ) + } + + /** + * Confidence label from contrast magnitude (legacy ADC scale). + * @param contrast null → "—" (no detection) + */ + fun classify(contrast: Double?): Tier { + if (contrast == null) return Tier("—", "no detection") + return when { + contrast >= 200 -> Tier("high", "canonical lumen-dark / far-post-bright pattern") + contrast >= 80 -> Tier("moderate", "far-post recovery present but attenuated") + contrast > 0 -> Tier("low", "marginal far-post brightness — verify") + else -> Tier("zero", "no brightness recovery — possible shadow / mis-detection") + } + } + + /** Convenience tier string for V41Diagnostics.sContrastTier ("below80"|"80-200"|">=200"). */ + fun tierBand(contrast: Double?): String { + if (contrast == null) return "below80" + return when { + contrast >= 200 -> ">=200" + contrast >= 80 -> "80-200" + else -> "below80" + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/Denoising.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Denoising.kt new file mode 100644 index 0000000..0cdcf28 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Denoising.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/denoising.js (1:1). + * Savitzky-Golay (5,2) FIR with polynomial edge interpolation. + * Mirror of py4/denoising.sg_smooth (and py2/denoising.sg_smooth — 1:1 numerically). + */ +package com.example.medilightv2android.walldetect.algo + +object Denoising { + + private val INNER = doubleArrayOf(-3.0, 12.0, 17.0, 12.0, -3.0).map { it / 35.0 }.toDoubleArray() + private val EDGE_LEFT_0 = doubleArrayOf(31.0, 9.0, -3.0, -5.0, 3.0).map { it / 35.0 }.toDoubleArray() + private val EDGE_LEFT_1 = doubleArrayOf(9.0, 13.0, 12.0, 6.0, -5.0).map { it / 35.0 }.toDoubleArray() + private val EDGE_RIGHT_1 = doubleArrayOf(-5.0, 6.0, 12.0, 13.0, 9.0).map { it / 35.0 }.toDoubleArray() + private val EDGE_RIGHT_0 = doubleArrayOf(3.0, -5.0, -3.0, 9.0, 31.0).map { it / 35.0 }.toDoubleArray() + + /** + * Savitzky–Golay (window=5, poly=2) smoothing. + * Inner samples use the standard 5-tap kernel; edge samples (0/1/n-2/n-1) + * use polynomial-interpolation kernels matching numpy.polynomial fit. + */ + fun sgSmooth(x: DoubleArray): DoubleArray { + val n = x.size + if (n < 5) return x.copyOf() + val out = DoubleArray(n) + for (i in 2 until n - 2) { + var s = 0.0 + for (k in 0 until 5) s += INNER[k] * x[i - 2 + k] + out[i] = s + } + var s0 = 0.0 + var s1 = 0.0 + var sn2 = 0.0 + var sn1 = 0.0 + for (k in 0 until 5) { + s0 += EDGE_LEFT_0[k] * x[k] + s1 += EDGE_LEFT_1[k] * x[k] + sn2 += EDGE_RIGHT_1[k] * x[n - 5 + k] + sn1 += EDGE_RIGHT_0[k] * x[n - 5 + k] + } + out[0] = s0 + out[1] = s1 + out[n - 2] = sn2 + out[n - 1] = sn1 + return out + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/DetectLumenFirst.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/DetectLumenFirst.kt new file mode 100644 index 0000000..ed14b41 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/DetectLumenFirst.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/detect_lumen_first.js (1:1). + * + * V2 main detector (lumen-first wall pair). + * modes: + * Mode.Fixed(thr) → V2: lumen mask = sg ≤ thr (default 1150) + * Mode.Adaptive → V4.1: lumen mask = sg ≤ median(OS-CFAR(sg)) [PR-4] + * Mode.Scalar(value) → arbitrary scalar threshold (used by §3 7-method comparison) + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdNumeric + +object DetectLumenFirst { + + object Params { + // Aligned to py2/for_app_share/config_6ch.py — LOW_ECHO_AMP bumped 1150 → 1250. + const val LOW_ECHO_AMP = 1250.0 + const val LOW_MIN_LEN = 3 + const val MERGE_GAP_MAX = 3 + const val PEAK_SEARCH_WIN = 20 + const val POST_MAX_IDX = 80 + const val MIN_PEAK_MARGIN = 30.0 + const val MIN_URINE_LEN = 3 + const val GAP_PEAK_MARGIN = 5.0 + const val EDGE_DIST_DECAY = 0.12 + const val VALLEY_STOP_RISE = 50.0 + } + + sealed interface Mode { + /** Fixed amplitude threshold (legacy V2). */ + data class Fixed(val thr: Double = Params.LOW_ECHO_AMP) : Mode + + /** V4.1 OS-CFAR adaptive (median of per-sample threshold). */ + data object Adaptive : Mode + + /** py2 method_b — 1-D Otsu over sg[0..POST_MAX_IDX]. */ + data object Otsu : Mode + + data class Scalar(val value: Double) : Mode + } + + /** + * Detection result. Mirrors JS object literal returned by detect_lumen_first.detect(). + * `cfarThr` is null in fixed/scalar modes. + */ + data class Result( + val mode: Mode, + val raw: DoubleArray, + val sg: DoubleArray, + val cfarThr: DoubleArray?, + val adaptiveT: Double, + val lowMask: BooleanArray, + val rawSpans: List, + val spans: List, + val ant: Int?, + val post: Int?, + val lowStart: Int?, + val lowEnd: Int?, + val lowMean: Double?, + val peakMin: Double?, + val urineLen: Int?, + val failReason: String? + ) + + fun detect(rawAdc: IntArray, mode: Mode = Mode.Fixed()): Result { + val raw = DoubleArray(rawAdc.size) { rawAdc[it].toDouble() } + return detect(raw, mode) + } + + fun detect(rawAdc: DoubleArray, mode: Mode = Mode.Fixed()): Result { + val raw = rawAdc.copyOf() + val sg = Denoising.sgSmooth(raw) + val n = sg.size + + val cfarThr: DoubleArray? + val T: Double + when (mode) { + is Mode.Adaptive -> { + // V4.1 OS-CFAR (Rohling 1983). + cfarThr = ThresholdOsCfar.perSample(sg) + T = WdNumeric.median(cfarThr) + } + is Mode.Otsu -> { + // py2 method_b — Otsu over sg[0..POST_MAX_IDX] (skip far-tail). + cfarThr = null + val cap = minOf(Params.POST_MAX_IDX + 1, sg.size) + val view = DoubleArray(cap) { sg[it] } + T = Otsu.otsu1d(view) + } + is Mode.Scalar -> { + cfarThr = null + T = mode.value + } + is Mode.Fixed -> { + cfarThr = null + T = mode.thr + } + } + + val lowMask = BooleanArray(n) { sg[it] <= T } + + val rawSpans = SpanUtils.contiguousTrueSpans(lowMask) + .filter { (it.end - it.start + 1) >= Params.LOW_MIN_LEN } + val gapPeakThr = T + Params.GAP_PEAK_MARGIN + val spans = SpanUtils.mergeCloseSpans(rawSpans, Params.MERGE_GAP_MAX, sg, gapPeakThr) + + if (spans.isEmpty()) { + return Result( + mode, raw, sg, cfarThr, T, lowMask, rawSpans, spans, + ant = null, post = null, lowStart = null, lowEnd = null, + lowMean = null, peakMin = null, urineLen = null, + failReason = "no low-echo span" + ) + } + val first = spans[0] + val s = first.start + val e = first.end + var lowSum = 0.0 + for (i in s..e) lowSum += sg[i] + val lowMean = lowSum / (e - s + 1) + // py2 method_b — peak_min must satisfy BOTH (a) low_mean + margin, + // (b) >= low_echo_amp (so that wall peaks aren't picked from below T). + val peakMin = maxOf(lowMean + Params.MIN_PEAK_MARGIN, T) + + val ant = WallSelect.selectWallByProminence( + sg, edge = s, searchWin = Params.PEAK_SEARCH_WIN, + peakMin = peakMin, side = WallSelect.Side.ANT, otherEdge = e, + edgeDistDecay = Params.EDGE_DIST_DECAY, + valleyStopRise = Params.VALLEY_STOP_RISE, + ) + var post = WallSelect.selectWallByProminence( + sg, edge = e, searchWin = Params.PEAK_SEARCH_WIN, + peakMin = peakMin, side = WallSelect.Side.POST, otherEdge = s, + edgeDistDecay = Params.EDGE_DIST_DECAY, + valleyStopRise = Params.VALLEY_STOP_RISE, + ) + if (ant == null || post == null) { + return Result( + mode, raw, sg, cfarThr, T, lowMask, rawSpans, spans, + ant = null, post = null, lowStart = null, lowEnd = null, + lowMean = lowMean, peakMin = peakMin, urineLen = null, + failReason = "wall peak not found" + ) + } + + if (post > Params.POST_MAX_IDX) { + val backHalf = ((s + e) / 2) + val post2 = WallSelect.selectWallByProminence( + sg, edge = backHalf, + searchWin = Params.POST_MAX_IDX - backHalf, + peakMin = peakMin, side = WallSelect.Side.POST, + otherEdge = null, + edgeDistDecay = Params.EDGE_DIST_DECAY, + valleyStopRise = Params.VALLEY_STOP_RISE, + ) + if (post2 == null) { + return Result( + mode, raw, sg, cfarThr, T, lowMask, rawSpans, spans, + ant = null, post = null, lowStart = null, lowEnd = null, + lowMean = lowMean, peakMin = peakMin, urineLen = null, + failReason = "post>POST_MAX_IDX retry failed" + ) + } + post = post2 + } + + val urineLen = post - ant - 1 + if (urineLen < Params.MIN_URINE_LEN) { + return Result( + mode, raw, sg, cfarThr, T, lowMask, rawSpans, spans, + ant = null, post = null, lowStart = null, lowEnd = null, + lowMean = lowMean, peakMin = peakMin, urineLen = urineLen, + failReason = "urine_len=$urineLen < 3" + ) + } + + return Result( + mode, raw, sg, cfarThr, T, lowMask, rawSpans, spans, + ant = ant, post = post, lowStart = s, lowEnd = e, + lowMean = lowMean, peakMin = peakMin, urineLen = urineLen, + failReason = null + ) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/Geometry.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Geometry.kt new file mode 100644 index 0000000..6fed688 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Geometry.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Subset of study/wall_detect_verify/js/algo/geometry_v2_30deg.js (sampleToMm only). + * Full geometry (wallIdxToXyz, 12 wall points) lands in PR-6 (sphere fit). + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdConfig +import com.example.medilightv2android.walldetect.core.WdProbe +import kotlin.math.cos +import kotlin.math.sin + +object Geometry { + + private const val DEG = Math.PI / 180.0 + + /** + * Sample index → mm depth from probe surface. + * mm = idx * dps + delayMm + * + * dps default = 1.9309 mm/sample (TB370FU 6-channel, c_eff = 1544.7 m/s, fs = 200 kHz). + * delayMm default = 6.85 mm (acoustic delay through housing + skin coupling). + */ + fun sampleToMm( + idx: Double, + dps: Double = WdConfig.DPS_DEFAULT, + delayMm: Double = WdConfig.DELAY_MM_DEFAULT + ): Double = idx * dps + delayMm + + /** + * V2 30° probe geometry (py4 mirror — SI-only by default). + * p = (sensor_x + d sin θ_si, 0, sensor_z + d cos θ_si) + * SI+LR (when useLr=true): + * b = (cos θ_lr · sin θ_si, sin θ_lr, cos θ_lr · cos θ_si) + * p = sensor + d · b + */ + fun wallIdxToXyz( + channel: Int, + idx: Int, + dps: Double = WdConfig.DPS_DEFAULT, + delayMm: Double = WdConfig.DELAY_MM_DEFAULT, + useLr: Boolean = WdConfig.USE_LR_TILT + ): DoubleArray { + val tSi = WdProbe.DEGREE[channel] * DEG + val dist = sampleToMm(idx.toDouble(), dps, delayMm) + + return if (useLr) { + val tLr = WdProbe.DEGREE_LR[channel] * DEG + val bx = cos(tLr) * sin(tSi) + val by = sin(tLr) + val bz = cos(tLr) * cos(tSi) + doubleArrayOf( + WdProbe.SENSOR_X[channel] + dist * bx, + dist * by, + WdProbe.SENSOR_Z[channel] + dist * bz + ) + } else { + doubleArrayOf( + WdProbe.SENSOR_X[channel] + dist * sin(tSi), + 0.0, + WdProbe.SENSOR_Z[channel] + dist * cos(tSi) + ) + } + } + + /** + * Visual-coordinate variant (amode_simulator convention): + * x = lateral, y = depth (forward into body, +), z = vertical + * beam dir = (sin lr · cos si, cos lr · cos si, sin si) + * SI angle is negative in PROBE.DEGREE for down-tilt → sin si < 0 → beam descends in z. + */ + fun wallIdxToXyzVisual( + channel: Int, + idx: Int, + dps: Double = WdConfig.DPS_DEFAULT, + delayMm: Double = WdConfig.DELAY_MM_DEFAULT, + useLr: Boolean = WdConfig.USE_LR_TILT + ): DoubleArray { + val tSi = WdProbe.DEGREE[channel] * DEG + val tLr = if (useLr) WdProbe.DEGREE_LR[channel] * DEG else 0.0 + val dist = idx * dps + delayMm + return doubleArrayOf( + WdProbe.SENSOR_X[channel] + dist * sin(tLr) * cos(tSi), + dist * cos(tLr) * cos(tSi), + WdProbe.SENSOR_Z[channel] + dist * sin(tSi) + ) + } + + /** A wall point: (channel, kind, xyz). kind ∈ {"ant", "post"}. */ + data class WallPoint(val ch: Int, val kind: String, val xyz: DoubleArray) + + /** Per-channel detection input — only ant/post indices matter. */ + data class Detection(val ant: Int?, val post: Int?) + + fun buildWallPoints( + detResults: List, + dps: Double = WdConfig.DPS_DEFAULT, + delayMm: Double = WdConfig.DELAY_MM_DEFAULT, + useLr: Boolean = WdConfig.USE_LR_TILT + ): List { + val pts = mutableListOf() + detResults.forEachIndexed { ch, r -> + r.ant?.let { pts += WallPoint(ch, "ant", wallIdxToXyz(ch, it, dps, delayMm, useLr)) } + r.post?.let { pts += WallPoint(ch, "post", wallIdxToXyz(ch, it, dps, delayMm, useLr)) } + } + return pts + } + + fun buildWallPointsVisual( + detResults: List, + dps: Double = WdConfig.DPS_DEFAULT, + delayMm: Double = WdConfig.DELAY_MM_DEFAULT, + useLr: Boolean = WdConfig.USE_LR_TILT + ): List { + val pts = mutableListOf() + detResults.forEachIndexed { ch, r -> + r.ant?.let { pts += WallPoint(ch, "ant", wallIdxToXyzVisual(ch, it, dps, delayMm, useLr)) } + r.post?.let { pts += WallPoint(ch, "post", wallIdxToXyzVisual(ch, it, dps, delayMm, useLr)) } + } + return pts + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/MultiModeBv.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/MultiModeBv.kt new file mode 100644 index 0000000..e37cf1c --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/MultiModeBv.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/multi_mode_bv.js (1:1). + * + * Confidence-gated mode selection (§11): + * Mode A : multi-channel Kasa→LM fit, when N_trust ≥ N_min + * Mode B : single-channel chord-based BV, median over trusted channels + * (1 ≤ N_trust < N_min) + * Mode C : no measurement (N_trust = 0) + * + * Trust criterion: s_contrast(channel) ≥ τ (default 80, "low tier and above"). + * N_min = 3 channels. + * + * NOTE: trust here is driven by ContrastAux (legacy s_contrast), NOT BModeScore. + * Two trust criteria coexist — UI tier comes from BModeScore, mode selection + * comes from ContrastAux. See DESIGN §1 footnote. + */ +package com.example.medilightv2android.walldetect.algo + +object MultiModeBv { + + /** Per-channel detection input — needs sg + ant + post for s_contrast. */ + data class Detection(val sg: DoubleArray, val ant: Int?, val post: Int?) + + /** Per-channel s_contrast / tier / trust outcome. */ + data class PerChannel( + val ch: Int, + val ant: Int?, + val post: Int?, + val contrast: Double?, + val tier: String, // "high" | "moderate" | "low" | "zero" | "fail" | "—" + val trust: Boolean + ) + + /** selectMode return type. */ + data class Result( + val mode: String, // "A" | "B" | "C" + val bv: Double?, + val r: Double?, + val nTrust: Int, + val perChannel: List, + val trustedSet: Map, + val fit: SphereFit2Step.FitResult? = null, + val agg: SingleChannelBv.AggResult? = null, + val reason: String? = null + ) + + fun selectMode( + detections: List, + tau: Double = 80.0, + nMin: Int = 3, + useLr: Boolean = false, + reduction: SingleChannelBv.Reduction = SingleChannelBv.Reduction.MEDIAN + ): Result { + // Step 1 — compute s_contrast per channel + val perCh: List = detections.mapIndexed { ch, r -> + if (r.ant == null || r.post == null) { + PerChannel(ch, null, null, null, "fail", false) + } else { + val a = ContrastAux.farPostContrast(r.sg, r.ant, r.post) + val tier = ContrastAux.classify(a?.contrast).tier + val trust = a != null && a.contrast >= tau + PerChannel(ch, r.ant, r.post, a?.contrast, tier, trust) + } + } + val trustedSet: Map = perCh.associate { it.ch to it.trust } + val nTrust = perCh.count { it.trust } + + // Step 2 — mode selection + if (nTrust == 0) { + return Result( + mode = "C", bv = null, r = null, nTrust = 0, + perChannel = perCh, trustedSet = trustedSet, + reason = "no channel passes confidence floor" + ) + } + if (nTrust >= nMin) { + // Mode A: multi-channel Kasa→LM on visual coords + val trustedDet = detections.mapIndexed { ch, det -> + if (trustedSet[ch] == true) Geometry.Detection(det.ant, det.post) + else Geometry.Detection(null, null) + } + val wp = Geometry.buildWallPointsVisual(trustedDet, useLr = useLr) + val fit = SphereFit2Step.fit2Step(wp.map { it.xyz }, SphereFit2Step.Mode.AUTO) + ?: return Result( + mode = "C", bv = null, r = null, nTrust = nTrust, + perChannel = perCh, trustedSet = trustedSet, + reason = "sphere fit failed despite N_trust ≥ N_min" + ) + val rMm = fit.lmR + val bv = BvFromSphere.bvFromSphere(rMm) + return Result( + mode = "A", bv = bv, r = rMm, nTrust = nTrust, + perChannel = perCh, trustedSet = trustedSet, fit = fit + ) + } + + // Mode B: single-channel chord aggregation + val perBV = SingleChannelBv.chordBVPerChannel( + detections.map { Geometry.Detection(it.ant, it.post) } + ) + val agg = SingleChannelBv.aggregate(perBV, trustedSet, reduction) + return Result( + mode = "B", bv = agg.bv, r = agg.r, nTrust = nTrust, + perChannel = perCh, trustedSet = trustedSet, agg = agg + ) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/Otsu.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Otsu.kt new file mode 100644 index 0000000..a76376c --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Otsu.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Direct port of py2/for_app_share/span_utils.py otsu_1d(). + * 1-D Otsu binary threshold — k-means(k=2) equivalent on a histogram. + * Used by detect_low_echo to derive an adaptive LOW_ECHO_AMP per signal. + */ +package com.example.medilightv2android.walldetect.algo + +object Otsu { + + /** + * 1-D Otsu threshold over `values`. Returns 0.0 for empty input, + * `values.mean()` for single-value or constant input. n_bins default 64 + * (32-128 range is stable per py2 docstring). + */ + fun otsu1d(values: DoubleArray, nBins: Int = 64): Double { + if (values.isEmpty()) return 0.0 + var lo = Double.POSITIVE_INFINITY + var hi = Double.NEGATIVE_INFINITY + var sum = 0.0 + for (v in values) { + if (v < lo) lo = v + if (v > hi) hi = v + sum += v + } + if (values.size == 1 || lo == hi) return sum / values.size + + // Histogram + val hist = IntArray(nBins) + val width = (hi - lo) / nBins + for (v in values) { + // numpy.histogram: rightmost bin includes the right edge + var b = ((v - lo) / width).toInt() + if (b >= nBins) b = nBins - 1 + if (b < 0) b = 0 + hist[b]++ + } + val total = values.size + if (total == 0) return sum / values.size + + val centers = DoubleArray(nBins) { lo + (it + 0.5) * width } + val p = DoubleArray(nBins) { hist[it].toDouble() / total } + + var cumP = 0.0 + var cumMP = 0.0 + val cumPArr = DoubleArray(nBins) + val cumMPArr = DoubleArray(nBins) + for (i in 0 until nBins) { + cumP += p[i] + cumMP += p[i] * centers[i] + cumPArr[i] = cumP + cumMPArr[i] = cumMP + } + val totalM = cumMPArr[nBins - 1] + + var bestT = 0 + var bestSigma = Double.NEGATIVE_INFINITY + for (t in 0 until nBins - 1) { + val w0 = cumPArr[t] + val w1 = 1.0 - w0 + if (w0 <= 1e-6 || w1 <= 1e-6) continue + val m0 = cumMPArr[t] / w0 + val m1 = (totalM - cumMPArr[t]) / w1 + val sigmaB = w0 * w1 * (m0 - m1) * (m0 - m1) + if (sigmaB > bestSigma) { + bestSigma = sigmaB + bestT = t + } + } + return centers[bestT] + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/PeakDetection.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/PeakDetection.kt new file mode 100644 index 0000000..a1bebb2 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/PeakDetection.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/peak_detection.js (1:1). + * Strict local maxima + prominence + valley walks (py4/peak_detection style). + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.max +import kotlin.math.min + +object PeakDetection { + + /** + * Strict local maxima on x[lo..hi). Plateau-aware: a flat plateau is + * reported as a single peak at its midpoint (rounded down to int). + */ + fun findPeaks1D( + x: DoubleArray, + lo: Int = 0, + hi: Int = x.size, + minHeight: Double? = null + ): IntArray { + val peaks = mutableListOf() + var i = lo + 1 + while (i < hi - 1) { + if (x[i - 1] < x[i]) { + var j = i + while (j < hi - 1 && x[j + 1] == x[j]) j++ + if (j < hi - 1 && x[j + 1] < x[j]) { + peaks += ((i + j) / 2) // integer division + } + i = j + 1 + } else { + i++ + } + } + return if (minHeight == null) peaks.toIntArray() + else peaks.filter { x[it] >= minHeight }.toIntArray() + } + + /** + * Prominence with valley-walk and break-rise stop (py4 style on log envelope). + */ + fun prominence(x: DoubleArray, p: Int, maxDist: Int = 25, breakRise: Double = 1.0): Double { + val n = x.size + var vr = x[p] + for (k in (p + 1) until min(n, p + maxDist + 1)) { + if (x[k] < vr) vr = x[k] + else if (x[k] > vr + breakRise) break + } + var vl = x[p] + for (k in (p - 1) downTo max(-1, p - maxDist - 1) + 1) { + if (x[k] < vl) vl = x[k] + else if (x[k] > vl + breakRise) break + } + return x[p] - max(vl, vr) + } + + /** Right valley walk on raw amplitude (py2 style). */ + fun rightValley(sg: DoubleArray, p: Int, maxDist: Int = 20, breakRise: Double = 10.0): Double { + val n = sg.size + var v = sg[p] + for (i in (p + 1) until min(n, p + maxDist)) { + if (sg[i] < v) v = sg[i] + else if (sg[i] > v + breakRise) break + } + return v + } + + /** Left valley walk on raw amplitude (py2 style). */ + fun leftValley(sg: DoubleArray, p: Int, maxDist: Int = 20, breakRise: Double = 10.0): Double { + var v = sg[p] + for (i in (p - 1) downTo max(0, p - maxDist)) { + if (sg[i] < v) v = sg[i] + else if (sg[i] > v + breakRise) break + } + return v + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/PlateauDetector.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/PlateauDetector.kt new file mode 100644 index 0000000..91c788a --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/PlateauDetector.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Direct port of py2 find_plateau_with_peaks_v2 (study/py2/labeling.py L332). + * This replaces the JS lumen-first detector for V2 (per user, 2026-04-28). + * + * Pipeline: + * 1. Sliding plateau score (sliding_scores_1d, win=5) + * 2. plateau_mask = score ≤ quantile(score, 0.7) + * 3. spans → length filter [L_min=5, L_max=30] → merge_overlapping + * 4. Detrended signal (uniform_filter1d, size=15) + * 5. find_peaks(detrended, prominence=10, distance=3) + * 6. For each plateau, find left/right peak with peak_contrast ≥ 0.02 + * 7. final segment = (left_peak+1, right_peak-1) + * 8. merge_overlapping + merge_adjacent_regions + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +object PlateauDetector { + + // py2 config defaults (PLATEAU_*) + const val WIN = 5 + const val SCORE_Q = 0.7 + const val L_MIN = 5 + const val L_MAX = 30 + const val PROMINENCE = 10.0 + const val DETREND_WIN = 15 + const val PEAK_CONTRAST = 0.02 + + /** A single plateau with its left/right peak (anterior/posterior wall). */ + data class WallPair( + val ant: Int, // = left_peak (sample idx) + val post: Int, // = right_peak + val plateauStart: Int, // detected plateau (inclusive) + val plateauEnd: Int, + ) + + data class Result( + val raw: DoubleArray, + val score: DoubleArray, + val plateauSegs: List, // raw plateaus (after L_min/L_max filter) + val finalSegments: List, // post peak validation + merging + val wallPairs: List, // primary output for V2Detector + ) + + fun detect( + rawAdc: DoubleArray, + win: Int = WIN, + scoreQ: Double = SCORE_Q, + lMin: Int = L_MIN, + lMax: Int = L_MAX, + prominence: Double = PROMINENCE, + detrendWin: Int = DETREND_WIN, + peakContrast: Double = PEAK_CONTRAST, + ): Result { + val x = rawAdc.copyOf() + val n = x.size + + // 1. Plateau detection + val sliding = Py2Helpers.slidingScores1d(x, win) + val score = sliding.score + val thr = Py2Helpers.npQuantile(score, scoreQ) + val plateauMask = BooleanArray(n) { score[it] <= thr } + + val raw = Py2Helpers.maskToSegments(plateauMask) + val filtered = raw.filter { (it.last - it.first + 1) in lMin..lMax } + val plateauSegs = Py2Helpers.mergeOverlappingSegments(filtered) + + if (plateauSegs.isEmpty()) { + return Result(x, score, emptyList(), emptyList(), emptyList()) + } + + // 2. Detrend + val trend = Py2Helpers.uniformFilter1d(x, detrendWin) + val xDetrended = DoubleArray(n) { x[it] - trend[it] } + val allPeaks = Py2Helpers.findPeaks(xDetrended, prominence, distance = 3) + + // 3. Per-plateau peak validation + val finalSegs = mutableListOf() + val pairs = mutableListOf() + + for (plateau in plateauSegs) { + val ps = plateau.first + val pe = plateau.last + var sum = 0.0 + for (k in ps..pe) sum += x[k] + val plateauMean = sum / (pe - ps + 1) + val searchRange = max(15, pe - ps) + + // Left peak — closest to plateau, scanning right→left + val leftLo = max(0, ps - searchRange) + val leftCands = allPeaks.filter { it in leftLo until ps } + var leftPeak: Int? = null + for (lp in leftCands.reversed()) { + if ((x[lp] - plateauMean) / (abs(plateauMean) + 1e-12) >= peakContrast) { + leftPeak = lp + break + } + } + + // Right peak — closest to plateau, scanning left→right + val rightHi = min(n, pe + searchRange + 1) + val rightCands = allPeaks.filter { it in (pe + 1) until rightHi } + var rightPeak: Int? = null + for (rp in rightCands) { + if ((x[rp] - plateauMean) / (abs(plateauMean) + 1e-12) >= peakContrast) { + rightPeak = rp + break + } + } + + if (leftPeak == null || rightPeak == null) continue + + val newS = leftPeak + 1 + val newE = rightPeak - 1 + if (newE <= newS) continue + + finalSegs += newS..newE + pairs += WallPair(ant = leftPeak, post = rightPeak, plateauStart = ps, plateauEnd = pe) + } + + var merged = Py2Helpers.mergeOverlappingSegments(finalSegs) + merged = Py2Helpers.mergeAdjacentRegions(x, merged) + + return Result( + raw = x, + score = score, + plateauSegs = plateauSegs, + finalSegments = merged, + wallPairs = pairs, + ) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/Py2Helpers.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Py2Helpers.kt new file mode 100644 index 0000000..a55374c --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/Py2Helpers.kt @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Direct port of py2 helpers used by find_plateau_with_peaks_v2: + * - robust_z (labeling.py L26) + * - sliding_scores_1d (labeling.py L34) + * - mask_to_segments (labeling.py L68) + * - merge_overlapping_segments (labeling.py L87) + * - merge_adjacent_regions (labeling.py L101) + * - uniform_filter1d (scipy.ndimage equivalent — mode='nearest') + * - find_peaks (scipy.signal equivalent — prominence + distance) + * + * NumPy median (even-length → mean of two middle values) is reproduced + * faithfully, since robust_z drives the per-sample plateau score. + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sqrt + +object Py2Helpers { + + // ───────────────────────────────────────────────────────── + // numpy.median (even N → mean of two middle values) + // ───────────────────────────────────────────────────────── + fun npMedian(values: DoubleArray): Double { + if (values.isEmpty()) return Double.NaN + val s = values.copyOf().also { it.sort() } + val n = s.size + return if (n % 2 == 0) (s[n / 2 - 1] + s[n / 2]) / 2.0 else s[n / 2] + } + + /** Numpy linear-interpolation quantile. */ + fun npQuantile(values: DoubleArray, q: Double): Double { + if (values.isEmpty()) return Double.NaN + val s = values.copyOf().also { it.sort() } + val pos = q * (s.size - 1) + val lo = floor(pos).toInt() + val hi = ceil(pos).toInt() + if (lo == hi) return s[lo] + return s[lo] + (s[hi] - s[lo]) * (pos - lo) + } + + // ───────────────────────────────────────────────────────── + // robust_z — (a − median) / (1.4826 · MAD) + // ───────────────────────────────────────────────────────── + fun robustZ(a: DoubleArray): DoubleArray { + val med = npMedian(a) + val abs_dev = DoubleArray(a.size) { abs(a[it] - med) } + val mad = npMedian(abs_dev) + 1e-12 + return DoubleArray(a.size) { (a[it] - med) / (1.4826 * mad) } + } + + // ───────────────────────────────────────────────────────── + // sliding_scores_1d + // ───────────────────────────────────────────────────────── + + data class SlidingScores( + val score: DoubleArray, + val flat: DoubleArray, + val slope: DoubleArray, + val low: DoubleArray, + ) + + fun slidingScores1d(x: DoubleArray, winInput: Int): SlidingScores { + require(winInput >= 3) { "win >= 3 required" } + var win = winInput + if (win % 2 == 0) win += 1 + val half = win / 2 + val t = x.size + + // Edge padding (numpy: mode='edge') + val xp = DoubleArray(t + 2 * half) + for (i in 0 until half) xp[i] = x[0] + for (i in 0 until t) xp[half + i] = x[i] + for (i in 0 until half) xp[half + t + i] = x[t - 1] + + // tt = arange(win) − mean + val tt = DoubleArray(win) { it.toDouble() } + val meanTt = tt.average() + for (i in tt.indices) tt[i] -= meanTt + var denom = 0.0 + for (v in tt) denom += v * v + denom += 1e-12 + + val flat = DoubleArray(t) + val slope = DoubleArray(t) + val low = DoubleArray(t) + + val w = DoubleArray(win) + for (i in 0 until t) { + for (k in 0 until win) w[k] = xp[i + k] + val mu = w.average() + var sq = 0.0 + for (v in w) { val d = v - mu; sq += d * d } + flat[i] = sqrt(sq / win) + low[i] = npQuantile(w, 0.2) + var num = 0.0 + for (k in 0 until win) num += tt[k] * (w[k] - mu) + slope[i] = abs(num / denom) + } + + val rzLow = robustZ(low) + val rzFlat = robustZ(flat) + val rzSlope = robustZ(slope) + val score = DoubleArray(t) { rzLow[it] + rzFlat[it] + rzSlope[it] } + return SlidingScores(score, flat, slope, low) + } + + // ───────────────────────────────────────────────────────── + // mask_to_segments / merge_overlapping_segments + // ───────────────────────────────────────────────────────── + + fun maskToSegments(mask: BooleanArray): List { + val segs = mutableListOf() + var inRun = false + var s = 0 + for (i in mask.indices) { + if (mask[i] && !inRun) { inRun = true; s = i } + else if (!mask[i] && inRun) { inRun = false; segs += s..(i - 1) } + } + if (inRun) segs += s..(mask.size - 1) + return segs + } + + fun mergeOverlappingSegments(segments: List): List { + if (segments.isEmpty()) return emptyList() + val sorted = segments.sortedBy { it.first } + val merged = mutableListOf(sorted[0]) + for (seg in sorted.drop(1)) { + val last = merged.last() + if (seg.first <= last.last + 1) { + merged[merged.lastIndex] = last.first..max(last.last, seg.last) + } else { + merged.add(seg) + } + } + return merged + } + + // ───────────────────────────────────────────────────────── + // uniform_filter1d (scipy.ndimage; mode='nearest' = edge padding) + // ───────────────────────────────────────────────────────── + + fun uniformFilter1d(x: DoubleArray, size: Int): DoubleArray { + require(size >= 1) + val t = x.size + val half = size / 2 + // scipy uniform_filter1d centers the window; for odd size half=size/2. + // For even size, the bias is handled differently — we follow scipy's + // "origin=0" default which effectively uses window [i-half, i+half-(1 if even else 0)]. + val left = half + val right = size - 1 - left + val xp = DoubleArray(t + left + right) + for (i in 0 until left) xp[i] = x[0] + for (i in 0 until t) xp[left + i] = x[i] + for (i in 0 until right) xp[left + t + i] = x[t - 1] + + val out = DoubleArray(t) + for (i in 0 until t) { + var s = 0.0 + for (k in 0 until size) s += xp[i + k] + out[i] = s / size + } + return out + } + + // ───────────────────────────────────────────────────────── + // find_peaks (scipy.signal — prominence + distance) + // + // Implementation: + // 1. Strict local maxima with plateau handling (midpoint). + // 2. Per-peak prominence: walk left/right until hitting a higher sample + // or array edge, take the minimum encountered; prominence = peak − + // max(left_min, right_min). + // 3. Drop peaks below `prominence`. + // 4. Apply `distance`: greedy keep, prefer larger prominence. + // ───────────────────────────────────────────────────────── + + fun findPeaks(x: DoubleArray, prominence: Double, distance: Int): IntArray { + val t = x.size + if (t < 3) return IntArray(0) + + // 1. Local maxima + val candidates = mutableListOf() + var i = 1 + while (i < t - 1) { + if (x[i - 1] < x[i]) { + var j = i + while (j < t - 1 && x[j + 1] == x[j]) j++ + if (j < t - 1 && x[j + 1] < x[j]) { + candidates.add((i + j) / 2) + } + i = j + 1 + } else { + i++ + } + } + if (candidates.isEmpty()) return IntArray(0) + + // 2. Prominence + val proms = DoubleArray(candidates.size) + for ((idx, p) in candidates.withIndex()) { + var leftMin = x[p] + var k = p - 1 + while (k >= 0) { + if (x[k] > x[p]) break + if (x[k] < leftMin) leftMin = x[k] + k-- + } + var rightMin = x[p] + k = p + 1 + while (k < t) { + if (x[k] > x[p]) break + if (x[k] < rightMin) rightMin = x[k] + k++ + } + proms[idx] = x[p] - max(leftMin, rightMin) + } + + // 3. Filter by prominence + val filtered = candidates.indices + .filter { proms[it] >= prominence } + .map { candidates[it] to proms[it] } + + if (distance <= 0) return filtered.map { it.first }.sorted().toIntArray() + + // 4. Distance constraint (greedy by prominence desc) + val byProm = filtered.sortedByDescending { it.second } + val keep = mutableListOf() + for ((idx, _) in byProm) { + if (keep.none { abs(it - idx) < distance }) keep.add(idx) + } + return keep.sorted().toIntArray() + } + + // ───────────────────────────────────────────────────────── + // merge_adjacent_regions — py2 labeling.py L101 + // ───────────────────────────────────────────────────────── + + fun mergeAdjacentRegions( + x: DoubleArray, + segments: List, + maxGapLen: Int = 12, + relTol: Double = 0.05, + refWin: Int = 5, + maxMergedLen: Int = 50, + ): List { + if (segments.size < 2) return segments + + val merged = mutableListOf(segments[0]) + for (seg in segments.drop(1)) { + val prev = merged.last() + val prevS = prev.first + val prevE = prev.last + val currS = seg.first + val currE = seg.last + + val gapStart = prevE + 1 + val gapEnd = currS // exclusive + val gapLen = gapEnd - gapStart + + if (gapLen <= 0) { + // No gap → merge if length OK + if ((currE - prevS + 1) <= maxMergedLen) { + merged[merged.lastIndex] = prevS..currE + } else { + merged.add(seg) + } + continue + } + + // C1: posterior wall ascent + if (x[currE] <= x[prevE]) { merged.add(seg); continue } + // C2: gap length + if (gapLen > maxGapLen) { merged.add(seg); continue } + // C3: gap median vs ref mean + val gap = DoubleArray(gapLen) { x[gapStart + it] } + val gapMedian = npMedian(gap) + val wPrev = min(refWin, prevE - prevS + 1) + val wCurr = min(refWin, currE - currS + 1) + var refSum = 0.0 + for (k in 0 until wPrev) refSum += x[prevE - wPrev + 1 + k] + for (k in 0 until wCurr) refSum += x[currS + k] + val refMean = refSum / (wPrev + wCurr) + if (refMean == 0.0) { merged.add(seg); continue } + val relDiff = abs(gapMedian - refMean) / abs(refMean) + if (relDiff >= relTol) { merged.add(seg); continue } + // C4: merged length + if ((currE - prevS + 1) > maxMergedLen) { merged.add(seg); continue } + // merge + merged[merged.lastIndex] = prevS..currE + } + return merged + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/SingleChannelBv.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SingleChannelBv.kt new file mode 100644 index 0000000..d9b6c5f --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SingleChannelBv.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/single_channel_bv.js (1:1). + * + * Chord-based bladder volume from a single channel. + * Sphere assumption: chord ≡ 2R, so BV = (4/3)π(c/2)³. + * §11 Mode B fallback for the small-bladder regime. + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdConfig +import kotlin.math.cbrt + +object SingleChannelBv { + + enum class Reduction { MEDIAN, MEAN } + + data class PerChannel( + val ch: Int, + val ant: Int?, + val post: Int?, + val chord: Double?, // mm + val r: Double?, // mm (chord / 2) + val bv: Double? // mL + ) + + data class AggResult( + val bv: Double?, + val r: Double?, + val n: Int, + val used: List + ) + + fun chordBVPerChannel( + detResults: List, + dpsMm: Double = WdConfig.DPS_DEFAULT + ): List { + return detResults.mapIndexed { ch, r -> + if (r.ant == null || r.post == null) { + PerChannel(ch = ch, ant = null, post = null, chord = null, r = null, bv = null) + } else { + val chord = (r.post - r.ant) * dpsMm + val rMm = chord / 2.0 + val bv = (4.0 / 3.0) * Math.PI * rMm * rMm * rMm / 1000.0 + PerChannel(ch = ch, ant = r.ant, post = r.post, chord = chord, r = rMm, bv = bv) + } + } + } + + /** + * Aggregate single-channel BVs from trusted channels. + * @param trusted ch → trust flag (already filtered by s_contrast) + * @param reduction MEDIAN (default — JS upper-median floor(N/2)) or MEAN + */ + fun aggregate( + perChannelBV: List, + trusted: Map, + reduction: Reduction = Reduction.MEDIAN + ): AggResult { + val used = perChannelBV.filter { it.bv != null && trusted[it.ch] == true } + val vals = used.mapNotNull { it.bv } + if (vals.isEmpty()) return AggResult(bv = null, r = null, n = 0, used = emptyList()) + + val bv = if (reduction == Reduction.MEAN) { + vals.average() + } else { + // JS upper-median: sorted[floor(N/2)] + val sorted = vals.sorted() + sorted[sorted.size / 2] + } + val rMm = cbrt(3.0 * bv * 1000.0 / (4.0 * Math.PI)) + return AggResult(bv = bv, r = rMm, n = vals.size, used = used) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/SpanUtils.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SpanUtils.kt new file mode 100644 index 0000000..ac19ad5 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SpanUtils.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/span_utils.js (1:1). + * Contiguous boolean spans + close-span merge with peak guard. + */ +package com.example.medilightv2android.walldetect.algo + +object SpanUtils { + + /** A boolean-mask span [start, end] inclusive. Equivalent to JS [s, e] tuple. */ + data class Span(val start: Int, val end: Int) + + fun contiguousTrueSpans(mask: BooleanArray): List { + val spans = mutableListOf() + var start = -1 + for (i in mask.indices) { + if (mask[i] && start == -1) { + start = i + } else if (!mask[i] && start != -1) { + spans += Span(start, i - 1) + start = -1 + } + } + if (start != -1) spans += Span(start, mask.size - 1) + return spans + } + + /** + * Merge spans whose gap ≤ maxGap. Optional peak-guard: + * if `sg` is provided, gaps where any sample exceeds gapPeakThr are NOT merged + * (preserves spans separated by a strong peak). + */ + fun mergeCloseSpans( + spans: List, + maxGap: Int, + sg: DoubleArray? = null, + gapPeakThr: Double? = null + ): List { + if (spans.isEmpty()) return emptyList() + val ordered = spans.sortedBy { it.start } + val merged = mutableListOf(ordered[0]) + val usePeak = sg != null && gapPeakThr != null + for (k in 1 until ordered.size) { + val (s, e) = ordered[k] + val last = merged.last() + val pe = last.end + val gap = s - pe - 1 + if (gap <= maxGap) { + var skip = false + if (usePeak && gap >= 1) { + var mx = Double.NEGATIVE_INFINITY + for (i in (pe + 1) until s) if (sg!![i] > mx) mx = sg[i] + if (mx > gapPeakThr!!) skip = true + } + if (!skip) { + merged[merged.lastIndex] = Span(last.start, maxOf(pe, e)) + continue + } + } + merged += Span(s, e) + } + return merged + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereFit2Step.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereFit2Step.kt new file mode 100644 index 0000000..50e3074 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereFit2Step.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/sphere_fit_2step.js (1:1). + * + * Kasa → LM, the canonical CharlesKWON V4 sphere fit. + * Auto-selects circle (xz / yz / xy plane) when all points share one axis, + * else 3D sphere. + * + * 'auto' detection (1e-6 epsilon): + * constY → circle (drop y, xz plane) — V2 30° SI-only standard case + * constX → circle (drop x, yz plane) — central-column-only post-gate + * constZ → circle (drop z, xy plane) + * else → sphere (3D) + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.abs +import kotlin.math.sqrt + +object SphereFit2Step { + + enum class Mode { AUTO, CIRCLE, SPHERE } + + data class ResidualStats( + val residuals: DoubleArray, // (|P_i − C| − R) + val mean: Double, + val std: Double, + val max: Double // max(|residual|) + ) + + /** + * Fit result. `mode` is the actually-chosen mode (auto resolves to circle/sphere). + * `dropAxis` set only for circle: 0=x, 1=y, 2=z. + * `axes` is the index pair of the spanning plane (only set for circle). + * `kasaCenter`/`kasaR` is the initial guess; `lmCenter`/`lmR` is the refined fit. + * `residualStats` is computed against the LM solution. + */ + data class FitResult( + val mode: String, // "circle" | "sphere" + val dropAxis: Int?, // 0/1/2 for circle, null for sphere + val axes: IntArray?, // [a, b] spanning plane (circle only), null for sphere + val kasaCenter: DoubleArray, + val kasaR: Double, + val lmCenter: DoubleArray, // 2 entries for circle, 3 for sphere + val lmR: Double, + val lmIter: Int, + val residuals: DoubleArray, + val residualMean: Double, + val residualStd: Double, + val residualMax: Double + ) + + /** Distance from each point to centre. */ + fun residuals(p: List, center: DoubleArray): DoubleArray { + val k = center.size + return DoubleArray(p.size) { i -> + var s = 0.0 + for (d in 0 until k) { + val dd = p[i][d] - center[d] + s += dd * dd + } + sqrt(s) + } + } + + fun residualStats(p: List, center: DoubleArray, r: Double): ResidualStats { + val raw = residuals(p, center) + val res = DoubleArray(raw.size) { raw[it] - r } + var sum = 0.0 + for (v in res) sum += v + val mean = sum / res.size + var sq = 0.0 + for (v in res) { val d = v - mean; sq += d * d } + var maxAbs = 0.0 + for (v in res) { val a = abs(v); if (a > maxAbs) maxAbs = a } + return ResidualStats( + residuals = res, + mean = mean, + std = sqrt(sq / res.size), + max = maxAbs + ) + } + + /** Project P (N×3) onto the 2D plane spanned by axes (a, b). */ + private fun project(p: List, a: Int, b: Int): List = + p.map { doubleArrayOf(it[a], it[b]) } + + fun fit2Step(p: List, mode: Mode = Mode.AUTO): FitResult? { + if (p.size < 3) return null + val is3D = p.all { it.size == 3 } + + var resolvedMode = mode + var dropAxis: Int? = null + + if (mode == Mode.AUTO && is3D) { + val eps = 1e-6 + val constX = p.all { abs(it[0] - p[0][0]) < eps } + val constY = p.all { abs(it[1] - p[0][1]) < eps } + val constZ = p.all { abs(it[2] - p[0][2]) < eps } + when { + constY -> { resolvedMode = Mode.CIRCLE; dropAxis = 1 } // historical V2 30° + constX -> { resolvedMode = Mode.CIRCLE; dropAxis = 0 } // central column only + constZ -> { resolvedMode = Mode.CIRCLE; dropAxis = 2 } + else -> { resolvedMode = Mode.SPHERE } + } + } else if (mode == Mode.CIRCLE && is3D && dropAxis == null) { + // Caller forced circle without indicating axis → historical default (drop y, fit xz) + dropAxis = 1 + } + + return if (resolvedMode == Mode.CIRCLE) { + val axes = when (dropAxis) { + 0 -> intArrayOf(1, 2) + 1 -> intArrayOf(0, 2) + 2 -> intArrayOf(0, 1) + else -> intArrayOf(0, 1) // 2D input direct; axes meaningless + } + val pPlane = if (is3D) project(p, axes[0], axes[1]) else p + val k0 = SphereKasa.kasaCircle(pPlane) ?: return null + val fin = SphereLm.lmCircle(pPlane, k0.center, k0.r) + val stats = residualStats(pPlane, fin.center, fin.r) + FitResult( + mode = "circle", + dropAxis = dropAxis, + axes = axes, + kasaCenter = k0.center, + kasaR = k0.r, + lmCenter = fin.center, + lmR = fin.r, + lmIter = fin.iter, + residuals = stats.residuals, + residualMean = stats.mean, + residualStd = stats.std, + residualMax = stats.max + ) + } else { + val k0 = SphereKasa.kasaSphere(p) ?: return null + val fin = SphereLm.lmSphere(p, k0.center, k0.r) + val stats = residualStats(p, fin.center, fin.r) + FitResult( + mode = "sphere", + dropAxis = null, + axes = null, + kasaCenter = k0.center, + kasaR = k0.r, + lmCenter = fin.center, + lmR = fin.r, + lmIter = fin.iter, + residuals = stats.residuals, + residualMean = stats.mean, + residualStd = stats.std, + residualMax = stats.max + ) + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereKasa.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereKasa.kt new file mode 100644 index 0000000..99e818f --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereKasa.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/sphere_kasa.js (1:1). + * + * Kasa (1976) algebraic least-squares circle/sphere fit. Linearises + * |p|² = 2 c·p + (R² − |c|²) + * with unknowns (c, R²−|c|²). Used as the Mode A initial guess before LM. + * + * Reference: + * Kasa I., "A circle fitting procedure and its error analysis", + * IEEE Trans Instrum Meas IM-25(1):8–14, 1976. + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdLinalg +import kotlin.math.max +import kotlin.math.sqrt + +object SphereKasa { + + data class CircleFit(val center: DoubleArray, val r: Double) + data class SphereFit(val center: DoubleArray, val r: Double) + + /** P : list of (x, y) pairs. Returns null if n < 3 or singular. */ + fun kasaCircle(p: List): CircleFit? { + val n = p.size + if (n < 3) return null + val a = Array(n) { DoubleArray(3) } + val b = DoubleArray(n) + for (i in 0 until n) { + val x = p[i][0]; val y = p[i][1] + a[i][0] = 2 * x; a[i][1] = 2 * y; a[i][2] = 1.0 + b[i] = x * x + y * y + } + val sol = WdLinalg.solve(WdLinalg.ata(a), WdLinalg.atb(a, b)) ?: return null + val cx = sol[0]; val cy = sol[1]; val c = sol[2] + val r2 = c + cx * cx + cy * cy + return CircleFit(center = doubleArrayOf(cx, cy), r = sqrt(max(r2, 0.0))) + } + + /** P : list of (x, y, z) triples. Returns null if n < 4 or singular. */ + fun kasaSphere(p: List): SphereFit? { + val n = p.size + if (n < 4) return null + val a = Array(n) { DoubleArray(4) } + val b = DoubleArray(n) + for (i in 0 until n) { + val x = p[i][0]; val y = p[i][1]; val z = p[i][2] + a[i][0] = 2 * x; a[i][1] = 2 * y; a[i][2] = 2 * z; a[i][3] = 1.0 + b[i] = x * x + y * y + z * z + } + val sol = WdLinalg.solve(WdLinalg.ata(a), WdLinalg.atb(a, b)) ?: return null + val cx = sol[0]; val cy = sol[1]; val cz = sol[2]; val c = sol[3] + val r2 = c + cx * cx + cy * cy + cz * cz + return SphereFit(center = doubleArrayOf(cx, cy, cz), r = sqrt(max(r2, 0.0))) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereLm.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereLm.kt new file mode 100644 index 0000000..99191b6 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SphereLm.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/sphere_lm.js (1:1). + * + * Levenberg-Marquardt geometric LS for circle / sphere. + * Minimise Σ (|P_i − C| − R)² + * Jacobian: ∂r_i/∂c = −(P_i − C) / |P_i − C|; ∂r_i/∂R = −1 + * + * Reference: + * Levenberg K. (1944) Quart. Appl. Math. 2:164–168. + * Marquardt D. (1963) SIAM J. Appl. Math. 11:431–441. + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdLinalg +import kotlin.math.sqrt + +object SphereLm { + + data class CircleResult(val center: DoubleArray, val r: Double, val iter: Int) + data class SphereResult(val center: DoubleArray, val r: Double, val iter: Int) + + fun lmCircle( + p: List, + c0: DoubleArray, + r0: Double, + maxIter: Int = 50, + tol: Double = 1e-8, + lam: Double = 1e-3 + ): CircleResult { + var cx = c0[0]; var cy = c0[1]; var r = r0 + var iter = 0 + while (iter < maxIter) { + val n = p.size + val j = Array(n) { DoubleArray(3) } + val res = DoubleArray(n) + for (i in 0 until n) { + val dx = p[i][0] - cx + val dy = p[i][1] - cy + val d = sqrt(dx * dx + dy * dy).let { if (it == 0.0) 1e-12 else it } + j[i][0] = -dx / d + j[i][1] = -dy / d + j[i][2] = -1.0 + res[i] = d - r + } + val h = WdLinalg.ata(j) + h[0][0] += lam; h[1][1] += lam; h[2][2] += lam + val g = WdLinalg.atb(j, res) + val delta = WdLinalg.solve(h, doubleArrayOf(-g[0], -g[1], -g[2])) ?: break + cx += delta[0]; cy += delta[1]; r += delta[2] + iter++ + if (WdLinalg.vecNorm(delta) < tol) break + } + return CircleResult(center = doubleArrayOf(cx, cy), r = r, iter = iter + 1) + } + + fun lmSphere( + p: List, + c0: DoubleArray, + r0: Double, + maxIter: Int = 50, + tol: Double = 1e-8, + lam: Double = 1e-3 + ): SphereResult { + var cx = c0[0]; var cy = c0[1]; var cz = c0[2]; var r = r0 + var iter = 0 + while (iter < maxIter) { + val n = p.size + val j = Array(n) { DoubleArray(4) } + val res = DoubleArray(n) + for (i in 0 until n) { + val dx = p[i][0] - cx + val dy = p[i][1] - cy + val dz = p[i][2] - cz + val d = sqrt(dx * dx + dy * dy + dz * dz).let { if (it == 0.0) 1e-12 else it } + j[i][0] = -dx / d + j[i][1] = -dy / d + j[i][2] = -dz / d + j[i][3] = -1.0 + res[i] = d - r + } + val h = WdLinalg.ata(j) + for (k in 0..3) h[k][k] += lam + val g = WdLinalg.atb(j, res) + val delta = WdLinalg.solve(h, doubleArrayOf(-g[0], -g[1], -g[2], -g[3])) ?: break + cx += delta[0]; cy += delta[1]; cz += delta[2]; r += delta[3] + iter++ + if (WdLinalg.vecNorm(delta) < tol) break + } + return SphereResult(center = doubleArrayOf(cx, cy, cz), r = r, iter = iter + 1) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/SubsampleRefine.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SubsampleRefine.kt new file mode 100644 index 0000000..f809bed --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/SubsampleRefine.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/subsample_refine.js (1:1). + * Sub-sample wall position refinement (Cespedes 1995, parabolic 3-point fit). + * + * Method: + * y(x) = y0 + ½ y'' (x − x0)² + * 3-point fit (idx-1, idx, idx+1) gives parabola vertex at + * δ = ½ · (y[idx-1] − y[idx+1]) / (y[idx-1] − 2·y[idx] + y[idx+1]) + * |δ| ≤ 0.5 when idx is a strict local extremum (else fall back to integer idx). + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.abs + +object SubsampleRefine { + + enum class Kind { AUTO, PEAK, VALLEY } + + /** + * @return refined fractional sample index, or null if `idx` is null, + * or the integer `idx` if refinement is not applicable. + */ + fun refineParabolic(envelope: DoubleArray?, idx: Int?, kind: Kind = Kind.AUTO): Double? { + if (envelope == null || idx == null) return idx?.toDouble() + val n = envelope.size + if (idx < 1 || idx > n - 2) return idx.toDouble() + + val ym1 = envelope[idx - 1] + val y0 = envelope[idx] + val yp1 = envelope[idx + 1] + val denom = ym1 - 2 * y0 + yp1 + + if (denom == 0.0 || !denom.isFinite()) return idx.toDouble() + if (kind == Kind.PEAK && denom > 0) return idx.toDouble() + if (kind == Kind.VALLEY && denom < 0) return idx.toDouble() + + val delta = 0.5 * (ym1 - yp1) / denom + if (!delta.isFinite() || abs(delta) > 1.0) return idx.toDouble() + return idx + delta + } + + /** + * Linear interpolation of an envelope crossing T near `idx`. + * @param dir "up" — sign change neg → non-neg (ant-like) + * "down" — sign change non-neg → neg (post-like) + * null — accept either direction + */ + fun refineLinearCrossing( + envelope: DoubleArray?, + idx: Int?, + T: Double, + dir: String? + ): Double? { + if (envelope == null || idx == null) return idx?.toDouble() + val n = envelope.size + if (idx < 1 || idx > n - 1) return idx.toDouble() + + val samples = listOf(idx - 1, idx, idx + 1).filter { it in 0 until n } + for (s in 0 until samples.size - 1) { + val a = samples[s] + val b = samples[s + 1] + val va = envelope[a] - T + val vb = envelope[b] - T + val isUp = (va < 0 && vb >= 0) + val isDown = (va >= 0 && vb < 0) + if ((dir == "up" && isUp) || (dir == "down" && isDown) || + (dir == null && (isUp || isDown)) + ) { + val denom = vb - va + if (denom == 0.0) return a.toDouble() + val frac = -va / denom // ∈ [0, 1] + return a + frac + } + } + return idx.toDouble() + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/ThresholdOsCfar.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/ThresholdOsCfar.kt new file mode 100644 index 0000000..fb7136a --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/ThresholdOsCfar.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/threshold_oscfar.js (1:1). + * + * Order-Statistic CFAR (Rohling 1983) — adaptive threshold in V4.1. + * T_i = scale · Q_rank(window_i \ {i}) [per-sample] + * T = median(T_i) [scalar] + * + * Reference: + * Rohling H., "Radar CFAR Thresholding in Clutter and Multiple Target + * Situations", IEEE Trans Aerosp Electron Syst AES-19(4):608–621, 1983. + * + * Defaults (CONFIG): + * OSCFAR_WIN = 15 (centred window length, half = 7 each side) + * OSCFAR_RANK = 0.4 (40th percentile in window-without-CUT) + * OSCFAR_SCALE = 1.05 (5% safety margin above the rank percentile) + */ +package com.example.medilightv2android.walldetect.algo + +import com.example.medilightv2android.walldetect.core.WdConfig +import com.example.medilightv2android.walldetect.core.WdNumeric +import kotlin.math.max +import kotlin.math.min + +object ThresholdOsCfar { + + /** + * Per-sample OS-CFAR threshold trace. + * For each sample i: collect window samples (i ± half) excluding i itself, + * compute the rank-th quantile, scale by `scale`. Edge windows are truncated. + * If the local set is empty (n=1 input), threshold = sg[i]. + */ + fun perSample( + sg: DoubleArray, + win: Int = WdConfig.OSCFAR_WIN, + rank: Double = WdConfig.OSCFAR_RANK, + scale: Double = WdConfig.OSCFAR_SCALE + ): DoubleArray { + val n = sg.size + val half = win / 2 + val thr = DoubleArray(n) + for (i in 0 until n) { + val lo = max(0, i - half) + val hi = min(n, i + half + 1) + val localSize = (i - lo) + (hi - (i + 1)) + if (localSize <= 0) { + thr[i] = sg[i] + continue + } + val local = DoubleArray(localSize) + var p = 0 + for (k in lo until i) { local[p++] = sg[k] } + for (k in (i + 1) until hi) { local[p++] = sg[k] } + thr[i] = WdNumeric.quantile(local, rank) * scale + } + return thr + } + + /** + * Scalar OS-CFAR threshold = median of per-sample trace. + * This is what V4.1 lumen-first detector uses as the lumen mask cut value. + */ + fun scalar( + sg: DoubleArray, + win: Int = WdConfig.OSCFAR_WIN, + rank: Double = WdConfig.OSCFAR_RANK, + scale: Double = WdConfig.OSCFAR_SCALE + ): Double = WdNumeric.median(perSample(sg, win, rank, scale)) +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/WallSelect.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/WallSelect.kt new file mode 100644 index 0000000..73a048b --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/WallSelect.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/wall_select.js (1:1). + * V2 prominence-based wall selection with other_edge inner clamp. + * Mirror of py2/low_echo_detection_method_b._select_wall_by_prominence. + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +object WallSelect { + + const val PEAK_SEARCH_WIN = 20 + const val MAX_PEAK_CANDIDATES = 3 + + enum class Side { ANT, POST } + + /** + * @param sg envelope (smoothed) + * @param edge span endpoint (s for ant, e for post) + * @param searchWin search window size around edge + * @param peakMin minimum amplitude to consider a peak candidate + * @param side ANT → prominence vs right valley (urine direction) + * POST → prominence vs left valley (urine direction) + * @param otherEdge opposite span endpoint — inner search must not cross it. + * @param maxCandidates limit per side + * @return wall sample index, or null if no candidate + */ + fun selectWallByProminence( + sg: DoubleArray, + edge: Int, + searchWin: Int = PEAK_SEARCH_WIN, + peakMin: Double, + side: Side, + otherEdge: Int? = null, + maxCandidates: Int = MAX_PEAK_CANDIDATES, + edgeDistDecay: Double = 0.12, // py2 method_b EDGE_DIST_DECAY + valleyStopRise: Double = 50.0, // py2 method_b VALLEY_STOP_RISE + ): Int? { + val n = sg.size + var leftLo: Int + var rightHi: Int + + if (side == Side.ANT) { + leftLo = max(0, edge - searchWin) + rightHi = min(n - 1, edge + searchWin) + if (otherEdge != null) rightHi = min(rightHi, otherEdge) + } else { // POST + leftLo = max(0, edge - searchWin) + if (otherEdge != null) leftLo = max(leftLo, otherEdge) + rightHi = min(n - 1, edge + searchWin) + } + + // py2 method_b — left slice extended to edge+1 so that an edge−1 peak + // is detectable (find_peaks_1d excludes the rightmost sample). + var leftCand: List = emptyList() + if (edge > leftLo) { + val leftEnd = min(edge + 1, n) + val seg = DoubleArray(leftEnd - leftLo) { sg[leftLo + it] } + val pks = PeakDetection.findPeaks1D(seg) + leftCand = pks.map { leftLo + it } + .filter { it < edge && sg[it] >= peakMin } + .sortedBy { abs(it - edge) } + .take(maxCandidates) + } + + // py2 method_b — right slice starts at edge−1 so that an edge peak is detectable. + var rightCand: List = emptyList() + if (rightHi >= edge) { + val rightStart = max(edge - 1, 0) + val seg = DoubleArray(rightHi + 1 - rightStart) { sg[rightStart + it] } + val pks = PeakDetection.findPeaks1D(seg) + rightCand = pks.map { rightStart + it } + .filter { it >= edge && sg[it] >= peakMin } + .sortedBy { abs(it - edge) } + .take(maxCandidates) + } + + val candidates = leftCand + rightCand + if (candidates.isEmpty()) return null + + // py2 method_b prominence + edge-distance decay: + // score = prominence / (1 + EDGE_DIST_DECAY * |p - edge|) + // Valley walk uses VALLEY_STOP_RISE instead of legacy 10. + var best: Int? = null + var bestScore = Double.NEGATIVE_INFINITY + for (p in candidates) { + val valley = if (side == Side.ANT) + PeakDetection.rightValley(sg, p, breakRise = valleyStopRise) + else + PeakDetection.leftValley(sg, p, breakRise = valleyStopRise) + val prom = sg[p] - valley + val dist = abs(p - edge).toDouble() + val score = prom / (1.0 + edgeDistDecay * dist) + if (score > bestScore) { + bestScore = score + best = p + } + } + return best + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/WaveletDenoise.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/WaveletDenoise.kt new file mode 100644 index 0000000..890c16b --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/WaveletDenoise.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/algo/wavelet_denoise.js (1:1). + * + * Sun 2024 wavelet energy-ratio adaptive denoising for A-mode envelopes. + * Reference: Sun J et al., "A-Mode Ultrasound Bladder Volume Estimation + * Algorithm Based on Wavelet Energy Ratio Adaptive Denoising," + * Sensors 24(6):1984, 2024. doi:10.3390/s24061984 + * + * Pipeline: + * 1. Pad signal to multiple of 2^L (periodic extension at end). + * 2. L-level Daubechies-4 DWT decomposition. + * 3. Per-level noise sigma_j = MAD(d_j) × 1.4826 (Donoho 1995). + * 4. Per-level energy E_j = ||d_j||² / N_j; ratio R_j = E_j / max_k(E_k). + * 5. Adaptive soft threshold: λ_j = sigma_j · √(2·ln N_j) · ((1 − R_j) + ε). + * 6. Inverse DWT, truncate to original length. + * + * Convention: details[0] = level 1 (highest freq), details[L-1] = level L + * (lowest detail / nearest to approx). approx is stored separately. + */ +package com.example.medilightv2android.walldetect.algo + +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sqrt + +object WaveletDenoise { + + // ── DB4 (Daubechies-4) filter coefficients — PyWavelets canonical ── + val DB4_DEC_LO = doubleArrayOf( + -0.010597401784997278, 0.032883011666982945, 0.030841381835986965, -0.18703481171888114, + -0.027983769416983849, 0.63088076792959036, 0.71484657055254153, 0.23037781330885523 + ) + val DB4_DEC_HI = doubleArrayOf( + -0.23037781330885523, 0.71484657055254153, -0.63088076792959036, -0.027983769416983849, + 0.18703481171888114, 0.030841381835986965, -0.032883011666982945, -0.010597401784997278 + ) + val DB4_REC_LO = doubleArrayOf( + 0.23037781330885523, 0.71484657055254153, 0.63088076792959036, -0.027983769416983849, + -0.18703481171888114, 0.030841381835986965, 0.032883011666982945, -0.010597401784997278 + ) + val DB4_REC_HI = doubleArrayOf( + -0.010597401784997278, -0.032883011666982945, 0.030841381835986965, 0.18703481171888114, + -0.027983769416983849, -0.63088076792959036, 0.71484657055254153, -0.23037781330885523 + ) + + /** Single-level DWT result: a = approximation, d = detail. */ + data class Dwt1Result(val a: DoubleArray, val d: DoubleArray) + + /** + * Multi-level Mallat decomposition. + * details[0] = level 1 (highest freq), details[levels-1] = level L (deepest). + */ + data class Decomposition( + val approx: DoubleArray, + val details: List, + val lengths: IntArray, + val paddedLength: Int + ) + + /** Per-level diagnostics (energies, ratios, sigma estimates). */ + data class Diagnose( + val levels: Int, + val sigma: DoubleArray, + val energy: DoubleArray, + val ratio: DoubleArray, + val detailLengths: IntArray + ) + + // ───────────────────────────────────────────────────────── + // Single-level DWT / IDWT (periodic boundary) + // ───────────────────────────────────────────────────────── + + /** Standard convolution DWT: a[k] = Σ h_lo[m] · x[(2k − m + N) mod N]. */ + fun dwt1(x: DoubleArray, hLo: DoubleArray, hHi: DoubleArray): Dwt1Result { + val n = x.size + val m = n shr 1 + val l = hLo.size + val a = DoubleArray(m) + val d = DoubleArray(m) + for (k in 0 until m) { + var sa = 0.0 + var sd = 0.0 + for (nn in 0 until l) { + val i = (((2 * k - nn) % n) + n) % n + sa += hLo[nn] * x[i] + sd += hHi[nn] * x[i] + } + a[k] = sa + d[k] = sd + } + return Dwt1Result(a, d) + } + + /** + * Synthesis with (L-1) sample shift to align idwt(dwt(x)) = x. + * out[n] = Σ_k g_lo[((n + L − 1) − 2k) mod N] · a[k] + * + Σ_k g_hi[((n + L − 1) − 2k) mod N] · d[k] + */ + fun idwt1(a: DoubleArray, d: DoubleArray, gLo: DoubleArray, gHi: DoubleArray): DoubleArray { + val mLen = a.size + val n = 2 * mLen + val l = gLo.size + val out = DoubleArray(n) + val shift = l - 1 + for (nn in 0 until n) { + var s = 0.0 + for (k in 0 until mLen) { + val mIdx = (((nn + shift) - 2 * k) % n + n) % n + if (mIdx < l) s += gLo[mIdx] * a[k] + gHi[mIdx] * d[k] + } + out[nn] = s + } + return out + } + + // ───────────────────────────────────────────────────────── + // Multi-level Mallat pyramidal DWT/IDWT + // ───────────────────────────────────────────────────────── + + fun dwt(signal: DoubleArray, levels: Int = 3): Decomposition { + val n0 = signal.size + val pow = 1 shl levels + val target = (ceil(n0.toDouble() / pow) * pow).toInt() + + val x: DoubleArray = if (target != n0) { + DoubleArray(target) { signal[it % n0] } + } else { + signal.copyOf() + } + + val details = mutableListOf() + val lengths = mutableListOf(x.size) + var approx = x + for (j in 0 until levels) { + val out = dwt1(approx, DB4_DEC_LO, DB4_DEC_HI) + details += out.d + approx = out.a + lengths += approx.size + } + return Decomposition(approx, details.toList(), lengths.toIntArray(), x.size) + } + + fun idwt(decomp: Decomposition): DoubleArray { + var approx = decomp.approx + for (j in decomp.details.size - 1 downTo 0) { + approx = idwt1(approx, decomp.details[j], DB4_REC_LO, DB4_REC_HI) + } + return approx + } + + // ───────────────────────────────────────────────────────── + // MAD (median absolute deviation) — JS uses simple median, NOT linear-interp + // ───────────────────────────────────────────────────────── + + /** Mirror of JS `mad()`: median via floor(N/2), no linear interpolation. */ + fun mad(arr: DoubleArray): Double { + if (arr.isEmpty()) return 0.0 + val sorted = arr.copyOf().also { it.sort() } + val med = sorted[floor(sorted.size / 2.0).toInt()] + val devSorted = DoubleArray(sorted.size) { abs(sorted[it] - med) } + devSorted.sort() + return devSorted[floor(devSorted.size / 2.0).toInt()] + } + + // ───────────────────────────────────────────────────────── + // Soft threshold + // ───────────────────────────────────────────────────────── + + fun softThreshold(coeffs: DoubleArray, lambda: Double): DoubleArray { + val out = DoubleArray(coeffs.size) + for (i in coeffs.indices) { + val v = coeffs[i] + out[i] = when { + v > lambda -> v - lambda + v < -lambda -> v + lambda + else -> 0.0 + } + } + return out + } + + // ───────────────────────────────────────────────────────── + // Sun 2024 energy-ratio adaptive denoise + // ───────────────────────────────────────────────────────── + + fun denoise(signal: DoubleArray, levels: Int = 3, eps: Double = 0.1): DoubleArray { + val n0 = signal.size + if (n0 < (1 shl levels)) return signal.copyOf() + + val decomp = dwt(signal, levels) + + val sigmas = DoubleArray(decomp.details.size) { mad(decomp.details[it]) * 1.4826 } + + val energies = DoubleArray(decomp.details.size) { idx -> + val d = decomp.details[idx] + var s = 0.0 + for (v in d) s += v * v + s / d.size + } + + var eMax = 1e-30 + for (e in energies) if (e > eMax) eMax = e + val ratios = DoubleArray(energies.size) { energies[it] / eMax } + + val denoisedDetails = decomp.details.mapIndexed { j, d -> + val nj = d.size + val lambda = sigmas[j] * + sqrt(2.0 * ln(max(2.0, nj.toDouble()))) * + ((1.0 - ratios[j]) + eps) + softThreshold(d, lambda) + } + + val newDecomp = Decomposition( + approx = decomp.approx, + details = denoisedDetails, + lengths = decomp.lengths, + paddedLength = decomp.paddedLength + ) + val reconstructed = idwt(newDecomp) + return DoubleArray(n0) { reconstructed[it] } + } + + // ───────────────────────────────────────────────────────── + // Per-band reconstruction (frequency-decomposition view) + // ───────────────────────────────────────────────────────── + + /** + * @param levelIdx 0..levels-1 → reconstruct only that detail level + * -1 → reconstruct only the approximation + */ + fun reconstructLevel(decomp: Decomposition, levelIdx: Int, originalLength: Int = -1): DoubleArray { + val zerosA = DoubleArray(decomp.approx.size) + val zerosD = decomp.details.map { DoubleArray(it.size) } + val stub = Decomposition( + approx = if (levelIdx == -1) decomp.approx else zerosA, + details = decomp.details.mapIndexed { j, d -> if (j == levelIdx) d else zerosD[j] }, + lengths = decomp.lengths, + paddedLength = decomp.paddedLength + ) + val recon = idwt(stub) + val n = if (originalLength >= 0) originalLength else recon.size + return DoubleArray(n) { recon[it] } + } + + /** + * Boundary-clean per-band reconstruction with symmetric reflection padding. + * Suppresses periodic-boundary wraparound artefact at samples ~85..99. + */ + fun reconstructLevelClean(signal: DoubleArray, levels: Int, levelIdx: Int): DoubleArray { + val n = signal.size + val minPad = 32 + val mLevel = 1 shl levels + val mTarget = (ceil((n + 2 * minPad).toDouble() / mLevel) * mLevel).toInt() + val padLeft = ((mTarget - n) / 2) + val padRight = mTarget - n - padLeft + + val ext = DoubleArray(mTarget) + for (i in 0 until padLeft) ext[i] = signal[min(padLeft - 1 - i, n - 1)] + for (i in 0 until n) ext[padLeft + i] = signal[i] + for (i in 0 until padRight) ext[padLeft + n + i] = signal[max(n - 1 - i, 0)] + + val decomp = dwt(ext, levels) + val zerosA = DoubleArray(decomp.approx.size) + val zerosD = decomp.details.map { DoubleArray(it.size) } + val stub = Decomposition( + approx = if (levelIdx == -1) decomp.approx else zerosA, + details = decomp.details.mapIndexed { j, d -> if (j == levelIdx) d else zerosD[j] }, + lengths = decomp.lengths, + paddedLength = decomp.paddedLength + ) + val recon = idwt(stub) + + return DoubleArray(n) { recon[padLeft + it] } + } + + // ───────────────────────────────────────────────────────── + // Diagnostic (no thresholding — for visualisation) + // ───────────────────────────────────────────────────────── + + fun diagnose(signal: DoubleArray, levels: Int = 3): Diagnose? { + if (signal.size < (1 shl levels)) return null + val decomp = dwt(signal, levels) + val sigmas = DoubleArray(decomp.details.size) { mad(decomp.details[it]) * 1.4826 } + val energies = DoubleArray(decomp.details.size) { idx -> + val d = decomp.details[idx] + var s = 0.0 + for (v in d) s += v * v + s / d.size + } + var eMax = 1e-30 + for (e in energies) if (e > eMax) eMax = e + val ratios = DoubleArray(energies.size) { energies[it] / eMax } + return Diagnose( + levels = levels, + sigma = sigmas, + energy = energies, + ratio = ratios, + detailLengths = IntArray(decomp.details.size) { decomp.details[it].size } + ) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/core/Config.kt b/app/src/main/java/com/example/medilightv2android/walldetect/core/Config.kt new file mode 100644 index 0000000..600c434 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/core/Config.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/core/config.js (1:1). + * 상수 변경 금지 — 정의가 바뀌면 V2/V4.1 골든 테스트가 깨집니다. + */ +package com.example.medilightv2android.walldetect.core + +object WdConfig { + const val SG_WIN = 5 + const val SG_POLY = 2 + + const val OSCFAR_WIN = 15 + const val OSCFAR_RANK = 0.4 + const val OSCFAR_SCALE = 1.05 + + const val CHORD_IDEAL_MM = 50.0 + const val CHORD_SIGMA_MM = 30.0 + val CHORD_MM_RANGE = doubleArrayOf(5.0, 130.0) + const val PROMINENCE_DB_MIN = 1.5 + + const val W_LUMEN_DARKNESS = 25.0 + const val W_CHORD_PRIOR = 8.0 + const val W_SYMMETRY = 3.0 + const val CONTRAST_ALPHA = 2.0 + + const val CONTRAST_TAU = 6 + const val CONTRAST_WIN = 10 + + // 2026-04-29 #2: aligned to study/amode_simulator.html V4 운용 확정 (DPS card). + // Theory : DPS = c_ref(1530 m/s) · Δt(2.524 μs) / 2 = 1.931 mm/sp + // Phantom : D(100.406 mm) / chord(52 samples) = 1.9309 mm/sp + // K : c_eff / c_ref = 1.000 + // 결과적으로 amode_simulator 와 동일 좌표계 → 530 mL phantom 시 R≈50.20mm 회복. + const val DPS_DEFAULT = 1.9309 + const val DELAY_MM_DEFAULT = 6.85 + const val C_EFF_DEFAULT = 1530.0 + + // ── Low-echo detection tuning (method_b SSOT, config_6ch.py L116-127) ── + const val LOW_ECHO_AMP = 1250.0 + const val LOW_MIN_LEN = 3 + const val MERGE_GAP_MAX = 3 + const val PEAK_SEARCH_WIN = 20 + const val POST_MAX_IDX = 80 + const val MIN_PEAK_MARGIN = 30.0 + const val MIN_URINE_LEN = 3 + const val EDGE_DIST_DECAY = 0.12 + const val MAX_PEAK_CANDIDATES = 3 + const val VALLEY_STOP_RISE = 50.0 + + const val PEAK_LO = 4 + const val PEAK_HI_MARGIN = 2 + + const val PHANTOM_530_ANT_DEPTH_MM = 32.0 + const val PHANTOM_530_R_MM = 50.20 + const val PHANTOM_530_BV_ML = 530.0 + + const val USE_LR_TILT = false +} + +object WdProbe { + // 2026-04-29 #2: aligned to study/amode_simulator.html v2 (30°) probe. + // Mechanical SI : [0, -10, -20, -30, -10, -10] (CAD) + // Snell SI : [0.0, -6.89, -13.66, -20.20, -6.87, -6.87] ← acoustic ray + // Mechanical LR : [0, 0, 0, 0, -5, 5] + // Snell LR : [0, 0, 0, 0, -3.42, 3.42] + // sensor_z (mm) : [19.3, 13.0, 6.7, 0.0, 9.85, 9.85] CH0=top, CH3=bottom + // sensor_x (mm) : [0, 0, 0, 0, -10, 10] + val DEGREE = doubleArrayOf(0.0, -6.89, -13.66, -20.20, -6.87, -6.87) + val DEGREE_LR = doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42) + val MECH_DEGREE = doubleArrayOf(0.0, -10.0, -20.0, -30.0, -10.0, -10.0) + val MECH_DEGREE_LR = doubleArrayOf(0.0, 0.0, 0.0, 0.0, -5.0, 5.0) + val SENSOR_Z = doubleArrayOf(19.3, 13.0, 6.7, 0.0, 9.85, 9.85) + val SENSOR_X = doubleArrayOf(0.0, 0.0, 0.0, 0.0, -10.0, 10.0) + + /** v1 — 10° device (alternate). */ + val V1_DEGREE = doubleArrayOf(6.89, 0.0, -6.89, -13.66, -6.87, -6.87) + val V1_DEGREE_LR = doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42) + val V1_SENSOR_Z = doubleArrayOf(20.1, 12.8, 7.0, 0.0, 9.9, 9.9) + val PIEZO_W = intArrayOf(12, 12, 12, 12, 6, 6) + val PIEZO_H = intArrayOf(6, 6, 6, 6, 12, 12) + const val PIEZO_T = 1.0 + val Z_RECESS = doubleArrayOf(2.37, 1.188, 1.188, 1.188, 0.0, 0.0) + const val HOUSING_W = 30 + const val HOUSING_H = 40 + const val HOUSING_DEPTH = 8 + const val LAYOUT_NOTE = "CH0 (top) → CH3 (navel) vertical column · CH4 = left · CH5 = right" +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/core/Linalg.kt b/app/src/main/java/com/example/medilightv2android/walldetect/core/Linalg.kt new file mode 100644 index 0000000..636f88a --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/core/Linalg.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/core/linalg.js (1:1). + * Small dense linear-algebra helpers (Gauss-Jordan solve with partial pivoting). + * Used by sphere_kasa / sphere_lm. + */ +package com.example.medilightv2android.walldetect.core + +import kotlin.math.abs +import kotlin.math.sqrt + +object WdLinalg { + + /** + * Solve M·x = b where M is n×n. Returns x or null on singular matrix. + * Mutates internal copies; original M and b are untouched. + */ + fun solve(m: Array, b: DoubleArray): DoubleArray? { + val n = b.size + // Deep copy + val a = Array(m.size) { m[it].copyOf() } + val x = b.copyOf() + + for (i in 0 until n) { + // Partial pivot + var p = i + for (r in (i + 1) until n) { + if (abs(a[r][i]) > abs(a[p][i])) p = r + } + if (abs(a[p][i]) < 1e-12) return null + + if (p != i) { + val tmpRow = a[i]; a[i] = a[p]; a[p] = tmpRow + val tmp = x[i]; x[i] = x[p]; x[p] = tmp + } + + val piv = a[i][i] + for (j in i until n) a[i][j] /= piv + x[i] /= piv + + for (r in 0 until n) { + if (r == i) continue + val f = a[r][i] + if (f == 0.0) continue + for (j in i until n) a[r][j] -= f * a[i][j] + x[r] -= f * x[i] + } + } + return x + } + + /** Identity n×n. */ + fun eye(n: Int): Array { + val ii = Array(n) { DoubleArray(n) } + for (i in 0 until n) ii[i][i] = 1.0 + return ii + } + + /** + * out = A^T · A. + * A is m×n (rows of length n). + */ + fun ata(a: Array): Array { + val m = a.size + val n = a[0].size + val out = Array(n) { DoubleArray(n) } + for (i in 0 until n) { + for (j in 0 until n) { + var s = 0.0 + for (k in 0 until m) s += a[k][i] * a[k][j] + out[i][j] = s + } + } + return out + } + + /** A^T · b (m×n × m → n). */ + fun atb(a: Array, b: DoubleArray): DoubleArray { + val m = a.size + val n = a[0].size + val out = DoubleArray(n) + for (i in 0 until n) { + var s = 0.0 + for (k in 0 until m) s += a[k][i] * b[k] + out[i] = s + } + return out + } + + fun vecNorm(v: DoubleArray): Double { + var s = 0.0 + for (x in v) s += x * x + return sqrt(s) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/core/Numeric.kt b/app/src/main/java/com/example/medilightv2android/walldetect/core/Numeric.kt new file mode 100644 index 0000000..f202773 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/core/Numeric.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Mirror of study/wall_detect_verify/js/core/numeric.js (1:1). + * Pure numeric helpers (median / quantile / mean / std / minmax). + * + * 정밀도: 내부 계산은 Double로 수행 (JS는 모두 double). 호출자가 Float 으로 다운캐스트. + */ +package com.example.medilightv2android.walldetect.core + +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.sqrt + +object WdNumeric { + + /** + * Linear-interpolated quantile, matching JS `quantile()`: + * pos = q * (n - 1); lo = floor(pos); hi = ceil(pos); + * a[lo] + (a[hi] - a[lo]) * (pos - lo) + */ + fun quantile(arr: DoubleArray, q: Double): Double { + if (arr.isEmpty()) return Double.NaN + val a = arr.copyOf().also { it.sort() } + val pos = q * (a.size - 1) + val lo = floor(pos).toInt() + val hi = ceil(pos).toInt() + if (lo == hi) return a[lo] + return a[lo] + (a[hi] - a[lo]) * (pos - lo) + } + + fun median(arr: DoubleArray): Double = quantile(arr, 0.5) + + fun mean(arr: DoubleArray): Double { + if (arr.isEmpty()) return Double.NaN + var s = 0.0 + for (v in arr) s += v + return s / arr.size + } + + fun std(arr: DoubleArray, m: Double? = null): Double { + if (arr.isEmpty()) return Double.NaN + val mu = m ?: mean(arr) + var s = 0.0 + for (v in arr) { + val d = v - mu + s += d * d + } + return sqrt(s / arr.size) + } + + fun minmax(arr: DoubleArray): DoubleArray { + var lo = Double.POSITIVE_INFINITY + var hi = Double.NEGATIVE_INFINITY + for (v in arr) { + if (v < lo) lo = v + if (v > hi) hi = v + } + return doubleArrayOf(lo, hi) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/dto/BvDispatchResult.kt b/app/src/main/java/com/example/medilightv2android/walldetect/dto/BvDispatchResult.kt new file mode 100644 index 0000000..6e19891 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/dto/BvDispatchResult.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Multi-channel BV estimation result (4-layer dispatch — see + * docs/BV-CALCULATION-DESIGN.md). Replaces the sphere-only `v41Sphere.bvMl` + * for the primary "BV" metric while sphere fit stays for cross-check. + */ +package com.example.medilightv2android.walldetect.dto + + +data class BvDispatchResult( + val bvMl: Float?, // null = no estimate + val rMm: Float?, // equivalent sphere radius (sphere or chord-derived) + val method: String, // FrustumLR | FrustumNoLR | SphereLM | ConeFallback | Verathon | None + val confidence: Float, // 0..1 + val nCenter: Int, // gated center channels (CH0..CH3) + val nLateral: Int, // gated lateral channels (CH4, CH5) + val lrRatio: Float, // applied LR/AP ratio (1.0 if no lateral) + val warnings: List = emptyList(), + val sphereCrossCheckBvMl: Float? = null, +) diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/dto/ChannelResult.kt b/app/src/main/java/com/example/medilightv2android/walldetect/dto/ChannelResult.kt new file mode 100644 index 0000000..c9be15a --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/dto/ChannelResult.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Per-channel detector result. Schema is identical for V2 and V4.1. + * V2 결과는 v41Diag 가 항상 null. + */ +package com.example.medilightv2android.walldetect.dto + + +data class ChannelResult( + val ch: Int, + val sg: List, + val threshold: List, + val antIdx: Int?, + val postIdx: Int?, + val antRefined: Float?, + val postRefined: Float?, + val antMm: Float?, + val postMm: Float?, + val lumenStart: Int?, + val lumenEnd: Int?, + val chordMm: Float?, + val clipping: Boolean? = null, + val v41Diag: V41Diagnostics? = null +) diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/dto/DetectionResult.kt b/app/src/main/java/com/example/medilightv2android/walldetect/dto/DetectionResult.kt new file mode 100644 index 0000000..3e84b0e --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/dto/DetectionResult.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Top-level detector output. Identical schema for V2 and V4.1. + * Only `algorithm` / `algorithmVersion` differ; all other field names match. + */ +package com.example.medilightv2android.walldetect.dto + + +data class DetectionResult( + val requestId: String, + val timestampMs: Long, + val algorithm: String, + val algorithmVersion: String, + val processingMs: Double, + val perChannel: List, + val summary: DetectionSummary +) diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/dto/DetectionSummary.kt b/app/src/main/java/com/example/medilightv2android/walldetect/dto/DetectionSummary.kt new file mode 100644 index 0000000..2178931 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/dto/DetectionSummary.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Sweep-level summary. Schema identical for V2 and V4.1. + * V4.1-only fields are null in V2 results. + */ +package com.example.medilightv2android.walldetect.dto + + +data class DetectionSummary( + val matchCount: Int, + val chordMmMean: Float?, + val chordMmStd: Float?, + val tier: String? = null, + val scoreMean: Float? = null, + val scoreMin: Float? = null, + val scoreMax: Float? = null, + val gatedCount: Int? = null, + val v41Sphere: V41SphereFit? = null, + /** Multi-channel BV dispatch (PR-13). Replaces sphere-only BV as primary value. */ + val bvDispatch: BvDispatchResult? = null, +) diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/dto/SweepInput.kt b/app/src/main/java/com/example/medilightv2android/walldetect/dto/SweepInput.kt new file mode 100644 index 0000000..e9f1837 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/dto/SweepInput.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * Common input DTO for both V2 and V4.1 detectors. + * 두 detector 가 동일 입력을 받는다는 drift-zero 보장의 단일 source. + */ +package com.example.medilightv2android.walldetect.dto + + +data class SweepInput( + val requestId: String, + val timestampMs: Long, + val deviceId: String, + val sweepSeq: Int, + val probe: ProbeProfileDto, + val adc: List>, + val gainDb: Int = 0 +) + +data class ProbeProfileDto( + val name: String, + val rMm: Double, + val anteriorAnchorMm: Double, + val fsHz: Long, + val cMmPerUs: Double, + val anglesDeg: List +) diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/dto/V41Diagnostics.kt b/app/src/main/java/com/example/medilightv2android/walldetect/dto/V41Diagnostics.kt new file mode 100644 index 0000000..93a4718 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/dto/V41Diagnostics.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * V4.1-only per-channel diagnostics carrier. + * V2 결과에서는 ChannelResult.v41Diag = null. 스키마 자체는 양쪽 동일. + */ +package com.example.medilightv2android.walldetect.dto + + +data class V41Diagnostics( + val waveletDenoised: List, + val waveletEnergyRatio: WaveletEnergyRatio, + val waveletCoefs: WaveletCoefs, + val peaksAll: List, + val spans: List, + val score: Float, + val scoreSub: ScoreSubscores, + val gated: Boolean, + val tier: String, + val sContrast: Float, + val sContrastTier: String, + val singleChannelBvMl: Float? +) + +data class WaveletEnergyRatio(val l1: Float, val l2: Float, val l3: Float) + +data class WaveletCoefs( + val l1: List, + val l2: List, + val l3: List, + val a3: List +) + +data class ScoreSubscores( + val uFarPost: Float, + val uLumDark: Float, + val uAntGrad: Float, + val uPostGrad: Float +) diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/dto/V41SphereFit.kt b/app/src/main/java/com/example/medilightv2android/walldetect/dto/V41SphereFit.kt new file mode 100644 index 0000000..c5345fd --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/dto/V41SphereFit.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + * + * V4.1-only sweep-level sphere fit + BV result. + * Q5: gated < 4 인 sweep 에서는 null (JS sphere_fit_2step.js 와 동일). + */ +package com.example.medilightv2android.walldetect.dto + + +data class V41SphereFit( + val mode: String, + val center: Vec3, + val radiusMm: Float, + val bvMl: Float, + val residualStdMm: Float, + val wallPoints: List, + val deltaRMm: Float? = null, + val deltaBvMl: Float? = null, + val nPoints: Int +) diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/dto/Vec3.kt b/app/src/main/java/com/example/medilightv2android/walldetect/dto/Vec3.kt new file mode 100644 index 0000000..eb7c48f --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/walldetect/dto/Vec3.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2026 Medithings Co., Ltd. + * Author: Charles KWON OhJun + * Project: CharlesKWONsLaw — wall-detect live compare + */ +package com.example.medilightv2android.walldetect.dto + + +data class Vec3(val x: Float, val y: Float, val z: Float) + +data class IntRange2(val start: Int, val end: Int)