feat: Method C (V4.1 CharlesKWON) 통합 + Placement 설정 패널
Method C (walldetect/ 패키지, 38파일): - V41Detector: OS-CFAR threshold + wavelet denoise + anatomical gate + B-mode score - Multi-method BV dispatch: frustum(≥2ch) / Verathon(1ch) / sphere fit(cross-check) - Kasa→LM sphere fitting, sub-sample parabolic refinement - 패키지 이동: com.medithings.charleskwonslaw → com.example.medilightv2android 통합: - 설정 UI: A/B 스위치 → A/B/C 버튼 그룹 (A=보라, B=파랑, C=주황) - Monitoring: Method C 선택 시 V41Detector.detect() → BV dispatch 결과 사용 - Placement: 선택된 Method(A/B/C) 반영 + 톱니바퀴 설정 패널 추가 Placement 그래프 개선: - Threshold 수평선 (주황 점선) - Y축 눈금 (0, 1k, 2k) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
|
||||
+94
-36
@@ -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<Pair<Int, Int>?> = 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()
|
||||
|
||||
+167
-31
@@ -70,6 +70,7 @@ fun PlacementGuideView(appState: AppState) {
|
||||
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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"
|
||||
}
|
||||
@@ -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<ChannelResult> = (0..5).map { ch -> detectChannel(ch, input) }
|
||||
|
||||
val chordsMm: List<Float> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<List<Int>> // 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<ChannelResult> = (0..5).map { ch -> detectChannel(ch, input) }
|
||||
|
||||
// ── 5/6. sweep-level sphere fit (Mode A only — Q5: gated < 4 → null) ──
|
||||
val gatedDetections: List<Geometry.Detection> = 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<IntRange2> = 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<Geometry.WallPoint>
|
||||
): 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>): 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<Detection>,
|
||||
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<Detection>,
|
||||
centerIdx: List<Int>,
|
||||
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<Detection>,
|
||||
centerIdx: List<Int>,
|
||||
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<Detection>,
|
||||
gatedIdx: List<Int>,
|
||||
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<DoubleArray>()
|
||||
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<String>()
|
||||
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<Detection>,
|
||||
centerIdx: List<Int>,
|
||||
lateralIdx: List<Int>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<SpanUtils.Span>,
|
||||
val spans: List<SpanUtils.Span>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<Detection>,
|
||||
dps: Double = WdConfig.DPS_DEFAULT,
|
||||
delayMm: Double = WdConfig.DELAY_MM_DEFAULT,
|
||||
useLr: Boolean = WdConfig.USE_LR_TILT
|
||||
): List<WallPoint> {
|
||||
val pts = mutableListOf<WallPoint>()
|
||||
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<Detection>,
|
||||
dps: Double = WdConfig.DPS_DEFAULT,
|
||||
delayMm: Double = WdConfig.DELAY_MM_DEFAULT,
|
||||
useLr: Boolean = WdConfig.USE_LR_TILT
|
||||
): List<WallPoint> {
|
||||
val pts = mutableListOf<WallPoint>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<PerChannel>,
|
||||
val trustedSet: Map<Int, Boolean>,
|
||||
val fit: SphereFit2Step.FitResult? = null,
|
||||
val agg: SingleChannelBv.AggResult? = null,
|
||||
val reason: String? = null
|
||||
)
|
||||
|
||||
fun selectMode(
|
||||
detections: List<Detection>,
|
||||
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<PerChannel> = 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<Int, Boolean> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<Int>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<IntRange>, // raw plateaus (after L_min/L_max filter)
|
||||
val finalSegments: List<IntRange>, // post peak validation + merging
|
||||
val wallPairs: List<WallPair>, // 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<IntRange>()
|
||||
val pairs = mutableListOf<WallPair>()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<IntRange> {
|
||||
val segs = mutableListOf<IntRange>()
|
||||
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<IntRange>): List<IntRange> {
|
||||
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<Int>()
|
||||
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<Int>()
|
||||
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<IntRange>,
|
||||
maxGapLen: Int = 12,
|
||||
relTol: Double = 0.05,
|
||||
refWin: Int = 5,
|
||||
maxMergedLen: Int = 50,
|
||||
): List<IntRange> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<PerChannel>
|
||||
)
|
||||
|
||||
fun chordBVPerChannel(
|
||||
detResults: List<Geometry.Detection>,
|
||||
dpsMm: Double = WdConfig.DPS_DEFAULT
|
||||
): List<PerChannel> {
|
||||
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<PerChannel>,
|
||||
trusted: Map<Int, Boolean>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<Span> {
|
||||
val spans = mutableListOf<Span>()
|
||||
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<Span>,
|
||||
maxGap: Int,
|
||||
sg: DoubleArray? = null,
|
||||
gapPeakThr: Double? = null
|
||||
): List<Span> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<DoubleArray>, 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<DoubleArray>, 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<DoubleArray>, a: Int, b: Int): List<DoubleArray> =
|
||||
p.map { doubleArrayOf(it[a], it[b]) }
|
||||
|
||||
fun fit2Step(p: List<DoubleArray>, 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<DoubleArray>): 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<DoubleArray>): 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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<DoubleArray>,
|
||||
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<DoubleArray>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<Int> = 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<Int> = 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<DoubleArray>,
|
||||
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<DoubleArray>()
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<DoubleArray>, 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<DoubleArray> {
|
||||
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<DoubleArray>): Array<DoubleArray> {
|
||||
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<DoubleArray>, 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<String> = emptyList(),
|
||||
val sphereCrossCheckBvMl: Float? = null,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<Float>,
|
||||
val threshold: List<Float>,
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<ChannelResult>,
|
||||
val summary: DetectionSummary
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<List<Int>>,
|
||||
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<Double>
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<Float>,
|
||||
val waveletEnergyRatio: WaveletEnergyRatio,
|
||||
val waveletCoefs: WaveletCoefs,
|
||||
val peaksAll: List<Int>,
|
||||
val spans: List<IntRange2>,
|
||||
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<Float>,
|
||||
val l2: List<Float>,
|
||||
val l3: List<Float>,
|
||||
val a3: List<Float>
|
||||
)
|
||||
|
||||
data class ScoreSubscores(
|
||||
val uFarPost: Float,
|
||||
val uLumDark: Float,
|
||||
val uAntGrad: Float,
|
||||
val uPostGrad: Float
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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<Vec3>,
|
||||
val deltaRMm: Float? = null,
|
||||
val deltaBvMl: Float? = null,
|
||||
val nPoints: Int
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Medithings Co., Ltd.
|
||||
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
||||
* 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)
|
||||
Reference in New Issue
Block a user