refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan
사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId 둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요). ## 변경 범위 - Kotlin 148 파일: package + import 문 (714 occurrences) - 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan (main, test, androidTest 각각) - app/build.gradle.kts: namespace, applicationId - docs/FLAVOR_DEMO_STABLE.md: 참조 갱신 - V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix) ## 주의 - applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은 Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨. - Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
package com.medithings.vesiscan
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.managers.NirsManager
|
||||
import com.medithings.vesiscan.models.*
|
||||
import com.medithings.vesiscan.services.*
|
||||
|
||||
enum class AppScreen {
|
||||
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
|
||||
NIRS_CALIBRATION, PIEZO_PERSONALIZATION,
|
||||
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
||||
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
||||
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
|
||||
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
||||
CLINICAL_LIVE // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
||||
}
|
||||
|
||||
class AppState(private val context: Context) {
|
||||
var currentScreen by mutableStateOf(AppScreen.ONBOARDING)
|
||||
var user by mutableStateOf(User())
|
||||
var sensorMode by mutableStateOf(SensorMode.PIEZO)
|
||||
var bladderLevel by mutableStateOf(BladderLevel())
|
||||
var isDeviceConnected by mutableStateOf(false)
|
||||
var isDemoMode by mutableStateOf(false)
|
||||
var isDevMode by mutableStateOf(false)
|
||||
var piezoSettings by mutableStateOf(PiezoSettings())
|
||||
var catheterCount = mutableIntStateOf(0)
|
||||
// Placement 진입 경로 분기용 — Monitoring에서 재진입 시 true, Personalization에서 진입 시 false
|
||||
var placementFromMonitoring by mutableStateOf(false)
|
||||
|
||||
private val storage = UserStorage.getInstance(context)
|
||||
private val historyStore = MeasurementHistoryStore.getInstance(context)
|
||||
private val notificationService = NotificationService.getInstance(context)
|
||||
private val bleManager = BleManager.getInstance(context)
|
||||
private val reminderService = ReminderService.getInstance(context)
|
||||
|
||||
init {
|
||||
loadState()
|
||||
}
|
||||
|
||||
fun loadState() {
|
||||
user = storage.user
|
||||
sensorMode = storage.sensorMode
|
||||
bladderLevel = bladderLevel.copy(
|
||||
currentLevel = storage.bladderLevel,
|
||||
catheterizationThreshold = storage.catheterizationLevel
|
||||
)
|
||||
piezoSettings = storage.piezoSettings
|
||||
catheterCount.intValue = storage.catheterCount
|
||||
|
||||
currentScreen = when {
|
||||
!storage.hasCompletedOnboarding -> AppScreen.ONBOARDING
|
||||
!storage.isRegistered -> AppScreen.REGISTRATION
|
||||
!storage.hasPin -> AppScreen.PIN_SETUP
|
||||
else -> AppScreen.PIN_ENTRY
|
||||
}
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
storage.hasCompletedOnboarding = true
|
||||
storage.hasConsented = true
|
||||
currentScreen = AppScreen.REGISTRATION
|
||||
}
|
||||
|
||||
fun completeConsent() {
|
||||
storage.hasConsented = true
|
||||
storage.consentTimestamp = System.currentTimeMillis()
|
||||
currentScreen = AppScreen.REGISTRATION
|
||||
}
|
||||
|
||||
fun completeRegistration() {
|
||||
storage.user = user
|
||||
storage.isRegistered = true
|
||||
currentScreen = AppScreen.PIN_SETUP
|
||||
}
|
||||
|
||||
fun goHome() {
|
||||
NirsManager.instance.stopStreaming()
|
||||
NirsManager.instance.reset()
|
||||
// 회의 결정: 실제 BLE 연결은 HOME에서도 유지. 다음 Start 시 설정/scan skip하고
|
||||
// 바로 PiezoMonitoring으로 진입할 수 있도록.
|
||||
// Demo 모드는 가짜 연결이므로 isDeviceConnected reset.
|
||||
if (isDemoMode) isDeviceConnected = false
|
||||
isDemoMode = false
|
||||
currentScreen = AppScreen.HOME
|
||||
}
|
||||
|
||||
fun enterDemoMode() {
|
||||
isDemoMode = true
|
||||
isDeviceConnected = true
|
||||
currentScreen = if (sensorMode == SensorMode.NIRS) {
|
||||
AppScreen.VIVAMYO_MONITORING
|
||||
} else {
|
||||
AppScreen.PIEZO_MONITORING
|
||||
}
|
||||
}
|
||||
|
||||
fun startFromHome() {
|
||||
sensorMode = SensorMode.PIEZO
|
||||
storage.sensorMode = SensorMode.PIEZO
|
||||
// 회의 결정: 이미 연결된 상태이면 설정/Placement skip하고 바로 도넛차트.
|
||||
// 연결되지 않은 상태에서만 DeviceScan부터 일련의 과정 진행.
|
||||
currentScreen = if (isDeviceConnected) AppScreen.PIEZO_MONITORING else AppScreen.DEVICE_SCAN
|
||||
}
|
||||
|
||||
fun selectSensor(mode: SensorMode) {
|
||||
sensorMode = mode
|
||||
storage.sensorMode = mode
|
||||
currentScreen = AppScreen.DEVICE_SCAN
|
||||
}
|
||||
|
||||
fun backToSensorSelect() {
|
||||
currentScreen = AppScreen.HOME
|
||||
}
|
||||
|
||||
fun deviceConnected() {
|
||||
isDeviceConnected = true
|
||||
// 임상 모드에서 BLE 연결 시 ClinicalHome으로 복귀 (Personalization 우회)
|
||||
if (com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow) {
|
||||
currentScreen = AppScreen.CLINICAL_HOME
|
||||
return
|
||||
}
|
||||
currentScreen = if (sensorMode == SensorMode.NIRS) {
|
||||
AppScreen.NIRS_CALIBRATION
|
||||
} else {
|
||||
AppScreen.PIEZO_PERSONALIZATION
|
||||
}
|
||||
}
|
||||
|
||||
fun calibrationComplete() {
|
||||
currentScreen = AppScreen.VIVAMYO_MONITORING
|
||||
}
|
||||
|
||||
fun personalizationComplete() {
|
||||
storage.piezoSettings = piezoSettings
|
||||
placementFromMonitoring = false
|
||||
// 회의 #8: AutoScanInterval을 Personalization 페이지에 통합 — 별도 화면 폐기.
|
||||
currentScreen = AppScreen.PLACEMENT_GUIDE
|
||||
}
|
||||
|
||||
/** Persist piezoSettings (called from in-session settings save). */
|
||||
fun savePiezoSettingsNow() {
|
||||
// Re-assign to trigger the mutableStateOf delegate so any composables
|
||||
// observing piezoSettings recompose with the latest value, then persist
|
||||
// to disk so the change survives process death.
|
||||
piezoSettings = piezoSettings.copy()
|
||||
storage.piezoSettings = piezoSettings
|
||||
}
|
||||
|
||||
fun placementComplete() {
|
||||
bladderLevel = BladderLevel(currentLevel = 0, catheterizationThreshold = bladderLevel.catheterizationThreshold)
|
||||
storage.bladderLevel = 0
|
||||
placementFromMonitoring = false
|
||||
currentScreen = AppScreen.PIEZO_MONITORING
|
||||
}
|
||||
|
||||
fun enterPlacementFromMonitoring() {
|
||||
placementFromMonitoring = true
|
||||
currentScreen = AppScreen.PLACEMENT_GUIDE
|
||||
}
|
||||
|
||||
/** Clinical Mode alignment session 진입 — PlacementGuide 로 이동.
|
||||
* ClinicalSessionStore.startMeasurement(mode=ALIGNMENT) 가 먼저 호출돼 있어야 함. */
|
||||
fun enterAlignmentSession() {
|
||||
placementFromMonitoring = false
|
||||
currentScreen = AppScreen.PLACEMENT_GUIDE
|
||||
}
|
||||
|
||||
fun backFromPlacementGuide() {
|
||||
// 회의 #8: AutoScan을 Personalization으로 통합했으므로 Placement Back은
|
||||
// 바로 Personalization으로 (이전 AUTO_SCAN_SETUP 단계 폐기).
|
||||
currentScreen = if (placementFromMonitoring) AppScreen.PIEZO_MONITORING
|
||||
else AppScreen.PIEZO_PERSONALIZATION
|
||||
placementFromMonitoring = false
|
||||
}
|
||||
|
||||
fun pinSet(pin: String) {
|
||||
storage.pin = pin
|
||||
storage.hasPin = true
|
||||
currentScreen = AppScreen.HOME
|
||||
}
|
||||
|
||||
fun pinVerified() {
|
||||
currentScreen = AppScreen.HOME
|
||||
}
|
||||
|
||||
fun incrementLevel() {
|
||||
val previousUrgency = bladderLevel.urgency
|
||||
val newLevel = bladderLevel.copy()
|
||||
newLevel.increment()
|
||||
bladderLevel = newLevel
|
||||
storage.bladderLevel = bladderLevel.currentLevel
|
||||
|
||||
val record = MeasurementRecord(
|
||||
eventType = MeasurementEventType.MEASUREMENT,
|
||||
level = bladderLevel.currentLevel,
|
||||
estimatedVolume = estimatedVolume,
|
||||
urgencyLevel = bladderLevel.urgency,
|
||||
fillPercentage = fillPercentage
|
||||
)
|
||||
historyStore.addRecord(record)
|
||||
|
||||
val newUrgency = bladderLevel.urgency
|
||||
if (newUrgency != previousUrgency) {
|
||||
notificationService.sendUrgencyNotification(newUrgency, estimatedVolume)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetLevel() {
|
||||
val record = MeasurementRecord(
|
||||
eventType = MeasurementEventType.CATHETERIZATION,
|
||||
level = bladderLevel.currentLevel,
|
||||
estimatedVolume = estimatedVolume,
|
||||
urgencyLevel = bladderLevel.urgency,
|
||||
fillPercentage = fillPercentage
|
||||
)
|
||||
historyStore.addRecord(record)
|
||||
|
||||
val newLevel = bladderLevel.copy()
|
||||
newLevel.reset()
|
||||
bladderLevel = newLevel
|
||||
storage.bladderLevel = 0
|
||||
notificationService.cancelAll()
|
||||
reminderService.reschedule()
|
||||
|
||||
if (catheterCount.intValue > 0) {
|
||||
catheterCount.intValue--
|
||||
storage.catheterCount = catheterCount.intValue
|
||||
}
|
||||
}
|
||||
|
||||
fun showHistory() {
|
||||
currentScreen = AppScreen.MEASUREMENT_HISTORY
|
||||
}
|
||||
|
||||
fun backToMonitoring() {
|
||||
currentScreen = if (sensorMode == SensorMode.NIRS) AppScreen.VIVAMYO_MONITORING else AppScreen.PIEZO_MONITORING
|
||||
}
|
||||
|
||||
fun addCatheterCount(amount: Int = 1) {
|
||||
catheterCount.intValue += amount
|
||||
storage.catheterCount = catheterCount.intValue
|
||||
}
|
||||
|
||||
fun updateCatheterizationThreshold(threshold: Int) {
|
||||
bladderLevel = bladderLevel.copy(catheterizationThreshold = threshold)
|
||||
storage.catheterizationLevel = threshold
|
||||
}
|
||||
|
||||
fun savePiezoSettings() {
|
||||
storage.piezoSettings = piezoSettings
|
||||
}
|
||||
|
||||
val estimatedVolume: Int
|
||||
get() {
|
||||
if (BladderLevel.MAX_LEVEL <= 0) return 0
|
||||
return (bladderLevel.currentLevel.toDouble() / BladderLevel.MAX_LEVEL * piezoSettings.maxVolume).toInt()
|
||||
}
|
||||
|
||||
val fillPercentage: Double
|
||||
get() {
|
||||
if (BladderLevel.MAX_LEVEL <= 0) return 0.0
|
||||
return bladderLevel.currentLevel.toDouble() / BladderLevel.MAX_LEVEL * 100.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.medithings.vesiscan
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.rive.runtime.kotlin.core.Rive
|
||||
import com.medithings.vesiscan.services.KeychainService
|
||||
import com.medithings.vesiscan.services.NotificationService
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ui.theme.Medilightv2androidTheme
|
||||
import com.medithings.vesiscan.ui.views.calibration.CalibrationView
|
||||
import com.medithings.vesiscan.ui.views.connection.DeviceScanView
|
||||
import com.medithings.vesiscan.ui.views.history.MeasurementHistoryView
|
||||
import com.medithings.vesiscan.ui.views.home.HomeView
|
||||
import com.medithings.vesiscan.ui.views.monitoring.PlacementGuideView
|
||||
import com.medithings.vesiscan.ui.views.monitoring.PiezoMonitoringView
|
||||
import com.medithings.vesiscan.ui.views.monitoring.VivamyoMonitoringView
|
||||
import com.medithings.vesiscan.ui.views.monitoring.UrineCameraScreen
|
||||
import com.medithings.vesiscan.ui.views.monitoring.VoidingDiaryView
|
||||
import com.medithings.vesiscan.ui.views.onboarding.OnboardingView
|
||||
import com.medithings.vesiscan.ui.views.personalization.PiezoPersonalizationView
|
||||
import com.medithings.vesiscan.ui.views.pin.PinMode
|
||||
import com.medithings.vesiscan.ui.views.pin.PinView
|
||||
import com.medithings.vesiscan.ui.views.registration.RegistrationView
|
||||
import com.medithings.vesiscan.ui.views.consent.ConsentView
|
||||
import com.medithings.vesiscan.ui.views.reminder.ReminderSettingsView
|
||||
import com.medithings.vesiscan.ui.views.sensorselect.SensorSelectView
|
||||
import com.medithings.vesiscan.ui.views.clinical.ClinicalHomeView
|
||||
import com.medithings.vesiscan.ui.views.clinical.ClinicalLiveView
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
/** 사용자가 선택한 앱 언어 적용 — Activity 생성 전에 Locale wrap. */
|
||||
override fun attachBaseContext(newBase: android.content.Context) {
|
||||
super.attachBaseContext(com.medithings.vesiscan.services.LocalePref.wrap(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Initialize services
|
||||
Rive.init(this)
|
||||
KeychainService.init(this)
|
||||
NotificationService.getInstance(this)
|
||||
|
||||
enableEdgeToEdge()
|
||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
setContent {
|
||||
Medilightv2androidTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
MedilightApp(modifier = Modifier.padding(innerPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val appState = remember { AppState(context) }
|
||||
|
||||
// HOME에서 뒤로가기 → 앱 종료 확인
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
BackHandler(enabled = appState.currentScreen == AppScreen.HOME) {
|
||||
showExitDialog = true
|
||||
}
|
||||
if (showExitDialog) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showExitDialog = false },
|
||||
title = { androidx.compose.material3.Text(stringResource(R.string.exit_app_title)) },
|
||||
text = { androidx.compose.material3.Text(stringResource(R.string.exit_app_message)) },
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { (context as? android.app.Activity)?.finish() }) {
|
||||
androidx.compose.material3.Text(stringResource(R.string.exit_app_confirm), color = Color.Red)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showExitDialog = false }) {
|
||||
androidx.compose.material3.Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 다른 화면에서 뒤로가기 = 바로 전 화면
|
||||
BackHandler(enabled = appState.currentScreen != AppScreen.HOME
|
||||
&& appState.currentScreen != AppScreen.PIN_ENTRY) {
|
||||
when (appState.currentScreen) {
|
||||
// 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring
|
||||
// DeviceScan Back: 임상 흐름이면 ClinicalHome으로, 아니면 HOME
|
||||
AppScreen.DEVICE_SCAN ->
|
||||
appState.currentScreen =
|
||||
if (com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow)
|
||||
AppScreen.CLINICAL_HOME
|
||||
else AppScreen.HOME
|
||||
AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
|
||||
// 임상 모드 측정 중이면 endMeasurement → ClinicalHome 복귀
|
||||
AppScreen.PLACEMENT_GUIDE ->
|
||||
if (com.medithings.vesiscan.services.ClinicalSessionStore.currentSession != null) {
|
||||
com.medithings.vesiscan.services.ClinicalSessionStore.endMeasurement(context)
|
||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
} else appState.backFromPlacementGuide()
|
||||
AppScreen.PIEZO_MONITORING ->
|
||||
if (com.medithings.vesiscan.services.ClinicalSessionStore.currentSession != null) {
|
||||
com.medithings.vesiscan.services.ClinicalSessionStore.endMeasurement(context)
|
||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
} else appState.currentScreen = AppScreen.PLACEMENT_GUIDE
|
||||
AppScreen.CLINICAL_HOME -> {
|
||||
com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow = false
|
||||
appState.currentScreen = AppScreen.HOME
|
||||
}
|
||||
AppScreen.CLINICAL_LIVE -> {
|
||||
// 측정 폴더 finalize 후 ClinicalHome 복귀
|
||||
com.medithings.vesiscan.services.ClinicalSessionStore.endMeasurement(context)
|
||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
}
|
||||
// 모니터링 하위 화면 → 모니터링
|
||||
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
AppScreen.URINE_CAMERA -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
// 기타
|
||||
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
|
||||
AppScreen.VIVAMYO_MONITORING -> appState.currentScreen = AppScreen.HOME
|
||||
AppScreen.NIRS_CALIBRATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
|
||||
// 온보딩/등록 (뒤로가기 막지 않되 Home으로)
|
||||
else -> appState.currentScreen = AppScreen.HOME
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(
|
||||
targetState = appState.currentScreen,
|
||||
animationSpec = tween(300),
|
||||
modifier = modifier,
|
||||
label = "screen"
|
||||
) { screen ->
|
||||
when (screen) {
|
||||
AppScreen.ONBOARDING -> OnboardingView(appState)
|
||||
AppScreen.CONSENT -> ConsentView(appState)
|
||||
AppScreen.REGISTRATION -> RegistrationView(appState)
|
||||
AppScreen.HOME -> HomeView(appState)
|
||||
AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
|
||||
AppScreen.DEVICE_SCAN -> DeviceScanView(appState)
|
||||
AppScreen.NIRS_CALIBRATION -> CalibrationView(appState)
|
||||
AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState)
|
||||
AppScreen.PLACEMENT_GUIDE -> PlacementGuideView(appState)
|
||||
AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context)
|
||||
AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context)
|
||||
AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState)
|
||||
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
|
||||
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
|
||||
AppScreen.VOIDING_DIARY -> VoidingDiaryView(onBack = { appState.currentScreen = AppScreen.HOME })
|
||||
AppScreen.URINE_CAMERA -> UrineCameraScreen(
|
||||
onDone = { volume ->
|
||||
if (volume != null && volume > 0) appState.resetLevel()
|
||||
appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
},
|
||||
onCancel = { appState.currentScreen = AppScreen.PIEZO_MONITORING }
|
||||
)
|
||||
AppScreen.REMINDER_SETTINGS -> ReminderSettingsView(
|
||||
onBack = { appState.backToMonitoring() }
|
||||
)
|
||||
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
|
||||
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
package com.medithings.vesiscan.ble
|
||||
|
||||
object CRC16 {
|
||||
private const val POLYNOMIAL: Int = 0x1021
|
||||
private const val INITIAL_VALUE: Int = 0xFFFF
|
||||
|
||||
fun calculate(data: ByteArray): Int {
|
||||
var crc = INITIAL_VALUE
|
||||
for (byte in data) {
|
||||
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
|
||||
for (j in 0 until 8) {
|
||||
crc = if ((crc and 0x8000) != 0) {
|
||||
(crc shl 1) xor POLYNOMIAL
|
||||
} else {
|
||||
crc shl 1
|
||||
}
|
||||
crc = crc and 0xFFFF
|
||||
}
|
||||
}
|
||||
return crc
|
||||
}
|
||||
|
||||
fun calculate(string: String): Int = calculate(string.toByteArray(Charsets.UTF_8))
|
||||
|
||||
fun appendCRC(data: ByteArray): ByteArray {
|
||||
val crc = calculate(data)
|
||||
return data + byteArrayOf(
|
||||
(crc and 0xFF).toByte(), // Low byte first (Little Endian)
|
||||
((crc shr 8) and 0xFF).toByte() // High byte
|
||||
)
|
||||
}
|
||||
|
||||
fun appendCRC(string: String): ByteArray = appendCRC(string.toByteArray(Charsets.UTF_8))
|
||||
|
||||
fun verify(data: ByteArray): Boolean {
|
||||
if (data.size < 2) return false
|
||||
val payload = data.copyOfRange(0, data.size - 2)
|
||||
val receivedCRC = (data[data.size - 2].toInt() and 0xFF) or
|
||||
((data[data.size - 1].toInt() and 0xFF) shl 8)
|
||||
return calculate(payload) == receivedCRC
|
||||
}
|
||||
|
||||
fun buildCommand(cmd: String, params: IntArray = intArrayOf()): ByteArray {
|
||||
val cmdBytes = "$cmd?".toByteArray(Charsets.UTF_8)
|
||||
val paramBytes = ByteArray(params.size * 2)
|
||||
for (i in params.indices) {
|
||||
paramBytes[i * 2] = (params[i] and 0xFF).toByte() // Low byte
|
||||
paramBytes[i * 2 + 1] = ((params[i] shr 8) and 0xFF).toByte() // High byte
|
||||
}
|
||||
return appendCRC(cmdBytes + paramBytes)
|
||||
}
|
||||
|
||||
fun buildCommandASCII(cmd: String, asciiParams: String = ""): ByteArray {
|
||||
return appendCRC("$cmd?$asciiParams".toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
/** 파라미터를 16비트 Big Endian으로 추가 (펌웨어 테스터와 동일 포맷) */
|
||||
fun buildCommandBE(cmd: String, params: IntArray = intArrayOf()): ByteArray {
|
||||
val cmdBytes = "$cmd?".toByteArray(Charsets.UTF_8)
|
||||
val paramBytes = ByteArray(params.size * 2)
|
||||
for (i in params.indices) {
|
||||
paramBytes[i * 2] = ((params[i] shr 8) and 0xFF).toByte() // High byte first
|
||||
paramBytes[i * 2 + 1] = (params[i] and 0xFF).toByte() // Low byte
|
||||
}
|
||||
return appendCRC(cmdBytes + paramBytes)
|
||||
}
|
||||
|
||||
fun buildSimpleCommand(cmd: String): ByteArray {
|
||||
return appendCRC(cmd.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.medithings.vesiscan.ble
|
||||
|
||||
/**
|
||||
* IMU 6축 raw 샘플 (Accel xyz + Gyro xyz).
|
||||
*
|
||||
* FW VBTFW0116+ rim: 응답 한 패킷에 15 샘플 (ODR 50Hz → 약 300ms 분량).
|
||||
* Sensitivity:
|
||||
* - Accel ±4g: 1 LSB = 1/8192 g
|
||||
* - Gyro ±500 dps: 1 LSB = 1/65.536 dps
|
||||
*/
|
||||
data class ImuSample(
|
||||
val ax: Float, val ay: Float, val az: Float, // g 단위 (중력 가속도)
|
||||
val gx: Float, val gy: Float, val gz: Float // dps 단위 (degrees/sec)
|
||||
) {
|
||||
/** 전체 가속도 크기 — 정지 시 ≈ 1g */
|
||||
val accelMagnitude: Float get() = kotlin.math.sqrt(ax * ax + ay * ay + az * az)
|
||||
/** Gyro 전체 크기 — 정지 시 ≈ 0 dps */
|
||||
val gyroMagnitude: Float get() = kotlin.math.sqrt(gx * gx + gy * gy + gz * gz)
|
||||
}
|
||||
|
||||
/**
|
||||
* rim: 패킷 파서.
|
||||
*
|
||||
* 패킷 구조 (총 188B):
|
||||
* [tag 4B "rim:"] [num_sample 2B BE] [imu_raw_data 180B] [crc 2B]
|
||||
*
|
||||
* imu_raw_data = 15 samples × 12B/sample, 모두 BE int16:
|
||||
* [Ax 2B] [Ay 2B] [Az 2B] [Gx 2B] [Gy 2B] [Gz 2B]
|
||||
*
|
||||
* 샘플 시간 순서: samples[0] = oldest (FIFO 순), samples[N-1] = newest.
|
||||
* 실측 검증 완료 (Az ≈ +1g 평평 자세, 2026-05-27).
|
||||
*/
|
||||
class ImuPacketCollector {
|
||||
|
||||
var samples: List<ImuSample> = emptyList()
|
||||
private set
|
||||
|
||||
var onComplete: ((List<ImuSample>) -> Unit)? = null
|
||||
var onLog: ((String) -> Unit)? = null
|
||||
|
||||
companion object {
|
||||
private const val ACCEL_LSB_PER_G = 8192f // ±4g FSR
|
||||
private const val GYRO_LSB_PER_DPS = 65.536f // ±500 dps FSR
|
||||
private const val SAMPLE_SIZE_BYTES = 12
|
||||
private const val HEADER_BYTES = 6 // tag(4) + num(2)
|
||||
private const val CRC_BYTES = 2
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
samples = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* rsp: 패킷 처리 — msp 명령 응답.
|
||||
* Format 가정 (firmware spec 미확정 — defensive parsing):
|
||||
* 변형 1 (multi-sample, rim: 와 동일): [tag 4B][num 2B BE][N×12B sample][crc 2B]
|
||||
* 변형 2 (single-sample): [tag 4B][12B sample][crc 2B] = 18B
|
||||
*
|
||||
* 시도 순서: 변형 1 → 실패 시 변형 2 → 둘 다 실패 시 hex log + 무시.
|
||||
*/
|
||||
fun parseRsp(data: ByteArray) {
|
||||
if (data.size < 4 + CRC_BYTES) {
|
||||
onLog?.invoke("rsp: too short (${data.size}B)")
|
||||
return
|
||||
}
|
||||
// 변형 1 시도 (rim: 와 동일 format)
|
||||
val num1 = readBe16(data, 4)
|
||||
val expected1 = HEADER_BYTES + num1 * SAMPLE_SIZE_BYTES + CRC_BYTES
|
||||
if (num1 in 1..15 && data.size >= expected1) {
|
||||
parseMultiSample(data, num1)
|
||||
return
|
||||
}
|
||||
// 변형 2 시도 (single sample, 4B tag + 12B sample + 2B crc = 18B)
|
||||
if (data.size >= 18) {
|
||||
val parsed = ArrayList<ImuSample>(1)
|
||||
val off = 4
|
||||
val axRaw = readBe16Signed(data, off)
|
||||
val ayRaw = readBe16Signed(data, off + 2)
|
||||
val azRaw = readBe16Signed(data, off + 4)
|
||||
val gxRaw = readBe16Signed(data, off + 6)
|
||||
val gyRaw = readBe16Signed(data, off + 8)
|
||||
val gzRaw = readBe16Signed(data, off + 10)
|
||||
parsed.add(ImuSample(
|
||||
ax = axRaw / ACCEL_LSB_PER_G,
|
||||
ay = ayRaw / ACCEL_LSB_PER_G,
|
||||
az = azRaw / ACCEL_LSB_PER_G,
|
||||
gx = gxRaw / GYRO_LSB_PER_DPS,
|
||||
gy = gyRaw / GYRO_LSB_PER_DPS,
|
||||
gz = gzRaw / GYRO_LSB_PER_DPS,
|
||||
))
|
||||
samples = parsed
|
||||
val first = parsed[0]
|
||||
onLog?.invoke("rsp: 1 sample (single fmt, Az=${"%.3f".format(first.az)}g)")
|
||||
onComplete?.invoke(parsed)
|
||||
return
|
||||
}
|
||||
// 둘 다 실패 — hex dump
|
||||
val hex = data.take(kotlin.math.min(data.size, 24)).joinToString(" ") { "%02X".format(it) }
|
||||
onLog?.invoke("rsp: unknown format (${data.size}B) hex=$hex")
|
||||
}
|
||||
|
||||
private fun parseMultiSample(data: ByteArray, numSamples: Int) {
|
||||
val parsed = ArrayList<ImuSample>(numSamples)
|
||||
for (i in 0 until numSamples) {
|
||||
val off = HEADER_BYTES + i * SAMPLE_SIZE_BYTES
|
||||
val axRaw = readBe16Signed(data, off)
|
||||
val ayRaw = readBe16Signed(data, off + 2)
|
||||
val azRaw = readBe16Signed(data, off + 4)
|
||||
val gxRaw = readBe16Signed(data, off + 6)
|
||||
val gyRaw = readBe16Signed(data, off + 8)
|
||||
val gzRaw = readBe16Signed(data, off + 10)
|
||||
parsed.add(ImuSample(
|
||||
ax = axRaw / ACCEL_LSB_PER_G,
|
||||
ay = ayRaw / ACCEL_LSB_PER_G,
|
||||
az = azRaw / ACCEL_LSB_PER_G,
|
||||
gx = gxRaw / GYRO_LSB_PER_DPS,
|
||||
gy = gyRaw / GYRO_LSB_PER_DPS,
|
||||
gz = gzRaw / GYRO_LSB_PER_DPS,
|
||||
))
|
||||
}
|
||||
samples = parsed
|
||||
onLog?.invoke("rsp: $numSamples samples (multi fmt, last Az=${"%.3f".format(parsed.last().az)}g)")
|
||||
onComplete?.invoke(parsed)
|
||||
}
|
||||
|
||||
/** rim: 패킷 한 번 받으면 호출. 188B 전체 ByteArray. */
|
||||
fun parseRim(data: ByteArray) {
|
||||
if (data.size < HEADER_BYTES + CRC_BYTES) {
|
||||
onLog?.invoke("rim: too short (${data.size}B)")
|
||||
return
|
||||
}
|
||||
// RAW DUMP — division 전 원본 byte + int16 값 logcat 으로 출력 (디버그용).
|
||||
val totalHex = data.joinToString(" ") { "%02X".format(it) }
|
||||
android.util.Log.d("ImuRaw", "rim FULL HEX (${data.size}B): $totalHex")
|
||||
|
||||
val numSamples = readBe16(data, 4)
|
||||
val payloadBytes = data.size - HEADER_BYTES - CRC_BYTES
|
||||
val expectedBytes = numSamples * SAMPLE_SIZE_BYTES
|
||||
if (payloadBytes < expectedBytes) {
|
||||
onLog?.invoke("rim: payload short ($payloadBytes < $expectedBytes for $numSamples samples)")
|
||||
return
|
||||
}
|
||||
val parsed = ArrayList<ImuSample>(numSamples)
|
||||
for (i in 0 until numSamples) {
|
||||
val off = HEADER_BYTES + i * SAMPLE_SIZE_BYTES
|
||||
val axRaw = readBe16Signed(data, off)
|
||||
val ayRaw = readBe16Signed(data, off + 2)
|
||||
val azRaw = readBe16Signed(data, off + 4)
|
||||
val gxRaw = readBe16Signed(data, off + 6)
|
||||
val gyRaw = readBe16Signed(data, off + 8)
|
||||
val gzRaw = readBe16Signed(data, off + 10)
|
||||
|
||||
// 첫 sample / 마지막 sample 만 raw int16 dump (모든 sample 로깅하면 폭주)
|
||||
if (i == 0 || i == numSamples - 1) {
|
||||
android.util.Log.d("ImuRaw",
|
||||
"rim sample[$i] raw int16: ax=$axRaw ay=$ayRaw az=$azRaw " +
|
||||
"gx=$gxRaw gy=$gyRaw gz=$gzRaw " +
|
||||
"(g if /8192: ax=${"%.4f".format(axRaw / 8192f)} ay=${"%.4f".format(ayRaw / 8192f)} az=${"%.4f".format(azRaw / 8192f)})")
|
||||
}
|
||||
|
||||
parsed.add(ImuSample(
|
||||
ax = axRaw / ACCEL_LSB_PER_G,
|
||||
ay = ayRaw / ACCEL_LSB_PER_G,
|
||||
az = azRaw / ACCEL_LSB_PER_G,
|
||||
gx = gxRaw / GYRO_LSB_PER_DPS,
|
||||
gy = gyRaw / GYRO_LSB_PER_DPS,
|
||||
gz = gzRaw / GYRO_LSB_PER_DPS,
|
||||
))
|
||||
}
|
||||
samples = parsed
|
||||
onLog?.invoke("rim: $numSamples samples parsed (last: Az=${"%.3f".format(parsed.last().az)}g)")
|
||||
onComplete?.invoke(parsed)
|
||||
}
|
||||
|
||||
private fun readBe16(data: ByteArray, idx: Int): Int {
|
||||
val hi = data[idx].toInt() and 0xFF
|
||||
val lo = data[idx + 1].toInt() and 0xFF
|
||||
return (hi shl 8) or lo
|
||||
}
|
||||
|
||||
private fun readBe16Signed(data: ByteArray, idx: Int): Int {
|
||||
var v = readBe16(data, idx)
|
||||
if (v >= 0x8000) v -= 0x10000
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.medithings.vesiscan.ble
|
||||
|
||||
/**
|
||||
* Collects multi-packet Piezo echo responses: reb: → (red:) → raa:
|
||||
*
|
||||
* NEW packet layout (FW update — VBT post-2026-06):
|
||||
* reb: [tag 4B][ch_info 2B = ch_session 1B + ch_num 1B][num_sample 2B][adc 200B][crc 2B] = 210B
|
||||
* raa: [tag 4B][state 2B][crc 2B] = 8B (set 종료 마커, 변동 없음)
|
||||
*
|
||||
* Channel order is determined by the ch_num field (not arrival order). All 6 reb packets
|
||||
* within the same ch_session form one measurement set. If ch_session changes mid-set or
|
||||
* a duplicate ch_num arrives, the entire set is dropped and only the next valid set
|
||||
* triggers onMultiChannelComplete.
|
||||
*
|
||||
* Big Endian / Little Endian: VBT FW = BE. Endian is detected from the first ADC sample of
|
||||
* ch_num=0 and locked for the remaining channels in that set.
|
||||
*/
|
||||
class PiezoPacketCollector {
|
||||
|
||||
// Header metadata (last received reb 기준 — 호환용)
|
||||
var peakRaw: UShort = 0u; private set
|
||||
var peakIndex: UShort = 0u; private set
|
||||
var baselineRaw: UShort = 0u; private set
|
||||
var numSamples: UShort = 0u; private set
|
||||
var totalPackets: Int = 0; private set
|
||||
|
||||
// Collection state
|
||||
var currentPacket: Int = 0; private set
|
||||
var isComplete: Boolean = false; private set
|
||||
var hasError: Boolean = false; private set
|
||||
var errorMessage: String = ""; private set
|
||||
private var rawData = mutableListOf<Byte>() // 단일 채널 모드용
|
||||
private var hasFirstPacket = false // 단일 채널 모드용
|
||||
private var detectedBigEndian = false
|
||||
var forceBigEndian: Boolean = true // VBT 기기 기본 BE, 기기 연결 시 설정
|
||||
|
||||
// Multi-channel + session tracking
|
||||
var isMultiChannel: Boolean = false; private set
|
||||
var channelResults: MutableList<PiezoChannelData?> = mutableListOf(); private set
|
||||
private var currentSetSession: Int? = null
|
||||
var isSetDropped: Boolean = false; private set
|
||||
|
||||
// Callbacks
|
||||
var onComplete: ((PiezoEchoResult) -> Unit)? = null
|
||||
var onMultiChannelComplete: ((List<PiezoChannelData>) -> Unit)? = null
|
||||
/** true 면 다음 onMultiChannelComplete 1회 skip (자동 reset).
|
||||
* posture polling 으로 mtb 보낼 때 측정 callback 가 spurious fire 되는 거 차단용. */
|
||||
var skipNextComplete: Boolean = false
|
||||
var onLog: ((String) -> Unit)? = null
|
||||
|
||||
private var recursionDepth = 0
|
||||
|
||||
fun reset() {
|
||||
peakRaw = 0u; peakIndex = 0u; baselineRaw = 0u; numSamples = 0u
|
||||
totalPackets = 0; currentPacket = 0
|
||||
isComplete = false; hasError = false; errorMessage = ""
|
||||
rawData.clear(); hasFirstPacket = false
|
||||
isMultiChannel = false
|
||||
channelResults.clear(); detectedBigEndian = false
|
||||
currentSetSession = null
|
||||
isSetDropped = false
|
||||
}
|
||||
|
||||
fun startMultiChannel(channelCount: Int = 6) {
|
||||
reset()
|
||||
isMultiChannel = true
|
||||
channelResults = MutableList(channelCount) { null }
|
||||
}
|
||||
|
||||
fun addPacket(data: ByteArray) {
|
||||
if (isComplete || hasError) return
|
||||
if (data.size < 4) return
|
||||
|
||||
recursionDepth++
|
||||
if (recursionDepth >= 10) {
|
||||
markError("Max recursion depth"); recursionDepth--; return
|
||||
}
|
||||
|
||||
val prefix = String(data, 0, 4, Charsets.US_ASCII)
|
||||
when (prefix) {
|
||||
"rbb:" -> {
|
||||
onLog?.invoke("rbb: header skipped (${data.size}B) — waiting for reb:")
|
||||
}
|
||||
"reb:" -> processRebPacket(data)
|
||||
"red:" -> processRedPacket(data)
|
||||
"ree:", "raa:" -> processEndPacket(prefix, data)
|
||||
}
|
||||
recursionDepth--
|
||||
}
|
||||
|
||||
// reb: Data packet (NEW format, FW post-2026-06)
|
||||
// Structure: reb:(4) + ch_session(1) + ch_num(1) + num_sample(2, BE/LE) + 100 ADC(200) + CRC(2) = 210B
|
||||
private fun processRebPacket(bytes: ByteArray) {
|
||||
if (bytes.size < 10) { markError("reb: too short (${bytes.size}, expected ≥10)"); return }
|
||||
|
||||
val chSession = bytes[4].toInt() and 0xFF
|
||||
val chNum = bytes[5].toInt() and 0xFF
|
||||
|
||||
if (isMultiChannel) {
|
||||
// Session tracking — drop previous incomplete set when session changes
|
||||
when {
|
||||
currentSetSession == null -> {
|
||||
currentSetSession = chSession
|
||||
isSetDropped = false
|
||||
channelResults = MutableList(channelResults.size) { null }
|
||||
}
|
||||
chSession != currentSetSession -> {
|
||||
onLog?.invoke("reb: session change (was=$currentSetSession, got=$chSession) — drop previous incomplete set")
|
||||
currentSetSession = chSession
|
||||
isSetDropped = false
|
||||
channelResults = MutableList(channelResults.size) { null }
|
||||
}
|
||||
}
|
||||
|
||||
if (chNum !in channelResults.indices) {
|
||||
onLog?.invoke("reb: invalid ch_num=$chNum (range 0..${channelResults.size - 1}) — set marked dropped")
|
||||
isSetDropped = true
|
||||
return
|
||||
}
|
||||
if (channelResults[chNum] != null) {
|
||||
onLog?.invoke("reb: duplicate ch_num=$chNum in session=$chSession — set marked dropped")
|
||||
isSetDropped = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Endian detection — first ADC sample at bytes[8..9]. Lock for the remaining channels.
|
||||
// VBT = Big Endian, 2025MEDIP = Little Endian. Fallback: forceBigEndian flag.
|
||||
val isFirstChannelOfSet = if (isMultiChannel) (chNum == 0) else (!hasFirstPacket)
|
||||
if (isFirstChannelOfSet) {
|
||||
val b8 = bytes.getOrElse(8) { 0 }.toInt() and 0xFF
|
||||
val b9 = bytes.getOrElse(9) { 0 }.toInt() and 0xFF
|
||||
val firstSampleLE = b8 or (b9 shl 8)
|
||||
val firstSampleBE = (b8 shl 8) or b9
|
||||
detectedBigEndian = when {
|
||||
firstSampleLE > 4095 && firstSampleBE <= 4095 -> true
|
||||
firstSampleBE > 4095 && firstSampleLE <= 4095 -> false
|
||||
else -> forceBigEndian
|
||||
}
|
||||
onLog?.invoke("Endian: ${if (detectedBigEndian) "BE" else "LE"} (LE=$firstSampleLE BE=$firstSampleBE force=$forceBigEndian)")
|
||||
}
|
||||
|
||||
// num_sample at bytes[6..7]
|
||||
val b6 = bytes[6].toInt() and 0xFF
|
||||
val b7 = bytes[7].toInt() and 0xFF
|
||||
val repeatCount = if (detectedBigEndian) (b6 shl 8) or b7 else b6 or (b7 shl 8)
|
||||
|
||||
// ADC data at bytes[8..size-3] (exclude trailing CRC 2B)
|
||||
val adcStart = 8
|
||||
val adcEnd = bytes.size - 2
|
||||
if (adcEnd <= adcStart) { markError("reb: no ADC data"); return }
|
||||
val sampleCount = (adcEnd - adcStart) / 2
|
||||
|
||||
if (isMultiChannel) {
|
||||
// Parse this channel's ADC directly into a buffer and store at ch_num position
|
||||
val buffer = parseADCBufferRange(bytes, adcStart, adcEnd)
|
||||
channelResults[chNum] = PiezoChannelData(chNum, buffer)
|
||||
totalPackets++
|
||||
|
||||
peakRaw = buffer.maxOrNull() ?: 0u
|
||||
peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort()
|
||||
baselineRaw = if (buffer.size >= 10) {
|
||||
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
|
||||
} else 0u
|
||||
numSamples = sampleCount.toUShort()
|
||||
|
||||
val endianStr = if (detectedBigEndian) "BE" else "LE"
|
||||
onLog?.invoke("reb: session=$chSession CH$chNum [$endianStr] repeat=$repeatCount $sampleCount samples, peak=$peakRaw @ $peakIndex")
|
||||
} else {
|
||||
// Single-channel mode — buffer to rawData, finalize on ree:/raa:
|
||||
currentPacket = 0
|
||||
hasFirstPacket = true
|
||||
rawData.clear()
|
||||
for (i in adcStart until adcEnd) rawData.add(bytes[i])
|
||||
val buffer = parseADCBuffer()
|
||||
peakRaw = buffer.maxOrNull() ?: 0u
|
||||
peakIndex = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort()
|
||||
baselineRaw = if (buffer.size >= 10) {
|
||||
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
|
||||
} else 0u
|
||||
numSamples = sampleCount.toUShort()
|
||||
totalPackets = 1
|
||||
|
||||
val endianStr = if (detectedBigEndian) "BE" else "LE"
|
||||
onLog?.invoke("reb: single [$endianStr] repeat=$repeatCount $sampleCount samples, peak=$peakRaw @ $peakIndex")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseADCBufferRange(bytes: ByteArray, start: Int, end: Int): List<UShort> {
|
||||
val buffer = mutableListOf<UShort>()
|
||||
var i = start
|
||||
while (i + 1 < end) {
|
||||
val b0 = bytes[i].toInt() and 0xFF
|
||||
val b1 = bytes[i + 1].toInt() and 0xFF
|
||||
val value: UShort = if (detectedBigEndian) {
|
||||
((b0 shl 8) or b1).toUShort()
|
||||
} else {
|
||||
(b0 or (b1 shl 8)).toUShort()
|
||||
}
|
||||
buffer.add(value)
|
||||
i += 2
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
// red: continuation packet (legacy — 신구조에서는 210B reb 한 번에 들어와 사실상 미사용).
|
||||
// multi-channel 모드에서는 ch_num 기반 indexing이라 red: 의미 모호 — single-channel 모드에서만 처리.
|
||||
private fun processRedPacket(bytes: ByteArray) {
|
||||
if (isMultiChannel) {
|
||||
onLog?.invoke("red: ignored in multi-channel mode (ch_num-indexed set)")
|
||||
return
|
||||
}
|
||||
if (!hasFirstPacket) { markError("red: before reb:"); return }
|
||||
if (bytes.size < 6) return
|
||||
for (i in 6 until bytes.size) rawData.add(bytes[i])
|
||||
currentPacket++
|
||||
}
|
||||
|
||||
private fun processEndPacket(prefix: String, bytes: ByteArray) {
|
||||
if (isMultiChannel && prefix == "raa:") {
|
||||
// Set 종료 마커 — 완전성 검사 후 콜백 결정
|
||||
isComplete = true
|
||||
when {
|
||||
isSetDropped -> {
|
||||
onLog?.invoke("raa: set dropped (session=$currentSetSession) — no callback")
|
||||
}
|
||||
channelResults.any { it == null } -> {
|
||||
val missing = channelResults.mapIndexedNotNull { i, c -> if (c == null) i else null }
|
||||
onLog?.invoke("raa: incomplete set (session=$currentSetSession, missing CH=$missing) — drop")
|
||||
}
|
||||
else -> {
|
||||
val validChannels = channelResults.filterNotNull()
|
||||
onLog?.invoke("raa: session=$currentSetSession complete — ${validChannels.size} channels")
|
||||
if (skipNextComplete) {
|
||||
skipNextComplete = false
|
||||
onLog?.invoke("raa: skip onComplete (posture poll)")
|
||||
} else {
|
||||
onMultiChannelComplete?.invoke(validChannels)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 다음 set을 위해 reset (isMultiChannel/onComplete 등 유지)
|
||||
currentSetSession = null
|
||||
isSetDropped = false
|
||||
channelResults = MutableList(channelResults.size) { null }
|
||||
} else if (isMultiChannel) {
|
||||
// ree: in multi-channel — 신구조에서는 ch_num이 명시되므로 채널 구분자 불필요. 무시.
|
||||
onLog?.invoke("ree: ignored in multi-channel mode")
|
||||
} else {
|
||||
// Single-channel mode 완료 (ree: 또는 raa:)
|
||||
finishSingleChannel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishSingleChannel() {
|
||||
isComplete = true
|
||||
val buffer = parseADCBuffer()
|
||||
val peak = buffer.maxOrNull() ?: 0u
|
||||
val peakIdx = buffer.indexOf(peak).coerceAtLeast(0).toUShort()
|
||||
val base: UShort = if (buffer.size >= 10) {
|
||||
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
|
||||
} else 0u
|
||||
val result = PiezoEchoResult(0, buffer, peak, peakIdx, base, buffer.size, rawData.size)
|
||||
onLog?.invoke("Complete: ${buffer.size} samples, peak=$peak @ idx $peakIdx")
|
||||
onComplete?.invoke(result)
|
||||
}
|
||||
|
||||
private fun parseADCBuffer(): List<UShort> {
|
||||
val buffer = mutableListOf<UShort>()
|
||||
var i = 0
|
||||
while (i + 1 < rawData.size) {
|
||||
val b0 = rawData[i].toInt() and 0xFF
|
||||
val b1 = rawData[i + 1].toInt() and 0xFF
|
||||
val value: UShort = if (detectedBigEndian) {
|
||||
((b0 shl 8) or b1).toUShort()
|
||||
} else {
|
||||
(b0 or (b1 shl 8)).toUShort()
|
||||
}
|
||||
buffer.add(value)
|
||||
i += 2
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
private fun markError(msg: String) {
|
||||
hasError = true; errorMessage = msg
|
||||
onLog?.invoke("ERROR: $msg")
|
||||
}
|
||||
}
|
||||
|
||||
// Data Models
|
||||
|
||||
data class PiezoEchoResult(
|
||||
val channel: Int,
|
||||
val buffer: List<UShort>,
|
||||
val peakRaw: UShort,
|
||||
val peakIndex: UShort,
|
||||
val baselineRaw: UShort,
|
||||
val numSamples: Int,
|
||||
val rawByteCount: Int
|
||||
)
|
||||
|
||||
data class PiezoChannelData(
|
||||
val channel: Int,
|
||||
val buffer: List<UShort>
|
||||
) {
|
||||
val peakRaw: UShort get() = buffer.maxOrNull() ?: 0u
|
||||
val peakIndex: UShort get() = buffer.indexOf(peakRaw).coerceAtLeast(0).toUShort()
|
||||
val baselineRaw: UShort get() = if (buffer.size >= 10) {
|
||||
(buffer.takeLast(10).sumOf { it.toInt() } / 10).toUShort()
|
||||
} else 0u
|
||||
val numSamples: Int get() = buffer.size
|
||||
val isValid: Boolean get() = buffer.size >= 20 && peakRaw > 200u && peakRaw < 4090u
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
/*
|
||||
* Alignment Advisor V2 — port of vesiscan_test/alignment.py.
|
||||
*
|
||||
* 새 알고리즘 (한 팀장 / 대표님 회의 결정사항):
|
||||
* ─ method_d 기반 urine_len 으로 정렬 판단
|
||||
* ─ 2-phase: V (CH3 검출 → 머리방향) → L (CH4/CH5 urine_len 균형)
|
||||
* ─ |u4 − u5| ≤ LAT_TOL(8 samples ≈ 16mm) 이면 STOP
|
||||
* ─ ulen 큰 쪽으로 이동 (= 큰 쪽이 방광 중심)
|
||||
* ─ 둘 다 미검출 → 좌우 PROBE 모드 (hill-climbing 으로 최대점 추적)
|
||||
*
|
||||
* 입력은 raw 6채널 (각 길이 100, UShort/Int 어느 쪽이든 toDouble() 가능).
|
||||
* RollingAligner 가 N=accumK(default 10) 프레임 평균 후 한 번씩 commit.
|
||||
*
|
||||
* UI 측에서는 `push(raw6)` 호출 → AdvisorState.action 확인 → directionIcon/Hint
|
||||
* 매핑. 위치 이동 직후엔 `reset()` 으로 누적 버퍼 비우기 권장.
|
||||
*
|
||||
* 기존 PlacementGuide computePlacementGuide(...) 와 병행 — V1/V2 토글로 분기.
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.walldetect.MethodDResult
|
||||
import com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
/** action 상수. MOVE_DOWN 은 4-stage guide 의 set 회복 (잃은 center channel 다시 찾기) 용. */
|
||||
enum class AlignAction { MOVE_UP, MOVE_DOWN, MOVE_LEFT, MOVE_RIGHT, PROBE_LR, STOP }
|
||||
|
||||
/** RollingAligner.push() 결과. */
|
||||
data class AdvisorState(
|
||||
val state: String, // 'accum' 또는 'commit'
|
||||
val action: AlignAction?,
|
||||
val msg: String,
|
||||
/** Localized message string resource id (null if no localized message). */
|
||||
val msgKey: Int? = null,
|
||||
/** Args for msgKey (formatted into string). */
|
||||
val msgArgs: List<Any> = emptyList(),
|
||||
val dets: List<MethodDResult?>?,
|
||||
val summary: AlignSummary?,
|
||||
val accumProgress: Int = 0, // 'accum' 일 때 (현재/총)
|
||||
val accumTarget: Int = 0,
|
||||
)
|
||||
|
||||
data class AlignSummary(
|
||||
val ch3: Boolean,
|
||||
val u4: Int?,
|
||||
val u5: Int?,
|
||||
val chN: Int,
|
||||
/** Per-channel detection (size 6). center=[0,1,2,3], lateral=[4,5]. */
|
||||
val detected: BooleanArray = BooleanArray(6),
|
||||
/** 2026-07-02: 각 채널 chord (angle-corrected mm). CENTER_OPTIMIZE CV 계산용.
|
||||
* chord[ch] = (post - ant) × cos(angle) × distancePerSample. null 이면 미검출. */
|
||||
val chords: Array<Double?> = Array(6) { null },
|
||||
)
|
||||
|
||||
object AlignmentConstants {
|
||||
const val CH3 = 3 // 치골쪽 최하단 center 채널
|
||||
const val LAT_TOL = 8 // 좌우 균형 허용 |u4-u5| (samples, ≈16mm)
|
||||
val CENTER_CH = intArrayOf(0, 1, 2, 3)
|
||||
val LATERAL_CH = intArrayOf(4, 5)
|
||||
|
||||
// 02bed02 (4494098 VBTWD201): cross-channel ant tie-break — config_6ch.py 매칭.
|
||||
/** Cross-channel 전벽 tie-break: top1/top2 score 비율 < TIEBREAK_RATIO 이면 동률 → 합의 z 로 선택.
|
||||
* 1.3 = 명확한 동률(top1/top2 1.1~1.2)만 잡고 압승(>=1.5)은 측정값 보존. */
|
||||
const val ANT_TIEBREAK_RATIO = 1.3
|
||||
/** Near-field 가드: ant 가 이웃 합의 z 보다 이 값 이상 "얕은" (probe 쪽) 경우 entry 복합체
|
||||
* 아티팩트로 보고 후보 교체. 깊은(urine 쪽) 이탈은 dome 곡률로 정상이라 허용. 8mm ≈ 4 sample. */
|
||||
const val ANT_NEARFIELD_TOL_MM = 8.0
|
||||
|
||||
// 2026-07-02: CENTER_OPTIMIZE phase — CH0~CH3 chord CV 로 최적 vertical 위치 판정.
|
||||
/** CV 통과 임계 — V1 (computePlacementGuide) GREEN 조건 실증값 참조. */
|
||||
const val CV_THRESHOLD_STRICT = 0.15
|
||||
/** 방향 힌트 활성 임계 — CV 이 이상이면 사용자에게 up/down 힌트. */
|
||||
const val CV_THRESHOLD_LOOSE = 0.20
|
||||
/** CENTER_OPTIMIZE 통과 요구 연속 commit 수 (hysteresis). */
|
||||
const val CENTER_OPT_CONFIRM_STREAK = 3
|
||||
}
|
||||
|
||||
/** stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). */
|
||||
object AlignmentAdvice {
|
||||
fun advise(ch3: Boolean, u4: Int?, u5: Int?, latTol: Int = AlignmentConstants.LAT_TOL): Pair<AlignAction, String> {
|
||||
if (!ch3) return Pair(AlignAction.MOVE_UP, "↑ 머리방향으로 이동 (ch3 미검출)")
|
||||
if (u4 != null && u5 != null) {
|
||||
val d = abs(u4 - u5)
|
||||
if (d <= latTol) return Pair(AlignAction.STOP, "■ 정렬 완료 — 정지 (|Δ|=$d)")
|
||||
return if (u4 < u5)
|
||||
Pair(AlignAction.MOVE_RIGHT, "→ 우측으로 이동 (ch4 $u4 < ch5 $u5, |Δ|=$d)")
|
||||
else
|
||||
Pair(AlignAction.MOVE_LEFT, "← 좌측으로 이동 (ch5 $u5 < ch4 $u4, |Δ|=$d)")
|
||||
}
|
||||
if (u4 == null && u5 == null)
|
||||
return Pair(AlignAction.PROBE_LR, "↔ 좌우로 천천히 이동해 lateral 탐색 (ch4·ch5 미검출)")
|
||||
return if (u4 == null) Pair(AlignAction.MOVE_RIGHT, "→ 우측으로 이동 (ch4 미검출)")
|
||||
else Pair(AlignAction.MOVE_LEFT, "← 좌측으로 이동 (ch5 미검출)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-phase stateful guide — Python `AlignGuide`.
|
||||
* (RollingAligner 는 단일 commit 마다 stateless advice 만 사용하지만, 향후
|
||||
* imbal hill-climbing 모드에서 쓸 수 있도록 같이 포팅.)
|
||||
*/
|
||||
class AlignGuide(private val latTol: Int = AlignmentConstants.LAT_TOL) {
|
||||
private enum class Phase { V, L }
|
||||
private var phase = Phase.V
|
||||
private var imbal: Int? = null
|
||||
private var u: Int? = null
|
||||
private var dir: AlignAction? = null
|
||||
private var triedBoth = false
|
||||
|
||||
fun reset() {
|
||||
phase = Phase.V
|
||||
imbal = null; u = null; dir = null; triedBoth = false
|
||||
}
|
||||
|
||||
fun step(summary: AlignSummary): Pair<AlignAction, String> {
|
||||
if (phase == Phase.V) {
|
||||
if (!summary.ch3) return Pair(AlignAction.MOVE_UP, "ch3 미검출 — 머리방향 이동")
|
||||
phase = Phase.L
|
||||
}
|
||||
return lateral(summary)
|
||||
}
|
||||
|
||||
private fun lateral(s: AlignSummary): Pair<AlignAction, String> {
|
||||
val u4 = s.u4
|
||||
val u5 = s.u5
|
||||
if (u4 != null && u5 != null) {
|
||||
val curImbal = abs(u4 - u5)
|
||||
if (curImbal <= latTol) return Pair(AlignAction.STOP, "좌우 균형 |Δulen|=$curImbal<=$latTol")
|
||||
if (dir == null) {
|
||||
dir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
|
||||
} else if (imbal != null && curImbal > imbal!!) {
|
||||
// 악화 → 반대로
|
||||
dir = if (dir == AlignAction.MOVE_LEFT) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
|
||||
}
|
||||
imbal = curImbal
|
||||
return Pair(dir!!, "불균형 |Δulen|=$curImbal 감소 방향")
|
||||
}
|
||||
// 미검출 (probe 모드)
|
||||
val cur = max(u4 ?: 0, u5 ?: 0)
|
||||
if (dir == null) {
|
||||
dir = AlignAction.MOVE_LEFT
|
||||
u = cur
|
||||
return Pair(AlignAction.MOVE_LEFT, "lateral 미검출 — 좌측 probe")
|
||||
}
|
||||
if (cur > (u ?: 0)) {
|
||||
u = cur
|
||||
return Pair(dir!!, "ulen 증가($cur) — ${dir!!.name} 계속")
|
||||
}
|
||||
if (!triedBoth && dir == AlignAction.MOVE_LEFT) {
|
||||
dir = AlignAction.MOVE_RIGHT
|
||||
triedBoth = true
|
||||
u = cur
|
||||
return Pair(AlignAction.MOVE_RIGHT, "좌측 미개선 — 우측 probe")
|
||||
}
|
||||
return Pair(AlignAction.STOP, "ulen 최대(${u ?: 0}) 위치 정지")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4-stage stateful guide — 사용자 설계 (2026-06-16).
|
||||
*
|
||||
* 단계:
|
||||
* 1. INITIAL_ACCUM — 첫 10 cycle warm-up, 안내 X (msg 만)
|
||||
* 2. VERTICAL_CLIMB — ch3 검출까지 ↑
|
||||
* 3. CH3_STABILIZE — ch3 검출 후 10 cycle 또 누적 (자세 확정)
|
||||
* 4. LR_BALANCE — current center set 유지하며 좌우 균형
|
||||
* set 잃으면 회복 (lost ch3 → ↑, else → ↓)
|
||||
*
|
||||
* Stage 4 의 bestCenterSet 은 ever-best — set 이 한 번 ch3+ch2+ch1 까지 갔다가
|
||||
* ch1 사라져도, 목표는 여전히 ch3+ch2+ch1. 회복 후 좌우 계속.
|
||||
*/
|
||||
class AlignGuide4Stage(
|
||||
private val latTol: Int = AlignmentConstants.LAT_TOL,
|
||||
private val initialTarget: Int = 10,
|
||||
private val stabilizeTarget: Int = 10,
|
||||
/** VERTICAL_CLIMB 에서 CH3_STABILIZE 로 넘어가기 위한 ch3 연속 commit 수. */
|
||||
private val verticalHitRequired: Int = 3,
|
||||
/** LR_BALANCE 반전 deadband (samples). */
|
||||
private val lrReverseDeadband: Int = 3,
|
||||
/** LR_BALANCE 반전 요구 연속 commit 수. */
|
||||
private val lrReverseConsecutive: Int = 2,
|
||||
/**
|
||||
* LR_BALANCE 의 `lost` 감지 hysteresis — N 연속 commit 에서 같은 채널이
|
||||
* 빠져있어야 진짜 "회복 필요" 로 인정. 1 cycle noise 깜빡임에 화살표
|
||||
* 흔들리는 현상 차단 (2026-06-30 실측 분석 결과).
|
||||
*/
|
||||
private val lostStreakRequired: Int = 3,
|
||||
/** FINAL_CONFIRM 누적 프레임 수 — flowchart 의 \"마지막 측정 5회 반복\". */
|
||||
val finalConfirmTarget: Int = 5,
|
||||
) {
|
||||
// 2026-07-02: CENTER_OPTIMIZE phase 추가 — CH0~CH3 chord CV 로 최적 vertical 위치 판정.
|
||||
enum class Phase4 {
|
||||
INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE,
|
||||
CENTER_OPTIMIZE, // ← 신규
|
||||
LR_BALANCE, FINAL_CONFIRM
|
||||
}
|
||||
|
||||
data class Out(
|
||||
val action: AlignAction?,
|
||||
val msg: String,
|
||||
val phase: Phase4,
|
||||
val progress: Int = 0,
|
||||
/** Localized string resource id for user-facing UI (msg is dev/debug). */
|
||||
val msgKey: Int? = null,
|
||||
/** Args for msgKey formatting. */
|
||||
val msgArgs: List<Any> = emptyList(),
|
||||
)
|
||||
|
||||
var phase: Phase4 = Phase4.INITIAL_ACCUM
|
||||
private set
|
||||
var bestCenterSet: Set<Int> = emptySet()
|
||||
private set
|
||||
private var initialCount = 0
|
||||
private var verticalHitStreak = 0
|
||||
private var stabilizeCount = 0
|
||||
private var lateralDir: AlignAction? = null
|
||||
private var lateralImbal: Int? = null
|
||||
private var lateralWorseStreak = 0
|
||||
private var finalConfirmCount = 0 // FINAL_CONFIRM 누적 카운터
|
||||
private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis
|
||||
private var centerOptimizeStreak = 0 // CENTER_OPTIMIZE CV 통과 연속 카운터
|
||||
|
||||
fun reset() {
|
||||
phase = Phase4.INITIAL_ACCUM
|
||||
bestCenterSet = emptySet()
|
||||
initialCount = 0
|
||||
verticalHitStreak = 0
|
||||
stabilizeCount = 0
|
||||
lateralDir = null
|
||||
lateralImbal = null
|
||||
lateralWorseStreak = 0
|
||||
finalConfirmCount = 0
|
||||
lostStreak = 0
|
||||
centerOptimizeStreak = 0
|
||||
}
|
||||
|
||||
fun step(s: AlignSummary): Out {
|
||||
val curSet = (0..3).filter { it < s.detected.size && s.detected[it] }.toSet()
|
||||
|
||||
// === Phase 1: INITIAL_ACCUM ===
|
||||
if (phase == Phase4.INITIAL_ACCUM) {
|
||||
initialCount++
|
||||
if (initialCount < initialTarget) {
|
||||
return Out(null, "측정 준비 중... (${initialCount}/${initialTarget})",
|
||||
phase, initialCount,
|
||||
msgKey = R.string.align_hold_still,
|
||||
msgArgs = listOf(initialCount, initialTarget))
|
||||
}
|
||||
phase = Phase4.VERTICAL_CLIMB
|
||||
}
|
||||
|
||||
// === Phase 2: VERTICAL_CLIMB ===
|
||||
if (phase == Phase4.VERTICAL_CLIMB) {
|
||||
if (!s.ch3) {
|
||||
verticalHitStreak = 0 // 연속 카운터 리셋 — false detection 거부
|
||||
return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요 (ch3 미검출)",
|
||||
phase, 0,
|
||||
msgKey = R.string.align_move_up)
|
||||
}
|
||||
verticalHitStreak++
|
||||
if (verticalHitStreak < verticalHitRequired) {
|
||||
// ch3 일시 검출 — verticalHitRequired 회 연속 확인 전까지 사용자 정지.
|
||||
// (현재 위치가 진짜 ch3 인지 평균이 만든 false echo 인지 확인 중)
|
||||
return Out(AlignAction.STOP,
|
||||
"✓ ch3 일시 검출 — 확인 중 (${verticalHitStreak}/${verticalHitRequired})",
|
||||
phase, verticalHitStreak,
|
||||
msgKey = R.string.align_hold_position,
|
||||
msgArgs = listOf(verticalHitStreak, verticalHitRequired))
|
||||
}
|
||||
phase = Phase4.CH3_STABILIZE
|
||||
stabilizeCount = 0
|
||||
bestCenterSet = curSet
|
||||
}
|
||||
|
||||
// === Phase 3: CH3_STABILIZE ===
|
||||
if (phase == Phase4.CH3_STABILIZE) {
|
||||
stabilizeCount++
|
||||
if (curSet.size > bestCenterSet.size) bestCenterSet = curSet
|
||||
if (!s.ch3) {
|
||||
// ch3 잃었으면 → VERTICAL_CLIMB 회귀
|
||||
phase = Phase4.VERTICAL_CLIMB
|
||||
stabilizeCount = 0
|
||||
return Out(AlignAction.MOVE_UP, "↑ ch3 다시 잃음 — 위로",
|
||||
phase, 0,
|
||||
msgKey = R.string.align_recover_up)
|
||||
}
|
||||
if (stabilizeCount < stabilizeTarget) {
|
||||
return Out(AlignAction.STOP,
|
||||
"✓ ch3 검출! 위치 유지 (${stabilizeCount}/${stabilizeTarget})",
|
||||
phase, stabilizeCount,
|
||||
msgKey = R.string.align_hold_position,
|
||||
msgArgs = listOf(stabilizeCount, stabilizeTarget))
|
||||
}
|
||||
// 2026-07-02: CH3_STABILIZE 통과 → CENTER_OPTIMIZE (CV 로 4채널 균등 확인)
|
||||
phase = Phase4.CENTER_OPTIMIZE
|
||||
centerOptimizeStreak = 0
|
||||
}
|
||||
|
||||
// === Phase 4a: CENTER_OPTIMIZE (신규) — CH0~CH3 chord CV 로 최적 vertical 정밀 조정 ===
|
||||
// 전제: CH3 는 이미 안정 검출됨. 4채널 chord CV 가 낮으면 (0.15 이하) 정렬 완료로 인정.
|
||||
// CV 높으면: chord[3] > chord[0] 이면 probe 가 high → MOVE_DOWN,
|
||||
// chord[0] > chord[3] 이면 probe 가 low → MOVE_UP.
|
||||
if (phase == Phase4.CENTER_OPTIMIZE) {
|
||||
val center4Detected = (0..3).all { it < s.detected.size && s.detected[it] }
|
||||
if (!s.ch3) {
|
||||
// CH3 잃으면 CH3_STABILIZE 로 복귀
|
||||
phase = Phase4.CH3_STABILIZE
|
||||
stabilizeCount = 0
|
||||
centerOptimizeStreak = 0
|
||||
return Out(AlignAction.MOVE_UP, "↑ ch3 재확인 필요",
|
||||
phase, 0, msgKey = R.string.align_recover_up)
|
||||
}
|
||||
if (!center4Detected) {
|
||||
// CH0~2 중 일부 미검출 → probe 를 위로 올려 CH0 잡기
|
||||
centerOptimizeStreak = 0
|
||||
val missing = (0..3).filter { !s.detected[it] }
|
||||
val missingLabel = missing.joinToString(",") { "ch$it" }
|
||||
return Out(AlignAction.MOVE_UP,
|
||||
"↑ 조금 더 위로 ($missingLabel 미검출)", phase, 0,
|
||||
msgKey = R.string.align_move_up)
|
||||
}
|
||||
// 4채널 모두 검출됨 → chord CV 계산 (summarize 에서 angle-corrected mm 값 저장됨)
|
||||
val chords = DoubleArray(4) { ch -> s.chords[ch] ?: 0.0 }
|
||||
val chordMean = chords.average()
|
||||
if (chordMean <= 0.1) {
|
||||
phase = Phase4.VERTICAL_CLIMB
|
||||
verticalHitStreak = 0
|
||||
centerOptimizeStreak = 0
|
||||
return Out(AlignAction.MOVE_UP, "↑ 재정렬 필요", phase, 0,
|
||||
msgKey = R.string.align_move_up)
|
||||
}
|
||||
val variance = chords.map { (it - chordMean) * (it - chordMean) }.average()
|
||||
val chordStd = kotlin.math.sqrt(variance)
|
||||
val cv = chordStd / chordMean
|
||||
|
||||
if (cv <= AlignmentConstants.CV_THRESHOLD_STRICT) {
|
||||
centerOptimizeStreak++
|
||||
if (centerOptimizeStreak >= AlignmentConstants.CENTER_OPT_CONFIRM_STREAK) {
|
||||
bestCenterSet = (0..3).toSet()
|
||||
phase = Phase4.LR_BALANCE
|
||||
return Out(AlignAction.STOP,
|
||||
"✓ 최적 vertical 확인 (CV=${"%.2f".format(cv)}) — 좌우 균형 확인",
|
||||
phase, 0,
|
||||
msgKey = R.string.align_hold_position,
|
||||
msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK))
|
||||
}
|
||||
return Out(AlignAction.STOP,
|
||||
"✓ 최적 근접 (CV=${"%.2f".format(cv)}) — 확인 중 " +
|
||||
"(${centerOptimizeStreak}/${AlignmentConstants.CENTER_OPT_CONFIRM_STREAK})",
|
||||
phase, centerOptimizeStreak,
|
||||
msgKey = R.string.align_hold_position,
|
||||
msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK))
|
||||
}
|
||||
// CV 너무 큼 → 방향 힌트
|
||||
centerOptimizeStreak = 0
|
||||
val ch0Chord = chords[0]
|
||||
val ch3Chord = chords[3]
|
||||
return if (ch3Chord > ch0Chord) {
|
||||
Out(AlignAction.MOVE_DOWN,
|
||||
"↓ 조금 아래로 (CV=${"%.2f".format(cv)}, CH3 극대)", phase, 0,
|
||||
msgKey = R.string.align_recover_down_short)
|
||||
} else {
|
||||
Out(AlignAction.MOVE_UP,
|
||||
"↑ 조금 위로 (CV=${"%.2f".format(cv)}, CH0 극대)", phase, 0,
|
||||
msgKey = R.string.align_recover_up_short)
|
||||
}
|
||||
}
|
||||
|
||||
// === Phase 4b (기존 4): LR_BALANCE ===
|
||||
if (phase == Phase4.LR_BALANCE) {
|
||||
if (curSet.size > bestCenterSet.size) bestCenterSet = curSet
|
||||
val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" }
|
||||
val lost = bestCenterSet - curSet
|
||||
if (lost.isNotEmpty()) {
|
||||
// 2026-06-30 hysteresis: N 연속 commit 에서 같은 채널이 lost 여야 진짜 회복 모드.
|
||||
// 1 cycle 노이즈 깜빡임 (실측 CH1=15%, CH3=12% flap) 에 화살표 흔들리는 현상 차단.
|
||||
lostStreak++
|
||||
if (lostStreak >= lostStreakRequired) {
|
||||
val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN
|
||||
val lostLabel = lost.sortedDescending().joinToString(",") { "ch$it" }
|
||||
val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓"
|
||||
val recoveryKey = if (recoveryDir == AlignAction.MOVE_UP)
|
||||
R.string.align_recover_up_short else R.string.align_recover_down_short
|
||||
return Out(recoveryDir, "$arrow $lostLabel 잃음 — 위치 회복", phase, 0,
|
||||
msgKey = recoveryKey)
|
||||
}
|
||||
// streak 미달 → noise 로 보고 STOP 유지.
|
||||
return Out(AlignAction.STOP,
|
||||
"✓ 그대로 측정 가능 (noise check $lostStreak/$lostStreakRequired)", phase, 0,
|
||||
msgKey = R.string.align_complete)
|
||||
} else {
|
||||
lostStreak = 0 // full set 복귀 — streak 리셋
|
||||
}
|
||||
|
||||
// full set 유지 중 — 좌우 균형 (deadband + consecutive 악화 로 oscillation 차단)
|
||||
val u4 = s.u4
|
||||
val u5 = s.u5
|
||||
if (u4 != null && u5 != null) {
|
||||
val imbal = kotlin.math.abs(u4 - u5)
|
||||
if (imbal <= latTol) {
|
||||
// 균형 도달 → FINAL_CONFIRM 으로 전이. RollingAligner 가 buf clear → 5 fresh 프레임 누적.
|
||||
phase = Phase4.FINAL_CONFIRM
|
||||
finalConfirmCount = 0
|
||||
return Out(AlignAction.STOP,
|
||||
"✓ 균형 도달 — 최종 확인 중 (0/${finalConfirmTarget})", phase, 0,
|
||||
msgKey = R.string.align_hold_position,
|
||||
msgArgs = listOf(0, finalConfirmTarget))
|
||||
}
|
||||
if (lateralDir == null) {
|
||||
lateralDir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
|
||||
lateralWorseStreak = 0
|
||||
} else if (lateralImbal != null) {
|
||||
val significantlyWorse = imbal > lateralImbal!! + lrReverseDeadband
|
||||
if (significantlyWorse) {
|
||||
lateralWorseStreak++
|
||||
if (lateralWorseStreak >= lrReverseConsecutive) {
|
||||
lateralDir = if (lateralDir == AlignAction.MOVE_LEFT)
|
||||
AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
|
||||
lateralWorseStreak = 0
|
||||
}
|
||||
} else {
|
||||
lateralWorseStreak = 0
|
||||
}
|
||||
}
|
||||
lateralImbal = imbal
|
||||
val arrow = if (lateralDir == AlignAction.MOVE_LEFT) "←" else "→"
|
||||
val sideKey = if (lateralDir == AlignAction.MOVE_LEFT)
|
||||
R.string.align_left else R.string.align_right
|
||||
return Out(lateralDir!!, "$arrow $setLabel 유지 + 좌우 균형 (|Δ|=$imbal)",
|
||||
phase, 0,
|
||||
msgKey = sideKey)
|
||||
}
|
||||
return Out(AlignAction.PROBE_LR, "↔ $setLabel 유지 + 좌우 천천히 (lateral 미검출)",
|
||||
phase, 0,
|
||||
msgKey = R.string.align_probe_lr)
|
||||
}
|
||||
|
||||
// === Phase 5: FINAL_CONFIRM ===
|
||||
// RollingAligner 가 진입 시 buf 를 clear → 5 fresh 프레임 누적 후 imbal 재평가.
|
||||
if (phase == Phase4.FINAL_CONFIRM) {
|
||||
// center set 유지 확인 — 잃었으면 LR_BALANCE 회귀.
|
||||
val lost = bestCenterSet - curSet
|
||||
if (lost.isNotEmpty()) {
|
||||
val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN
|
||||
val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓"
|
||||
phase = Phase4.LR_BALANCE
|
||||
finalConfirmCount = 0
|
||||
val recoveryKey = if (recoveryDir == AlignAction.MOVE_UP)
|
||||
R.string.align_recover_up_short else R.string.align_recover_down_short
|
||||
return Out(recoveryDir, "$arrow 채널 잃음 — 재정렬", phase, 0,
|
||||
msgKey = recoveryKey)
|
||||
}
|
||||
|
||||
finalConfirmCount++
|
||||
|
||||
// 누적 중 — 5 프레임 모일 때까지 정지 안내
|
||||
if (finalConfirmCount < finalConfirmTarget) {
|
||||
return Out(AlignAction.STOP,
|
||||
"✓ 최종 확인 중 (${finalConfirmCount}/${finalConfirmTarget})",
|
||||
phase, finalConfirmCount,
|
||||
msgKey = R.string.align_hold_position,
|
||||
msgArgs = listOf(finalConfirmCount, finalConfirmTarget))
|
||||
}
|
||||
|
||||
// 5 프레임 도달 — imbal 재평가
|
||||
val u4 = s.u4; val u5 = s.u5
|
||||
if (u4 == null || u5 == null) {
|
||||
phase = Phase4.LR_BALANCE
|
||||
finalConfirmCount = 0
|
||||
return Out(AlignAction.PROBE_LR, "lateral 미검출 — 좌우 재확인", phase, 0,
|
||||
msgKey = R.string.align_probe_lr)
|
||||
}
|
||||
val imbal = kotlin.math.abs(u4 - u5)
|
||||
val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" }
|
||||
return if (imbal <= latTol) {
|
||||
// 최종 확인 통과 → 진짜 STOP
|
||||
Out(AlignAction.STOP,
|
||||
"■ 정렬 완료 ($setLabel, 확인 OK, |Δ|=$imbal)", phase, finalConfirmTarget,
|
||||
msgKey = R.string.align_complete)
|
||||
} else {
|
||||
// 재평가 실패 → LR_BALANCE 회귀하여 추가 조정
|
||||
phase = Phase4.LR_BALANCE
|
||||
finalConfirmCount = 0
|
||||
val arrow = if (u4 < u5) "→" else "←"
|
||||
val sideKey = if (u4 < u5) R.string.align_right else R.string.align_left
|
||||
Out(if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT,
|
||||
"$arrow 재조정 필요 — 좌우 균형 (|Δ|=$imbal)", phase, 0,
|
||||
msgKey = sideKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 도달 불가 (defensive)
|
||||
return Out(AlignAction.STOP, "unknown phase: $phase", phase, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* k-프레임 누적 + commit 한 번 — Python `RollingAligner`.
|
||||
*
|
||||
* push(raw6) 호출 시:
|
||||
* - buf.size < accumK 인 동안: state='accum'
|
||||
* - 충분히 모이면: 평균 → method_d → summarize → advice → state='commit'
|
||||
*
|
||||
* UI 는 commit 일 때만 화살표 갱신, accum 일 때는 "측정 중… N/K" 표시.
|
||||
*/
|
||||
class RollingAligner(
|
||||
private val latTol: Int = AlignmentConstants.LAT_TOL,
|
||||
/** INITIAL_ACCUM / VERTICAL_CLIMB / CH3_STABILIZE 단계 window 크기. flowchart 의 \"10회 측정 평균\". */
|
||||
private val accumKVertical: Int = 10,
|
||||
/** LR_BALANCE 단계 window 크기. flowchart 의 \"5회 측정 평균\" (빠른 반응). */
|
||||
private val accumKLateral: Int = 5,
|
||||
/** FINAL_CONFIRM 단계 window 크기. flowchart 의 \"마지막 5회 반복 측정\". */
|
||||
private val accumKConfirm: Int = 5,
|
||||
private val methodDParams: MethodDParams = MethodDParams.DEFAULT,
|
||||
) {
|
||||
private val buf = ArrayDeque<List<DoubleArray>>()
|
||||
private val guide4 = AlignGuide4Stage(latTol = latTol,
|
||||
initialTarget = accumKVertical, stabilizeTarget = accumKVertical,
|
||||
finalConfirmTarget = accumKConfirm)
|
||||
private var lastSeenPhase: AlignGuide4Stage.Phase4? = null
|
||||
/** 마지막 commit 결과. */
|
||||
var lastCommit: AdvisorState? = null
|
||||
private set
|
||||
val phase: AlignGuide4Stage.Phase4 get() = guide4.phase
|
||||
val bestCenterSet: Set<Int> get() = guide4.bestCenterSet
|
||||
|
||||
/** UI 그래프에서 N-cycle 평균 신호를 보여주기 위해 마지막 avg 노출. */
|
||||
var lastAvg: List<DoubleArray>? = null
|
||||
private set
|
||||
|
||||
fun reset() {
|
||||
buf.clear()
|
||||
guide4.reset()
|
||||
lastSeenPhase = null
|
||||
lastAvg = null
|
||||
}
|
||||
|
||||
/** 단일 채널 길이 100 가정. raw6.size == 6 이어야 함. */
|
||||
fun push(raw6: List<DoubleArray>): AdvisorState {
|
||||
// Phase 전이 감지 — FINAL_CONFIRM 진입 시 buf clear 로 fresh 5 프레임 누적 보장.
|
||||
// (flowchart \"마지막 측정 및 확인 — 5회 반복 측정\")
|
||||
val curPhase = guide4.phase
|
||||
if (lastSeenPhase != null && lastSeenPhase != AlignGuide4Stage.Phase4.FINAL_CONFIRM
|
||||
&& curPhase == AlignGuide4Stage.Phase4.FINAL_CONFIRM) {
|
||||
buf.clear()
|
||||
}
|
||||
|
||||
// Phase 별 동적 window 크기.
|
||||
val windowSize = when (curPhase) {
|
||||
AlignGuide4Stage.Phase4.LR_BALANCE -> accumKLateral
|
||||
AlignGuide4Stage.Phase4.FINAL_CONFIRM -> accumKConfirm
|
||||
else -> accumKVertical
|
||||
}
|
||||
|
||||
buf.addLast(raw6)
|
||||
while (buf.size > windowSize) buf.removeFirst()
|
||||
|
||||
val nCh = raw6.size
|
||||
val avg = List(nCh) { ch ->
|
||||
val len = buf.first()[ch].size
|
||||
DoubleArray(len) { i ->
|
||||
var sum = 0.0
|
||||
for (frame in buf) sum += frame[ch][i]
|
||||
sum / buf.size
|
||||
}
|
||||
}
|
||||
lastAvg = avg
|
||||
val dets = MethodDRunner.detectMultichannel(avg, methodDParams)
|
||||
val summary = summarize(dets)
|
||||
val out = guide4.step(summary)
|
||||
lastSeenPhase = guide4.phase
|
||||
|
||||
// accum state — INITIAL_ACCUM 의 첫 N 프레임 + FINAL_CONFIRM 누적 중.
|
||||
val isAccumulating = out.phase == AlignGuide4Stage.Phase4.INITIAL_ACCUM ||
|
||||
(out.phase == AlignGuide4Stage.Phase4.FINAL_CONFIRM &&
|
||||
out.progress in 1 until accumKConfirm)
|
||||
val state = if (isAccumulating) "accum" else "commit"
|
||||
val effectiveTarget = when (out.phase) {
|
||||
AlignGuide4Stage.Phase4.FINAL_CONFIRM -> accumKConfirm
|
||||
else -> accumKVertical
|
||||
}
|
||||
val st = AdvisorState(
|
||||
state = state, action = out.action, msg = out.msg,
|
||||
msgKey = out.msgKey, msgArgs = out.msgArgs,
|
||||
dets = dets, summary = summary,
|
||||
accumProgress = if (state == "accum") out.progress else effectiveTarget,
|
||||
accumTarget = effectiveTarget,
|
||||
)
|
||||
lastCommit = st
|
||||
return st
|
||||
}
|
||||
|
||||
private fun summarize(dets: List<MethodDResult?>): AlignSummary {
|
||||
fun u(i: Int) = if (i < dets.size) dets[i]?.urineLen else null
|
||||
val ch3 = dets.size > AlignmentConstants.CH3 && dets[AlignmentConstants.CH3] != null
|
||||
val chN = AlignmentConstants.CENTER_CH.count { it < dets.size && dets[it] != null }
|
||||
val detected = BooleanArray(6) { i -> i < dets.size && dets[i] != null }
|
||||
// 2026-07-02: per-channel chord (angle-corrected mm) — CENTER_OPTIMIZE CV 계산용
|
||||
val chords = Array<Double?>(6) { ch ->
|
||||
val d = if (ch < dets.size) dets[ch] else null
|
||||
if (d == null) null
|
||||
else {
|
||||
val urineLen = kotlin.math.max(0, d.post - d.ant - 1).toDouble()
|
||||
val angleRad = Math.toRadians(PiezoHW.degreeAll[ch])
|
||||
urineLen * kotlin.math.cos(angleRad) * PiezoHW.distancePerSample
|
||||
}
|
||||
}
|
||||
return AlignSummary(ch3 = ch3, u4 = u(4), u5 = u(5), chN = chN,
|
||||
detected = detected, chords = chords)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* 후면반사 탐지+제거 시스템 — Python span_utils.py + pipeline.py 1:1 포팅
|
||||
*
|
||||
* 흐름: build_candidate → consensus → is_reflection_only → resolve → suppress → rerun → choose
|
||||
*/
|
||||
object BackReflection {
|
||||
|
||||
private const val MIN_BACK_REFLECTION_PROM = 50.0
|
||||
private const val POST_MATCH_TOL = 3
|
||||
private const val OUTLIER_TOL = 4
|
||||
private const val SHARED_TOL = 3
|
||||
private const val MIN_CHANNELS = 3
|
||||
private const val RADIUS = 6
|
||||
private const val EXTEND = 20
|
||||
|
||||
// ── Valley helpers (50.0 rise early-stop) ──
|
||||
|
||||
private fun findLeftValleyValue(x: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||||
var v = x[peakIdx]
|
||||
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
|
||||
val cur = x[i]
|
||||
if (cur < v) v = cur
|
||||
else if (cur > v + 50.0) break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
private fun findRightValleyValue(x: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||||
var v = x[peakIdx]
|
||||
for (i in (peakIdx + 1) until min(x.size, peakIdx + maxDist + 1)) {
|
||||
val cur = x[i]
|
||||
if (cur < v) v = cur
|
||||
else if (cur > v + 50.0) break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ── Strongest significant peak ──
|
||||
|
||||
private fun strongestSignificantPeak(x: DoubleArray, lo: Int, hi: Int, minProm: Double = MIN_BACK_REFLECTION_PROM): Int? {
|
||||
if (lo >= hi || lo < 0 || hi > x.size) return null
|
||||
val seg: DoubleArray = x.sliceArray(lo until hi)
|
||||
val peaks: List<Int> = PiezoEchoAnalyzer.shared.findPeaks1D(seg)
|
||||
if (peaks.isEmpty()) return null
|
||||
|
||||
var bestIdx: Int? = null
|
||||
var bestAmp = Double.NEGATIVE_INFINITY
|
||||
for (p: Int in peaks) {
|
||||
val gp = p + lo
|
||||
val leftV = findLeftValleyValue(x, gp)
|
||||
val rightV = findRightValleyValue(x, gp)
|
||||
val prom = x[gp] - min(leftV, rightV)
|
||||
if (prom < minProm) continue
|
||||
if (x[gp] > bestAmp) { bestAmp = x[gp]; bestIdx = gp }
|
||||
}
|
||||
return bestIdx
|
||||
}
|
||||
|
||||
// ── find_back_reflection_idx (legacy, 전체 범위) ──
|
||||
|
||||
fun findBackReflectionIdx(tgcSg: DoubleArray, postMaxIdx: Int, minSearchIdx: Int = 40, extend: Int = EXTEND): Int {
|
||||
val n = tgcSg.size
|
||||
val searchEnd = min(n, postMaxIdx + extend)
|
||||
if (minSearchIdx >= searchEnd) return postMaxIdx
|
||||
val seg: DoubleArray = tgcSg.sliceArray(minSearchIdx until searchEnd)
|
||||
val peaks: List<Int> = PiezoEchoAnalyzer.shared.findPeaks1D(seg)
|
||||
if (peaks.isEmpty()) return postMaxIdx
|
||||
val globalPeaks: List<Int> = peaks.map { p -> p + minSearchIdx }
|
||||
return globalPeaks.maxByOrNull { idx -> tgcSg[idx] } ?: postMaxIdx
|
||||
}
|
||||
|
||||
// ── find_back_reflection_after_post ──
|
||||
|
||||
fun findBackReflectionAfterPost(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, extend: Int = EXTEND): Int? {
|
||||
val searchStart = postIdx + 1
|
||||
val searchEnd = min(tgcSg.size, postMaxIdx + extend)
|
||||
if (searchStart >= searchEnd) return null
|
||||
return strongestSignificantPeak(tgcSg, searchStart, searchEnd)
|
||||
}
|
||||
|
||||
// ── find_back_reflection_near_idx ──
|
||||
|
||||
fun findBackReflectionNearIdx(tgcSg: DoubleArray, centerIdx: Int, postIdx: Int, postMaxIdx: Int, radius: Int = RADIUS, extend: Int = EXTEND): Int? {
|
||||
val n = tgcSg.size
|
||||
val lo = max(postIdx + 1, centerIdx - radius)
|
||||
val hi = min(n, min(postMaxIdx + extend, centerIdx + radius + 1))
|
||||
if (lo >= hi) return null
|
||||
return strongestSignificantPeak(tgcSg, lo, hi)
|
||||
}
|
||||
|
||||
// ── build_back_reflection_candidate ──
|
||||
|
||||
fun buildCandidate(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, postMatchTol: Int = POST_MATCH_TOL): Int? {
|
||||
val globalBr = findBackReflectionIdx(tgcSg, postMaxIdx)
|
||||
if (abs(globalBr - postIdx) <= postMatchTol) return null // 후위벽≈전체BR → 별도 BR 없음
|
||||
return findBackReflectionAfterPost(tgcSg, postIdx, postMaxIdx)
|
||||
}
|
||||
|
||||
// ── consensus_back_reflection_idx ──
|
||||
|
||||
private const val MIN_VOTES = 3
|
||||
private const val AMP_RATIO = 1.5
|
||||
|
||||
fun consensusIdx(candidates: List<Int?>, outlierTol: Int = OUTLIER_TOL, minVotes: Int = MIN_VOTES): Int? {
|
||||
val vals = candidates.filterNotNull()
|
||||
if (vals.size < minVotes) return null
|
||||
val sorted = vals.sorted()
|
||||
val med = sorted[sorted.size / 2].toDouble()
|
||||
val inliers = vals.filter { abs(it - med) <= outlierTol }
|
||||
val finalVals = inliers.ifEmpty { vals }
|
||||
val finalSorted = finalVals.sorted()
|
||||
return finalSorted[finalSorted.size / 2]
|
||||
}
|
||||
|
||||
// ── is_suspicious_post_peak ──
|
||||
|
||||
fun isSuspiciousPostPeak(tgcSg: DoubleArray, postIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, sharedTol: Int = 2, ampRatio: Double = AMP_RATIO): Boolean {
|
||||
val n = tgcSg.size
|
||||
if (postIdx < 0 || postIdx >= n) return false
|
||||
var suspicious = false
|
||||
if (antIdx != null && antIdx in 0 until n && tgcSg[postIdx] > tgcSg[antIdx] * ampRatio) suspicious = true
|
||||
if (sharedIdx != null && abs(postIdx - sharedIdx) <= sharedTol) suspicious = true
|
||||
return suspicious
|
||||
}
|
||||
|
||||
// ── select_back_reflection_idx ──
|
||||
|
||||
fun selectIdx(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, channelCandidate: Int? = null): Int? {
|
||||
val suspiciousPost = isSuspiciousPostPeak(tgcSg, postIdx, antIdx = antIdx, sharedIdx = sharedIdx)
|
||||
|
||||
var local: Int? = null
|
||||
if (sharedIdx != null) {
|
||||
local = findBackReflectionNearIdx(tgcSg, sharedIdx, postIdx, postMaxIdx)
|
||||
}
|
||||
|
||||
if (suspiciousPost && sharedIdx != null) {
|
||||
val postDist = abs(postIdx - sharedIdx)
|
||||
val localDist = local?.let { abs(it - sharedIdx) }
|
||||
val candDist = channelCandidate?.let { abs(it - sharedIdx) }
|
||||
var nearestOther: Int? = localDist
|
||||
if (candDist != null) nearestOther = if (nearestOther == null) candDist else min(nearestOther, candDist)
|
||||
if (nearestOther == null || postDist < nearestOther) return postIdx
|
||||
}
|
||||
|
||||
if (local != null) return local
|
||||
if (channelCandidate != null) return channelCandidate
|
||||
if (suspiciousPost) return postIdx
|
||||
return null
|
||||
}
|
||||
|
||||
// ── resolve_back_reflection_indices ──
|
||||
|
||||
data class ResolveResult(val brIdx: Int?, val suppressIdx: Int?)
|
||||
|
||||
fun resolveIndices(tgcSg: DoubleArray, postIdx: Int, postMaxIdx: Int, antIdx: Int? = null, sharedIdx: Int? = null, channelCandidate: Int? = null): ResolveResult {
|
||||
val br = selectIdx(tgcSg, postIdx, postMaxIdx, antIdx = antIdx, sharedIdx = sharedIdx, channelCandidate = channelCandidate)
|
||||
?: return ResolveResult(null, null)
|
||||
val suppressIdx = sharedIdx ?: br
|
||||
return ResolveResult(br, suppressIdx)
|
||||
}
|
||||
|
||||
// ── is_reflection_only_multichannel ──
|
||||
|
||||
fun isReflectionOnlyMultichannel(
|
||||
tgcSignals: List<DoubleArray>,
|
||||
wallPairs: List<Pair<Int?, Int?>>,
|
||||
sharedIdx: Int? = null,
|
||||
sharedTol: Int = SHARED_TOL,
|
||||
minChannels: Int = MIN_CHANNELS,
|
||||
ampRatio: Double = AMP_RATIO
|
||||
): Boolean {
|
||||
data class Valid(val i: Int, val ant: Int, val post: Int)
|
||||
val valid = wallPairs.mapIndexedNotNull { i, (ant, post) ->
|
||||
if (ant != null && post != null) Valid(i, ant, post) else null
|
||||
}
|
||||
if (valid.size < minChannels) return false
|
||||
val ratio = ampRatio
|
||||
|
||||
// 조건 A: shared_idx 기반
|
||||
var effectiveShared = sharedIdx
|
||||
if (effectiveShared == null) {
|
||||
val postVals = valid.map { it.post }.sorted()
|
||||
val med = postVals[postVals.size / 2].toDouble()
|
||||
val inliers = valid.filter { abs(it.post - med) <= sharedTol }
|
||||
if (inliers.size >= minChannels) effectiveShared = med.roundToInt()
|
||||
}
|
||||
if (effectiveShared != null) {
|
||||
if (valid.all { abs(it.post - effectiveShared!!) <= sharedTol && tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] * ratio }) return true
|
||||
}
|
||||
|
||||
// 조건 B: post 클러스터
|
||||
val postVals = valid.map { it.post }.sorted()
|
||||
val medPost = postVals[postVals.size / 2].toDouble()
|
||||
val inlierTriples = valid.filter { abs(it.post - medPost) <= sharedTol }
|
||||
if (inlierTriples.size >= minChannels) {
|
||||
if (inlierTriples.all { tgcSignals[it.i][it.post] > tgcSignals[it.i][it.ant] * ratio }) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── choose_rerun_or_initial_result ──
|
||||
|
||||
fun chooseResult(
|
||||
initial: LowEchoResult?, rerun: LowEchoResult?,
|
||||
brIdx: Int?, suppressIdx: Int?,
|
||||
minGap: Int, minBrSep: Int = 0, minInitialUrineLen: Int = 0
|
||||
): LowEchoResult? {
|
||||
if (rerun != null) return rerun
|
||||
if (canFallbackToInitial(initial, brIdx, suppressIdx, minGap, minBrSep, minInitialUrineLen)) return initial
|
||||
return null
|
||||
}
|
||||
|
||||
private fun canFallbackToInitial(initial: LowEchoResult?, brIdx: Int?, suppressIdx: Int?, minGap: Int, minBrSep: Int, minInitialUrineLen: Int): Boolean {
|
||||
if (initial == null || brIdx == null || suppressIdx == null) return false
|
||||
if (abs(brIdx - initial.post) < minBrSep) return false
|
||||
if ((suppressIdx - initial.post) < minGap) return false
|
||||
if (initial.urineLen < minInitialUrineLen) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// ── suppress_back_reflection (valley early-stop 50.0) ──
|
||||
|
||||
fun suppress(sg: DoubleArray, backRefIdx: Int, searchMargin: Int = 10): DoubleArray {
|
||||
val cleaned = sg.copyOf()
|
||||
val n = sg.size
|
||||
if (backRefIdx >= n) return cleaned
|
||||
|
||||
val lo = max(0, backRefIdx - min(searchMargin, 3))
|
||||
val hi = min(n, backRefIdx + searchMargin + 1)
|
||||
var actualPeak = lo
|
||||
for (i in lo until hi) if (sg[i] > sg[actualPeak]) actualPeak = i
|
||||
|
||||
// Left valley with 50.0 rise early-stop
|
||||
var leftValley = actualPeak
|
||||
var leftBest = sg[actualPeak]
|
||||
for (i in (actualPeak - 1) downTo max(0, actualPeak - searchMargin)) {
|
||||
val cur = sg[i]
|
||||
if (cur < leftBest) { leftBest = cur; leftValley = i }
|
||||
else if (cur > leftBest + 50.0) break
|
||||
}
|
||||
|
||||
// Right valley with 50.0 rise early-stop
|
||||
var rightValley = actualPeak
|
||||
var rightBest = sg[actualPeak]
|
||||
for (i in (actualPeak + 1) until min(n, actualPeak + searchMargin + 1)) {
|
||||
val cur = sg[i]
|
||||
if (cur < rightBest) { rightBest = cur; rightValley = i }
|
||||
else if (cur > rightBest + 50.0) break
|
||||
}
|
||||
|
||||
if (rightValley > leftValley) {
|
||||
val leftVal = sg[leftValley]
|
||||
val rightVal = sg[rightValley]
|
||||
val length = rightValley - leftValley
|
||||
for (i in 0..length) {
|
||||
cleaned[leftValley + i] = leftVal + (rightVal - leftVal) * i.toDouble() / length
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* R-fit trend tracker — watches per-frame sphere-fit radius and classifies:
|
||||
* RISING — sliding toward bladder centre (R increasing)
|
||||
* PEAK — at / near session maximum R
|
||||
* FALLING — sliding past centre (R decreasing)
|
||||
* NOISE — fluctuating without clear trend
|
||||
* IDLE — no valid fit (too few channels)
|
||||
*
|
||||
* Ported from CKLaw BladderSeekRTracker.kt.
|
||||
*/
|
||||
class BladderSeekRTracker(
|
||||
private val historySize: Int = 8,
|
||||
private val peakToleranceMm: Double = 1.5,
|
||||
private val noiseDeltaMm: Double = 0.4,
|
||||
) {
|
||||
enum class Trend { RISING, PEAK, FALLING, NOISE, IDLE }
|
||||
|
||||
data class Status(
|
||||
val currentR: Double?,
|
||||
val bestR: Double,
|
||||
val trend: Trend,
|
||||
val improvementMm: Double,
|
||||
val hintEn: String,
|
||||
)
|
||||
|
||||
private val history = ArrayDeque<Double>()
|
||||
var bestR: Double = 0.0
|
||||
private set
|
||||
|
||||
fun reset() {
|
||||
history.clear()
|
||||
bestR = 0.0
|
||||
}
|
||||
|
||||
fun submit(r: Double?): Status {
|
||||
if (r == null || r.isNaN() || r <= 0.0) {
|
||||
return Status(null, bestR, Trend.IDLE, 0.0,
|
||||
"Not enough channels — check signal")
|
||||
}
|
||||
history.addLast(r)
|
||||
while (history.size > historySize) history.removeFirst()
|
||||
if (r > bestR) bestR = r
|
||||
|
||||
val trend = computeTrend(r)
|
||||
val gap = r - bestR
|
||||
val en = when (trend) {
|
||||
Trend.RISING -> "Getting closer (R=${"%.0f".format(r)}mm)"
|
||||
Trend.PEAK -> "At peak (R=${"%.0f".format(r)}mm)"
|
||||
Trend.FALLING -> "Past peak — reverse (best ${"%.0f".format(bestR)}mm)"
|
||||
Trend.NOISE -> "Searching (R=${"%.0f".format(r)}mm)"
|
||||
Trend.IDLE -> ""
|
||||
}
|
||||
return Status(r, bestR, trend, gap, en)
|
||||
}
|
||||
|
||||
private fun computeTrend(currentR: Double): Trend {
|
||||
if (history.size < 3) return Trend.NOISE
|
||||
if (bestR - currentR < peakToleranceMm) return Trend.PEAK
|
||||
val prev = history.toList().dropLast(1).takeLast(3)
|
||||
val prevMean = prev.average()
|
||||
val delta = currentR - prevMean
|
||||
return when {
|
||||
abs(delta) < noiseDeltaMm -> Trend.NOISE
|
||||
delta > 0 -> Trend.RISING
|
||||
else -> Trend.FALLING
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Sagittal-plane circle fit + coronal lateral offset + argmax.
|
||||
*
|
||||
* Ported from CKLaw BladderSphereSeek.kt, simplified to use PiezoHW
|
||||
* geometry directly (no SphereFit2Step / Geometry dependencies).
|
||||
*
|
||||
* Ch0..Ch3 wall points are projected to the YZ plane (sagittal slice)
|
||||
* and fitted with a 2D algebraic circle (Kasa method). The fit radius
|
||||
* serves as real-time alignment quality — maximised when probe is
|
||||
* centred over the bladder.
|
||||
*/
|
||||
object BladderSphereSeek {
|
||||
|
||||
data class FitResult(
|
||||
val radiusMm: Double,
|
||||
val centerY: Double, // along body axis (cranial+, caudal-)
|
||||
val centerZ: Double, // depth from probe surface
|
||||
val residualStdMm: Double,
|
||||
val nPoints: Int, // 3..8 wall points
|
||||
val confidence: Float, // 0..1
|
||||
)
|
||||
|
||||
data class CoronalResult(
|
||||
val lateralOffsetMm: Double,
|
||||
val nChannels: Int,
|
||||
val ch4MidXMm: Double?,
|
||||
val ch5MidXMm: Double?,
|
||||
val confidence: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Convert wall sample index to YZ coordinates (mm) for a given channel.
|
||||
* Y = sensor_z + depth * sin(theta) (along body, cranial+)
|
||||
* Z = depth * cos(theta) (perpendicular depth into body)
|
||||
*/
|
||||
private fun wallIdxToYZ(ch: Int, sampleIdx: Int): DoubleArray {
|
||||
val hw = PiezoHW
|
||||
val depth = sampleIdx * hw.distancePerSample + hw.delayOffsetMm
|
||||
val theta = hw.degreeAll[ch] * Math.PI / 180.0
|
||||
val y = hw.sensorZMmAll[ch] + depth * sin(theta)
|
||||
val z = depth * cos(theta)
|
||||
return doubleArrayOf(y, z)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wall sample index to X coordinate (mm) for lateral channels.
|
||||
* X = sensor_x + depth * sin(theta_lr)
|
||||
*/
|
||||
private fun wallIdxToX(ch: Int, sampleIdx: Int): Double {
|
||||
val hw = PiezoHW
|
||||
val depth = sampleIdx * hw.distancePerSample + hw.delayOffsetMm
|
||||
val thetaLR = hw.degreeLRAll[ch] * Math.PI / 180.0
|
||||
return hw.sensorXMmAll[ch] + depth * sin(thetaLR)
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D circle fit (Kasa algebraic method) on YZ wall points from Ch0..Ch3.
|
||||
*
|
||||
* @param walls list of 6 Pair(antIdx, postIdx)?, null for undetected channels
|
||||
* @param detected list of 6 booleans
|
||||
*/
|
||||
fun fitSagittalCircle(
|
||||
walls: List<Pair<Int, Int>?>,
|
||||
detected: List<Boolean>,
|
||||
): FitResult? {
|
||||
val pts = mutableListOf<DoubleArray>()
|
||||
for (ch in 0..3) {
|
||||
if (!detected.getOrElse(ch) { false }) continue
|
||||
val w = walls.getOrNull(ch) ?: continue
|
||||
pts.add(wallIdxToYZ(ch, w.first)) // ant
|
||||
pts.add(wallIdxToYZ(ch, w.second)) // post
|
||||
}
|
||||
if (pts.size < 3) return null
|
||||
|
||||
// Kasa circle fit: minimize algebraic distance
|
||||
// Solve [y^2+z^2, y, z, 1] * [1, -2cy, -2cz, cy^2+cz^2-r^2]^T = 0
|
||||
val n = pts.size
|
||||
var syy = 0.0; var sy = 0.0; var sz = 0.0
|
||||
var syz = 0.0; var szz = 0.0; var s1 = n.toDouble()
|
||||
var syyy = 0.0; var syzz = 0.0; var syyz = 0.0; var szzz = 0.0
|
||||
var sd = 0.0; var sdy = 0.0; var sdz = 0.0
|
||||
|
||||
for (p in pts) {
|
||||
val y = p[0]; val z = p[1]
|
||||
val d = y * y + z * z
|
||||
sy += y; sz += z; syy += y * y; szz += z * z; syz += y * z
|
||||
sd += d; sdy += d * y; sdz += d * z
|
||||
}
|
||||
|
||||
// Normal equations for Kasa fit
|
||||
val a11 = syy; val a12 = syz; val a13 = sy
|
||||
val a21 = syz; val a22 = szz; val a23 = sz
|
||||
val a31 = sy; val a32 = sz; val a33 = s1
|
||||
val b1 = sdy; val b2 = sdz; val b3 = sd
|
||||
|
||||
// Solve 3x3 system via Cramer's rule
|
||||
val det = a11 * (a22 * a33 - a23 * a32) -
|
||||
a12 * (a21 * a33 - a23 * a31) +
|
||||
a13 * (a21 * a32 - a22 * a31)
|
||||
if (kotlin.math.abs(det) < 1e-12) return null
|
||||
|
||||
val cy = (b1 * (a22 * a33 - a23 * a32) -
|
||||
a12 * (b2 * a33 - a23 * b3) +
|
||||
a13 * (b2 * a32 - a22 * b3)) / det / 2.0
|
||||
val cz = (a11 * (b2 * a33 - a23 * b3) -
|
||||
b1 * (a21 * a33 - a23 * a31) +
|
||||
a13 * (a21 * b3 - b2 * a31)) / det / 2.0
|
||||
val c3 = (a11 * (a22 * b3 - b2 * a32) -
|
||||
a12 * (a21 * b3 - b2 * a31) +
|
||||
b1 * (a21 * a32 - a22 * a31)) / det
|
||||
|
||||
val rSq = cy * cy + cz * cz + c3
|
||||
if (rSq <= 0) return null
|
||||
val r = sqrt(rSq)
|
||||
|
||||
// Residual: std of (|p - center| - r)
|
||||
var sumResidSq = 0.0
|
||||
for (p in pts) {
|
||||
val dist = sqrt((p[0] - cy) * (p[0] - cy) + (p[1] - cz) * (p[1] - cz))
|
||||
val resid = dist - r
|
||||
sumResidSq += resid * resid
|
||||
}
|
||||
val residStd = sqrt(sumResidSq / n)
|
||||
|
||||
// Sanity: anatomically possible range
|
||||
if (r < 15.0 || r > 120.0) return null
|
||||
if (residStd > 5.0) return null
|
||||
|
||||
val nFactor = ((n - 3).coerceAtLeast(0).toFloat() / 5f).coerceIn(0f, 1f)
|
||||
val residFactor = (1.0 - (residStd / 5.0).coerceIn(0.0, 1.0)).toFloat()
|
||||
val conf = (0.4f * nFactor + 0.6f * residFactor).coerceIn(0f, 1f)
|
||||
|
||||
return FitResult(r, cy, cz, residStd, n, conf)
|
||||
}
|
||||
|
||||
/**
|
||||
* Among Ch0..Ch3, returns the index of the channel with the longest chord.
|
||||
* 0 = centred over bladder, 1-3 = increasingly off-centre.
|
||||
* Returns null if any of Ch0..Ch3 is not detected.
|
||||
*/
|
||||
fun sagittalArgMaxCh(
|
||||
walls: List<Pair<Int, Int>?>,
|
||||
detected: List<Boolean>,
|
||||
): Int? {
|
||||
var bestCh = -1
|
||||
var bestLen = -1
|
||||
for (ch in 0..3) {
|
||||
if (!detected.getOrElse(ch) { false }) return null
|
||||
val w = walls.getOrNull(ch) ?: return null
|
||||
val len = w.second - w.first
|
||||
if (len > bestLen) { bestLen = len; bestCh = ch }
|
||||
}
|
||||
return if (bestCh >= 0) bestCh else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Coronal lateral offset from Ch4/Ch5 chord midpoints.
|
||||
* Returns null if neither Ch4 nor Ch5 detected.
|
||||
*/
|
||||
fun computeCoronalLateral(
|
||||
walls: List<Pair<Int, Int>?>,
|
||||
detected: List<Boolean>,
|
||||
): CoronalResult? {
|
||||
var ch4Mid: Double? = null
|
||||
var ch5Mid: Double? = null
|
||||
for (ch in 4..5) {
|
||||
if (!detected.getOrElse(ch) { false }) continue
|
||||
val w = walls.getOrNull(ch) ?: continue
|
||||
val xAnt = wallIdxToX(ch, w.first)
|
||||
val xPost = wallIdxToX(ch, w.second)
|
||||
val midX = (xAnt + xPost) / 2.0
|
||||
if (ch == 4) ch4Mid = midX else ch5Mid = midX
|
||||
}
|
||||
val xs = listOfNotNull(ch4Mid, ch5Mid)
|
||||
if (xs.isEmpty()) return null
|
||||
return CoronalResult(
|
||||
lateralOffsetMm = xs.average(),
|
||||
nChannels = xs.size,
|
||||
ch4MidXMm = ch4Mid,
|
||||
ch5MidXMm = ch5Mid,
|
||||
confidence = if (xs.size == 2) 1.0f else 0.5f,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 센서 미부착 감지 — detachment_detection.py 1:1 포팅
|
||||
*
|
||||
* ADC 신호의 [20:80] 구간에서 baseline([80:100] median)을 빼고,
|
||||
* mean(|y|)과 std(y)가 모두 threshold 미만이면 미부착으로 판정.
|
||||
*
|
||||
* 미부착 시 LED state 4 (DETACH_WARNING) 전송.
|
||||
*/
|
||||
object DetachmentDetection {
|
||||
|
||||
const val WINDOW_START = 20
|
||||
const val WINDOW_END = 80
|
||||
const val BASELINE_WINDOW_START = 80
|
||||
const val BASELINE_WINDOW_END = 100
|
||||
const val THR_MEAN = 30.0
|
||||
const val THR_STD = 30.0
|
||||
const val N_CENTER_CH = 4
|
||||
|
||||
data class DetachmentResult(
|
||||
val isDetached: Boolean,
|
||||
val meanAbs: Double,
|
||||
val stdVal: Double,
|
||||
val meanAbsPerCh: DoubleArray,
|
||||
val stdPerCh: DoubleArray
|
||||
)
|
||||
|
||||
/**
|
||||
* 4채널(CH0~CH3) ADC 신호에서 미부착 여부 판정.
|
||||
*
|
||||
* @param signals 6채널 ADC 데이터 (List<List<UShort>>)
|
||||
* @return DetachmentResult
|
||||
*/
|
||||
fun detect(signals: List<List<UShort>>): DetachmentResult {
|
||||
if (signals.size < N_CENTER_CH) {
|
||||
return DetachmentResult(true, 0.0, 0.0, DoubleArray(N_CENTER_CH), DoubleArray(N_CENTER_CH))
|
||||
}
|
||||
|
||||
val nSample = signals[0].size
|
||||
val ws = WINDOW_START.coerceAtMost(nSample)
|
||||
val we = WINDOW_END.coerceAtMost(nSample)
|
||||
val bs = BASELINE_WINDOW_START.coerceAtMost(nSample)
|
||||
val be = BASELINE_WINDOW_END.coerceAtMost(nSample)
|
||||
|
||||
if (we <= ws || be <= bs) {
|
||||
return DetachmentResult(true, 0.0, 0.0, DoubleArray(N_CENTER_CH), DoubleArray(N_CENTER_CH))
|
||||
}
|
||||
|
||||
val meanAbsPerCh = DoubleArray(N_CENTER_CH)
|
||||
val stdPerCh = DoubleArray(N_CENTER_CH)
|
||||
|
||||
for (ch in 0 until N_CENTER_CH) {
|
||||
val raw = signals[ch]
|
||||
if (raw.size < be) {
|
||||
meanAbsPerCh[ch] = 0.0
|
||||
stdPerCh[ch] = 0.0
|
||||
continue
|
||||
}
|
||||
|
||||
// Baseline = median of [bs:be]
|
||||
val baselineSlice = (bs until be).map { raw[it].toDouble() }.sorted()
|
||||
val baseline = baselineSlice[baselineSlice.size / 2]
|
||||
|
||||
// y = signal[ws:we] - baseline
|
||||
val y = DoubleArray(we - ws) { raw[ws + it].toDouble() - baseline }
|
||||
|
||||
// mean(|y|)
|
||||
meanAbsPerCh[ch] = y.map { abs(it) }.average()
|
||||
|
||||
// std(y)
|
||||
val mean = y.average()
|
||||
stdPerCh[ch] = sqrt(y.map { (it - mean) * (it - mean) }.average())
|
||||
}
|
||||
|
||||
val meanAbs = meanAbsPerCh.average()
|
||||
val stdVal = stdPerCh.average()
|
||||
|
||||
// "and" rule: 둘 다 threshold 미만이면 미부착
|
||||
val isDetached = meanAbs < THR_MEAN && stdVal < THR_STD
|
||||
|
||||
return DetachmentResult(
|
||||
isDetached = isDetached,
|
||||
meanAbs = meanAbs,
|
||||
stdVal = stdVal,
|
||||
meanAbsPerCh = meanAbsPerCh,
|
||||
stdPerCh = stdPerCh
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
/**
|
||||
* Green Zone 전체 파이프라인의 공유 상수 — Single Source of Truth.
|
||||
*
|
||||
* UrinAI, PeakDetection, Scoring 모두 이 상수를 참조.
|
||||
* 파일 간 임계값 불일치 방지.
|
||||
*
|
||||
* 원본: xBench/GreenZoneConstants.kt (Charles KWON)
|
||||
* 포팅일: 2026-04-17
|
||||
*
|
||||
* ⚠ 미세 조정 시 이 파일만 수정하면 전체 파이프라인에 반영됨.
|
||||
* 각 상수의 의미와 영향 범위를 아래 주석 참고.
|
||||
*/
|
||||
enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C, METHOD_D }
|
||||
enum class PlacementGuideMode { SIMPLE, BOUNDARY, SWEEP }
|
||||
/**
|
||||
* BV 계산 방법.
|
||||
* - FRUSTUM : legacy analyzer walls + estimateBladderVolume6ch (Tanaka frustum)
|
||||
* - V41 : Method C 의 SphereFit (methodCBvMl 직접 사용)
|
||||
* - METHOD_D : Method D walls (cross-channel ant tie-break + OS-CFAR) + estimateBladderVolume6ch
|
||||
* → Python `for_app_share` 임상 검증 pipeline 와 1:1 정렬 (2026-06-30).
|
||||
*
|
||||
* 사용 지침 (phantom 측정 vs 임상):
|
||||
* - 구형 phantom (QC/calibration) : V41 (SphereFit) 권장. METHOD_D 는 lr_ratio < 1.0
|
||||
* 영역에서 BV underestimate 가능 (대칭 phantom 검증 시 -38% 오차 사례).
|
||||
* - 임상 인체 측정 : METHOD_D 권장. 인체 방광은 비대칭이므로 lr_ratio
|
||||
* [0.5, 1.5] 자유 범위가 anatomically 올바름. Python algorithm = appshare 팀
|
||||
* reference 동작과 정확 일치 (lr floor / shrinkage 의도적으로 없음).
|
||||
* - METHOD_D 에 phantom-style lr floor (1.0) 를 추가하지 말 것. 사용자 결정
|
||||
* 2026-06-30: "phantom QC 는 V41 로, 인체는 Python 1:1".
|
||||
*/
|
||||
enum class BvMethod { FRUSTUM, V41, METHOD_D }
|
||||
|
||||
/** Sensor alignment 알고리즘 — V1=기존 (computePlacementGuide), V2=신규 (alignment.py 포팅). */
|
||||
enum class AlignmentAlgo { V1, V2 }
|
||||
|
||||
object GreenZoneConstants {
|
||||
|
||||
@Volatile var postMaxIdx: Int = 80
|
||||
|
||||
@Volatile var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C
|
||||
@Volatile var bvMethod: BvMethod = BvMethod.V41
|
||||
|
||||
/**
|
||||
* lr_ratio 강제 override (algorithm 팀 최신 표준: piezophantomtest 4830e7c).
|
||||
* - null : computed (기존 Python `compute_lr_ratio` 알고리즘)
|
||||
* - Double : 강제 값 (algorithm 팀 default = 1.0)
|
||||
*
|
||||
* Phantom 실측 검증: computed lr=0.68 → BV -38%. lr=1.0 fixed → -1.6% (APP 일치).
|
||||
* 6채널 hardware LR 분해능 한계로 computed lr 신뢰도 낮음. Dev toggle 전환 가능.
|
||||
*/
|
||||
@Volatile var lrRatioOverride: Double? = 1.0
|
||||
|
||||
@Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
|
||||
@Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V1 // default V1 (일반 사용자). V2 (Method D) 는 clinical alignment session 전용.
|
||||
|
||||
/** Posture pitch 임계값 (deg). |pitch| > 이 값 → LYING, ≤ 이 값 → NON_LYING. dev 모드에서 조정 가능. */
|
||||
@Volatile var pitchLyingThreshold: Float = 25f
|
||||
|
||||
/** Posture acc magnitude gate min (g). 이 값 미만이면 classifier UNKNOWN. */
|
||||
@Volatile var postureAccMagMin: Float = 0.8f
|
||||
/** Posture acc magnitude gate max (g). 이 값 초과면 classifier UNKNOWN. */
|
||||
@Volatile var postureAccMagMax: Float = 1.2f
|
||||
|
||||
/**
|
||||
* Posture polling 간격 (ms). PiezoMonitoring 에서 msp 보내는 주기.
|
||||
* 6/30 변경: mtb (1Hz × 15sample) → msp (5Hz × 1sample) 로 전환. piezo burst 가청음 제거.
|
||||
* Walking detector 의 1.5초 window 안에 7-8 sample 확보 위해 200ms 주기.
|
||||
* dev 모드에서 조정 가능.
|
||||
*/
|
||||
@Volatile var posturePollIntervalMs: Long = 200L
|
||||
|
||||
// ── Walking detector 임계값 (flowchart 4-2 / 5-2) ─────────────────
|
||||
/** Walking 인정 최소 pitch (deg). |pitch| > 이 값 이라야 walking 후보. */
|
||||
@Volatile var walkingMinPitchDeg: Float = 45f
|
||||
/** Walking 시작 — gyro 이동평균 (wmean) 임계 (dps). 실측: stand-up + 자세조정
|
||||
* motion 이 5~10 dps 지속. 진짜 보행은 50+ dps. 10 으로 완화하여 짧은 보행도 잡음. */
|
||||
@Volatile var walkingStartWmean: Float = 10f
|
||||
/** Walking 시작 — gyro 이동표준편차 (wstd) 임계 (dps). */
|
||||
@Volatile var walkingStartWstd: Float = 2f
|
||||
/** Walking 시작 — 두 임계 모두 만족한 채 지속해야 하는 최소 시간 (ms). 실측: stand-up
|
||||
* motion 이 3-5초 → 6초 로 차단. 실제 보행은 무한 지속이라 6초 이상 자동 만족. */
|
||||
@Volatile var walkingStartHoldMs: Long = 6000L
|
||||
/** Walking 유지 — gyro wmean 임계 (dps). 시작보다 낮음. */
|
||||
@Volatile var walkingKeepWmean: Float = 2f
|
||||
/** Walking 유지 — wmean 임계 미달 후 종료까지 grace 시간 (ms). 미만 지속이 이 값 넘으면 walking 종료. */
|
||||
@Volatile var walkingKeepHoldMs: Long = 2000L
|
||||
/** Walking detector sliding window (ms). 이 시간 분량 sample 평균/표준편차. */
|
||||
@Volatile var walkingWindowMs: Long = 1500L
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 신호 범위
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** raw ADC 중 사용할 샘플 수.
|
||||
* Kotlin 원본: 90. VesiScan maa 응답은 100 samples 전송하지만 끝부분 10개는
|
||||
* CRC/noise 가능성 → 앞 90개만 사용.
|
||||
* 영향: UrinAI 탐색 범위, PeakDetection 탐색 범위 */
|
||||
const val useSamples: Int = 90
|
||||
|
||||
/** 초기 ringdown 스킵 (센서 근접 반사 무시).
|
||||
* idx 0~2는 피에조 소자 직접 반사 → 항상 높은 값 → 액체로 오판 방지.
|
||||
* 영향: UrinAI.fw 탐색 시작점, PeakDetection.fw 탐색 시작점 */
|
||||
const val ringSkip: Int = 3
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 액체(소변) 판정 임계 — 12-bit ADC (0~4095)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** Low-echo detection 임계값 (denoised 신호 기준).
|
||||
* denoised ≤ 이 값 → low-echo (소변 후보).
|
||||
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
|
||||
* Python 원본: 1150 (low_echo_detection_method_b.py LOW_ECHO_AMP, 6ch 버전)
|
||||
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
|
||||
@Volatile var lowEchoAmp: Float = 1250f
|
||||
|
||||
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
|
||||
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
|
||||
* Kotlin 원본: 1400.
|
||||
* 영향: UrinAI.binarize → fw/bw 탐색의 기반 */
|
||||
const val liquidThrLoose: Float = 1400f
|
||||
|
||||
/** 순수 액체 확인용 엄격 임계값: raw < 이 값 → "확실한 소변".
|
||||
* liquidThrLoose보다 낮아야 함.
|
||||
* 이 기준으로 연속 액체 구간(liquidRun) 측정.
|
||||
* Kotlin 원본: 1100. VesiScan 실측에서 팬텀 low-echo가 1100~1400 범위.
|
||||
* 1100→run=0, 1300→run=2 (미달). 1400으로 상향.
|
||||
* liquidThrLoose(1400)와 동일하게 설정 — 팬텀에서는 loose/strict 구분 불필요.
|
||||
* ⚠ 인체 측정 시 loose > strict 으로 재분리 필요할 수 있음
|
||||
* 영향: UrinAI.maxRun 계산 → 최종 판정의 핵심 */
|
||||
const val liquidThrStrict: Float = 1400f
|
||||
|
||||
/** 최소 연속 순수 액체 길이.
|
||||
* maxRun ≥ 이 값이어야 "소변 있음" 판정.
|
||||
* 소방광(50mL) 대응: chord≈24 중 순수 액체 5+ 필요.
|
||||
* Kotlin 원본: 5.
|
||||
* 영향: UrinAI 최종 판정 (detected 조건 1/3) */
|
||||
const val minLiquidRun: Int = 5
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 벽 검출 범위
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** bw - fw 최소 (chord 최소 길이).
|
||||
* 소방광 한계: 15 → 30mL(chord≈20) 이상 검출 가능.
|
||||
* 50mL(chord≈24): margin 9 ✓
|
||||
* 30mL(chord≈20): margin 5 ⚠
|
||||
* 25mL 이하: 검출 불가 (임상적으로 PVR 50mL+ 의미 있는 범위)
|
||||
* Kotlin 원본: 15.
|
||||
* 영향: UrinAI 최종 판정 (detected 조건 2/3), PeakDetection bw 탐색 하한 */
|
||||
const val minChord: Int = 15
|
||||
|
||||
/** bw - fw 최대 (이론적 상한).
|
||||
* USE_SAMPLES(90)가 실질 제한.
|
||||
* Kotlin 원본: 80.
|
||||
* 영향: PeakDetection bw 탐색 상한 */
|
||||
const val maxChord: Int = 80
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 품질
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** (wallPeak - lumenFloor) / wallPeak 최소.
|
||||
* 벽과 소변 사이 대비가 이 값 이상이어야 유효.
|
||||
* 높일수록 엄격 (선명한 벽 요구), 낮출수록 관대.
|
||||
* Kotlin 원본: 0.15.
|
||||
* 영향: UrinAI 최종 판정 (detected 조건 3/3) */
|
||||
const val contrastMin: Float = 0.15f
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Green Zone Finder
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** score ≥ 이 값 → center lock (Green Zone 확정).
|
||||
* Scoring.THR_EXCELLENT(75)보다 낮아 빠른 UX 확보.
|
||||
* Kotlin 원본: 70.
|
||||
* 영향: Placement Guide에서 Good 판정 기준 */
|
||||
const val lockThreshold: Int = 70
|
||||
|
||||
/** 최대 기억 측정 수 (히스토리).
|
||||
* Kotlin 원본: 20. */
|
||||
const val maxHistory: Int = 20
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 물리 상수 (교정용)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** ADC 샘플링 레이트 (Hz). VesiScan HW 사양. */
|
||||
const val fs: Double = 400_000.0
|
||||
|
||||
/** 기준 조직 음속 (m/s). */
|
||||
const val cRef: Double = 1540.0
|
||||
|
||||
/** 교정용 팬텀 직경 (mm). V=530mL 구. */
|
||||
const val dPhantomMm: Double = 100.406
|
||||
|
||||
/** 팬텀 용적 (mL). */
|
||||
const val vPhantomMl: Double = 530.0
|
||||
|
||||
/** 팬텀 반지름 (mm). */
|
||||
const val rPhantomMm: Double = 50.203
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.ble.ImuSample
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* IMU 샘플 묶음(한 mtb 응답 = 15샘플)에서 자세/안정성 지표 추출.
|
||||
*
|
||||
* 가정: VBT 센서의 Accel z축이 환자 몸 바깥 방향(센서 면 → 위).
|
||||
* 평평하게 누운 환자(Supine)에 부착 시 Az ≈ -1g (또는 +1g, 부착 면에 따라).
|
||||
*
|
||||
* 향후 임상 데이터로 calibration 필요.
|
||||
*/
|
||||
object ImuAnalysis {
|
||||
|
||||
/** 한 응답 안의 모든 샘플 평균 Accel vector */
|
||||
fun meanAccel(samples: List<ImuSample>): Triple<Float, Float, Float>? {
|
||||
if (samples.isEmpty()) return null
|
||||
var sx = 0f; var sy = 0f; var sz = 0f
|
||||
for (s in samples) { sx += s.ax; sy += s.ay; sz += s.az }
|
||||
val n = samples.size
|
||||
return Triple(sx / n, sy / n, sz / n)
|
||||
}
|
||||
|
||||
/** 한 응답 안의 모든 샘플 평균 Gyro vector */
|
||||
fun meanGyro(samples: List<ImuSample>): Triple<Float, Float, Float>? {
|
||||
if (samples.isEmpty()) return null
|
||||
var sx = 0f; var sy = 0f; var sz = 0f
|
||||
for (s in samples) { sx += s.gx; sy += s.gy; sz += s.gz }
|
||||
val n = samples.size
|
||||
return Triple(sx / n, sy / n, sz / n)
|
||||
}
|
||||
|
||||
/**
|
||||
* 흔들림 강도 — Gyro 표준편차의 RMS. 정지 시 ≈ 0~5 dps, 흔드는 중 ≈ 50+ dps.
|
||||
*/
|
||||
fun shakeRmsDps(samples: List<ImuSample>): Float {
|
||||
if (samples.size < 2) return 0f
|
||||
val mean = meanGyro(samples) ?: return 0f
|
||||
var sum = 0f
|
||||
for (s in samples) {
|
||||
val dx = s.gx - mean.first
|
||||
val dy = s.gy - mean.second
|
||||
val dz = s.gz - mean.third
|
||||
sum += dx * dx + dy * dy + dz * dz
|
||||
}
|
||||
return sqrt(sum / samples.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* 자세 추정 (간이 — Accel만 사용).
|
||||
* 평균 Accel vector의 방향 → 어느 축이 중력에 가까운지 판정.
|
||||
*
|
||||
* 반환: "Supine" / "Sitting" / "Standing" / "Tilted" / "Moving"
|
||||
*
|
||||
* 임시 기준 (임상 데이터로 보정 필요):
|
||||
* - |Az| 가장 큼 + Az > 0.7g: 환자 몸 누움 (Supine, 센서면 위 향함)
|
||||
* - |Az| 가장 큼 + Az < -0.7g: 센서 뒤집힘
|
||||
* - |Ay| 가장 큼: Sitting (몸 세움)
|
||||
* - |Ax| 가장 큼: Standing 또는 옆 기울기
|
||||
* - 기타: Tilted
|
||||
* - shakeRmsDps > 30: Moving (자세 판정 불가)
|
||||
*/
|
||||
fun estimatePosture(samples: List<ImuSample>): String {
|
||||
if (samples.isEmpty()) return "—"
|
||||
if (shakeRmsDps(samples) > 30f) return "Moving"
|
||||
val (ax, ay, az) = meanAccel(samples) ?: return "—"
|
||||
val axAbs = abs(ax); val ayAbs = abs(ay); val azAbs = abs(az)
|
||||
return when {
|
||||
azAbs >= axAbs && azAbs >= ayAbs && azAbs > 0.7f ->
|
||||
if (az > 0) "Supine" else "Supine (flip)"
|
||||
ayAbs >= axAbs && ayAbs > 0.6f -> "Sitting"
|
||||
axAbs > 0.6f -> "Standing"
|
||||
else -> "Tilted"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tilt 각도 (도) — 센서 z축이 중력에 대해 얼마나 기울었는지.
|
||||
* 0° = z축이 정확히 중력 방향, 90° = z축이 수평.
|
||||
*/
|
||||
fun tiltDegrees(samples: List<ImuSample>): Float {
|
||||
val (ax, ay, az) = meanAccel(samples) ?: return 0f
|
||||
val mag = sqrt(ax * ax + ay * ay + az * az)
|
||||
if (mag < 0.1f) return 0f // 자유낙하/이상치
|
||||
val tiltRad = atan2(sqrt(ax * ax + ay * ay), abs(az))
|
||||
return (tiltRad * 180.0 / Math.PI).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Posture classifier — flowchart `Posture classification` 기반 (2026-06-22).
|
||||
*
|
||||
* 단순화: 3 state (SUPINE/SITTING/STANDING) → **2 state (LYING / NON_LYING)**.
|
||||
* - 낙상 예방 목적상 sitting/standing 구분 불필요. "누웠나, 일어났나" 만 중요.
|
||||
*
|
||||
* 단계:
|
||||
* 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부
|
||||
* 2. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부
|
||||
* 3. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π
|
||||
* 4. 분류 (|pitch| 기준, threshold = GreenZoneConstants.pitchLyingThreshold):
|
||||
* > threshold → LYING (누움)
|
||||
* ≤ threshold → NON_LYING (앉음/섬)
|
||||
* threshold default = 25°, dev 모드 설정에서 변경 가능.
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.ble.ImuSample
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.sqrt
|
||||
|
||||
enum class Posture { UNKNOWN, LYING, NON_LYING, WALKING }
|
||||
|
||||
object ImuPostureClassifier {
|
||||
|
||||
// Acc gate 임계값은 GreenZoneConstants.postureAccMagMin/Max 사용 (dev 조절 가능).
|
||||
// Gyro gate 비활성화 — 일부 디바이스의 gyro calibration bias (-25 dps 등) 가
|
||||
// flowchart 임계(9.16) 보다 커서 정지 상태에서도 fail → 분류 영구 불가.
|
||||
// accel magnitude gate 가 이미 큰 모션 거름. gyro 게이트 제거.
|
||||
// 단 dev 모드 디버그 위해 magnitude 는 계속 계산해 로그/Result 에 노출.
|
||||
|
||||
/** 자세 + 진단 정보 (UI/log용). */
|
||||
data class Result(
|
||||
val posture: Posture,
|
||||
val pitchDeg: Float,
|
||||
val accMag: Float,
|
||||
val gyroMagDps: Float,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
fun classify(samples: List<ImuSample>): Result {
|
||||
if (samples.isEmpty()) {
|
||||
return Result(Posture.UNKNOWN, 0f, 0f, 0f, "no samples")
|
||||
}
|
||||
|
||||
var sumAx = 0f; var sumAy = 0f; var sumAz = 0f
|
||||
var sumGx = 0f; var sumGy = 0f; var sumGz = 0f
|
||||
for (s in samples) {
|
||||
sumAx += s.ax; sumAy += s.ay; sumAz += s.az
|
||||
sumGx += s.gx; sumGy += s.gy; sumGz += s.gz
|
||||
}
|
||||
val n = samples.size
|
||||
val ax = sumAx / n; val ay = sumAy / n; val az = sumAz / n
|
||||
val gx = sumGx / n; val gy = sumGy / n; val gz = sumGz / n
|
||||
|
||||
val accMag = sqrt(ax * ax + ay * ay + az * az)
|
||||
val gyroMag = sqrt(gx * gx + gy * gy + gz * gz)
|
||||
|
||||
// Pitch 는 acc gate 통과 여부와 무관하게 항상 계산 (walking detector 가 사용).
|
||||
val pitchPre = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
|
||||
|
||||
if (accMag < GreenZoneConstants.postureAccMagMin || accMag > GreenZoneConstants.postureAccMagMax) {
|
||||
return Result(Posture.UNKNOWN, pitchPre, accMag, gyroMag,
|
||||
"acc mag out of range (%.2f)".format(accMag))
|
||||
}
|
||||
// Gyro gate 제거 — bias 큰 디바이스에서 영구 fail 문제 (위 주석 참조)
|
||||
|
||||
val absPitch = abs(pitchPre)
|
||||
val thr = GreenZoneConstants.pitchLyingThreshold
|
||||
|
||||
// Pitch 의미: AX 가 중력과 일치할수록 큰 값 (≈90°), AZ 가 중력과 일치하면 작은 값 (≈0°).
|
||||
// 사용자 직관: 센서 평평하게 둠 (AZ up) = LYING, 세움 (AX up) = NON_LYING (upright).
|
||||
// → 작은 pitch → LYING, 큰 pitch → NON_LYING
|
||||
val posture = if (absPitch <= thr) Posture.LYING else Posture.NON_LYING
|
||||
return Result(posture, pitchPre, accMag, gyroMag, "ok")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.models.NirsConstants
|
||||
import com.medithings.vesiscan.models.NirsMagResponse
|
||||
import com.medithings.vesiscan.models.NirsOxyResult
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
|
||||
class NirsCalcEngine {
|
||||
|
||||
// Gain Calibration
|
||||
private var gainValues: IntArray = intArrayOf()
|
||||
val hasGain: Boolean get() = gainValues.size >= 24
|
||||
|
||||
fun setGainFromMag(response: NirsMagResponse) {
|
||||
gainValues = response.gainValues
|
||||
}
|
||||
|
||||
fun clearGain() {
|
||||
gainValues = intArrayOf()
|
||||
}
|
||||
|
||||
// AGC DAC -> Software Amplification
|
||||
private fun agcGain(dacValue: Int): Double {
|
||||
return 10.0.pow(((dacValue.toDouble() * 3.3 / 4096.0 * -80.0) + 88.0) / 20.0)
|
||||
}
|
||||
|
||||
// DeGain Calculation
|
||||
fun degain(rawLed: IntArray): DoubleArray {
|
||||
if (rawLed.size < 24 || !hasGain) return DoubleArray(24) { 0.0 }
|
||||
|
||||
val result = DoubleArray(24)
|
||||
for (i in 0 until 24) {
|
||||
val raw = rawLed[i].toDouble()
|
||||
val sag = gainValues[i]
|
||||
val pkg = i / 6
|
||||
val wl = i % 6
|
||||
|
||||
val softAmp = agcGain(sag)
|
||||
val hAmp = NirsConstants.hardwareAmp[pkg]
|
||||
val spResponse = NirsConstants.spectralResponse[wl]
|
||||
val dpf = NirsConstants.dpf[wl]
|
||||
|
||||
val denominator = softAmp * hAmp
|
||||
if (denominator > 0) {
|
||||
var dg = raw / denominator
|
||||
dg /= spResponse
|
||||
dg *= dpf
|
||||
result[i] = dg
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Butterworth IIR Filter
|
||||
private var filterState: Array<DoubleArray> = emptyArray()
|
||||
private var filterInitialized = false
|
||||
var isFilterEnabled = true
|
||||
|
||||
fun resetFilter() {
|
||||
filterState = Array(24) { doubleArrayOf(0.0, 0.0, 0.0, 0.0) }
|
||||
filterInitialized = false
|
||||
}
|
||||
|
||||
private fun applyFilter(x: Double, state: DoubleArray): Double {
|
||||
val b = NirsConstants.filterB
|
||||
val a = NirsConstants.filterA
|
||||
|
||||
val y = b[0] * x + b[1] * state[0] + b[2] * state[1] - a[1] * state[2] - a[2] * state[3]
|
||||
|
||||
state[1] = state[0]
|
||||
state[0] = x
|
||||
state[3] = state[2]
|
||||
state[2] = y
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
fun filterDeGain(degainValues: DoubleArray): DoubleArray {
|
||||
if (degainValues.size < 24) return degainValues
|
||||
|
||||
if (filterState.isEmpty()) resetFilter()
|
||||
|
||||
if (!filterInitialized) {
|
||||
for (i in 0 until 24) {
|
||||
val v = degainValues[i]
|
||||
filterState[i] = doubleArrayOf(v, v, v, v)
|
||||
}
|
||||
filterInitialized = true
|
||||
}
|
||||
|
||||
val filtered = DoubleArray(24)
|
||||
for (i in 0 until 24) {
|
||||
filtered[i] = applyFilter(degainValues[i], filterState[i])
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Oxygenation Calculation
|
||||
fun calcOxygenation(degainValues: DoubleArray): NirsOxyResult {
|
||||
if (degainValues.size < 24) return NirsOxyResult.zero
|
||||
|
||||
val c = NirsConstants
|
||||
val muA = DoubleArray(c.numWavelengths)
|
||||
|
||||
for (i in 0 until c.numWavelengths) {
|
||||
val i2cm = degainValues[i]
|
||||
val i5cm = degainValues[18 + i]
|
||||
|
||||
val i2norm = i2cm / (c.ledPower[0] / c.referencePower)
|
||||
val i5norm = i5cm / (c.ledPower[3] / c.referencePower)
|
||||
|
||||
val mix = c.srsMixNear * i2norm + c.srsMixFar * i5norm
|
||||
|
||||
if (mix > 0) {
|
||||
val slope = -ln(mix) / c.srsPathLength
|
||||
muA[i] = -slope
|
||||
}
|
||||
}
|
||||
|
||||
val solution = solveNormalEq6x3(muA)
|
||||
|
||||
var hbO2 = max(0.0, solution.first) * c.oxyScale
|
||||
var hb = max(0.0, solution.second) * c.oxyScale
|
||||
|
||||
val tHb = hbO2 + hb
|
||||
val stO2 = if (tHb > 0) (hbO2 / tHb) * 100.0 else 0.0
|
||||
|
||||
return NirsOxyResult(hbO2 = hbO2, hb = hb, tHb = tHb, stO2 = stO2)
|
||||
}
|
||||
|
||||
// Least Squares Solver (6x3)
|
||||
private fun solveNormalEq6x3(muA: DoubleArray): Triple<Double, Double, Double> {
|
||||
val eps = arrayOf(
|
||||
NirsConstants.extinctionHbO2,
|
||||
NirsConstants.extinctionHb,
|
||||
NirsConstants.extinctionH2O
|
||||
)
|
||||
|
||||
val n = Array(3) { DoubleArray(3) }
|
||||
for (i in 0 until 3) {
|
||||
for (j in 0 until 3) {
|
||||
var sum = 0.0
|
||||
for (k in 0 until 6) sum += eps[i][k] * eps[j][k]
|
||||
n[i][j] = sum
|
||||
}
|
||||
}
|
||||
|
||||
val b = DoubleArray(3)
|
||||
for (i in 0 until 3) {
|
||||
var sum = 0.0
|
||||
for (k in 0 until 6) sum += eps[i][k] * muA[k]
|
||||
b[i] = sum
|
||||
}
|
||||
|
||||
val det = n[0][0] * (n[1][1] * n[2][2] - n[1][2] * n[2][1]) -
|
||||
n[0][1] * (n[1][0] * n[2][2] - n[1][2] * n[2][0]) +
|
||||
n[0][2] * (n[1][0] * n[2][1] - n[1][1] * n[2][0])
|
||||
|
||||
if (abs(det) < 1e-12) return Triple(0.0, 0.0, 0.0)
|
||||
|
||||
val inv = Array(3) { DoubleArray(3) }
|
||||
inv[0][0] = (n[1][1] * n[2][2] - n[1][2] * n[2][1]) / det
|
||||
inv[0][1] = -(n[0][1] * n[2][2] - n[0][2] * n[2][1]) / det
|
||||
inv[0][2] = (n[0][1] * n[1][2] - n[0][2] * n[1][1]) / det
|
||||
inv[1][0] = -(n[1][0] * n[2][2] - n[1][2] * n[2][0]) / det
|
||||
inv[1][1] = (n[0][0] * n[2][2] - n[0][2] * n[2][0]) / det
|
||||
inv[1][2] = -(n[0][0] * n[1][2] - n[0][2] * n[1][0]) / det
|
||||
inv[2][0] = (n[1][0] * n[2][1] - n[1][1] * n[2][0]) / det
|
||||
inv[2][1] = -(n[0][0] * n[2][1] - n[0][1] * n[2][0]) / det
|
||||
inv[2][2] = (n[0][0] * n[1][1] - n[0][1] * n[1][0]) / det
|
||||
|
||||
val c = DoubleArray(3) { i ->
|
||||
inv[i][0] * b[0] + inv[i][1] * b[1] + inv[i][2] * b[2]
|
||||
}
|
||||
|
||||
return Triple(c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
// Full Processing Pipeline
|
||||
data class ProcessResult(
|
||||
val degain: DoubleArray,
|
||||
val degainFiltered: DoubleArray,
|
||||
val oxy: NirsOxyResult,
|
||||
val oxyFiltered: NirsOxyResult
|
||||
)
|
||||
|
||||
fun process(rawLed: IntArray): ProcessResult {
|
||||
val dg = degain(rawLed)
|
||||
val oxyRaw = calcOxygenation(dg)
|
||||
|
||||
val dgf: DoubleArray
|
||||
val oxyF: NirsOxyResult
|
||||
if (isFilterEnabled) {
|
||||
dgf = filterDeGain(dg)
|
||||
oxyF = calcOxygenation(dgf)
|
||||
} else {
|
||||
dgf = dg
|
||||
oxyF = oxyRaw
|
||||
}
|
||||
|
||||
return ProcessResult(dg, dgf, oxyRaw, oxyF)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.medithings.vesiscan.models.NirsMagResponse
|
||||
import com.medithings.vesiscan.models.NirsMcjPacket
|
||||
import com.medithings.vesiscan.models.NirsConstants
|
||||
import com.medithings.vesiscan.BuildConfig
|
||||
import com.medithings.vesiscan.models.NirsOxyResult
|
||||
|
||||
class NirsManager private constructor() {
|
||||
companion object {
|
||||
val instance: NirsManager by lazy { NirsManager() }
|
||||
}
|
||||
|
||||
// State
|
||||
val isStreaming = mutableStateOf(false)
|
||||
val isCalibrated = mutableStateOf(false)
|
||||
val isFilterEnabled = mutableStateOf(true)
|
||||
|
||||
// Current Values
|
||||
val currentOxy = mutableStateOf(NirsOxyResult.zero)
|
||||
val sampleCount = mutableIntStateOf(0)
|
||||
|
||||
// Chart History
|
||||
val oxyHbO2History = mutableStateListOf<Double>()
|
||||
val oxyHbHistory = mutableStateListOf<Double>()
|
||||
|
||||
// Internal
|
||||
val calcEngine = NirsCalcEngine()
|
||||
private val maxPoints = NirsConstants.maxHistoryPoints
|
||||
|
||||
// Calibration
|
||||
fun setCalibrationGain(response: NirsMagResponse) {
|
||||
calcEngine.setGainFromMag(response)
|
||||
isCalibrated.value = true
|
||||
}
|
||||
|
||||
// Process mcj Packet
|
||||
fun processMcjPacket(packet: NirsMcjPacket) {
|
||||
val result = calcEngine.process(packet.ledValues)
|
||||
currentOxy.value = result.oxyFiltered
|
||||
sampleCount.intValue++
|
||||
|
||||
if (BuildConfig.DEBUG && (sampleCount.intValue <= 3 || sampleCount.intValue % 50 == 0)) {
|
||||
android.util.Log.d("NirsManager", "Sample #${sampleCount.intValue}: " +
|
||||
"hasGain=${calcEngine.hasGain}, " +
|
||||
"led[0..5]=${packet.ledValues.take(6)}, " +
|
||||
"degain[0..2]=${result.degain.take(3).map { "%.2f".format(it) }}, " +
|
||||
"oxy=StO2:${"%.1f".format(result.oxyFiltered.stO2)} HbO2:${"%.1f".format(result.oxyFiltered.hbO2)} HHb:${"%.1f".format(result.oxyFiltered.hb)}")
|
||||
}
|
||||
|
||||
appendTrimming(oxyHbO2History, result.oxyFiltered.hbO2)
|
||||
appendTrimming(oxyHbHistory, result.oxyFiltered.hb)
|
||||
}
|
||||
|
||||
// Controls
|
||||
fun toggleFilter() {
|
||||
isFilterEnabled.value = !isFilterEnabled.value
|
||||
calcEngine.isFilterEnabled = isFilterEnabled.value
|
||||
if (isFilterEnabled.value) calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
oxyHbO2History.clear()
|
||||
oxyHbHistory.clear()
|
||||
currentOxy.value = NirsOxyResult.zero
|
||||
sampleCount.intValue = 0
|
||||
calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun startStreaming() {
|
||||
isStreaming.value = true
|
||||
calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun stopStreaming() {
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
stopStreaming()
|
||||
clearData()
|
||||
isCalibrated.value = false
|
||||
calcEngine.clearGain()
|
||||
}
|
||||
|
||||
// Helpers
|
||||
private fun appendTrimming(list: MutableList<Double>, value: Double) {
|
||||
list.add(value)
|
||||
while (list.size > maxPoints) list.removeAt(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.walldetect.core.WdConfig
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.min
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// ==========================================================================
|
||||
// Bladder Volume Estimation — Multi-Channel Frustum + Cap (5ch vertical)
|
||||
//
|
||||
// 1:1 port of PiezoBVEstimator.swift / bv_estimation.py (algo-test branch)
|
||||
// Only "traditional" mode is used (coord/hybrid disabled in Python too)
|
||||
// ==========================================================================
|
||||
|
||||
// ── Hardware Parameters (5ch vertical array) ──
|
||||
|
||||
/**
|
||||
* Probe geometry — probe model별 고정
|
||||
*/
|
||||
object PiezoHW {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Device Presets — 6ch 기기 3가지 버전 (Snell's law 굴절 보정 적용)
|
||||
// config_6ch.py 1:1 포팅
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
enum class DevicePreset {
|
||||
LEGACY_5CH,
|
||||
V0, // All 0° (BLE 테스트용)
|
||||
V1, // Max 20° housing → Snell's law refraction
|
||||
V2 // Max 30° housing → Snell's law refraction
|
||||
}
|
||||
|
||||
var activePreset: DevicePreset = DevicePreset.V0
|
||||
|
||||
fun autoDetectPreset(deviceName: String) {
|
||||
if (deviceName.startsWith("VBT") && deviceName.length >= 4) {
|
||||
// VBT...n0x → 뒤에서 3번째 글자(n)가 각도 타입
|
||||
// n=0 → V0, n=2 → V1, n=3 → V2
|
||||
val n = deviceName[deviceName.length - 3]
|
||||
activePreset = when (n) {
|
||||
'0' -> DevicePreset.V0
|
||||
'2' -> DevicePreset.V1
|
||||
'3' -> DevicePreset.V2
|
||||
else -> DevicePreset.V1 // 알 수 없으면 V1 기본
|
||||
}
|
||||
} else if (deviceName.startsWith("2025MEDIP")) {
|
||||
activePreset = DevicePreset.LEGACY_5CH
|
||||
}
|
||||
android.util.Log.d("PiezoHW", "autoDetectPreset: '$deviceName' → ${activePreset.name}")
|
||||
}
|
||||
|
||||
val centerCh = intArrayOf(0, 1, 2, 3)
|
||||
val lateralCh = intArrayOf(4, 5)
|
||||
val lateralNeighbors = intArrayOf(1, 2)
|
||||
|
||||
// 6채널 전체 z 좌표 (CH0~CH5)
|
||||
val sensorZMmAll: DoubleArray
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
|
||||
DevicePreset.V0 -> doubleArrayOf(21.0, 14.0, 7.0, 0.0, 10.5, 10.5)
|
||||
DevicePreset.V1 -> doubleArrayOf(20.1, 12.8, 7.0, 0.0, 9.9, 9.9)
|
||||
DevicePreset.V2 -> doubleArrayOf(19.3, 13.0, 6.7, 0.0, 9.85, 9.85)
|
||||
}
|
||||
|
||||
// 6채널 전체 x 좌표
|
||||
val sensorXMmAll: DoubleArray
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
else -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -10.0, 10.0)
|
||||
}
|
||||
|
||||
// 6채널 전체 SI 빔 각도 (Snell's law 굴절 후)
|
||||
val degreeAll: DoubleArray
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8, 0.0)
|
||||
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.V1 -> doubleArrayOf(6.89, 0.0, -6.89, -13.66, -6.87, -6.87)
|
||||
DevicePreset.V2 -> doubleArrayOf(0.0, -6.89, -13.66, -20.20, -6.87, -6.87)
|
||||
}
|
||||
|
||||
// 6채널 전체 LR 빔 각도 (Snell's law 굴절 후)
|
||||
val degreeLRAll: DoubleArray
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.V1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
|
||||
DevicePreset.V2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
|
||||
}
|
||||
|
||||
// BV용 center 채널만 (CH0~CH3)
|
||||
val sensorZMm: DoubleArray get() = centerCh.map { sensorZMmAll[it] }.toDoubleArray()
|
||||
val sensorXMm: DoubleArray get() = centerCh.map { sensorXMmAll[it] }.toDoubleArray()
|
||||
val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray()
|
||||
val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray()
|
||||
|
||||
/** Acoustic calibration (런타임 조절 가능).
|
||||
* Setter는 walldetect.core.WdConfig.DPS_DEFAULT 도 함께 동기화한다.
|
||||
* V41Detector / Geometry / AnatomicalGate 가 WdConfig 측 상수를 사용하기
|
||||
* 때문에 한 곳만 바꾸면 BV 두 경로 (6ch estimate vs V41 dispatch / gate) 가
|
||||
* 서로 다른 dps 로 계산되어 측정이 어긋난다. */
|
||||
// `.also` 로 초기값을 walldetect.core.WdConfig 측에도 동시에 반영 — 시작
|
||||
// 시점부터 두 경로의 dps 가 일치한다.
|
||||
// 02bed02 (config_6ch.py): DISTANCE_PER_SAMPLE 1.981 → 1.936 (HW 샘플링레이트 변경).
|
||||
// 우리 default 도 1.968 → 1.936 으로 정렬 (FW VBTFW118+ 기준).
|
||||
@Volatile private var _distancePerSample: Double = 1.936.also { WdConfig.DPS_DEFAULT = it }
|
||||
var distancePerSample: Double
|
||||
get() = _distancePerSample
|
||||
set(value) {
|
||||
_distancePerSample = value
|
||||
WdConfig.DPS_DEFAULT = value
|
||||
}
|
||||
const val delayOffsetMm: Double = 6.85
|
||||
|
||||
/** Volume model */
|
||||
val areaK: Double = Math.PI / 4.0
|
||||
const val defaultLrRatio: Double = 1.0
|
||||
|
||||
// lr_ratio fallbacks
|
||||
const val lrRatioNoDetection: Double = 1.0
|
||||
const val lrRatioInvalid: Double = 1.2
|
||||
const val lrPrior: Double = 1.2
|
||||
|
||||
val presetName: String
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> "Legacy 5ch"
|
||||
DevicePreset.V0 -> "V0 (all 0°)"
|
||||
DevicePreset.V1 -> "V1 (max 20°, Snell)"
|
||||
DevicePreset.V2 -> "V2 (max 30°, Snell)"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Center Wall Repair (6ch) ──
|
||||
|
||||
/**
|
||||
* 6ch center 채널(CH0~CH3) 패턴을 BV 계산용으로 보정.
|
||||
* - gap 1개: 선형 보간
|
||||
* - gap 2개+: 위쪽 그룹 버리고 아래쪽만 사용
|
||||
* Port of bv_estimation.py _repair_center_walls_for_6ch (#21 merge)
|
||||
*/
|
||||
private fun repairCenterWallsFor6ch(
|
||||
centerWalls: List<Pair<Int, Int>?>
|
||||
): List<Pair<Int, Int>?> {
|
||||
val result = centerWalls.toMutableList()
|
||||
val valid = result.indices.filter { i ->
|
||||
val w = result[i]; w != null
|
||||
}
|
||||
if (valid.size < 2) return result
|
||||
|
||||
for (k in 0 until valid.size - 1) {
|
||||
val prevIdx = valid[k]
|
||||
val nextIdx = valid[k + 1]
|
||||
val gap = nextIdx - prevIdx - 1
|
||||
if (gap <= 0) continue
|
||||
if (gap == 1) {
|
||||
val pw = result[prevIdx]!!
|
||||
val nw = result[nextIdx]!!
|
||||
val ant = ((pw.first + nw.first) / 2.0 + 0.5).toInt()
|
||||
val post = ((pw.second + nw.second) / 2.0 + 0.5).toInt()
|
||||
result[prevIdx + 1] = Pair(ant, post)
|
||||
continue
|
||||
}
|
||||
// gap >= 2: drop top group
|
||||
for (i in 0 until nextIdx) result[i] = null
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── LR Ratio Computation (x-z ellipse fitting, #21 merge) ──
|
||||
|
||||
/**
|
||||
* x-z 평면 타원 피팅 기반 LR/AP ratio.
|
||||
* CH4/CH5의 SI 높이에서 center(CH1,CH2) 벽 좌표를 보간하고,
|
||||
* lateral 벽 좌표와 합쳐 6개 경계점으로 x-z 평면 타원 피팅.
|
||||
* lr_ratio = LR 반축 / AP 반축.
|
||||
*/
|
||||
fun computeLrRatio(
|
||||
centerWalls: List<Pair<Int, Int>?>,
|
||||
lateralWalls: List<Pair<Int, Int>?>,
|
||||
maxRatio: Double = 1.0,
|
||||
sensorZMm: DoubleArray = PiezoHW.sensorZMmAll
|
||||
): Double {
|
||||
val hw = PiezoHW
|
||||
val neighbors = hw.lateralNeighbors
|
||||
val availableNbrs = neighbors.filter { ni ->
|
||||
ni < centerWalls.size && centerWalls[ni] != null
|
||||
}
|
||||
if (availableNbrs.size < 2) return hw.lrRatioNoDetection
|
||||
|
||||
// Lateral wall points (x-z plane)
|
||||
val latPtsX = mutableListOf<Double>()
|
||||
val latPtsZ = mutableListOf<Double>()
|
||||
val latSiPositions = mutableListOf<Double>()
|
||||
val latChords = mutableListOf<Double>()
|
||||
|
||||
for ((li, lCh) in hw.lateralCh.withIndex()) {
|
||||
if (li >= lateralWalls.size) continue
|
||||
val w = lateralWalls[li] ?: continue
|
||||
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
|
||||
val alpha = hw.degreeAll[lCh] * Math.PI / 180.0
|
||||
val beta = hw.degreeLRAll[lCh] * Math.PI / 180.0
|
||||
val sx = hw.sensorXMmAll[lCh]
|
||||
for (d in listOf(dNear, dFar)) {
|
||||
latPtsX.add(sx + d * cos(alpha) * sin(beta))
|
||||
latPtsZ.add(d * cos(alpha) * cos(beta))
|
||||
}
|
||||
latSiPositions.add(sensorZMm[lCh])
|
||||
latChords.add(abs(dFar - dNear))
|
||||
}
|
||||
if (latPtsX.isEmpty()) return hw.lrRatioNoDetection
|
||||
val targetSi = latSiPositions.average()
|
||||
|
||||
// Center wall z-coords at neighbor channels, interpolated to lateral SI height
|
||||
val nbrSi = mutableListOf<Double>()
|
||||
val nbrZAnt = mutableListOf<Double>()
|
||||
val nbrZPost = mutableListOf<Double>()
|
||||
for (ni in availableNbrs) {
|
||||
val w = centerWalls[ni]!!
|
||||
val ch = hw.centerCh[ni]
|
||||
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
|
||||
val th = hw.degreeAll[ch] * Math.PI / 180.0
|
||||
nbrSi.add(sensorZMm[ch])
|
||||
nbrZAnt.add(dNear * cos(th))
|
||||
nbrZPost.add(dFar * cos(th))
|
||||
}
|
||||
|
||||
val zAntCenter: Double
|
||||
val zPostCenter: Double
|
||||
if (nbrSi.size >= 2) {
|
||||
val w = if (abs(nbrSi[0] - nbrSi[1]) > 1e-6)
|
||||
((targetSi - nbrSi[1]) / (nbrSi[0] - nbrSi[1])).coerceIn(0.0, 1.0) else 0.5
|
||||
zAntCenter = nbrZAnt[1] + w * (nbrZAnt[0] - nbrZAnt[1])
|
||||
zPostCenter = nbrZPost[1] + w * (nbrZPost[0] - nbrZPost[1])
|
||||
} else {
|
||||
zAntCenter = nbrZAnt[0]
|
||||
zPostCenter = nbrZPost[0]
|
||||
}
|
||||
|
||||
// 6 boundary points: 2 center (x=0) + 4 lateral
|
||||
val xw = (listOf(0.0, 0.0) + latPtsX).toDoubleArray()
|
||||
val zw = (listOf(zAntCenter, zPostCenter) + latPtsZ).toDoubleArray()
|
||||
|
||||
// Normalized ellipse fit: α·x̂² + β·ẑ² + γ·x̂ + δ·ẑ = 1
|
||||
val xm = xw.average(); val xs = std(xw) + 1e-12
|
||||
val zm = zw.average(); val zs = std(zw) + 1e-12
|
||||
val xn = DoubleArray(xw.size) { (xw[it] - xm) / xs }
|
||||
val zn = DoubleArray(zw.size) { (zw[it] - zm) / zs }
|
||||
|
||||
val sol = solveEllipseLSQ(xn, zn, xw.size) ?: return hw.lrRatioInvalid
|
||||
val (alphaF, betaF, _, _) = sol.let { Triple(it[0], it[1], Pair(it[2], it[3])) }
|
||||
.let { doubleArrayOf(sol[0], sol[1], sol[2], sol[3]) }
|
||||
|
||||
if (sol[0] <= 1e-12 || sol[1] <= 1e-12) return hw.lrRatioInvalid
|
||||
val rVal = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1])
|
||||
if (rVal <= 0) return hw.lrRatioInvalid
|
||||
|
||||
val bLr = sqrt(rVal / sol[0]) * xs // LR 반축 (mm)
|
||||
val aAp = sqrt(rVal / sol[1]) * zs // AP 반축 (mm)
|
||||
if (aAp <= 0) return hw.lrRatioInvalid
|
||||
|
||||
val lrRaw = max(bLr / aAp, 1.0)
|
||||
|
||||
// Shrinkage toward prior
|
||||
val dCenterMean = abs(zPostCenter - zAntCenter)
|
||||
if (dCenterMean <= 0) return hw.lrRatioInvalid
|
||||
val avgRatio = if (latChords.isNotEmpty()) latChords.average() / dCenterMean else 1.0
|
||||
val confidence = ((1.0 - avgRatio) / 0.10).coerceIn(0.0, 1.0)
|
||||
val result = hw.lrPrior + (lrRaw - hw.lrPrior) * confidence
|
||||
return result.coerceAtLeast(1.0)
|
||||
}
|
||||
|
||||
// ── Result ──
|
||||
|
||||
data class BVResult(
|
||||
val volumeMl: Double,
|
||||
val volumeMm3: Double,
|
||||
|
||||
// Per-channel (입력 순서, valid만)
|
||||
val validChannels: List<Int>,
|
||||
val dAntMm: DoubleArray,
|
||||
val dPostMm: DoubleArray,
|
||||
val dMm: DoubleArray, // 단면 직경 (cos 보정)
|
||||
val sMm2: DoubleArray, // 단면적
|
||||
|
||||
// Sorted by y (bottom → top)
|
||||
val sortedChannels: List<Int>,
|
||||
val sortedYMm: DoubleArray,
|
||||
val sortedSMm2: DoubleArray,
|
||||
|
||||
// Frustum / caps
|
||||
val vFrustumMm3: DoubleArray,
|
||||
val vCoreMm3: Double,
|
||||
val vBottomMm3: Double,
|
||||
val vTopMm3: Double,
|
||||
val bottomHMm: Double,
|
||||
val topHMm: Double,
|
||||
val bottomKind: String,
|
||||
val topKind: String,
|
||||
|
||||
// Parameters used
|
||||
val lrRatio: Double,
|
||||
val distancePerSample: Double,
|
||||
val delayOffsetMm: Double
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is BVResult) return false
|
||||
return volumeMl == other.volumeMl && volumeMm3 == other.volumeMm3
|
||||
&& validChannels == other.validChannels
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 31 * volumeMl.hashCode() + volumeMm3.hashCode()
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
/** Sample index → distance (mm) */
|
||||
private fun sampleToMm(
|
||||
idx: Double,
|
||||
dps: Double = PiezoHW.distancePerSample,
|
||||
offset: Double = PiezoHW.delayOffsetMm
|
||||
): Double = offset + idx * dps
|
||||
|
||||
/** (ant, post) sample indices → (d_near, d_far) mm */
|
||||
private fun segmentToDistancesMm(
|
||||
ant: Int, post: Int,
|
||||
dps: Double = PiezoHW.distancePerSample,
|
||||
offset: Double = PiezoHW.delayOffsetMm
|
||||
): Pair<Double, Double> {
|
||||
val d1 = sampleToMm(ant.toDouble(), dps, offset)
|
||||
val d2 = sampleToMm(post.toDouble(), dps, offset)
|
||||
return Pair(min(d1, d2), max(d1, d2))
|
||||
}
|
||||
|
||||
/** 4×4 least squares: M^T M x = M^T 1 */
|
||||
private fun solveEllipseLSQ(px: DoubleArray, py: DoubleArray, n: Int): DoubleArray? {
|
||||
// Build 4×4 normal equations: (M^T M) params = M^T ones
|
||||
// M columns: [x², y², x, y]
|
||||
val mtm = Array(4) { DoubleArray(4) }
|
||||
val mtb = DoubleArray(4)
|
||||
|
||||
for (k in 0 until n) {
|
||||
val x = px[k]; val y = py[k]
|
||||
val row = doubleArrayOf(x * x, y * y, x, y)
|
||||
for (i in 0 until 4) {
|
||||
for (j in 0 until 4) mtm[i][j] += row[i] * row[j]
|
||||
mtb[i] += row[i] // RHS = 1
|
||||
}
|
||||
}
|
||||
return solve4x4(mtm, mtb)
|
||||
}
|
||||
|
||||
private fun solve4x4(A: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
|
||||
val a = Array(4) { A[it].copyOf() }
|
||||
val bb = b.copyOf()
|
||||
for (col in 0 until 4) {
|
||||
var maxRow = col; var maxVal = abs(a[col][col])
|
||||
for (row in (col + 1) until 4) {
|
||||
if (abs(a[row][col]) > maxVal) { maxVal = abs(a[row][col]); maxRow = row }
|
||||
}
|
||||
if (maxVal < 1e-12) return null
|
||||
if (maxRow != col) {
|
||||
val tmpA = a[col]; a[col] = a[maxRow]; a[maxRow] = tmpA
|
||||
val tmpB = bb[col]; bb[col] = bb[maxRow]; bb[maxRow] = tmpB
|
||||
}
|
||||
for (row in (col + 1) until 4) {
|
||||
val factor = a[row][col] / a[col][col]
|
||||
for (j in col until 4) a[row][j] -= factor * a[col][j]
|
||||
bb[row] -= factor * bb[col]
|
||||
}
|
||||
}
|
||||
val x = DoubleArray(4)
|
||||
for (i in 3 downTo 0) {
|
||||
var sum = bb[i]
|
||||
for (j in (i + 1) until 4) sum -= a[i][j] * x[j]
|
||||
if (abs(a[i][i]) < 1e-12) return null
|
||||
x[i] = sum / a[i][i]
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// ── NEW BV lib (piezophantomtest 4830e7c) helpers ─────────────────────────
|
||||
|
||||
/** posterior arc chord-대비 최대 수직 이탈 (sagitta, mm). Python `_sagitta_mm`. */
|
||||
private fun sagittaMm(yp: DoubleArray, zp: DoubleArray): Double {
|
||||
if (yp.size < 3) return 0.0
|
||||
val order = (0 until yp.size).sortedBy { yp[it] }
|
||||
val y = DoubleArray(yp.size) { yp[order[it]] }
|
||||
val z = DoubleArray(zp.size) { zp[order[it]] }
|
||||
val p0y = y[0]; val p0z = z[0]
|
||||
val dY = y[y.size - 1] - p0y
|
||||
val dZ = z[z.size - 1] - p0z
|
||||
val ln = hypot(dY, dZ)
|
||||
if (ln < 1e-6) return 0.0
|
||||
val nY = -dZ / ln; val nZ = dY / ln
|
||||
var m = 0.0
|
||||
for (i in 1 until y.size - 1) {
|
||||
val d = abs((y[i] - p0y) * nY + (z[i] - p0z) * nZ)
|
||||
if (d > m) m = d
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* posterior arc 원 fit (Kåsa algebraic) + sagitta shrinkage → R_eff.
|
||||
* Python `_shrink_si_radius`. 실패 시 null.
|
||||
*/
|
||||
private fun shrinkSiRadius(
|
||||
yWallPost: DoubleArray, zWallPost: DoubleArray,
|
||||
cApPrior: Double?,
|
||||
sNoise: Double = BOTTOM_CAP_SAGITTA_NOISE_MM,
|
||||
): Double? {
|
||||
val n = yWallPost.size
|
||||
if (n < 3 || cApPrior == null || cApPrior <= 0) return null
|
||||
val ata = Array(3) { DoubleArray(3) }
|
||||
val atb = DoubleArray(3)
|
||||
for (i in 0 until n) {
|
||||
val row = doubleArrayOf(yWallPost[i], zWallPost[i], 1.0)
|
||||
val r = yWallPost[i] * yWallPost[i] + zWallPost[i] * zWallPost[i]
|
||||
for (a in 0 until 3) {
|
||||
for (b in 0 until 3) ata[a][b] += row[a] * row[b]
|
||||
atb[a] += row[a] * r
|
||||
}
|
||||
}
|
||||
val sol = solve3x3(ata, atb) ?: return null
|
||||
val yc = sol[0] / 2.0
|
||||
val zc = sol[1] / 2.0
|
||||
val rFit = sqrt(max(sol[2] + yc * yc + zc * zc, 1e-9))
|
||||
val s = sagittaMm(yWallPost, zWallPost)
|
||||
val w = (s * s) / (s * s + sNoise * sNoise)
|
||||
val kappa = w / rFit + (1.0 - w) / cApPrior
|
||||
return if (kappa > 1e-9) 1.0 / kappa else cApPrior
|
||||
}
|
||||
|
||||
/** R 구에서 base a 인 minor 구면 캡 높이. Python `_minor_cap_height`. */
|
||||
private fun minorCapHeight(rEff: Double, aBase: Double): Double {
|
||||
val a = min(aBase, rEff)
|
||||
return rEff - sqrt(max(rEff * rEff - a * a, 0.0))
|
||||
}
|
||||
|
||||
/** 구면 캡 부피 (lr 보정). V = π·h²·(3R−h)/3 · lr. Python `_spherical_cap_volume`. */
|
||||
private fun sphericalCapVolume(rEff: Double, h: Double, lrRatio: Double): Double =
|
||||
Math.PI * h * h * (3.0 * rEff - h) / 3.0 * lrRatio
|
||||
|
||||
/**
|
||||
* 벽 인셋: (ant + f·(ls-ant), post - f·(post-le)) → 벽을 lumen 내부로 살짝 밀어 넣기.
|
||||
* Python `_apply_lumen_inset_one`. frac=0 이면 (ant, post) 그대로.
|
||||
* ls, le 없으면 (ant, post) 그대로.
|
||||
*/
|
||||
fun applyLumenInsetOne(
|
||||
ant: Int, post: Int, ls: Int?, le: Int?, frac: Double = LUMEN_INSET_FRAC,
|
||||
): Pair<Int, Int> {
|
||||
if (frac <= 0 || ls == null || le == null) return Pair(ant, post)
|
||||
val ai = kotlin.math.round(ant + frac * (ls - ant)).toInt()
|
||||
val pi = kotlin.math.round(post - frac * (post - le)).toInt()
|
||||
return Pair(ai, pi)
|
||||
}
|
||||
|
||||
/** Python `BOTTOM_CAP_SAGITTA_NOISE_MM` (bv_estimation.py:895). */
|
||||
const val BOTTOM_CAP_SAGITTA_NOISE_MM: Double = 4.0
|
||||
|
||||
/** Python `LUMEN_INSET_FRAC` (config_6ch.py:41). */
|
||||
const val LUMEN_INSET_FRAC: Double = 0.15
|
||||
|
||||
// ── Core BV Computation ──
|
||||
|
||||
/**
|
||||
* Traditional frustum BV 추정 (cos 보정 + SI 축 기반)
|
||||
* 1:1 port of _bv_core(mode="traditional")
|
||||
*/
|
||||
/**
|
||||
* 6ch BV 추정 — center 4채널(CH0~CH3) + lateral 2채널(CH4/CH5)에서 lr_ratio 계산
|
||||
* config_6ch.py / bv_estimation.py estimate_bladder_volume_6ch 1:1 포팅
|
||||
*/
|
||||
fun estimateBladderVolume6ch(
|
||||
allWalls: List<Pair<Int, Int>?>, // 6채널 전체 (ant, post), null = invalid
|
||||
): BVResult? {
|
||||
var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||
centerWalls = repairCenterWallsFor6ch(centerWalls)
|
||||
val lateralWalls = PiezoHW.lateralCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||
// 2026-07-01: algorithm 팀 최신 표준 (piezophantomtest 4830e7c) — lr_ratio_override=1.0
|
||||
// Phantom 검증: computed lr 은 6채널 HW 한계로 -38% 오차. Dev panel 로 전환 가능.
|
||||
val lrRatio = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride
|
||||
?: computeLrRatio(centerWalls, lateralWalls, sensorZMm = PiezoHW.sensorZMmAll)
|
||||
return estimateBladderVolume(
|
||||
walls = centerWalls,
|
||||
lrRatio = lrRatio,
|
||||
edgeCh = 3,
|
||||
edgeRefChannels = listOf(1, 2)
|
||||
)
|
||||
}
|
||||
|
||||
fun estimateBladderVolume(
|
||||
walls: List<Pair<Int, Int>?>,
|
||||
distancePerSample: Double = PiezoHW.distancePerSample,
|
||||
delayOffsetMm: Double = PiezoHW.delayOffsetMm,
|
||||
sensorZMm: DoubleArray = PiezoHW.sensorZMm,
|
||||
degreeDeg: DoubleArray = PiezoHW.degree,
|
||||
areaK: Double = PiezoHW.areaK,
|
||||
lrRatio: Double = PiezoHW.defaultLrRatio,
|
||||
applyAngleCorrection: Boolean = true,
|
||||
edgeCh: Int = walls.size - 1,
|
||||
edgeRefChannels: List<Int> = listOf(walls.size - 2, walls.size - 3)
|
||||
): BVResult? {
|
||||
|
||||
// 1) Edge channel filter — cap 방식 결정용 플래그
|
||||
var edgeIsShort = false
|
||||
if (edgeCh < walls.size) {
|
||||
val wEdge = walls[edgeCh]
|
||||
if (wEdge != null) {
|
||||
val lEdge = wEdge.second - wEdge.first
|
||||
val refLens = mutableListOf<Int>()
|
||||
for (ci in edgeRefChannels) {
|
||||
if (ci in walls.indices) {
|
||||
val w = walls[ci]
|
||||
if (w != null) refLens.add(w.second - w.first)
|
||||
}
|
||||
}
|
||||
val minRef = refLens.minOrNull()
|
||||
if (minRef != null && lEdge.toDouble() < 0.9 * minRef.toDouble()) {
|
||||
edgeIsShort = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 유효 채널 추출 + sample → mm
|
||||
val validChannels = mutableListOf<Int>()
|
||||
val dAnt = mutableListOf<Double>()
|
||||
val dPost = mutableListOf<Double>()
|
||||
|
||||
for ((i, w) in walls.withIndex()) {
|
||||
if (w == null) continue
|
||||
validChannels.add(i)
|
||||
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second, distancePerSample, delayOffsetMm)
|
||||
dAnt.add(dNear)
|
||||
dPost.add(dFar)
|
||||
}
|
||||
|
||||
// 1채널 케이스: 구(sphere) 부피 (Python _single_channel_bv)
|
||||
if (validChannels.size < 2) {
|
||||
if (validChannels.size == 1) {
|
||||
val ch = validChannels[0]
|
||||
val theta = degreeDeg[ch] * Math.PI / 180.0
|
||||
val dRaw = dPost[0] - dAnt[0]
|
||||
val D = if (applyAngleCorrection) dRaw * cos(theta) else dRaw
|
||||
val R = D / 2.0
|
||||
val bvMm3 = (4.0 / 3.0) * Math.PI * R * R * R
|
||||
val S = areaK * D * D * lrRatio
|
||||
val yMid = sensorZMm[ch] + (dAnt[0] + dPost[0]) / 2.0 * sin(theta)
|
||||
return BVResult(
|
||||
volumeMl = bvMm3 / 1000.0, volumeMm3 = bvMm3,
|
||||
validChannels = validChannels, dAntMm = dAnt.toDoubleArray(), dPostMm = dPost.toDoubleArray(),
|
||||
dMm = doubleArrayOf(D), sMm2 = doubleArrayOf(S),
|
||||
sortedChannels = listOf(ch), sortedYMm = doubleArrayOf(yMid), sortedSMm2 = doubleArrayOf(S),
|
||||
vFrustumMm3 = doubleArrayOf(), vCoreMm3 = 0.0,
|
||||
vBottomMm3 = bvMm3, vTopMm3 = 0.0,
|
||||
bottomHMm = R, topHMm = 0.0,
|
||||
bottomKind = "sphere", topKind = "sphere",
|
||||
lrRatio = lrRatio, distancePerSample = distancePerSample, delayOffsetMm = delayOffsetMm
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
var n = validChannels.size
|
||||
|
||||
// 2-1) Post median outlier 제거 (#21 merge)
|
||||
if (n >= 3) {
|
||||
val posts = dPost.toDoubleArray()
|
||||
val med = posts.sorted()[posts.size / 2]
|
||||
val postTol = max(med * 0.25, 5.0 * distancePerSample)
|
||||
val keep = (0 until n).filter { abs(posts[it] - med) <= postTol }
|
||||
if (keep.size >= 2 && keep.size < n) {
|
||||
val newValid = keep.map { validChannels[it] }.toMutableList()
|
||||
val newDAnt = keep.map { dAnt[it] }.toMutableList()
|
||||
val newDPost = keep.map { dPost[it] }.toMutableList()
|
||||
validChannels.clear(); validChannels.addAll(newValid)
|
||||
dAnt.clear(); dAnt.addAll(newDAnt)
|
||||
dPost.clear(); dPost.addAll(newDPost)
|
||||
n = validChannels.size
|
||||
}
|
||||
}
|
||||
|
||||
// theta, sensor_z for valid channels
|
||||
val theta = DoubleArray(n) { degreeDeg[validChannels[it]] * Math.PI / 180.0 }
|
||||
val sensZ = DoubleArray(n) { sensorZMm[validChannels[it]] }
|
||||
|
||||
// 3) 단면 직경 (traditional: cos 보정)
|
||||
val lRaw = DoubleArray(n) { dPost[it] - dAnt[it] }
|
||||
val D = DoubleArray(n) { if (applyAngleCorrection) lRaw[it] * cos(theta[it]) else lRaw[it] }
|
||||
|
||||
// 4) 단면적 + AP 반지름
|
||||
val S = DoubleArray(n) { areaK * D[it] * D[it] * lrRatio }
|
||||
val aCap = DoubleArray(n) { D[it] / 2.0 }
|
||||
|
||||
// 5) Midpoint + 벽 좌표 (y-z 평면, 타원 피팅용)
|
||||
val dMid = DoubleArray(n) { (dAnt[it] + dPost[it]) / 2.0 }
|
||||
val y = DoubleArray(n) { sensZ[it] + dMid[it] * sin(theta[it]) }
|
||||
val yWallAnt = DoubleArray(n) { sensZ[it] + dAnt[it] * sin(theta[it]) }
|
||||
val zWallAnt = DoubleArray(n) { dAnt[it] * cos(theta[it]) }
|
||||
val yWallPost = DoubleArray(n) { sensZ[it] + dPost[it] * sin(theta[it]) }
|
||||
val zWallPost = DoubleArray(n) { dPost[it] * cos(theta[it]) }
|
||||
|
||||
// 6) y 오름차순 정렬
|
||||
val order = (0 until n).sortedBy { y[it] }
|
||||
val yS = DoubleArray(order.size) { y[order[it]] }
|
||||
val sS = DoubleArray(order.size) { S[order[it]] }
|
||||
val aCapS = DoubleArray(order.size) { aCap[order[it]] }
|
||||
val sortedCh = order.map { validChannels[it] }
|
||||
|
||||
// 7) y-z 평면 타원 피팅 + 반복 outlier 제거 (#22 merge)
|
||||
val allYw = DoubleArray(2 * n) { i -> if (i < n) yWallAnt[i] else yWallPost[i - n] }
|
||||
val allZw = DoubleArray(2 * n) { i -> if (i < n) zWallAnt[i] else zWallPost[i - n] }
|
||||
val nPts = allYw.size
|
||||
var capKind = "fallback"
|
||||
var z0Ellipse: Double? = null
|
||||
var cApPrior: Double? = null // shrink_bottom_cap 용 AP 반축
|
||||
var hCapBot = aCapS[0]
|
||||
var hCapTop = aCapS[n - 1]
|
||||
val ellipseCostThr = 0.5 // 점당 평균 잔차 임계
|
||||
|
||||
// 타원 피팅 helper: 성공 시 (y0, z0, bSi, aAp, residuals) 반환
|
||||
fun fitEllipsePts(ywF: DoubleArray, zwF: DoubleArray): Array<Any>? {
|
||||
val cnt = ywF.size
|
||||
val ym = ywF.average(); val ysS = std(ywF) + 1e-12
|
||||
val zm = zwF.average(); val zsS = std(zwF) + 1e-12
|
||||
val yn = DoubleArray(cnt) { (ywF[it] - ym) / ysS }
|
||||
val zn = DoubleArray(cnt) { (zwF[it] - zm) / zsS }
|
||||
val sol = solveEllipseLSQ(yn, zn, cnt) ?: return null
|
||||
if (sol[0] <= 1e-12 || sol[1] <= 1e-12) return null
|
||||
val rr = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1])
|
||||
if (rr <= 0) return null
|
||||
val y0 = (-sol[2] / (2.0 * sol[0])) * ysS + ym
|
||||
val z0 = (-sol[3] / (2.0 * sol[1])) * zsS + zm
|
||||
val bSi = sqrt(rr / sol[0]) * ysS
|
||||
val aAp = sqrt(rr / sol[1]) * zsS
|
||||
val resid = DoubleArray(cnt) {
|
||||
abs(((ywF[it] - y0) / bSi) * ((ywF[it] - y0) / bSi) +
|
||||
((zwF[it] - z0) / aAp) * ((zwF[it] - z0) / aAp) - 1.0)
|
||||
}
|
||||
return arrayOf(y0, z0, bSi, aAp, resid)
|
||||
}
|
||||
|
||||
if (nPts >= 5) {
|
||||
val keep = BooleanArray(nPts) { true }
|
||||
var fit = fitEllipsePts(allYw, allZw)
|
||||
|
||||
if (fit != null) {
|
||||
var y0 = fit[0] as Double; var z0 = fit[1] as Double
|
||||
var bSi = fit[2] as Double; var aAp = fit[3] as Double
|
||||
var resid = fit[4] as DoubleArray
|
||||
var meanRes = resid.average()
|
||||
|
||||
// 반복 outlier 제거: worst 점 하나씩, 최소 5점 유지
|
||||
while (meanRes > ellipseCostThr && keep.count { it } > 5) {
|
||||
val worstLocal = resid.indices.maxByOrNull { resid[it] } ?: break
|
||||
val activeIndices = keep.indices.filter { keep[it] }
|
||||
keep[activeIndices[worstLocal]] = false
|
||||
val keptY = keep.indices.filter { keep[it] }.map { allYw[it] }.toDoubleArray()
|
||||
val keptZ = keep.indices.filter { keep[it] }.map { allZw[it] }.toDoubleArray()
|
||||
val fit2 = fitEllipsePts(keptY, keptZ) ?: break
|
||||
val meanRes2 = (fit2[4] as DoubleArray).average()
|
||||
if (meanRes2 < meanRes) {
|
||||
y0 = fit2[0] as Double; z0 = fit2[1] as Double
|
||||
bSi = fit2[2] as Double; aAp = fit2[3] as Double
|
||||
resid = fit2[4] as DoubleArray; meanRes = meanRes2
|
||||
} else break
|
||||
}
|
||||
|
||||
// 품질 판정: 평균 잔차 ≤ threshold
|
||||
if (meanRes <= ellipseCostThr) {
|
||||
z0Ellipse = z0
|
||||
// b_si 상한: a_ap × 1.3 (해부학적 SI/AP 비율 제한)
|
||||
if (bSi > aAp * 1.3) bSi = aAp * 1.3
|
||||
hCapBot = max(0.0, yS[0] - (y0 - bSi))
|
||||
hCapTop = max(0.0, (y0 + bSi) - yS[n - 1])
|
||||
hCapBot = min(hCapBot, aCapS[0])
|
||||
hCapTop = min(hCapTop, aCapS[n - 1])
|
||||
cApPrior = aAp
|
||||
capKind = "ellipse"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shrink_bottom_cap=true (Python default) — posterior arc 원 fit + sagitta shrink 로
|
||||
// R_eff 공유 곡률 산출. 성공 시 both caps 높이를 정규화 (자유 b_si 제거).
|
||||
val rEffCap: Double? =
|
||||
if (cApPrior != null) shrinkSiRadius(yWallPost, zWallPost, cApPrior) else null
|
||||
if (rEffCap != null) {
|
||||
hCapBot = minorCapHeight(rEffCap, aCapS[0])
|
||||
hCapTop = minorCapHeight(rEffCap, aCapS[n - 1])
|
||||
}
|
||||
|
||||
// Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한
|
||||
val nTotalCh = sensorZMm.size
|
||||
val topSortedCh = sortedCh.last()
|
||||
if (n < nTotalCh && topSortedCh > 0) {
|
||||
val upperCh = topSortedCh - 1
|
||||
val upperTheta = degreeDeg[upperCh] * Math.PI / 180.0
|
||||
val yBeamUpper = if (z0Ellipse != null && abs(cos(upperTheta)) > 1e-6) {
|
||||
val dAtZ0 = z0Ellipse!! / cos(upperTheta)
|
||||
sensorZMm[upperCh] + dAtZ0 * sin(upperTheta)
|
||||
} else {
|
||||
sensorZMm[upperCh].toDouble()
|
||||
}
|
||||
val hTopLimit = yBeamUpper - yS[n - 1]
|
||||
if (hTopLimit > 0 && hCapTop > hTopLimit) hCapTop = hTopLimit
|
||||
}
|
||||
|
||||
// 8) Core frustum (traditional: h = dy)
|
||||
val dy = DoubleArray(n - 1) { yS[it + 1] - yS[it] }
|
||||
val vFrustum = DoubleArray(n - 1) {
|
||||
(dy[it] / 3.0) * (sS[it] + sS[it + 1] + sqrt(sS[it] * sS[it + 1]))
|
||||
}
|
||||
val vCore = vFrustum.sum()
|
||||
|
||||
// 9) Caps — Bottom: spherical cap (shrink 시 R_eff 공유), Top: cone
|
||||
val vBottom: Double
|
||||
val vTop: Double
|
||||
val bottomKind: String
|
||||
val topKind: String
|
||||
if (rEffCap != null) {
|
||||
vBottom = sphericalCapVolume(rEffCap, hCapBot, lrRatio)
|
||||
vTop = sS[n - 1] * hCapTop / 3.0
|
||||
bottomKind = "shrink sphere"
|
||||
topKind = "shrink cone"
|
||||
} else {
|
||||
// fallback (ellipse fit 실패): 기존 hemisphere 공식.
|
||||
vBottom = sS[0] * hCapBot / 2.0 + Math.PI * hCapBot * hCapBot * hCapBot / 6.0
|
||||
vTop = sS[n - 1] * hCapTop / 3.0
|
||||
bottomKind = "$capKind sphere"
|
||||
topKind = "$capKind cone"
|
||||
}
|
||||
|
||||
// 10) 합산
|
||||
val bvMm3 = vCore + vBottom + vTop
|
||||
|
||||
return BVResult(
|
||||
volumeMl = bvMm3 / 1000.0,
|
||||
volumeMm3 = bvMm3,
|
||||
validChannels = validChannels,
|
||||
dAntMm = dAnt.toDoubleArray(),
|
||||
dPostMm = dPost.toDoubleArray(),
|
||||
dMm = D,
|
||||
sMm2 = S,
|
||||
sortedChannels = sortedCh,
|
||||
sortedYMm = yS,
|
||||
sortedSMm2 = sS,
|
||||
vFrustumMm3 = vFrustum,
|
||||
vCoreMm3 = vCore,
|
||||
vBottomMm3 = vBottom,
|
||||
vTopMm3 = vTop,
|
||||
bottomHMm = hCapBot,
|
||||
topHMm = hCapTop,
|
||||
bottomKind = bottomKind,
|
||||
topKind = topKind,
|
||||
lrRatio = lrRatio,
|
||||
distancePerSample = distancePerSample,
|
||||
delayOffsetMm = delayOffsetMm
|
||||
)
|
||||
}
|
||||
|
||||
// ── Math Utilities ──
|
||||
|
||||
/**
|
||||
* Degree 2 polynomial fit: returns Triple(c2, c1, c0) where f(x) = c2*x² + c1*x + c0
|
||||
* Least squares via normal equations (Vandermonde)
|
||||
*/
|
||||
private fun polyfit2(x: DoubleArray, y: DoubleArray): Triple<Double, Double, Double> {
|
||||
val n = x.size
|
||||
if (n < 3) return Triple(0.0, 0.0, y.firstOrNull() ?: 0.0)
|
||||
|
||||
// Build normal equations for Ax = b where A is Vandermonde [x^0, x^1, x^2]
|
||||
val sx = DoubleArray(5)
|
||||
val sy = DoubleArray(3)
|
||||
for (i in 0 until n) {
|
||||
val xi = x[i]
|
||||
val yi = y[i]
|
||||
var xp = 1.0
|
||||
for (k in 0 until 5) {
|
||||
sx[k] += xp
|
||||
xp *= xi
|
||||
}
|
||||
sy[0] += yi
|
||||
sy[1] += yi * xi
|
||||
sy[2] += yi * xi * xi
|
||||
}
|
||||
|
||||
// 3x3 system
|
||||
val a = arrayOf(
|
||||
doubleArrayOf(sx[0], sx[1], sx[2]),
|
||||
doubleArrayOf(sx[1], sx[2], sx[3]),
|
||||
doubleArrayOf(sx[2], sx[3], sx[4])
|
||||
)
|
||||
val b = doubleArrayOf(sy[0], sy[1], sy[2])
|
||||
|
||||
val sol = solve3x3(a, b) ?: return Triple(0.0, 0.0, y.firstOrNull() ?: 0.0)
|
||||
return Triple(sol[2], sol[1], sol[0]) // (c2, c1, c0)
|
||||
}
|
||||
|
||||
/** Evaluate degree 2 polynomial: c2*x² + c1*x + c0 */
|
||||
private fun polyval2(c: Triple<Double, Double, Double>, x: Double): Double =
|
||||
c.first * x * x + c.second * x + c.third
|
||||
|
||||
/** Solve 3x3 linear system via Gaussian elimination with partial pivoting */
|
||||
private fun solve3x3(A: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
|
||||
val a = Array(3) { A[it].copyOf() }
|
||||
val bb = b.copyOf()
|
||||
|
||||
for (col in 0 until 3) {
|
||||
// Partial pivoting
|
||||
var maxRow = col
|
||||
var maxVal = abs(a[col][col])
|
||||
for (row in (col + 1) until 3) {
|
||||
if (abs(a[row][col]) > maxVal) {
|
||||
maxVal = abs(a[row][col])
|
||||
maxRow = row
|
||||
}
|
||||
}
|
||||
if (maxVal < 1e-12) return null
|
||||
if (maxRow != col) {
|
||||
val tmpA = a[col]; a[col] = a[maxRow]; a[maxRow] = tmpA
|
||||
val tmpB = bb[col]; bb[col] = bb[maxRow]; bb[maxRow] = tmpB
|
||||
}
|
||||
|
||||
// Eliminate
|
||||
for (row in (col + 1) until 3) {
|
||||
val factor = a[row][col] / a[col][col]
|
||||
for (j in col until 3) a[row][j] -= factor * a[col][j]
|
||||
bb[row] -= factor * bb[col]
|
||||
}
|
||||
}
|
||||
|
||||
// Back substitution
|
||||
val x = DoubleArray(3)
|
||||
for (i in 2 downTo 0) {
|
||||
var sum = bb[i]
|
||||
for (j in (i + 1) until 3) sum -= a[i][j] * x[j]
|
||||
if (abs(a[i][i]) < 1e-12) return null
|
||||
x[i] = sum / a[i][i]
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
/** Standard deviation */
|
||||
private fun std(arr: DoubleArray): Double {
|
||||
val n = arr.size.toDouble()
|
||||
if (n <= 0) return 0.0
|
||||
val mean = arr.sum() / n
|
||||
val variance = arr.sumOf { (it - mean).let { d -> d * d } } / n
|
||||
return sqrt(variance)
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// ── Result Types ──
|
||||
|
||||
/**
|
||||
* Low-echo 기반 urine region 탐지 결과 (원은지 B방식 포팅)
|
||||
*/
|
||||
data class LowEchoResult(
|
||||
val ant: Int, // 전벽 index
|
||||
val post: Int, // 후벽 index
|
||||
val lowStart: Int, // low-echo span 시작
|
||||
val lowEnd: Int, // low-echo span 끝 (inclusive)
|
||||
val lowMean: Double, // low-echo 구간 평균 진폭
|
||||
val urineLen: Int, // post - ant
|
||||
val score: Double, // low_depth * urine_len
|
||||
val innerPeaks: List<Int> // (ant, post) 내부 local peak indices
|
||||
) {
|
||||
/** 방광 직경 (mm) = urine_len × distancePerSample */
|
||||
val diameterMm: Double
|
||||
get() = urineLen.toDouble() * PiezoConstants.distancePerSample
|
||||
|
||||
/** 방광 용적 (ml) = K × (D_cm)³ (DrBench 공식, 팬텀 캘리브레이션) */
|
||||
val volumeMl: Double
|
||||
get() = PiezoConstants.volumeMl(diameterMm)
|
||||
|
||||
/** 전벽 깊이 (mm) */
|
||||
val antDepthMm: Double
|
||||
get() = PiezoConstants.distanceMm(ant)
|
||||
|
||||
/** 후벽 깊이 (mm) */
|
||||
val postDepthMm: Double
|
||||
get() = PiezoConstants.distanceMm(post)
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널별 분석 결과
|
||||
*/
|
||||
data class ChannelAnalysisResult(
|
||||
val channel: Int,
|
||||
val result: LowEchoResult?,
|
||||
val rawSignal: DoubleArray,
|
||||
val denoisedSignal: DoubleArray
|
||||
) {
|
||||
val isValid: Boolean get() = result != null
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ChannelAnalysisResult) return false
|
||||
return channel == other.channel && result == other.result
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 31 * channel + (result?.hashCode() ?: 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 측정 분석 결과
|
||||
*/
|
||||
data class PiezoAnalysisResult(
|
||||
val channels: List<ChannelAnalysisResult>,
|
||||
val volumeMl: Double,
|
||||
val confidence: Double, // 0~100
|
||||
val validChannelCount: Int,
|
||||
val method: String // "single", "multi_avg", "multi_weighted"
|
||||
) {
|
||||
val bestChannel: ChannelAnalysisResult?
|
||||
get() = channels.filter { it.isValid }
|
||||
.maxByOrNull { it.result?.score ?: 0.0 }
|
||||
}
|
||||
|
||||
// ── PiezoConstants (referenced by LowEchoResult) ──
|
||||
|
||||
/**
|
||||
* Physical constants for Piezo ultrasound echo measurement.
|
||||
* Mirrors iOS PiezoConstants, delegates distancePerSample/delayOffsetMm to PiezoHW.
|
||||
*/
|
||||
object PiezoConstants {
|
||||
const val adcMaxValue: Int = 4095
|
||||
const val adcVrefMv: Double = 3300.0
|
||||
|
||||
const val sampleRateMhz: Double = 0.5375
|
||||
const val sampleIntervalUs: Double = 1.86
|
||||
const val soundSpeedMmUs: Double = 1.54
|
||||
|
||||
val distancePerSample: Double get() = PiezoHW.distancePerSample
|
||||
val delayOffsetMm: Double get() = PiezoHW.delayOffsetMm
|
||||
|
||||
const val volumeK: Double = 1.76
|
||||
|
||||
fun distanceMm(index: Int): Double =
|
||||
index.toDouble() * distancePerSample + delayOffsetMm
|
||||
|
||||
fun voltageMv(adcValue: Int): Double =
|
||||
adcValue.toDouble() / adcMaxValue.toDouble() * adcVrefMv
|
||||
|
||||
fun volumeMl(diameterMm: Double): Double {
|
||||
val dCm = diameterMm / 10.0
|
||||
return volumeK * dCm * dCm * dCm
|
||||
}
|
||||
}
|
||||
|
||||
// ── Analyzer ──
|
||||
|
||||
/**
|
||||
* Piezo 초음파 에코 분석기 — 원은지 연구원 B방식 알고리즘 포팅
|
||||
* Pipeline: Raw ADC → TVD Denoise → SG Smooth → Low-Echo Detection → Volume
|
||||
*/
|
||||
class PiezoEchoAnalyzer private constructor() {
|
||||
|
||||
companion object {
|
||||
val shared = PiezoEchoAnalyzer()
|
||||
}
|
||||
|
||||
// ── Parameters (노트북 검증값) ──
|
||||
|
||||
// TVD
|
||||
val tvdWeight: Double = 0.06
|
||||
val tvdMaxIter: Int = 200
|
||||
val tvdTol: Double = 2e-4
|
||||
val tvdTau: Double = 0.25
|
||||
|
||||
// Savitzky-Golay (window=5, poly=2 → 고정 계수)
|
||||
val sgCoeffs: DoubleArray = doubleArrayOf(-3.0/35.0, 12.0/35.0, 17.0/35.0, 12.0/35.0, -3.0/35.0)
|
||||
|
||||
// Low Echo Detection
|
||||
val lowEchoAmpDefault: Double get() = GreenZoneConstants.lowEchoAmp.toDouble()
|
||||
val lowMinLen: Int = 3
|
||||
val mergeGapMax: Int = 3
|
||||
val peakSearchWin: Int = 20
|
||||
val postMaxIdx: Int get() = GreenZoneConstants.postMaxIdx
|
||||
val minPeakMargin: Double = 30.0
|
||||
val minUrineLen: Int = 3
|
||||
val postRefineTolRatio: Double = 0.05
|
||||
val maxRealisticVolumeMl: Double = 1500.0
|
||||
val valleyStopRise: Double = 50.0
|
||||
val edgeDistDecay: Double = 0.12
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
/** 단일 채널 raw ADC → 용적 분석 (crash-safe) */
|
||||
fun analyzeChannel(rawADC: List<UShort>, channel: Int = 0): ChannelAnalysisResult {
|
||||
val raw = DoubleArray(rawADC.size) { rawADC[it].toDouble() }
|
||||
if (raw.size < 10) {
|
||||
return ChannelAnalysisResult(channel = channel, result = null, rawSignal = raw, denoisedSignal = raw.copyOf())
|
||||
}
|
||||
return try {
|
||||
val denoised = denoise(raw)
|
||||
val result = detectLowEcho(raw = raw, denoised = denoised)
|
||||
ChannelAnalysisResult(channel = channel, result = result, rawSignal = raw, denoisedSignal = denoised)
|
||||
} catch (_: Exception) {
|
||||
ChannelAnalysisResult(channel = channel, result = null, rawSignal = raw, denoisedSignal = raw.copyOf())
|
||||
}
|
||||
}
|
||||
|
||||
/** 다채널 파이프라인 — Python pipeline.py detect_walls_multichannel 1:1 포팅
|
||||
* denoise → TGC(전체 채널 통합) → initial detect → BR consensus → resolve → suppress → rerun → choose
|
||||
*/
|
||||
fun analyzeMultiChannelWithTgc(channelData: List<PiezoChannelData>): List<ChannelAnalysisResult> {
|
||||
val raws = channelData.map { DoubleArray(it.buffer.size) { i -> it.buffer[i].toDouble() } }
|
||||
val denoised = raws.map { denoise(it) }
|
||||
|
||||
// TGC 전체 채널 통합 (Python preprocess_multichannel)
|
||||
val preprocessed = applyTgcMultichannel(denoised)
|
||||
|
||||
// 채널별 각도 보정 (Python _make_angle_kwargs_b)
|
||||
val beamAngles = PiezoHW.degreeAll
|
||||
fun channelOtsuRatio(chIdx: Int): Double {
|
||||
if (chIdx >= beamAngles.size) return otsuRatio
|
||||
return otsuRatio * kotlin.math.cos(beamAngles[chIdx] * Math.PI / 180.0)
|
||||
}
|
||||
|
||||
// 1) Initial detect + BR candidate
|
||||
val initialResults = mutableListOf<LowEchoResult?>()
|
||||
val brCandidates = mutableListOf<Int?>()
|
||||
for (i in channelData.indices) {
|
||||
val r = detectLowEcho(raw = raws[i], denoised = preprocessed[i], otsuRatioOverride = channelOtsuRatio(channelData[i].channel))
|
||||
initialResults.add(r)
|
||||
brCandidates.add(if (r != null) BackReflection.buildCandidate(preprocessed[i], r.post, postMaxIdx) else null)
|
||||
}
|
||||
|
||||
// 2) Consensus + reflection_only
|
||||
val sharedBrIdx = BackReflection.consensusIdx(brCandidates)
|
||||
val wallPairs = initialResults.map { r -> if (r != null) Pair<Int?, Int?>(r.ant, r.post) else Pair(null, null) }
|
||||
val reflectionOnly = BackReflection.isReflectionOnlyMultichannel(preprocessed, wallPairs, sharedIdx = sharedBrIdx)
|
||||
|
||||
// 3) Per-channel: resolve → suppress → rerun → choose
|
||||
return channelData.mapIndexed { i, ch ->
|
||||
try {
|
||||
val r = initialResults[i]
|
||||
var suppressIdx: Int? = null
|
||||
|
||||
if (r == null) {
|
||||
if (reflectionOnly && sharedBrIdx != null) suppressIdx = sharedBrIdx
|
||||
else return@mapIndexed ChannelAnalysisResult(ch.channel, null, raws[i], preprocessed[i])
|
||||
} else {
|
||||
val resolved = BackReflection.resolveIndices(preprocessed[i], r.post, postMaxIdx, antIdx = r.ant, sharedIdx = sharedBrIdx, channelCandidate = brCandidates[i])
|
||||
suppressIdx = resolved.suppressIdx
|
||||
if (suppressIdx == null && reflectionOnly && sharedBrIdx != null) suppressIdx = sharedBrIdx
|
||||
}
|
||||
|
||||
if (suppressIdx == null) {
|
||||
return@mapIndexed ChannelAnalysisResult(ch.channel, r, raws[i], preprocessed[i])
|
||||
}
|
||||
|
||||
val cleaned = BackReflection.suppress(preprocessed[i], suppressIdx)
|
||||
val r2 = detectLowEcho(raw = raws[i], denoised = cleaned, otsuSignal = preprocessed[i], dynamicPostMax = suppressIdx, otsuRatioOverride = channelOtsuRatio(ch.channel))
|
||||
val chosen = BackReflection.chooseResult(r, r2, brIdx = BackReflection.selectIdx(preprocessed[i], r?.post ?: 0, postMaxIdx, antIdx = r?.ant, sharedIdx = sharedBrIdx, channelCandidate = brCandidates[i]), suppressIdx = suppressIdx, minGap = 8)
|
||||
ChannelAnalysisResult(ch.channel, chosen, raws[i], cleaned)
|
||||
} catch (_: Exception) {
|
||||
ChannelAnalysisResult(ch.channel, null, raws[i], preprocessed[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TGC 전체 채널 통합 처리 (Python preprocess_multichannel) */
|
||||
fun applyTgcMultichannel(denoisedChannels: List<DoubleArray>): List<DoubleArray> {
|
||||
if (denoisedChannels.isEmpty()) return denoisedChannels
|
||||
val n = denoisedChannels[0].size
|
||||
val nCh = denoisedChannels.size
|
||||
|
||||
// Log 압축 → 채널별 slope 계산
|
||||
val slopes = DoubleArray(nCh)
|
||||
for (ch in 0 until nCh) {
|
||||
val logSig = DoubleArray(n) { kotlin.math.ln(max(denoisedChannels[ch][it], 1.0)) }
|
||||
val x = DoubleArray(n) { it.toDouble() }
|
||||
var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumX2 = 0.0
|
||||
for (i in 0 until n) { sumX += x[i]; sumY += logSig[i]; sumXY += x[i] * logSig[i]; sumX2 += x[i] * x[i] }
|
||||
slopes[ch] = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
}
|
||||
|
||||
// 채널별 보상 적용
|
||||
return denoisedChannels.mapIndexed { ch, signal ->
|
||||
val slope = slopes[ch]
|
||||
if (slope >= 0) return@mapIndexed signal.copyOf()
|
||||
val absSlope = abs(slope)
|
||||
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
|
||||
val ratio = if (absSlope < slopeThresh) 1.0
|
||||
else max(ratioMin, 1.0 - (1.0 - ratioMin) * (absSlope - slopeThresh) / (slopeMax - slopeThresh))
|
||||
val targetSlope = slope * ratio
|
||||
val x = DoubleArray(n) { it.toDouble() }
|
||||
DoubleArray(n) { signal[it] + (targetSlope - slope) * x[it] }
|
||||
}
|
||||
}
|
||||
|
||||
/** 다채널 분석 → 최종 용적 */
|
||||
fun analyzeMultiChannel(channelData: List<PiezoChannelData>): PiezoAnalysisResult {
|
||||
val results = channelData.map { ch ->
|
||||
analyzeChannel(ch.buffer, ch.channel)
|
||||
}
|
||||
|
||||
// Step 1: Get valid results (algorithm found urine region)
|
||||
val validResults = results.filter { it.isValid }
|
||||
|
||||
// Step 2: Filter out unrealistic volumes (container wall artifacts)
|
||||
val realisticResults = validResults.filter { (it.result?.volumeMl ?: 0.0) <= maxRealisticVolumeMl }
|
||||
|
||||
// Step 3: Outlier removal — if 3+ results, remove > 2× median
|
||||
var filtered = realisticResults
|
||||
if (filtered.size >= 3) {
|
||||
val vols = filtered.mapNotNull { it.result?.volumeMl }.sorted()
|
||||
val median = vols[vols.size / 2]
|
||||
filtered = filtered.filter { cr ->
|
||||
val v = cr.result?.volumeMl ?: return@filter false
|
||||
v <= median * 2.5 && v >= median * 0.3
|
||||
}
|
||||
}
|
||||
|
||||
val usedCount = filtered.size
|
||||
val volumeMl: Double
|
||||
val method: String
|
||||
val confidence: Double
|
||||
|
||||
if (usedCount == 0) {
|
||||
volumeMl = 0.0
|
||||
method = "none"
|
||||
confidence = 0.0
|
||||
} else if (usedCount == 1) {
|
||||
volumeMl = filtered[0].result!!.volumeMl
|
||||
method = "single"
|
||||
confidence = min(100.0, filtered[0].result!!.score / 100.0)
|
||||
} else {
|
||||
// Median-based: use median volume (robust against remaining outliers)
|
||||
val vols = filtered.mapNotNull { it.result?.volumeMl }.sorted()
|
||||
val median = vols[vols.size / 2]
|
||||
volumeMl = median
|
||||
method = "median_${usedCount}ch"
|
||||
confidence = min(100.0, usedCount.toDouble() / results.size.toDouble() * 100.0)
|
||||
}
|
||||
|
||||
return PiezoAnalysisResult(
|
||||
channels = results,
|
||||
volumeMl = volumeMl,
|
||||
confidence = confidence,
|
||||
validChannelCount = usedCount,
|
||||
method = method
|
||||
)
|
||||
}
|
||||
|
||||
// ── Denoising Pipeline ──
|
||||
|
||||
var useSgFilter: Boolean = true
|
||||
|
||||
fun denoise(signal: DoubleArray): DoubleArray {
|
||||
return if (useSgFilter) sgSmooth(signal) else signal.copyOf()
|
||||
}
|
||||
|
||||
/** 1D Total Variation Denoising — Chambolle (2004) dual projected gradient */
|
||||
fun tvDenoiseChambolle1D(signal: DoubleArray): DoubleArray {
|
||||
val n = signal.size
|
||||
if (n < 2 || tvdWeight <= 0) return signal.copyOf()
|
||||
// Skip if signal is flat (all same values)
|
||||
val mn = signal.min()
|
||||
val mx = signal.max()
|
||||
if (mx - mn < 1.0) return signal.copyOf()
|
||||
|
||||
val p = DoubleArray(n - 1)
|
||||
var uPrev = signal.copyOf()
|
||||
val tauW = tvdTau / tvdWeight
|
||||
|
||||
for (iter in 0 until tvdMaxIter) {
|
||||
// u = x + weight * div(p)
|
||||
val divP = DoubleArray(n)
|
||||
divP[0] = p[0]
|
||||
for (i in 1 until (n - 1)) {
|
||||
divP[i] = p[i] - p[i - 1]
|
||||
}
|
||||
divP[n - 1] = -p[n - 2]
|
||||
|
||||
val u = DoubleArray(n) { signal[it] + tvdWeight * divP[it] }
|
||||
|
||||
// grad(u) = diff(u) and dual update
|
||||
var maxDiff = 0.0
|
||||
for (i in 0 until (n - 1)) {
|
||||
val gradU = u[i + 1] - u[i]
|
||||
p[i] = (p[i] + tauW * gradU) / (1.0 + tauW * abs(gradU))
|
||||
maxDiff = max(maxDiff, abs(u[i] - uPrev[i]))
|
||||
}
|
||||
maxDiff = max(maxDiff, abs(u[n - 1] - uPrev[n - 1]))
|
||||
|
||||
if (maxDiff < tvdTol) break
|
||||
uPrev = u
|
||||
}
|
||||
|
||||
// Final reconstruction
|
||||
val divP = DoubleArray(n)
|
||||
divP[0] = p[0]
|
||||
for (i in 1 until (n - 1)) {
|
||||
divP[i] = p[i] - p[i - 1]
|
||||
}
|
||||
divP[n - 1] = -p[n - 2]
|
||||
|
||||
return DoubleArray(n) { signal[it] + tvdWeight * divP[it] }
|
||||
}
|
||||
|
||||
/** Savitzky-Golay 5-tap FIR smoothing (window=5, poly=2) */
|
||||
fun sgSmooth(signal: DoubleArray): DoubleArray {
|
||||
val n = signal.size
|
||||
if (n < 5) return signal.copyOf()
|
||||
|
||||
val out = DoubleArray(n)
|
||||
|
||||
// Inner: convolution with fixed coefficients [-3, 12, 17, 12, -3]/35
|
||||
for (i in 2 until (n - 2)) {
|
||||
out[i] = sgCoeffs[0] * signal[i - 2] +
|
||||
sgCoeffs[1] * signal[i - 1] +
|
||||
sgCoeffs[2] * signal[i] +
|
||||
sgCoeffs[3] * signal[i + 1] +
|
||||
sgCoeffs[4] * signal[i + 2]
|
||||
}
|
||||
|
||||
// Edge: polynomial fit for first/last 2 samples
|
||||
// Left edge: fit poly2 to signal[0..4], evaluate at 0,1
|
||||
val leftCoeffs = polyFit2(signal.sliceArray(0 until 5))
|
||||
out[0] = evalPoly2(leftCoeffs, -2.0)
|
||||
out[1] = evalPoly2(leftCoeffs, -1.0)
|
||||
|
||||
// Right edge: fit poly2 to signal[n-5..n-1], evaluate at n-2,n-1
|
||||
val rightCoeffs = polyFit2(signal.sliceArray((n - 5) until n))
|
||||
out[n - 2] = evalPoly2(rightCoeffs, 1.0)
|
||||
out[n - 1] = evalPoly2(rightCoeffs, 2.0)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Quadratic LS fit to 5 points centered at 0: x = [-2,-1,0,1,2]
|
||||
// Returns Triple(c0, c1, c2) where f(t) = c0 + c1*t + c2*t²
|
||||
private fun polyFit2(y: DoubleArray): Triple<Double, Double, Double> {
|
||||
val c0 = (-3*y[0] + 12*y[1] + 17*y[2] + 12*y[3] - 3*y[4]) / 35.0
|
||||
val c1 = (-2*y[0] - y[1] + y[3] + 2*y[4]) / 10.0
|
||||
val c2 = (2*y[0] - y[1] - 2*y[2] - y[3] + 2*y[4]) / 14.0
|
||||
return Triple(c0, c1, c2)
|
||||
}
|
||||
|
||||
private fun evalPoly2(c: Triple<Double, Double, Double>, t: Double): Double =
|
||||
c.first + c.second * t + c.third * t * t
|
||||
|
||||
// ── Low Echo Detection ──
|
||||
|
||||
var useAdaptiveThreshold: Boolean = true
|
||||
var lastOtsuThreshold: Double = 0.0; private set
|
||||
var lastOtsuSeparability: Double = 0.0; private set
|
||||
|
||||
val otsuRatio: Double = 0.9
|
||||
val minScore: Double = 3000.0
|
||||
|
||||
/** 단일 1D 채널 → urine region 탐지 (Otsu-only, Python 동기화) */
|
||||
fun detectLowEcho(raw: DoubleArray, denoised: DoubleArray, otsuSignal: DoubleArray? = null, dynamicPostMax: Int? = null, lowEchoAmpOverride: Double? = null, otsuRatioOverride: Double? = null): LowEchoResult? {
|
||||
val sg = denoised
|
||||
if (sg.size < 10) return null
|
||||
val effectivePostMax = dynamicPostMax ?: postMaxIdx
|
||||
|
||||
if (lowEchoAmpOverride != null) {
|
||||
lastOtsuThreshold = lowEchoAmpOverride
|
||||
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpOverride, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
|
||||
if (!useAdaptiveThreshold) {
|
||||
lastOtsuThreshold = lowEchoAmpDefault
|
||||
return detectLowEchoCore(sg = sg, threshold = lowEchoAmpDefault, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
|
||||
// Otsu-only: 단일 시도 (Python detect_low_echo_method_b 동일)
|
||||
val effectiveRatio = otsuRatioOverride ?: otsuRatio
|
||||
val otsuInput = otsuSignal ?: sg
|
||||
val (thr, sep) = otsu1dWithSeparability(otsuInput, otsuInput.size)
|
||||
lastOtsuThreshold = thr * effectiveRatio
|
||||
lastOtsuSeparability = sep
|
||||
return detectLowEchoCore(sg = sg, threshold = lastOtsuThreshold, effectivePostMax = effectivePostMax)
|
||||
}
|
||||
|
||||
// ── TGC (Time Gain Compensation) ──
|
||||
|
||||
fun applyTgc(signal: DoubleArray): DoubleArray {
|
||||
val n = signal.size
|
||||
if (n < 5) return signal.copyOf()
|
||||
|
||||
// Log 압축 후 linear fit (Python fit_attenuation_lines 동일)
|
||||
val logSignal = DoubleArray(n) { kotlin.math.ln(max(signal[it], 1.0)) }
|
||||
val x = DoubleArray(n) { it.toDouble() }
|
||||
var sumX = 0.0; var sumY = 0.0; var sumXY = 0.0; var sumX2 = 0.0
|
||||
for (i in 0 until n) { sumX += x[i]; sumY += logSignal[i]; sumXY += x[i] * logSignal[i]; sumX2 += x[i] * x[i] }
|
||||
val slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
|
||||
if (slope >= 0) return signal.copyOf()
|
||||
|
||||
val absSlope = abs(slope)
|
||||
val slopeThresh = 3.0; val slopeMax = 15.0; val ratioMin = 0.1
|
||||
val ratio = if (absSlope < slopeThresh) 1.0
|
||||
else max(ratioMin, 1.0 - (1.0 - ratioMin) * (absSlope - slopeThresh) / (slopeMax - slopeThresh))
|
||||
|
||||
val targetSlope = slope * ratio
|
||||
val compensation = DoubleArray(n) { (targetSlope - slope) * x[it] }
|
||||
return DoubleArray(n) { signal[it] + compensation[it] }
|
||||
}
|
||||
|
||||
// ── Back Reflection Detection & Suppression ──
|
||||
|
||||
fun findBackReflectionIdx(tgcSg: DoubleArray, postMaxIdx: Int, minSearchIdx: Int = 40, extend: Int = 20): Int {
|
||||
val n = tgcSg.size
|
||||
val searchEnd = min(n, postMaxIdx + extend)
|
||||
if (minSearchIdx >= searchEnd) return postMaxIdx
|
||||
|
||||
val seg = tgcSg.sliceArray(minSearchIdx until searchEnd)
|
||||
val peaks = findPeaks1D(seg)
|
||||
if (peaks.isEmpty()) return postMaxIdx
|
||||
|
||||
val globalPeaks = peaks.map { it + minSearchIdx }
|
||||
return globalPeaks.maxByOrNull { tgcSg[it] } ?: postMaxIdx
|
||||
}
|
||||
|
||||
fun suppressBackReflection(sg: DoubleArray, backRefIdx: Int, searchMargin: Int = 10): DoubleArray {
|
||||
val cleaned = sg.copyOf()
|
||||
val n = sg.size
|
||||
if (backRefIdx >= n) return cleaned
|
||||
|
||||
val lo = max(0, backRefIdx - min(searchMargin, 3))
|
||||
val hi = min(n, backRefIdx + searchMargin + 1)
|
||||
var actualPeak = lo
|
||||
for (i in lo until hi) if (sg[i] > sg[actualPeak]) actualPeak = i
|
||||
|
||||
// left valley
|
||||
val valleyLoStart = max(0, actualPeak - searchMargin)
|
||||
var leftValley = valleyLoStart
|
||||
for (i in valleyLoStart..actualPeak) if (sg[i] < sg[leftValley]) leftValley = i
|
||||
|
||||
// right valley
|
||||
val valleyHiEnd = min(n, actualPeak + searchMargin + 1)
|
||||
var rightValley = actualPeak
|
||||
for (i in actualPeak until valleyHiEnd) if (sg[i] < sg[rightValley]) rightValley = i
|
||||
|
||||
if (rightValley > leftValley) {
|
||||
val leftVal = sg[leftValley]
|
||||
val rightVal = sg[rightValley]
|
||||
val length = rightValley - leftValley
|
||||
for (i in 0..length) {
|
||||
cleaned[leftValley + i] = leftVal + (rightVal - leftVal) * i.toDouble() / length
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/** 다채널 후면 반사 위치 합의 (median) */
|
||||
fun findBackReflectionMultichannel(tgcSignals: List<DoubleArray>, postMaxIdx: Int): Int {
|
||||
val detected = mutableListOf<Int>()
|
||||
for (tgc in tgcSignals) {
|
||||
val idx = findBackReflectionIdx(tgc, postMaxIdx)
|
||||
if (idx < postMaxIdx) detected.add(idx)
|
||||
}
|
||||
if (detected.isEmpty()) return postMaxIdx
|
||||
detected.sort()
|
||||
return detected[detected.size / 2]
|
||||
}
|
||||
|
||||
/**
|
||||
* 1D Otsu threshold + separability
|
||||
*/
|
||||
data class OtsuResult(val threshold: Double, val separability: Double)
|
||||
|
||||
fun otsu1dWithSeparability(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): OtsuResult {
|
||||
val n = min(values.size, limit)
|
||||
if (n == 0) return OtsuResult(0.0, 0.0)
|
||||
|
||||
var vMin = values[0]; var vMax = values[0]
|
||||
for (i in 1 until n) {
|
||||
if (values[i] < vMin) vMin = values[i]
|
||||
if (values[i] > vMax) vMax = values[i]
|
||||
}
|
||||
if (vMax == vMin) return OtsuResult(vMin, 0.0)
|
||||
|
||||
val binWidth = (vMax - vMin) / nBins
|
||||
val hist = IntArray(nBins)
|
||||
for (i in 0 until n) {
|
||||
val bin = ((values[i] - vMin) / binWidth).toInt().coerceIn(0, nBins - 1)
|
||||
hist[bin]++
|
||||
}
|
||||
|
||||
val centers = DoubleArray(nBins) { vMin + (it + 0.5) * binWidth }
|
||||
val total = n.toDouble()
|
||||
val p = DoubleArray(nBins) { hist[it] / total }
|
||||
|
||||
val cumP = DoubleArray(nBins)
|
||||
val cumMP = DoubleArray(nBins)
|
||||
cumP[0] = p[0]; cumMP[0] = p[0] * centers[0]
|
||||
for (i in 1 until nBins) {
|
||||
cumP[i] = cumP[i - 1] + p[i]
|
||||
cumMP[i] = cumMP[i - 1] + p[i] * centers[i]
|
||||
}
|
||||
val totalM = cumMP[nBins - 1]
|
||||
|
||||
var bestSigma = -1.0
|
||||
var bestIdx = 0
|
||||
for (t in 0 until nBins - 1) {
|
||||
val w0 = cumP[t]; val w1 = 1.0 - w0
|
||||
if (w0 < 1e-6 || w1 < 1e-6) continue
|
||||
val m0 = cumMP[t] / w0
|
||||
val m1 = (totalM - cumMP[t]) / w1
|
||||
val sigmaB = w0 * w1 * (m0 - m1) * (m0 - m1)
|
||||
if (sigmaB > bestSigma) {
|
||||
bestSigma = sigmaB
|
||||
bestIdx = t
|
||||
}
|
||||
}
|
||||
// Separability: σ_b² / σ_total²
|
||||
var totalVar = 0.0
|
||||
for (i in 0 until nBins) totalVar += p[i] * (centers[i] - totalM) * (centers[i] - totalM)
|
||||
val separability = if (totalVar > 1e-12) bestSigma / totalVar else 0.0
|
||||
|
||||
return OtsuResult(centers[bestIdx], separability)
|
||||
}
|
||||
|
||||
fun otsu1d(values: DoubleArray, limit: Int = values.size, nBins: Int = 64): Double {
|
||||
return otsu1dWithSeparability(values, limit, nBins).threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-echo 탐지 핵심 — Python _detect_core 1:1 포팅 (비대칭 score + FP 필터)
|
||||
*/
|
||||
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double, effectivePostMax: Int = postMaxIdx): LowEchoResult? {
|
||||
if (sg.size < 10) return null
|
||||
|
||||
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold }
|
||||
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
|
||||
|
||||
val gapPeakThr = threshold + 5.0
|
||||
val spans = mergeCloseSpansWithWallCheck(rawSpans, maxGap = mergeGapMax, signal = sg, gapPeakThr = gapPeakThr)
|
||||
val firstSpan = spans.firstOrNull() ?: return null
|
||||
|
||||
// refine_right_edge
|
||||
val (s0, e0) = refineRightEdge(rawSpans, firstSpan, lowMinLen)
|
||||
val s = max(0, s0)
|
||||
val e = min(sg.size - 1, e0)
|
||||
if (s > e) return null
|
||||
|
||||
val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
|
||||
if (lowSlice.isEmpty()) return null
|
||||
val lowMean = lowSlice.sum() / lowSlice.size.toDouble()
|
||||
val peakMin = max(lowMean + minPeakMargin, threshold)
|
||||
|
||||
// POST 먼저, ANT 나중 (Python 순서)
|
||||
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
|
||||
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
|
||||
var ant = selectWallByProminence(sg = sg, edge = s, searchWin = peakSearchWin,
|
||||
peakMin = peakMin, side = WallSide.ANT, otherEdge = e)
|
||||
// ant fallback: low-echo 오른쪽 경계와 post 사이 거리만큼 왼쪽 반사
|
||||
if (ant == null && post != null) ant = max(0, s - (post - e))
|
||||
if (ant == null && s > 0) ant = s - 1
|
||||
if (ant == null) return null
|
||||
// sg[ant] < threshold 또는 sg[post] < threshold → reject
|
||||
if (sg[ant] < threshold || sg[post] < threshold) return null
|
||||
|
||||
if (post > effectivePostMax) {
|
||||
val backHalfEdge = (s + e) / 2
|
||||
post = selectWallByProminence(
|
||||
sg = sg, edge = backHalfEdge,
|
||||
searchWin = effectivePostMax - backHalfEdge,
|
||||
peakMin = peakMin, side = WallSide.POST, otherEdge = null
|
||||
) ?: return null
|
||||
}
|
||||
|
||||
val antH = sg[ant]
|
||||
val postH = sg[post]
|
||||
val antProm = antH - findRightValley(sg, ant)
|
||||
val postProm = postH - findLeftValley(sg, post)
|
||||
|
||||
val urineLen = post - ant - 1
|
||||
if (urineLen < minUrineLen) return null
|
||||
|
||||
// FP 필터: post prominence 최소
|
||||
if (postProm < minPeakMargin) return null
|
||||
|
||||
// 비대칭 score (ant_reliability)
|
||||
val antDepth = max(0.0, antH - lowMean)
|
||||
val postDepth = max(0.0, postH - lowMean)
|
||||
val antReliability = (antProm / max(postProm, 1e-6)).coerceIn(0.25, 1.0)
|
||||
val lowDepth = ((antReliability * antDepth) + postDepth) / (1.0 + antReliability)
|
||||
val score = lowDepth * urineLen.toDouble()
|
||||
|
||||
if (score < minScore) return null
|
||||
|
||||
return LowEchoResult(
|
||||
ant = ant, post = post,
|
||||
lowStart = s, lowEnd = e, lowMean = lowMean,
|
||||
urineLen = urineLen, score = score,
|
||||
innerPeaks = findInnerPeaks(sg, left = ant, right = post)
|
||||
)
|
||||
}
|
||||
|
||||
/** 병합 span 우측 트리밍 (Python _refine_right_edge_from_merged_components) */
|
||||
private fun refineRightEdge(rawSpans: List<Pair<Int, Int>>, mergedSpan: Pair<Int, Int>, lowMinLen: Int): Pair<Int, Int> {
|
||||
val (s, e) = mergedSpan
|
||||
val components = rawSpans.filter { it.first >= s && it.second <= e }
|
||||
if (components.size <= 1) return mergedSpan
|
||||
val (firstS, firstE) = components[0]
|
||||
val (secondS, _) = components[1]
|
||||
val spanLen = e - s + 1
|
||||
if ((secondS - s) >= (spanLen / 2) && (firstE - s + 1) >= lowMinLen) return Pair(firstS, firstE)
|
||||
return mergedSpan
|
||||
}
|
||||
|
||||
// ── Prominence-based Wall Selection (Python 동기화: ANT=nearest, POST=edge_score) ──
|
||||
|
||||
private val maxPeakCandidates = 3
|
||||
private enum class WallSide { ANT, POST }
|
||||
|
||||
/** valley with descending-first logic (Python _find_right_valley) */
|
||||
private fun findRightValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||||
val n = sg.size
|
||||
var v = sg[peakIdx]
|
||||
var descending = false
|
||||
for (i in (peakIdx + 1) until min(n, peakIdx + maxDist + 1)) {
|
||||
val cur = sg[i]
|
||||
if (cur <= v) { v = cur; descending = true; continue }
|
||||
if (descending) break
|
||||
if (cur > v + valleyStopRise) break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||||
var v = sg[peakIdx]
|
||||
var descending = false
|
||||
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
|
||||
val cur = sg[i]
|
||||
if (cur <= v) { v = cur; descending = true; continue }
|
||||
if (descending) break
|
||||
if (cur > v + valleyStopRise) break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall peak 선택 — Python _select_wall_by_prominence 1:1 포팅
|
||||
* ANT: 가장 가까운 outer peak (prominence는 tie-breaker)
|
||||
* POST: edge_score = prom / (1 + decay * dist)
|
||||
*/
|
||||
private fun selectWallByProminence(
|
||||
sg: DoubleArray, edge: Int, searchWin: Int,
|
||||
peakMin: Double, side: WallSide, otherEdge: Int?
|
||||
): Int? {
|
||||
val n = sg.size
|
||||
if (n <= 0 || edge < 0 || edge >= n) return null
|
||||
|
||||
val leftLo: Int
|
||||
val rightHi: Int
|
||||
if (side == WallSide.ANT) {
|
||||
leftLo = max(0, edge - searchWin)
|
||||
var rh = min(n - 1, edge + searchWin)
|
||||
if (otherEdge != null) rh = min(rh, otherEdge)
|
||||
rightHi = rh
|
||||
} else {
|
||||
var ll = max(0, edge - searchWin)
|
||||
if (otherEdge != null) ll = max(ll, otherEdge)
|
||||
leftLo = ll
|
||||
rightHi = min(n - 1, edge + searchWin)
|
||||
}
|
||||
|
||||
var leftCandidates = listOf<Int>()
|
||||
if (edge > leftLo) {
|
||||
val leftEnd = min(edge + 1, n)
|
||||
val seg = safeSlice(sg, from = leftLo, to = leftEnd - 1)
|
||||
if (seg != null) {
|
||||
val pks = findPeaks1D(seg)
|
||||
leftCandidates = pks.map { it + leftLo }.filter { it < edge }.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
var rightCandidates = listOf<Int>()
|
||||
if (rightHi >= edge) {
|
||||
val rightStart = max(edge - 1, 0)
|
||||
val seg = safeSlice(sg, from = rightStart, to = rightHi)
|
||||
if (seg != null) {
|
||||
val pks = findPeaks1D(seg)
|
||||
rightCandidates = pks.map { it + rightStart }.filter { it >= edge }.sortedBy { abs(it - edge) }.take(maxPeakCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
// Score candidates
|
||||
data class Scored(val p: Int, val prom: Double, val dist: Int, val sidePref: Int, val edgeScore: Double)
|
||||
fun scoreGroup(group: List<Int>, groupName: String): List<Scored> {
|
||||
return group.mapNotNull { p ->
|
||||
val valley = if (side == WallSide.ANT) findRightValley(sg, p) else findLeftValley(sg, p)
|
||||
val prom = sg[p] - valley
|
||||
if (sg[p] < peakMin) return@mapNotNull null
|
||||
val dist = abs(p - edge)
|
||||
val sidePref = if (groupName == "outer") 0 else 1
|
||||
val es = prom / (1.0 + edgeDistDecay * dist)
|
||||
Scored(p, prom, dist, sidePref, es)
|
||||
}
|
||||
}
|
||||
|
||||
val (outerCands, innerCands) = if (side == WallSide.ANT)
|
||||
Pair(leftCandidates, rightCandidates) else Pair(rightCandidates, leftCandidates)
|
||||
val outerScored = scoreGroup(outerCands, "outer")
|
||||
val innerScored = scoreGroup(innerCands, "inner")
|
||||
|
||||
// ANT: 가장 가까운 outer peak (distance 우선, prominence tie-break)
|
||||
if (side == WallSide.ANT) {
|
||||
if (outerScored.isNotEmpty()) {
|
||||
return outerScored.sortedWith(compareBy<Scored> { it.dist }.thenByDescending { it.prom }.thenBy { it.p }).first().p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// POST: edge_score 최대
|
||||
val allScored = outerScored + innerScored
|
||||
if (allScored.isEmpty()) return null
|
||||
return allScored.sortedWith(compareByDescending<Scored> { it.edgeScore }.thenByDescending { it.prom }.thenBy { it.dist }.thenBy { it.sidePref }.thenBy { it.p }).first().p
|
||||
}
|
||||
|
||||
// ── Span Utilities ──
|
||||
|
||||
fun findPeaks1D(x: DoubleArray, height: Double? = null): List<Int> {
|
||||
val n = x.size
|
||||
if (n < 3) return emptyList()
|
||||
|
||||
val peaks = mutableListOf<Int>()
|
||||
var i = 1
|
||||
while (i < n - 1) {
|
||||
if (x[i - 1] < x[i]) {
|
||||
var j = i
|
||||
while (j < n - 1 && x[j + 1] == x[j]) j++
|
||||
if (j < n - 1 && x[j + 1] < x[j]) {
|
||||
peaks.add((i + j) / 2)
|
||||
}
|
||||
i = j + 1
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return if (height != null) {
|
||||
peaks.filter { x[it] >= height }
|
||||
} else {
|
||||
peaks
|
||||
}
|
||||
}
|
||||
|
||||
fun contiguousTrueSpans(mask: BooleanArray): List<Pair<Int, Int>> {
|
||||
val spans = mutableListOf<Pair<Int, Int>>()
|
||||
var start: Int? = null
|
||||
for ((i, m) in mask.withIndex()) {
|
||||
if (m && start == null) {
|
||||
start = i
|
||||
} else if (!m && start != null) {
|
||||
spans.add(Pair(start, i - 1))
|
||||
start = null
|
||||
}
|
||||
}
|
||||
if (start != null) {
|
||||
spans.add(Pair(start, mask.size - 1))
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
fun mergeCloseSpans(spans: List<Pair<Int, Int>>, maxGap: Int): List<Pair<Int, Int>> {
|
||||
return mergeCloseSpansWithWallCheck(spans, maxGap = maxGap, signal = null, gapPeakThr = 0.0)
|
||||
}
|
||||
|
||||
/** 병합 시 gap 내부에 벽 후보(gapPeakThr 초과 peak)가 있으면 병합하지 않음 */
|
||||
fun mergeCloseSpansWithWallCheck(
|
||||
spans: List<Pair<Int, Int>>, maxGap: Int,
|
||||
signal: DoubleArray?, gapPeakThr: Double
|
||||
): List<Pair<Int, Int>> {
|
||||
if (spans.isEmpty()) return emptyList()
|
||||
val ordered = spans.sortedBy { it.first }
|
||||
val merged = mutableListOf(ordered[0])
|
||||
for (i in 1 until ordered.size) {
|
||||
val (s, e) = ordered[i]
|
||||
val prevEnd = merged[merged.size - 1].second
|
||||
val gap = s - prevEnd - 1
|
||||
if (gap <= maxGap) {
|
||||
// gap < 3은 무조건 병합 (Python peak_check_min_gap=3)
|
||||
var hasWallPeak = false
|
||||
if (gap >= 3 && signal != null && gapPeakThr > 0) {
|
||||
val gapStart = prevEnd + 1
|
||||
val gapEnd = s - 1
|
||||
if (gapStart <= gapEnd) {
|
||||
val gapSlice = safeSlice(signal, from = gapStart, to = gapEnd)
|
||||
if (gapSlice != null) {
|
||||
val peaks = findPeaks1D(gapSlice, height = gapPeakThr)
|
||||
if (peaks.isNotEmpty()) hasWallPeak = true
|
||||
// Also check endpoints
|
||||
if (!hasWallPeak) {
|
||||
if ((gapSlice.firstOrNull() ?: 0.0) > gapPeakThr) hasWallPeak = true
|
||||
if ((gapSlice.lastOrNull() ?: 0.0) > gapPeakThr) hasWallPeak = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasWallPeak) {
|
||||
// Wall candidate in gap — don't merge
|
||||
merged.add(Pair(s, e))
|
||||
} else {
|
||||
merged[merged.size - 1] = Pair(merged[merged.size - 1].first, max(prevEnd, e))
|
||||
}
|
||||
} else {
|
||||
merged.add(Pair(s, e))
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
fun nearestPeak(signal: DoubleArray, center: Int, left: Int, right: Int, minHeight: Double): Int? {
|
||||
if (signal.isEmpty()) return null
|
||||
val l = max(0, left)
|
||||
val r = min(signal.size - 1, right)
|
||||
val local = safeSlice(signal, from = l, to = r) ?: return null
|
||||
val pkLocal = findPeaks1D(local, height = minHeight)
|
||||
val candidates = (pkLocal.map { it + l }).toMutableSet()
|
||||
|
||||
// Endpoint correction
|
||||
if (l == r) {
|
||||
if (signal[l] >= minHeight) candidates.add(l)
|
||||
} else {
|
||||
if (signal[l] >= minHeight && signal[l] >= signal[l + 1]) candidates.add(l)
|
||||
if (signal[r] >= minHeight && signal[r] >= signal[r - 1]) candidates.add(r)
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
val sorted = candidates.sorted()
|
||||
// Find nearest to center, tie-break by amplitude
|
||||
var bestIdx = sorted[0]
|
||||
var bestDist = abs(sorted[0] - center)
|
||||
for (c in sorted) {
|
||||
val dist = abs(c - center)
|
||||
if (dist < bestDist || (dist == bestDist && signal[c] > signal[bestIdx])) {
|
||||
bestIdx = c
|
||||
bestDist = dist
|
||||
}
|
||||
}
|
||||
return bestIdx
|
||||
}
|
||||
|
||||
fun findInnerPeaks(signal: DoubleArray, left: Int, right: Int): List<Int> {
|
||||
val l = left + 1
|
||||
val r = right - 1
|
||||
val local = safeSlice(signal, from = l, to = r) ?: return emptyList()
|
||||
return findPeaks1D(local).map { it + l }
|
||||
}
|
||||
|
||||
// ── Safe Array Slicing ──
|
||||
|
||||
/** Safe inclusive range slice — returns null if bounds are invalid */
|
||||
private fun safeSlice(arr: DoubleArray, from: Int, to: Int): DoubleArray? {
|
||||
val l = max(0, from)
|
||||
val r = min(arr.size - 1, to)
|
||||
if (l > r || arr.isEmpty()) return null
|
||||
return arr.sliceArray(l..r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Method A — TVD → SG → Derivative + Plateau-first wall expansion.
|
||||
* 1:1 port of low_echo_detection_method_a.py (원은지 연구원)
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Condat(2013) 1D TVD
|
||||
* 2. SG smooth
|
||||
* 3. Composite signal y = sg + α|d1| + β|d2|
|
||||
* 4. Sliding plateau score
|
||||
* 5. Plateau span detection + merge
|
||||
* 6. Prominence-based wall selection from plateau edges
|
||||
*/
|
||||
class PiezoEchoAnalyzerA private constructor() {
|
||||
|
||||
companion object {
|
||||
val shared = PiezoEchoAnalyzerA()
|
||||
}
|
||||
|
||||
// ── Parameters (notebook 실측 튜닝값) ──
|
||||
val tvLambda: Double = 3.0
|
||||
val alpha: Double = 1.0
|
||||
val beta: Double = 1.0
|
||||
val postMaxIdx: Int get() = GreenZoneConstants.postMaxIdx
|
||||
val plateauMinLen: Int = 3
|
||||
val plateauMergeGap: Int = 5
|
||||
val scoreWin: Int = 7
|
||||
val plateauQ: Double = 0.5
|
||||
val edgeDistDecay: Double = 0.15
|
||||
val peakSearchWin: Int = 20
|
||||
val minWallProminence: Double = 3.0
|
||||
val minWallContrastRatio: Double = 1.2
|
||||
val minUrineLen: Int = 3
|
||||
val minEffectiveDist: Int = 3
|
||||
val valleyStopRise: Double = 5.0
|
||||
|
||||
var lastOtsuThreshold: Double = 0.0; private set
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
fun analyzeChannel(rawADC: List<UShort>, channel: Int = 0): ChannelAnalysisResult {
|
||||
val raw = DoubleArray(rawADC.size) { rawADC[it].toDouble() }
|
||||
if (raw.size < 10) {
|
||||
return ChannelAnalysisResult(channel, null, raw, raw.copyOf())
|
||||
}
|
||||
return try {
|
||||
val sg = PiezoEchoAnalyzer.shared.denoise(raw)
|
||||
// 각도 보정: min_wall_contrast_ratio × cos(θ)
|
||||
val beamAngles = PiezoHW.degreeAll
|
||||
val angleFactor = if (channel < beamAngles.size) kotlin.math.cos(beamAngles[channel] * Math.PI / 180.0) else 1.0
|
||||
val result = detectMethodA(raw, sg, contrastRatioOverride = minWallContrastRatio * angleFactor)
|
||||
ChannelAnalysisResult(channel, result, raw, sg)
|
||||
} catch (_: Exception) {
|
||||
ChannelAnalysisResult(channel, null, raw, raw.copyOf())
|
||||
}
|
||||
}
|
||||
|
||||
fun detectMethodA(raw: DoubleArray, sg: DoubleArray, contrastRatioOverride: Double? = null): LowEchoResult? {
|
||||
val effectiveContrastRatio = contrastRatioOverride ?: minWallContrastRatio
|
||||
if (sg.size < 10) return null
|
||||
|
||||
// 1) Composite signal y = sg + α|d1| + β|d2|
|
||||
val d1 = DoubleArray(sg.size - 1) { sg[it + 1] - sg[it] }
|
||||
val d2 = DoubleArray(sg.size - 2) { sg[it + 2] - 2 * sg[it + 1] + sg[it] }
|
||||
val L = d2.size
|
||||
if (L < 10) return null
|
||||
val y = DoubleArray(L) { sg[it] + alpha * abs(d1[it]) + beta * abs(d2[it]) }
|
||||
|
||||
// 2) Plateau score — threshold from full array (np.quantile 보간)
|
||||
val platScore = slidingScores1d(sg, scoreWin)
|
||||
val scoreThr = npQuantile(platScore, plateauQ)
|
||||
val limit = min(platScore.size, postMaxIdx + 1)
|
||||
|
||||
// 3) Plateau spans + merge (postMaxIdx 이내만)
|
||||
var plateauSpans = findPlateauSpans(platScore, limit, scoreThr, plateauMinLen)
|
||||
if (plateauSpans.size > 1) {
|
||||
val merged = mutableListOf(plateauSpans[0])
|
||||
for (i in 1 until plateauSpans.size) {
|
||||
val (s, e) = plateauSpans[i]
|
||||
val (ps, pe) = merged.last()
|
||||
if (s - pe <= plateauMergeGap) {
|
||||
merged[merged.size - 1] = Pair(ps, e)
|
||||
} else {
|
||||
merged.add(Pair(s, e))
|
||||
}
|
||||
}
|
||||
plateauSpans = merged
|
||||
}
|
||||
|
||||
// 4) Plateau-first matching
|
||||
for ((spanStart, spanEnd) in plateauSpans) {
|
||||
val leftLo = max(0, spanStart - peakSearchWin)
|
||||
val rightHi = min(sg.size, spanEnd + peakSearchWin + 1)
|
||||
|
||||
val platMean = if (spanEnd >= spanStart) {
|
||||
var sum = 0.0
|
||||
for (i in spanStart..spanEnd) sum += sg[i]
|
||||
sum / (spanEnd - spanStart + 1)
|
||||
} else continue
|
||||
|
||||
if (platMean <= 0) continue
|
||||
|
||||
// Contrast check
|
||||
var leftMax = 0.0
|
||||
for (i in leftLo..min(spanStart, sg.size - 1)) if (sg[i] > leftMax) leftMax = sg[i]
|
||||
var rightMax = 0.0
|
||||
for (i in spanEnd until rightHi) if (sg[i] > rightMax) rightMax = sg[i]
|
||||
if (leftMax / platMean < effectiveContrastRatio || rightMax / platMean < effectiveContrastRatio) continue
|
||||
|
||||
// Find peaks
|
||||
val leftPks = findPeaksInRange(sg, leftLo, spanStart).filter { it < spanStart }
|
||||
val rightPks = findPeaksInRange(sg, spanEnd, rightHi - 1).filter { it > spanEnd && it <= postMaxIdx }
|
||||
if (leftPks.isEmpty() || rightPks.isEmpty()) continue
|
||||
|
||||
// Score left candidates
|
||||
var bestLeftScore = -1.0; var bestLeft = -1
|
||||
for (p in leftPks) {
|
||||
val prom = sg[p] - findRightValley(sg, p)
|
||||
if (prom < minWallProminence) continue
|
||||
val dist = spanStart - p
|
||||
val effDist = max(dist, minEffectiveDist)
|
||||
val score = prom / (1.0 + edgeDistDecay * effDist)
|
||||
if (score > bestLeftScore) { bestLeftScore = score; bestLeft = p }
|
||||
}
|
||||
|
||||
// Score right candidates
|
||||
var bestRightScore = -1.0; var bestRight = -1
|
||||
for (p in rightPks) {
|
||||
val prom = sg[p] - findLeftValley(sg, p)
|
||||
if (prom < minWallProminence) continue
|
||||
val dist = p - spanEnd
|
||||
val effDist = max(dist, minEffectiveDist)
|
||||
val score = prom / (1.0 + edgeDistDecay * effDist)
|
||||
if (score > bestRightScore) { bestRightScore = score; bestRight = p }
|
||||
}
|
||||
|
||||
if (bestLeft < 0 || bestRight < 0 || bestRight <= bestLeft) continue
|
||||
val urineLen = bestRight - bestLeft - 1
|
||||
if (urineLen < minUrineLen) continue
|
||||
|
||||
val lowSlice = sg.sliceArray(spanStart..spanEnd)
|
||||
val lowMean = lowSlice.average()
|
||||
|
||||
return LowEchoResult(
|
||||
ant = bestLeft, post = bestRight,
|
||||
lowStart = spanStart, lowEnd = spanEnd,
|
||||
lowMean = lowMean, urineLen = urineLen,
|
||||
score = (sg[bestLeft] + sg[bestRight]).toDouble(),
|
||||
innerPeaks = PiezoEchoAnalyzer.shared.findInnerPeaks(sg, bestLeft, bestRight)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Sliding plateau score ──
|
||||
|
||||
private fun slidingScores1d(x: DoubleArray, win: Int): DoubleArray {
|
||||
val w = if (win % 2 == 0) win + 1 else win
|
||||
val half = w / 2
|
||||
val T = x.size
|
||||
val flat = DoubleArray(T)
|
||||
val slope = DoubleArray(T)
|
||||
val low = DoubleArray(T)
|
||||
|
||||
// Edge padding (mode="edge") — Python np.pad 동일
|
||||
val xp = DoubleArray(T + 2 * half)
|
||||
for (i in 0 until half) xp[i] = x[0]
|
||||
for (i in 0 until T) xp[i + half] = x[i]
|
||||
for (i in 0 until half) xp[T + half + i] = x[T - 1]
|
||||
|
||||
val tt = DoubleArray(w) { (it - half).toDouble() }
|
||||
var ttSqSum = 0.0
|
||||
for (t in tt) ttSqSum += t * t
|
||||
ttSqSum += 1e-12
|
||||
|
||||
for (i in 0 until T) {
|
||||
val ww = DoubleArray(w) { xp[i + it] }
|
||||
var sum = 0.0; var sqSum = 0.0
|
||||
for (v in ww) { sum += v; sqSum += v * v }
|
||||
val mean = sum / w
|
||||
flat[i] = sqrt(max(0.0, sqSum / w - mean * mean))
|
||||
|
||||
val sorted = ww.sorted()
|
||||
low[i] = npQuantile(sorted, 0.2)
|
||||
|
||||
var slopeNum = 0.0
|
||||
for (j in 0 until w) {
|
||||
slopeNum += tt[j] * (ww[j] - mean)
|
||||
}
|
||||
slope[i] = abs(slopeNum / ttSqSum)
|
||||
}
|
||||
|
||||
val rzLow = robustZ(low)
|
||||
val rzFlat = robustZ(flat)
|
||||
val rzSlope = robustZ(slope)
|
||||
return DoubleArray(T) { rzLow[it] + rzFlat[it] + rzSlope[it] }
|
||||
}
|
||||
|
||||
private fun robustZ(a: DoubleArray): DoubleArray {
|
||||
val sorted = a.sorted().toDoubleArray()
|
||||
val n = sorted.size
|
||||
val med = if (n % 2 == 0) (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 else sorted[n / 2]
|
||||
val deviations = DoubleArray(n) { abs(a[it] - med) }
|
||||
val devSorted = deviations.sorted().toDoubleArray()
|
||||
val madMed = if (n % 2 == 0) (devSorted[n / 2 - 1] + devSorted[n / 2]) / 2.0 else devSorted[n / 2]
|
||||
val mad = madMed + 1e-12
|
||||
return DoubleArray(n) { (a[it] - med) / (1.4826 * mad) }
|
||||
}
|
||||
|
||||
// ── Plateau span detection ──
|
||||
|
||||
private fun findPlateauSpans(score: DoubleArray, limit: Int, thr: Double, minLen: Int): MutableList<Pair<Int, Int>> {
|
||||
val spans = mutableListOf<Pair<Int, Int>>()
|
||||
var runStart: Int? = null
|
||||
for (i in 0 until limit) {
|
||||
if (score[i] < thr) {
|
||||
if (runStart == null) runStart = i
|
||||
} else {
|
||||
if (runStart != null && (i - runStart) >= minLen) {
|
||||
spans.add(Pair(runStart, i - 1))
|
||||
}
|
||||
runStart = null
|
||||
}
|
||||
}
|
||||
if (runStart != null && (limit - runStart) >= minLen) {
|
||||
spans.add(Pair(runStart, limit - 1))
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// ── Cross-validation (채널간 벽 보정) ──
|
||||
// 1:1 port of _cross_validate_walls()
|
||||
|
||||
private val crossValMaxDev = 5
|
||||
private val crossValMaxGradient = 3
|
||||
|
||||
fun crossValidateWalls(
|
||||
walls: List<Pair<Int, Int>?>,
|
||||
centerCh: List<Int> = listOf(0, 1, 2, 3)
|
||||
): List<Pair<Int, Int>?> {
|
||||
val corrected = walls.toMutableList()
|
||||
|
||||
// 1단계: 글로벌 중앙값 기반
|
||||
val ants = corrected.mapNotNull { it?.first }
|
||||
val posts = corrected.mapNotNull { it?.second }
|
||||
if (ants.size >= 3) {
|
||||
val medAnt = ants.sorted()[ants.size / 2]
|
||||
val medPost = posts.sorted()[posts.size / 2]
|
||||
for (i in corrected.indices) {
|
||||
val w = corrected[i] ?: continue
|
||||
val a = if (abs(w.first - medAnt) >= crossValMaxDev) medAnt else w.first
|
||||
val p = if (abs(w.second - medPost) >= crossValMaxDev) medPost else w.second
|
||||
corrected[i] = Pair(a, p)
|
||||
}
|
||||
}
|
||||
|
||||
// 2단계: 인접 center 채널 smoothness
|
||||
val validCenter = centerCh.filter { it < corrected.size && corrected[it] != null }
|
||||
if (validCenter.size < 3) return corrected
|
||||
|
||||
for (field in listOf("ant", "post")) {
|
||||
val vals = validCenter.map { if (field == "ant") corrected[it]!!.first else corrected[it]!!.second }
|
||||
for (j in vals.indices) {
|
||||
val neighbors = mutableListOf<Int>()
|
||||
if (j > 0) neighbors.add(vals[j - 1])
|
||||
if (j < vals.size - 1) neighbors.add(vals[j + 1])
|
||||
if (neighbors.isEmpty()) continue
|
||||
val neighborMean = neighbors.average()
|
||||
if (abs(vals[j] - neighborMean) > crossValMaxGradient) {
|
||||
val newVal = Math.round(neighborMean).toInt()
|
||||
val chIdx = validCenter[j]
|
||||
val w = corrected[chIdx]!!
|
||||
corrected[chIdx] = if (field == "ant") Pair(newVal, w.second) else Pair(w.first, newVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
return corrected
|
||||
}
|
||||
|
||||
// ── np.quantile 보간 (linear interpolation, numpy default) ──
|
||||
private fun npQuantile(data: DoubleArray, q: Double): Double {
|
||||
if (data.isEmpty()) return 0.0
|
||||
val sorted = data.sorted().toDoubleArray()
|
||||
return npQuantile(sorted.toList(), q)
|
||||
}
|
||||
|
||||
private fun npQuantile(sorted: List<Double>, q: Double): Double {
|
||||
if (sorted.isEmpty()) return 0.0
|
||||
val n = sorted.size
|
||||
val idx = q * (n - 1)
|
||||
val lo = idx.toInt().coerceIn(0, n - 1)
|
||||
val hi = (lo + 1).coerceAtMost(n - 1)
|
||||
val frac = idx - lo
|
||||
return sorted[lo] + frac * (sorted[hi] - sorted[lo])
|
||||
}
|
||||
|
||||
// ── Peak / Valley helpers ──
|
||||
|
||||
private fun findPeaksInRange(sg: DoubleArray, from: Int, to: Int): List<Int> {
|
||||
val lo = max(0, from); val hi = min(sg.size - 1, to)
|
||||
if (hi - lo < 2) return emptyList()
|
||||
val seg = sg.sliceArray(lo..hi)
|
||||
return PiezoEchoAnalyzer.shared.findPeaks1D(seg).map { it + lo }
|
||||
}
|
||||
|
||||
private fun findRightValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||||
var v = sg[peakIdx]
|
||||
for (i in (peakIdx + 1) until min(sg.size, peakIdx + maxDist)) {
|
||||
if (sg[i] < v) v = sg[i]
|
||||
else if (sg[i] > v + valleyStopRise) break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
private fun findLeftValley(sg: DoubleArray, peakIdx: Int, maxDist: Int = 20): Double {
|
||||
var v = sg[peakIdx]
|
||||
for (i in (peakIdx - 1) downTo max(0, peakIdx - maxDist)) {
|
||||
if (sg[i] < v) v = sg[i]
|
||||
else if (sg[i] > v + valleyStopRise) break
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
/**
|
||||
* UrinAI (UrinGateKeeper) — 소변(액체) 존재 판정 엔진.
|
||||
*
|
||||
* 역할: raw ADC 신호에서 "방광(액체)이 있는가?" → true/false 반환.
|
||||
* true일 때만 벽 검출 엔진(PiezoEchoAnalyzer)이 실행됨.
|
||||
*
|
||||
* 원리: 이진 임계 기법
|
||||
* raw < LIQUID_THR_LOOSE → 액체 / raw ≥ LIQUID_THR_LOOSE → 조직
|
||||
* 연속 액체 구간 + contrast 검증 → 생물학적 판정
|
||||
*
|
||||
* 1:1 port of UrinAI.swift / UrinAI.kt (Charles KWON)
|
||||
*/
|
||||
object UrinAI {
|
||||
|
||||
// ── Result ──
|
||||
|
||||
data class Result(
|
||||
/** 핵심: 소변 존재 여부 */
|
||||
val detected: Boolean,
|
||||
/** lumen 시작점 (액체 진입) */
|
||||
val fw: Int,
|
||||
/** posterior wall peak */
|
||||
val bw: Int,
|
||||
/** 연속 순수 액체 최대 길이 */
|
||||
val liquidRun: Int,
|
||||
/** wall-lumen 대비 (0~1) */
|
||||
val contrast: Float,
|
||||
/** 원본 raw (그래프 표시용) */
|
||||
val rawSignal: List<UShort>,
|
||||
/** 이진 마스크 (true=액체) */
|
||||
val binaryMask: List<Boolean>,
|
||||
/** 순수 액체 구간들 */
|
||||
val urineZones: List<IntRange>,
|
||||
/** anterior wall 평가 가능 여부 */
|
||||
val antWallAvailable: Boolean
|
||||
)
|
||||
|
||||
// ── Detection ──
|
||||
|
||||
/** 소변 존재 판정 */
|
||||
fun detect(rawAdc: List<UShort>): Result {
|
||||
if (rawAdc.isEmpty()) {
|
||||
return emptyResult(rawCopy = emptyList(), binary = emptyList())
|
||||
}
|
||||
|
||||
val n = minOf(rawAdc.size, GreenZoneConstants.useSamples)
|
||||
val raw = FloatArray(n) { rawAdc[it].toFloat() }
|
||||
val rawCopy = rawAdc.take(n)
|
||||
|
||||
val binarizeThr = GreenZoneConstants.liquidThrLoose
|
||||
val inUrineThr = GreenZoneConstants.liquidThrStrict
|
||||
val minLiquidRun = GreenZoneConstants.minLiquidRun
|
||||
val minDepth = GreenZoneConstants.minChord
|
||||
val contrastMin = GreenZoneConstants.contrastMin
|
||||
val ringSkip = GreenZoneConstants.ringSkip
|
||||
|
||||
// 1. Binarize (LIQUID_THR_LOOSE 기준)
|
||||
val binary = List(n) { raw[it] < binarizeThr }
|
||||
|
||||
// 2. fw = 첫 번째 연속 액체 (3샘플 이상, ringdown skip)
|
||||
var fw = -1
|
||||
for (i in ringSkip until (n - 2)) {
|
||||
if (binary[i] && binary[i + 1] && binary[i + 2]) {
|
||||
fw = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (fw < 0) {
|
||||
return emptyResult(rawCopy = rawCopy, binary = binary)
|
||||
}
|
||||
|
||||
// 3. bw = fw 이후 가장 강한 조직 그룹의 peak
|
||||
// Two-pass: 엄격(3샘플) → 실패 시 완화(2샘플, 소방광 대응)
|
||||
var bw = findBw(fw = fw, binary = binary, raw = raw, minGroupLen = 3)
|
||||
|
||||
if (bw < 0 || bw - fw < minDepth) {
|
||||
val bw2 = findBw(fw = fw, binary = binary, raw = raw, minGroupLen = 2)
|
||||
if (bw2 >= 0 && bw2 - fw >= minDepth) {
|
||||
bw = bw2
|
||||
}
|
||||
}
|
||||
|
||||
if (bw < 0) {
|
||||
return emptyResult(rawCopy = rawCopy, binary = binary, fw = fw)
|
||||
}
|
||||
if (bw - fw < minDepth) {
|
||||
return emptyResult(rawCopy = rawCopy, binary = binary, fw = fw, bw = bw)
|
||||
}
|
||||
|
||||
// 4. 순수 액체 연속 구간 (strict threshold)
|
||||
var maxRun = 0
|
||||
var run = 0
|
||||
var zoneStart = -1
|
||||
val zones = mutableListOf<IntRange>()
|
||||
|
||||
for (j in fw until bw) {
|
||||
if (raw[j] < inUrineThr) {
|
||||
if (zoneStart < 0) zoneStart = j
|
||||
run += 1
|
||||
} else {
|
||||
if (run > 0) {
|
||||
if (run > maxRun) maxRun = run
|
||||
zones.add(zoneStart until j)
|
||||
}
|
||||
run = 0
|
||||
zoneStart = -1
|
||||
}
|
||||
}
|
||||
if (run > 0 && zoneStart >= 0) {
|
||||
if (run > maxRun) maxRun = run
|
||||
zones.add(zoneStart until bw)
|
||||
}
|
||||
|
||||
// 5. Contrast
|
||||
val antWallAvailable = fw > ringSkip + 2
|
||||
|
||||
var antWallPeak = raw[fw]
|
||||
for (j in maxOf(ringSkip, fw - 5) until fw) {
|
||||
if (raw[j] > antWallPeak) antWallPeak = raw[j]
|
||||
}
|
||||
|
||||
val wallPeak = maxOf(antWallPeak, raw[bw])
|
||||
|
||||
var lumenFloor = raw[fw + 1]
|
||||
for (j in (fw + 2) until bw) {
|
||||
if (raw[j] < lumenFloor) lumenFloor = raw[j]
|
||||
}
|
||||
|
||||
val contrast: Float = if (wallPeak > 0) (wallPeak - lumenFloor) / wallPeak else 0f
|
||||
|
||||
// 6. 판정
|
||||
val detected = maxRun >= minLiquidRun
|
||||
&& (bw - fw) >= minDepth
|
||||
&& contrast >= contrastMin
|
||||
|
||||
return Result(
|
||||
detected = detected,
|
||||
fw = fw, bw = bw,
|
||||
liquidRun = maxRun, contrast = contrast,
|
||||
rawSignal = rawCopy, binaryMask = binary,
|
||||
urineZones = zones,
|
||||
antWallAvailable = antWallAvailable
|
||||
)
|
||||
}
|
||||
|
||||
// ── Private ──
|
||||
|
||||
/** fw 이후 가장 강한 조직 그룹의 peak index */
|
||||
private fun findBw(fw: Int, binary: List<Boolean>, raw: FloatArray, minGroupLen: Int): Int {
|
||||
val n = raw.size
|
||||
var bw = -1
|
||||
var maxGroupSum = 0f
|
||||
var i = fw + 1
|
||||
|
||||
// fw 이후 liquid 구간 건너뛰기
|
||||
while (i < n && binary[i]) i++
|
||||
|
||||
while (i < n) {
|
||||
if (!binary[i]) {
|
||||
val gs = i
|
||||
var gSum = 0f
|
||||
while (i < n && !binary[i]) {
|
||||
gSum += raw[i]
|
||||
i++
|
||||
}
|
||||
val ge = i - 1
|
||||
if (ge - gs + 1 >= minGroupLen && gSum > maxGroupSum) {
|
||||
maxGroupSum = gSum
|
||||
// peak index within group
|
||||
var peakIdx = gs
|
||||
for (k in (gs + 1)..ge) {
|
||||
if (raw[k] > raw[peakIdx]) peakIdx = k
|
||||
}
|
||||
bw = peakIdx
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return bw
|
||||
}
|
||||
|
||||
/** 실패 시 빈 결과 */
|
||||
private fun emptyResult(
|
||||
rawCopy: List<UShort>, binary: List<Boolean>,
|
||||
fw: Int = 0, bw: Int = 0
|
||||
): Result = Result(
|
||||
detected = false,
|
||||
fw = fw, bw = bw,
|
||||
liquidRun = 0, contrast = 0f,
|
||||
rawSignal = rawCopy, binaryMask = binary,
|
||||
urineZones = emptyList(),
|
||||
antWallAvailable = false
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* WakeupDetector — LYING → NON_LYING 전이 감지 (낙상 예방용 기상 알람).
|
||||
*
|
||||
* 로직:
|
||||
* - 사용자가 LYING 상태로 들어가면 startTimestamp 기록
|
||||
* - LYING 으로 lyingDurationSec(default 180) 이상 유지 후 NON_LYING 으로 전환 → 기상 인정
|
||||
* - 기상 인정 시 onWakeup(elapsedSec) 콜백 호출 (한 번)
|
||||
* - 알람 발동 후 다시 LYING 으로 돌아가야 재무장 (재발동 가능)
|
||||
*
|
||||
* UNKNOWN 상태는 무시 (직전 상태 유지). 자세 분류 게이트 실패 시 잠깐 ?_ 떠도 detector 영향 X.
|
||||
*
|
||||
* 사용:
|
||||
* val detector = WakeupDetector(lyingDurationSec = 180)
|
||||
* detector.onWakeup = { elapsedSec -> showAlarm() }
|
||||
* // 매 posture 갱신 시:
|
||||
* detector.update(currentPosture, System.currentTimeMillis())
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
class WakeupDetector(
|
||||
/** 알람 발동을 위해 직전 LYING 상태로 유지되어야 하는 최소 시간 (초). */
|
||||
var lyingDurationSec: Int = 180,
|
||||
) {
|
||||
/** 일어남 감지 시 호출 — argument 는 직전 LYING 유지 시간 (초). */
|
||||
var onWakeup: ((elapsedSec: Long) -> Unit)? = null
|
||||
|
||||
private var lastPosture: Posture = Posture.UNKNOWN
|
||||
private var lyingStartMs: Long = 0L // 현재 LYING streak 시작 시각 (0 = LYING 아님)
|
||||
private var armed: Boolean = true // false 면 알람 1회 발동 후 LYING 복귀 대기
|
||||
|
||||
/** 외부 reset (Settings 토글 변경 등). */
|
||||
fun reset() {
|
||||
lastPosture = Posture.UNKNOWN
|
||||
lyingStartMs = 0L
|
||||
armed = true
|
||||
}
|
||||
|
||||
/** 현재 자세 + 현재 시각으로 갱신. UNKNOWN 은 직전 상태 유지. */
|
||||
fun update(posture: Posture, nowMs: Long = System.currentTimeMillis()) {
|
||||
if (posture == Posture.UNKNOWN) return
|
||||
|
||||
val prev = lastPosture
|
||||
lastPosture = posture
|
||||
|
||||
when {
|
||||
// LYING 진입 — start timestamp 기록 + 재무장
|
||||
posture == Posture.LYING && prev != Posture.LYING -> {
|
||||
lyingStartMs = nowMs
|
||||
armed = true
|
||||
}
|
||||
// LYING 유지 — 그대로
|
||||
posture == Posture.LYING -> { /* keep streak */ }
|
||||
// LYING → NON_LYING 전이 — 알람 판정
|
||||
posture == Posture.NON_LYING && prev == Posture.LYING -> {
|
||||
val elapsedMs = nowMs - lyingStartMs
|
||||
val elapsedSec = elapsedMs / 1000
|
||||
if (armed && lyingStartMs > 0 && elapsedSec >= lyingDurationSec) {
|
||||
armed = false
|
||||
onWakeup?.invoke(elapsedSec)
|
||||
}
|
||||
lyingStartMs = 0L
|
||||
}
|
||||
// NON_LYING 유지 — 그대로
|
||||
else -> { /* nothing */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* WalkingDetector — flowchart Path B (4-2 / 5-2) 구현.
|
||||
*
|
||||
* 알고리즘:
|
||||
* 1. Sliding window (walkingWindowMs, default 1.5초) 동안 IMU gyro sample 누적
|
||||
* 2. window 의 gyro magnitude 평균 (wmean) 과 표준편차 (wstd) 계산
|
||||
* 3. 상태 머신:
|
||||
* IDLE (not walking):
|
||||
* 조건: |pitch| > walkingMinPitchDeg
|
||||
* AND wmean > walkingStartWmean (default 4)
|
||||
* AND wstd > walkingStartWstd (default 2)
|
||||
* → 조건 만족이 walkingStartHoldMs (default 1초) 이상 지속 → WALKING
|
||||
* WALKING:
|
||||
* 조건: |pitch| > walkingMinPitchDeg
|
||||
* AND wmean > walkingKeepWmean (default 2)
|
||||
* → 조건 미달이 walkingKeepHoldMs (default 2초) 이상 지속 → IDLE
|
||||
*
|
||||
* 입력: 매 mtb cycle (≈1초 주기) 마다 IMU sample 리스트 + 현재 pitch + 시각
|
||||
* 출력: 현재 walking 상태 (Boolean) + 진단 정보
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.ble.ImuSample
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class WalkingDetector {
|
||||
|
||||
/** 진단 결과. UI/log 용. */
|
||||
data class Snapshot(
|
||||
val isWalking: Boolean,
|
||||
val wmean: Float,
|
||||
val wstd: Float,
|
||||
val pitchOk: Boolean,
|
||||
val samplesInWindow: Int,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
private data class TsGyro(val tsMs: Long, val magDps: Float)
|
||||
|
||||
private val buffer = ArrayDeque<TsGyro>()
|
||||
private var walking = false
|
||||
/** IDLE 에서 시작 조건 처음 만족된 시각. null 이면 미만족 / 끊김. */
|
||||
private var startCondSinceMs: Long? = null
|
||||
/** WALKING 에서 유지 조건 마지막 만족 시각. */
|
||||
private var keepCondLastOkMs: Long? = null
|
||||
/** 직전 cycle 의 pitchOk — false→true 전이 시 buffer reset (sit-to-stand burst 차단). */
|
||||
private var lastPitchOk: Boolean = false
|
||||
|
||||
val isWalking: Boolean get() = walking
|
||||
|
||||
fun reset() {
|
||||
buffer.clear()
|
||||
walking = false
|
||||
startCondSinceMs = null
|
||||
keepCondLastOkMs = null
|
||||
lastPitchOk = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 매 cycle 마다 호출. samples = 이번 cycle 에 들어온 IMU sample 리스트.
|
||||
* pitchDeg = 같은 cycle 의 pitch (ImuPostureClassifier 가 계산한 값).
|
||||
*/
|
||||
fun update(samples: List<ImuSample>, pitchDeg: Float, nowMs: Long): Snapshot {
|
||||
val minPitch = GreenZoneConstants.walkingMinPitchDeg
|
||||
val pitchOk = abs(pitchDeg) > minPitch
|
||||
|
||||
// Pitch false→true 전이 (sit-to-stand 등) — buffer 비움.
|
||||
// 이유: 일어남 burst 가 pitch<45° 일 때 발생 → window 에 누적 → pitch 가 통과한
|
||||
// 순간 wmean 이 이미 큰 값 → 즉시 start cond 만족 → 가짜 walking trigger.
|
||||
// pitch 가 true 가 된 직후엔 새 데이터만으로 measure 하도록.
|
||||
if (pitchOk && !lastPitchOk && !walking) {
|
||||
buffer.clear()
|
||||
startCondSinceMs = null
|
||||
}
|
||||
lastPitchOk = pitchOk
|
||||
|
||||
// 1) sample buffer 에 추가 (timestamp 단순 분배 — cycle 내 균등 간격 가정)
|
||||
val windowMs = GreenZoneConstants.walkingWindowMs
|
||||
if (samples.isNotEmpty()) {
|
||||
// cycle 내 각 sample 의 timestamp 를 nowMs 기준으로 역산 (≈ ICM ODR 50Hz → 20ms 간격)
|
||||
val sampleGapMs = 20L
|
||||
val startTs = nowMs - (samples.size - 1) * sampleGapMs
|
||||
for ((i, s) in samples.withIndex()) {
|
||||
val mag = sqrt(s.gx * s.gx + s.gy * s.gy + s.gz * s.gz)
|
||||
buffer.addLast(TsGyro(startTs + i * sampleGapMs, mag))
|
||||
}
|
||||
}
|
||||
// 2) window 밖 sample 제거
|
||||
val cutoff = nowMs - windowMs
|
||||
while (buffer.isNotEmpty() && buffer.first().tsMs < cutoff) buffer.removeFirst()
|
||||
|
||||
// 3) wmean, wstd 계산
|
||||
val n = buffer.size
|
||||
if (n < 2) {
|
||||
return Snapshot(walking, 0f, 0f, false, n, "buffer too small")
|
||||
}
|
||||
var sum = 0f
|
||||
for (b in buffer) sum += b.magDps
|
||||
val wmean = sum / n
|
||||
var sqSum = 0f
|
||||
for (b in buffer) {
|
||||
val d = b.magDps - wmean
|
||||
sqSum += d * d
|
||||
}
|
||||
val wstd = sqrt(sqSum / n)
|
||||
|
||||
// 4) state machine (pitchOk 은 위에서 이미 계산됨)
|
||||
if (!walking) {
|
||||
// IDLE → WALKING 검사 (4-2)
|
||||
val cond = pitchOk &&
|
||||
wmean > GreenZoneConstants.walkingStartWmean &&
|
||||
wstd > GreenZoneConstants.walkingStartWstd
|
||||
if (cond) {
|
||||
if (startCondSinceMs == null) startCondSinceMs = nowMs
|
||||
val held = nowMs - startCondSinceMs!!
|
||||
if (held >= GreenZoneConstants.walkingStartHoldMs) {
|
||||
walking = true
|
||||
keepCondLastOkMs = nowMs
|
||||
return Snapshot(true, wmean, wstd, pitchOk, n,
|
||||
"WALKING start (held ${held}ms)")
|
||||
}
|
||||
return Snapshot(false, wmean, wstd, pitchOk, n,
|
||||
"walking start cond met for ${held}ms")
|
||||
} else {
|
||||
startCondSinceMs = null
|
||||
return Snapshot(false, wmean, wstd, pitchOk, n,
|
||||
"idle (pitchOk=$pitchOk wmean=${"%.1f".format(wmean)} wstd=${"%.1f".format(wstd)})")
|
||||
}
|
||||
} else {
|
||||
// WALKING → IDLE 검사 (5-2)
|
||||
val cond = pitchOk && wmean > GreenZoneConstants.walkingKeepWmean
|
||||
if (cond) {
|
||||
keepCondLastOkMs = nowMs
|
||||
return Snapshot(true, wmean, wstd, pitchOk, n,
|
||||
"WALKING keep (wmean=${"%.1f".format(wmean)})")
|
||||
} else {
|
||||
// 조건 미달 — grace 시간 체크
|
||||
val sinceOk = nowMs - (keepCondLastOkMs ?: nowMs)
|
||||
if (sinceOk >= GreenZoneConstants.walkingKeepHoldMs) {
|
||||
walking = false
|
||||
startCondSinceMs = null
|
||||
keepCondLastOkMs = null
|
||||
return Snapshot(false, wmean, wstd, pitchOk, n,
|
||||
"WALKING end (grace ${sinceOk}ms exceeded)")
|
||||
}
|
||||
return Snapshot(true, wmean, wstd, pitchOk, n,
|
||||
"WALKING grace (${sinceOk}ms / ${GreenZoneConstants.walkingKeepHoldMs}ms)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.medithings.vesiscan.measure
|
||||
|
||||
data class CCPosition(
|
||||
val cc: Int,
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
val predicted: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.medithings.vesiscan.measure
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.util.Log
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.mlkit.vision.text.TextRecognition
|
||||
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
|
||||
import com.medithings.vesiscan.measure.CCPosition
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* V2 측정 서비스 — 순수 Kotlin, C++/OpenCV 불필요.
|
||||
* iOS 3-zone Center 2-peak 방식.
|
||||
*
|
||||
* YOLO crop → OCR → 3-zone gradient (Center 2-peak) → yToCC
|
||||
*/
|
||||
object SimpleMeasureService {
|
||||
private const val TAG = "SimpleMeasure"
|
||||
|
||||
data class MeasureResult(
|
||||
val volume: Int?,
|
||||
val isSmallVolume: Boolean = false,
|
||||
val timeMs: Long = 0,
|
||||
val log: String = ""
|
||||
)
|
||||
|
||||
suspend fun measure(bitmap: Bitmap): MeasureResult = withContext(Dispatchers.Default) {
|
||||
val log = StringBuilder()
|
||||
val t0 = System.currentTimeMillis()
|
||||
|
||||
// 밝기 체크
|
||||
val brightness = checkBrightness(bitmap)
|
||||
log.appendLine("밝기: ${"%.0f".format(brightness)}/255")
|
||||
if (brightness < 80) {
|
||||
log.appendLine("⚠ 이미지가 너무 어둡습니다")
|
||||
return@withContext MeasureResult(null, log = log.toString(), timeMs = System.currentTimeMillis() - t0)
|
||||
}
|
||||
|
||||
// OCR
|
||||
val rawOcr = runOCR(bitmap)
|
||||
val t1 = System.currentTimeMillis()
|
||||
val ocrPositions = filterOcrPositions(rawOcr)
|
||||
log.appendLine("OCR: ${t1 - t0}ms → ${ocrPositions.size}개 눈금")
|
||||
|
||||
if (ocrPositions.size < 2) {
|
||||
return@withContext MeasureResult(null, log = log.toString() + "눈금 부족", timeMs = System.currentTimeMillis() - t0)
|
||||
}
|
||||
|
||||
// 스캔 영역: 0~500cc 전체 범위로 외삽
|
||||
val sorted = ocrPositions.sortedBy { it.y }
|
||||
val avgGap = (sorted.last().y - sorted.first().y).toFloat() / (sorted.size - 1)
|
||||
val pxPer50cc = avgGap
|
||||
val topOcrCC = sorted.first().cc.toFloat() // e.g., 450
|
||||
val botOcrCC = sorted.last().cc.toFloat() // e.g., 100
|
||||
val gapsAbove = (500f - topOcrCC) / 50f
|
||||
val gapsBelow = botOcrCC / 50f
|
||||
val scanTop = (sorted.first().y - pxPer50cc * gapsAbove - 10).toInt().coerceAtLeast(0)
|
||||
val scanBot = (sorted.last().y + pxPer50cc * gapsBelow + 10).toInt().coerceAtMost(bitmap.height - 1)
|
||||
val width = bitmap.width
|
||||
|
||||
// OCR 좌표 기반 zone 설정
|
||||
val ocrMaxX = ocrPositions.maxOf { it.x }
|
||||
val ocrOnLeft = ocrPositions.map { it.x }.average() < width / 2.0
|
||||
|
||||
val zoneALeft: Int; val zoneARight: Int
|
||||
val zoneBLeft: Int; val zoneBRight: Int
|
||||
val zoneCLeft: Int; val zoneCRight: Int
|
||||
|
||||
if (ocrOnLeft) {
|
||||
zoneALeft = (width * 0.15).toInt(); zoneARight = (width * 0.33).toInt()
|
||||
zoneBLeft = (width * 0.35).toInt(); zoneBRight = (width * 0.55).toInt()
|
||||
zoneCLeft = (width * 0.60).toInt(); zoneCRight = (width * 0.85).toInt()
|
||||
} else {
|
||||
zoneALeft = (width * 0.15).toInt(); zoneARight = (width * 0.40).toInt()
|
||||
zoneBLeft = (width * 0.45).toInt(); zoneBRight = (width * 0.65).toInt()
|
||||
zoneCLeft = (width * 0.67).toInt(); zoneCRight = (width * 0.85).toInt()
|
||||
}
|
||||
|
||||
// 3-zone: Center(B) primary + Left(A)/Right(C) cross-zone 확인
|
||||
val peaksB = computeZonePeaks(bitmap, scanTop, scanBot, zoneBLeft, zoneBRight)
|
||||
val peaksA = computeZonePeaks(bitmap, scanTop, scanBot, zoneALeft, zoneARight)
|
||||
val peaksC = computeZonePeaks(bitmap, scanTop, scanBot, zoneCLeft, zoneCRight)
|
||||
val t2 = System.currentTimeMillis()
|
||||
|
||||
val surfaceY: Int
|
||||
if (peaksB.isEmpty()) {
|
||||
val allSide = peaksA + peaksC
|
||||
if (allSide.isEmpty()) {
|
||||
return@withContext MeasureResult(null, log = log.toString() + "감지 실패", timeMs = System.currentTimeMillis() - t0)
|
||||
}
|
||||
surfaceY = allSide.maxByOrNull { it.second }!!.first
|
||||
} else {
|
||||
val maxVal = peaksB.maxOf { it.second }
|
||||
val rawSigPeaks = peaksB.filter { it.second >= maxVal * 0.3f }.sortedByDescending { it.second }
|
||||
|
||||
// 근접 peak 병합: 0.3*avgGap 이내면 건너뜀
|
||||
val minPeakDist = (avgGap * 0.3f).toInt()
|
||||
val sigPeaks = mutableListOf<Pair<Int, Float>>()
|
||||
for (sp in rawSigPeaks) {
|
||||
val tooClose = sigPeaks.any { kotlin.math.abs(it.first - sp.first) < minPeakDist }
|
||||
if (!tooClose) sigPeaks.add(sp)
|
||||
}
|
||||
|
||||
// Cross-zone 확인: Left(A)/Right(C)에서도 비슷한 y에 peak가 있는지 확인
|
||||
// Center peak가 OCR 텍스트 피처일 수 있으므로, 확인되는 peak를 우선 승격
|
||||
val confirmRadius = (avgGap * 0.5f).toInt()
|
||||
fun countConfirming(peakY: Int): Int {
|
||||
var count = 0
|
||||
if (peaksA.any { kotlin.math.abs(it.first - peakY) <= confirmRadius && it.second > 0.01f }) count++
|
||||
if (peaksC.any { kotlin.math.abs(it.first - peakY) <= confirmRadius && it.second > 0.01f }) count++
|
||||
return count
|
||||
}
|
||||
|
||||
// 상위 5개 중 첫 번째로 확인되는 peak를 승격
|
||||
val topCandidates = sigPeaks.take(5)
|
||||
val confirmedPeak = topCandidates.firstOrNull { countConfirming(it.first) >= 1 }
|
||||
val orderedPeaks = if (confirmedPeak != null && confirmedPeak != topCandidates.firstOrNull()) {
|
||||
log.appendLine("승격: y=${confirmedPeak.first} (확인됨) → 최강 y=${sigPeaks[0].first} 대체")
|
||||
val reordered = mutableListOf(confirmedPeak)
|
||||
reordered.addAll(sigPeaks.filter { it != confirmedPeak })
|
||||
reordered
|
||||
} else {
|
||||
sigPeaks
|
||||
}
|
||||
|
||||
if (orderedPeaks.size >= 2) {
|
||||
val ellipseTop = minOf(orderedPeaks[0].first, orderedPeaks[1].first)
|
||||
val ellipseBot = maxOf(orderedPeaks[0].first, orderedPeaks[1].first)
|
||||
val peakDistance = ellipseBot - ellipseTop
|
||||
val maxEllipseSpan = (avgGap * 1.0f).toInt()
|
||||
|
||||
if (peakDistance > maxEllipseSpan) {
|
||||
surfaceY = orderedPeaks[0].first
|
||||
log.appendLine("타원 거부: 거리 ${peakDistance}px > 한계 ${maxEllipseSpan}px → peak y=$surfaceY")
|
||||
} else {
|
||||
surfaceY = ellipseBot
|
||||
log.appendLine("타원: top=$ellipseTop, bot=$ellipseBot, 거리=${peakDistance}px")
|
||||
}
|
||||
} else {
|
||||
surfaceY = orderedPeaks[0].first
|
||||
}
|
||||
}
|
||||
|
||||
// yToCC 보간
|
||||
val volume = interpolate(surfaceY, sorted)
|
||||
val totalMs = System.currentTimeMillis() - t0
|
||||
log.appendLine("수면: y=$surfaceY → ${volume}ml (${totalMs}ms)")
|
||||
Log.d(TAG, "V2: ${volume}ml, ${totalMs}ms")
|
||||
|
||||
MeasureResult(volume = volume, timeMs = totalMs, log = log.toString())
|
||||
}
|
||||
|
||||
private fun computeZonePeaks(bitmap: Bitmap, scanTop: Int, scanBot: Int, leftX: Int, rightX: Int): List<Pair<Int, Float>> {
|
||||
val height = bitmap.height
|
||||
val width = bitmap.width
|
||||
val satProfile = FloatArray(height)
|
||||
|
||||
for (y in scanTop..scanBot) {
|
||||
var satSum = 0f; var count = 0
|
||||
for (x in leftX until rightX) {
|
||||
if (x < 0 || x >= width) continue
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
val r = Color.red(pixel) / 255f; val g = Color.green(pixel) / 255f; val b = Color.blue(pixel) / 255f
|
||||
val max = maxOf(r, g, b); val min = minOf(r, g, b)
|
||||
satSum += if (max > 0) (max - min) / max else 0f
|
||||
count++
|
||||
}
|
||||
satProfile[y] = if (count > 0) satSum / count else 0f
|
||||
}
|
||||
|
||||
val smoothed = FloatArray(height)
|
||||
for (y in scanTop..scanBot) {
|
||||
var sum = 0f; var cnt = 0
|
||||
for (k in -10..10) { val idx = y + k; if (idx in scanTop..scanBot) { sum += satProfile[idx]; cnt++ } }
|
||||
smoothed[y] = if (cnt > 0) sum / cnt else 0f
|
||||
}
|
||||
|
||||
val step = 15
|
||||
val grad = FloatArray(height)
|
||||
for (y in (scanTop + step)..(scanBot - step)) { grad[y] = smoothed[y + step] - smoothed[y] }
|
||||
|
||||
val gradSmooth = FloatArray(height)
|
||||
for (y in scanTop..scanBot) {
|
||||
var sum = 0f; var cnt = 0
|
||||
for (k in -5..5) { val idx = y + k; if (idx in scanTop..scanBot) { sum += grad[idx]; cnt++ } }
|
||||
gradSmooth[y] = if (cnt > 0) sum / cnt else 0f
|
||||
}
|
||||
|
||||
val peaks = mutableListOf<Pair<Int, Float>>()
|
||||
for (y in (scanTop + 1)..(scanBot - 1)) {
|
||||
if (gradSmooth[y] > 0 && gradSmooth[y] >= gradSmooth[y - 1] && gradSmooth[y] >= gradSmooth[y + 1]) {
|
||||
peaks.add(Pair(y, gradSmooth[y]))
|
||||
}
|
||||
}
|
||||
return peaks
|
||||
}
|
||||
|
||||
private fun interpolate(surfaceY: Int, sorted: List<CCPosition>): Int? {
|
||||
if (sorted.size < 2) return null
|
||||
|
||||
// 외삽: OCR 최상단 위 (450cc 이상 → 500cc 방향)
|
||||
if (surfaceY <= sorted.first().y) {
|
||||
val pxPerCC = (sorted[1].y - sorted[0].y).toFloat() / (sorted[1].cc - sorted[0].cc)
|
||||
val extrapolated = sorted.first().cc + (surfaceY - sorted.first().y) / pxPerCC
|
||||
return extrapolated.coerceIn(0f, 500f).roundToInt()
|
||||
}
|
||||
|
||||
// 외삽: OCR 최하단 아래 (100cc 이하 → 0cc 방향)
|
||||
if (surfaceY >= sorted.last().y) {
|
||||
val n = sorted.size
|
||||
val pxPerCC = (sorted[n - 1].y - sorted[n - 2].y).toFloat() / (sorted[n - 1].cc - sorted[n - 2].cc)
|
||||
val extrapolated = sorted.last().cc + (surfaceY - sorted.last().y) / pxPerCC
|
||||
return extrapolated.coerceIn(0f, 500f).roundToInt()
|
||||
}
|
||||
|
||||
// 보간: 인접한 두 마크 사이
|
||||
for (i in 0 until sorted.size - 1) {
|
||||
val upper = sorted[i]; val lower = sorted[i + 1]
|
||||
if (surfaceY in upper.y..lower.y) {
|
||||
val t = (surfaceY - upper.y).toFloat() / (lower.y - upper.y).toFloat()
|
||||
return (upper.cc + t * (lower.cc - upper.cc)).roundToInt()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun checkBrightness(bitmap: Bitmap): Float {
|
||||
val w = bitmap.width; val h = bitmap.height
|
||||
var sum = 0L; var count = 0
|
||||
var y = h / 6
|
||||
while (y < h / 3) {
|
||||
var x = w / 4
|
||||
while (x < w * 3 / 4) {
|
||||
val p = bitmap.getPixel(x, y)
|
||||
sum += (Color.red(p) * 299 + Color.green(p) * 587 + Color.blue(p) * 114) / 1000
|
||||
count++; x += 4
|
||||
}; y += 4
|
||||
}
|
||||
return if (count > 0) sum.toFloat() / count else 0f
|
||||
}
|
||||
|
||||
private suspend fun runOCR(bitmap: Bitmap): List<CCPosition> =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
|
||||
recognizer.process(InputImage.fromBitmap(bitmap, 0))
|
||||
.addOnSuccessListener { visionText ->
|
||||
val positions = mutableListOf<CCPosition>()
|
||||
val regex = Regex("""(\d{2,3})\s*(?:cc|ml|CC|ML|Cc|mL)?""")
|
||||
for (block in visionText.textBlocks) {
|
||||
for (line in block.lines) {
|
||||
val match = regex.find(line.text) ?: continue
|
||||
val ccVal = match.groupValues[1].toIntOrNull() ?: continue
|
||||
if (ccVal !in 50..500 || ccVal % 50 != 0) continue
|
||||
val bbox = line.boundingBox ?: continue
|
||||
positions.add(CCPosition(cc = ccVal, x = bbox.centerX(), y = bbox.centerY()))
|
||||
}
|
||||
}
|
||||
cont.resume(positions.distinctBy { it.cc }.sortedBy { it.y })
|
||||
}
|
||||
.addOnFailureListener { cont.resume(emptyList()) }
|
||||
}
|
||||
|
||||
private fun filterOcrPositions(raw: List<CCPosition>): List<CCPosition> {
|
||||
if (raw.size < 2) return raw
|
||||
val sortedByY = raw.sortedBy { it.y }
|
||||
val valid = mutableListOf<CCPosition>()
|
||||
for (pos in sortedByY) {
|
||||
if (valid.isEmpty() || pos.cc < valid.last().cc) valid.add(pos)
|
||||
}
|
||||
if (valid.size >= 3) {
|
||||
val gaps = (0 until valid.size - 1).map { valid[it + 1].y - valid[it].y }
|
||||
val medianGap = gaps.sorted()[gaps.size / 2]
|
||||
val filtered = mutableListOf(valid.first())
|
||||
for (i in 1 until valid.size) {
|
||||
val gap = valid[i].y - filtered.last().y
|
||||
if (gap > medianGap * 0.3 && gap < medianGap * 3.0) filtered.add(valid[i])
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
return valid
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.medithings.vesiscan.measure
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* 배뇨 기록 데이터
|
||||
*/
|
||||
data class VoidingRecord(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val volume: Int?, // ml (null이면 기상/취침)
|
||||
val activity: String = "none", // "none", "awake", "sleep"
|
||||
val method: String = "manual", // "manual", "voice", "camera"
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
) {
|
||||
companion object {
|
||||
private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
}
|
||||
|
||||
val timeString: String
|
||||
get() = timeFormat.format(Date(timestamp))
|
||||
|
||||
val dateString: String
|
||||
get() = dateFormat.format(Date(timestamp))
|
||||
|
||||
val displayText: String
|
||||
get() = when {
|
||||
volume != null -> "$timeString 소변량 $volume ml"
|
||||
activity == "awake" -> "$timeString 기상"
|
||||
activity == "sleep" -> "$timeString 취침"
|
||||
else -> "$timeString 기록"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 배뇨 기록 저장소 (SharedPreferences + JSON)
|
||||
*/
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
object VoidingRecordStore {
|
||||
private const val PREFS_NAME = "voiding_records"
|
||||
private const val KEY_RECORDS = "records"
|
||||
private val gson = Gson()
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun getAll(context: Context): List<VoidingRecord> {
|
||||
val json = prefs(context).getString(KEY_RECORDS, null) ?: return emptyList()
|
||||
val type = object : TypeToken<List<VoidingRecord>>() {}.type
|
||||
return try { gson.fromJson(json, type) } catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun add(context: Context, record: VoidingRecord) {
|
||||
val records = getAll(context).toMutableList()
|
||||
records.add(record)
|
||||
save(context, records)
|
||||
}
|
||||
|
||||
fun delete(context: Context, id: String) {
|
||||
val records = getAll(context).filter { it.id != id }
|
||||
save(context, records)
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
prefs(context).edit().remove(KEY_RECORDS).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(context: Context, records: List<VoidingRecord>) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun getToday(context: Context): List<VoidingRecord> {
|
||||
val todayStart = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
return getAll(context).filter { it.timestamp >= todayStart }.sortedBy { it.timestamp }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.medithings.vesiscan.measure
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
import android.util.Log
|
||||
import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
class YoloDetector(private val context: Context) {
|
||||
|
||||
private var session: OrtSession? = null
|
||||
private var ortEnv: OrtEnvironment? = null
|
||||
private val inputSize = 640
|
||||
private var isLoaded = false
|
||||
|
||||
data class DetectionResult(
|
||||
val boundingBox: RectF, // Normalized [0,1], top-left origin
|
||||
val confidence: Float,
|
||||
val label: String
|
||||
)
|
||||
|
||||
fun loadModel(): Boolean {
|
||||
return try {
|
||||
ortEnv = OrtEnvironment.getEnvironment()
|
||||
val modelBytes = context.assets.open("urinecup_best.onnx").readBytes()
|
||||
session = ortEnv!!.createSession(modelBytes)
|
||||
isLoaded = true
|
||||
Log.d(TAG, "ONNX YOLO model loaded successfully")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load ONNX model", e)
|
||||
isLoaded = false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isModelLoaded(): Boolean = isLoaded
|
||||
|
||||
fun detect(bitmap: Bitmap): DetectionResult? {
|
||||
val session = this.session ?: return null
|
||||
val env = this.ortEnv ?: return null
|
||||
|
||||
try {
|
||||
val resized = Bitmap.createScaledBitmap(bitmap, inputSize, inputSize, true)
|
||||
|
||||
// Bitmap → float array [1, 3, 640, 640] NCHW, normalized 0-1
|
||||
val floatBuffer = FloatBuffer.allocate(1 * 3 * inputSize * inputSize)
|
||||
val pixels = IntArray(inputSize * inputSize)
|
||||
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
|
||||
|
||||
for (c in 0 until 3) {
|
||||
for (i in pixels.indices) {
|
||||
val pixel = pixels[i]
|
||||
val value = when (c) {
|
||||
0 -> ((pixel shr 16) and 0xFF) / 255f // R
|
||||
1 -> ((pixel shr 8) and 0xFF) / 255f // G
|
||||
2 -> (pixel and 0xFF) / 255f // B
|
||||
else -> 0f
|
||||
}
|
||||
floatBuffer.put(value)
|
||||
}
|
||||
}
|
||||
floatBuffer.rewind()
|
||||
|
||||
val shape = longArrayOf(1, 3, inputSize.toLong(), inputSize.toLong())
|
||||
val inputTensor = OnnxTensor.createTensor(env, floatBuffer, shape)
|
||||
|
||||
val inputName = session.inputNames.first()
|
||||
val results = session.run(mapOf(inputName to inputTensor))
|
||||
|
||||
// YOLOv8 output: [1, 5, 8400]
|
||||
val outputTensor = results[0] as OnnxTensor
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val output = (outputTensor.value as Array<Array<FloatArray>>)[0]
|
||||
|
||||
inputTensor.close()
|
||||
results.close()
|
||||
|
||||
return parseYoloOutput(output)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "YOLO detection failed", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseYoloOutput(output: Array<FloatArray>): DetectionResult? {
|
||||
// output: [5, 8400] → rows = cx, cy, w, h, conf
|
||||
val numDetections = output[0].size
|
||||
var bestConf = 0.25f
|
||||
var bestBox: FloatArray? = null
|
||||
|
||||
for (i in 0 until numDetections) {
|
||||
val conf = output[4][i]
|
||||
if (conf > bestConf) {
|
||||
bestConf = conf
|
||||
val cx = output[0][i] / inputSize
|
||||
val cy = output[1][i] / inputSize
|
||||
val w = output[2][i] / inputSize
|
||||
val h = output[3][i] / inputSize
|
||||
bestBox = floatArrayOf(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
|
||||
}
|
||||
}
|
||||
|
||||
return bestBox?.let {
|
||||
DetectionResult(
|
||||
boundingBox = RectF(
|
||||
it[0].coerceIn(0f, 1f),
|
||||
it[1].coerceIn(0f, 1f),
|
||||
it[2].coerceIn(0f, 1f),
|
||||
it[3].coerceIn(0f, 1f)
|
||||
),
|
||||
confidence = bestConf,
|
||||
label = "urinecup"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
session?.close()
|
||||
session = null
|
||||
isLoaded = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "YoloDetector"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
data class BladderLevel(
|
||||
var currentLevel: Int = 0,
|
||||
var catheterizationThreshold: Int = 8
|
||||
) {
|
||||
val urgency: UrgencyLevel
|
||||
get() = when {
|
||||
currentLevel >= catheterizationThreshold -> UrgencyLevel.URGENT
|
||||
currentLevel == catheterizationThreshold - 1 -> UrgencyLevel.WARNING
|
||||
else -> UrgencyLevel.SAFE
|
||||
}
|
||||
|
||||
val progress: Double
|
||||
get() = currentLevel.toDouble() / MAX_LEVEL.toDouble()
|
||||
|
||||
fun increment() {
|
||||
if (currentLevel < MAX_LEVEL) currentLevel++
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
currentLevel = 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_LEVEL = 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 임상 R&D 측정 — "한 측정 단위" 모델.
|
||||
*
|
||||
* 한 ClinicalSession = 한 라벨 = 한 폴더 = 한 측정 사이클.
|
||||
* 사용자가 BLE 연결 후 매번 자세/위치를 입력하고 측정 진행 → Back 시 폴더 finalize.
|
||||
*
|
||||
* 디바이스 식별자는 BLE 연결된 기기 이름(예: VBT26050202)을 자동으로 사용.
|
||||
*
|
||||
* 데이터 구조 (한 측정당):
|
||||
* Downloads/VesiScan_Sessions/{folderName}/
|
||||
* ├─ meta.json
|
||||
* ├─ adc.csv
|
||||
* ├─ placements.jsonl (alignment) or events.jsonl (BV)
|
||||
* └─ ble.log
|
||||
*/
|
||||
|
||||
enum class ClinicalPosture(val label: String) {
|
||||
SUPINE("Supine"), SITTING("Sitting"), STANDING("Standing")
|
||||
}
|
||||
|
||||
/** 측정 대상 분류 — 회의 결정 (subject ID 자유 입력 대체). */
|
||||
enum class SubjectType(val label: String) {
|
||||
PHANTOM("Phantom"), HUMAN("Human"), ETC("ETC")
|
||||
}
|
||||
|
||||
/**
|
||||
* 측정 단계 — 회의 결정으로 BV 제거. 정렬모드 알고리즘 개발용 5단계만.
|
||||
* - ALIGN_0CM ~ ALIGN_4CM: 기기 아래 끝 기준 치골 위 0~4cm
|
||||
*/
|
||||
enum class ClinicalStep(val label: String, val description: String, val isAlignment: Boolean) {
|
||||
ALIGN_0CM("0 cm", "기기 아래 끝이 치골 바로 위", true),
|
||||
ALIGN_1CM("1 cm", "치골 위로 1 cm", true),
|
||||
ALIGN_2CM("2 cm", "치골 위로 2 cm", true),
|
||||
ALIGN_3CM("3 cm", "치골 위로 3 cm", true),
|
||||
ALIGN_4CM("4 cm", "치골 위로 4 cm", true),
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 ClinicalSession 의 데이터 수집 모드.
|
||||
* - ALIGNMENT : PlacementGuide(Sensor Alignment) 에서 V1 advisor 프레임 + 라벨 누적
|
||||
* → labdb 업로드 (dataType="002")
|
||||
* - MEASUREMENT: ClinicalLive 에서 6ch 실시간 측정 누적 (기존 동작)
|
||||
* → labdb 업로드 (dataType="001")
|
||||
*/
|
||||
enum class SessionMode { ALIGNMENT, MEASUREMENT }
|
||||
|
||||
data class ClinicalSession(
|
||||
val deviceName: String, // BLE 기기 이름 (예: "VBT26050202")
|
||||
val posture: ClinicalPosture,
|
||||
val step: ClinicalStep,
|
||||
val examiner: String = "",
|
||||
val notes: String = "",
|
||||
val subjectType: SubjectType = SubjectType.PHANTOM,
|
||||
val subject: String = "",
|
||||
val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL)
|
||||
val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm)
|
||||
val mode: SessionMode = SessionMode.MEASUREMENT, // 신규
|
||||
val alignmentAlgo: String = "", // demo-final: V1 only
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
var endedAt: Long? = null
|
||||
) {
|
||||
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
|
||||
/** mode == ALIGNMENT 시 "_ALIGNMODE_" 접미사 (Live 6ch 세션과 폴더 분리). */
|
||||
val folderName: String
|
||||
get() = buildString {
|
||||
append(subjectType.name)
|
||||
if (subject.isNotBlank()) {
|
||||
append('-')
|
||||
append(subject.replace(Regex("[^A-Za-z0-9_-]"), "-"))
|
||||
}
|
||||
append('_')
|
||||
append(deviceName); append('_')
|
||||
append(posture.name); append('_')
|
||||
append(step.name); append('_')
|
||||
if (mode == SessionMode.ALIGNMENT) append("ALIGNMODE_")
|
||||
append(folderFmt.format(Date(startedAt)))
|
||||
}
|
||||
|
||||
/** clinical_label 컬럼/필드 값 (라벨링 자동) */
|
||||
val label: String
|
||||
get() = "${deviceName}_${posture.name}_${step.name}"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
enum class MeasurementEventType {
|
||||
MEASUREMENT, CATHETERIZATION
|
||||
}
|
||||
|
||||
data class MeasurementRecord(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val eventType: MeasurementEventType,
|
||||
val level: Int,
|
||||
val estimatedVolume: Int,
|
||||
val urgency: String,
|
||||
val fillPercentage: Double
|
||||
) {
|
||||
constructor(
|
||||
eventType: MeasurementEventType,
|
||||
level: Int,
|
||||
estimatedVolume: Int,
|
||||
urgencyLevel: UrgencyLevel,
|
||||
fillPercentage: Double
|
||||
) : this(
|
||||
eventType = eventType,
|
||||
level = level,
|
||||
estimatedVolume = estimatedVolume,
|
||||
urgency = urgencyLevel.rawStringValue,
|
||||
fillPercentage = fillPercentage
|
||||
)
|
||||
|
||||
val urgencyLevel: UrgencyLevel
|
||||
get() = UrgencyLevel.fromString(urgency)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
object NirsConstants {
|
||||
// Hardware Parameters
|
||||
val hardwareAmp = doubleArrayOf(1.2, 5.008, 11.22, 37.072)
|
||||
val ledPower = doubleArrayOf(5.0, 10.0, 15.0, 20.0)
|
||||
val spectralResponse = doubleArrayOf(0.457, 0.561, 0.602, 0.653, 0.71, 0.725)
|
||||
val dpf = doubleArrayOf(7.0, 5.8, 5.5, 5.2, 4.9, 4.7)
|
||||
|
||||
// Extinction Coefficients
|
||||
val extinctionHbO2 = doubleArrayOf(0.303, 0.586, 0.816, 1.058, 1.224, 1.162)
|
||||
val extinctionHb = doubleArrayOf(2.968, 1.549, 0.762, 0.691, 0.777, 0.429)
|
||||
val extinctionH2O = doubleArrayOf(0.003, 0.026, 0.020, 0.044, 0.267, 0.448)
|
||||
|
||||
// Butterworth IIR Filter (2nd order, Fc=1.5Hz, Fs=4Hz)
|
||||
val filterB = doubleArrayOf(0.4208, 0.8416, 0.4208)
|
||||
val filterA = doubleArrayOf(1.0, 0.3533, 0.3300)
|
||||
|
||||
// SRS Parameters
|
||||
const val srsMixNear = 0.8
|
||||
const val srsMixFar = 0.2
|
||||
const val srsPathLength = 2.6
|
||||
const val referencePower = 20.0
|
||||
const val oxyScale = 22.0
|
||||
|
||||
// Dimensions
|
||||
const val numPackages = 4
|
||||
const val numWavelengths = 6
|
||||
const val numChannels = numPackages * numWavelengths
|
||||
|
||||
// Wavelength Definitions
|
||||
val wavelengths = intArrayOf(665, 760, 800, 850, 920, 970)
|
||||
val wavelengthNames = arrayOf("665nm", "760nm", "800nm", "850nm", "920nm", "970nm")
|
||||
|
||||
val wavelengthColors = listOf(
|
||||
Color.Red,
|
||||
Color.Blue,
|
||||
Color(0xFF33CC33),
|
||||
Color(0xFFFF9500),
|
||||
Color(0xFF9966FF),
|
||||
Color(0xFF666666)
|
||||
)
|
||||
|
||||
// Chart History
|
||||
const val maxHistoryPoints = 240
|
||||
const val streamingIntervalMs = 250L
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
data class NirsMcjPacket(
|
||||
val battery: Int,
|
||||
val temperature: Int,
|
||||
val accX: Short,
|
||||
val accY: Short,
|
||||
val accZ: Short,
|
||||
val gyrX: Short,
|
||||
val gyrY: Short,
|
||||
val gyrZ: Short,
|
||||
val ledValues: IntArray
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "NirsMcjPacket"
|
||||
|
||||
fun parse(data: ByteArray): NirsMcjPacket? {
|
||||
if (data.size < 72) {
|
||||
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
|
||||
android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun u16(offset: Int): Int =
|
||||
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
|
||||
fun s16(offset: Int): Short =
|
||||
u16(offset).toShort()
|
||||
|
||||
val base = 4
|
||||
val battery = u16(base)
|
||||
val temperature = u16(base + 2)
|
||||
val accX = s16(base + 4)
|
||||
val accY = s16(base + 6)
|
||||
val accZ = s16(base + 8)
|
||||
val gyrX = s16(base + 10)
|
||||
val gyrY = s16(base + 12)
|
||||
val gyrZ = s16(base + 14)
|
||||
|
||||
val ledValues = IntArray(24)
|
||||
for (i in 0 until 24) {
|
||||
val offset = base + 20 + (i * 2)
|
||||
if (offset + 1 >= data.size) {
|
||||
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
|
||||
android.util.Log.e(TAG, "parse: LED[$i] out of bounds: offset=${offset+1} >= size=${data.size}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
ledValues[i] = u16(offset)
|
||||
}
|
||||
|
||||
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
|
||||
android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}")
|
||||
}
|
||||
|
||||
return NirsMcjPacket(
|
||||
battery, temperature,
|
||||
accX, accY, accZ,
|
||||
gyrX, gyrY, gyrZ,
|
||||
ledValues
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is NirsMcjPacket) return false
|
||||
return battery == other.battery && ledValues.contentEquals(other.ledValues)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = battery * 31 + ledValues.contentHashCode()
|
||||
}
|
||||
|
||||
data class NirsMagResponse(val gainValues: IntArray) {
|
||||
companion object {
|
||||
fun parse(data: ByteArray): NirsMagResponse? {
|
||||
if (data.size < 52) return null
|
||||
val base = 4
|
||||
val values = IntArray(24) { i ->
|
||||
val offset = base + (i * 2)
|
||||
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||
}
|
||||
return NirsMagResponse(values)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is NirsMagResponse) return false
|
||||
return gainValues.contentEquals(other.gainValues)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = gainValues.contentHashCode()
|
||||
}
|
||||
|
||||
data class NirsOxyResult(
|
||||
val hbO2: Double = 0.0,
|
||||
val hb: Double = 0.0,
|
||||
val tHb: Double = 0.0,
|
||||
val stO2: Double = 0.0
|
||||
) {
|
||||
companion object {
|
||||
val zero = NirsOxyResult()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
enum class Posture(val displayName: String) {
|
||||
STANDING("Standing"),
|
||||
SITTING("Sitting"),
|
||||
LYING("Lying Down")
|
||||
}
|
||||
|
||||
enum class SensorPosition(val displayName: String, val description: String) {
|
||||
LOWER_ABDOMEN("Lower Abdomen", "Below the navel"),
|
||||
SUPRAPUBIC("Suprapubic", "Above the pubic bone")
|
||||
}
|
||||
|
||||
data class PiezoSettings(
|
||||
var posture: Posture = Posture.SITTING,
|
||||
var sensorPosition: SensorPosition = SensorPosition.LOWER_ABDOMEN,
|
||||
var maxVolume: Int = 500,
|
||||
// Charles 833c110: BV calculation tweak — anterior-wall pull-in magnitude in millimetres.
|
||||
var antOffsetMm: Int = 0,
|
||||
// 우리 4ae25d9: Auto Scan 측정 사이클 목표 간격 (ms).
|
||||
// BleManager.canSendMaa() time gate와 동일 값이 기본 (500ms).
|
||||
var autoScanIntervalMs: Int = 500
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
enum class SensorMode(val displayName: String) {
|
||||
NIRS("NIRS"),
|
||||
PIEZO("Piezo")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
enum class UrgencyLevel {
|
||||
SAFE, WARNING, URGENT;
|
||||
|
||||
val backgroundColor: Color
|
||||
get() = when (this) {
|
||||
SAFE -> Color(0xFFEDF8FF)
|
||||
WARNING -> Color(0xFFFFFAE6)
|
||||
URGENT -> Color(0xFFFFEBEB)
|
||||
}
|
||||
|
||||
val accentColor: Color
|
||||
get() = when (this) {
|
||||
SAFE -> Color(0xFF008FFF)
|
||||
WARNING -> Color(0xFFF5A623)
|
||||
URGENT -> Color(0xFFF04523)
|
||||
}
|
||||
|
||||
val gradientColors: List<Color>
|
||||
get() = when (this) {
|
||||
SAFE -> listOf(Color(0xFF00DBA1), Color(0xFF008FFF))
|
||||
WARNING -> listOf(Color(0xFFFECC2E), Color(0xFFF04A23))
|
||||
URGENT -> listOf(Color(0xFFF04A23), Color(0xFFCC1A1A))
|
||||
}
|
||||
|
||||
val message: String
|
||||
get() = when (this) {
|
||||
SAFE -> "Enjoy your day!"
|
||||
WARNING -> "Prepare for catheterization"
|
||||
URGENT -> "Please perform catheterization"
|
||||
}
|
||||
|
||||
val rawStringValue: String
|
||||
get() = when (this) {
|
||||
SAFE -> "safe"
|
||||
WARNING -> "warning"
|
||||
URGENT -> "urgent"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): UrgencyLevel = when (value) {
|
||||
"warning" -> WARNING
|
||||
"urgent" -> URGENT
|
||||
else -> SAFE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
enum class Sex { MALE, FEMALE }
|
||||
|
||||
data class User(
|
||||
var firstName: String = "",
|
||||
var lastName: String = "",
|
||||
var age: Int = 0,
|
||||
var sex: Sex? = null,
|
||||
var heightCm: Double = 0.0,
|
||||
var weightKg: Double = 0.0
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.os.Environment
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* ADC 데이터를 세션별 CSV 파일로 저장.
|
||||
*
|
||||
* 파일 위치: Downloads/VesiScan_ADC/YYYY-MM-DD_HHmmss.csv
|
||||
* newSession() 호출 시 새 파일로 전환 (BLE 연결 시 자동, 수동 호출 가능)
|
||||
*/
|
||||
object AdcCsvLogger {
|
||||
|
||||
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
var lastScanId: Int = 0
|
||||
private set
|
||||
private var scanCounter = 0
|
||||
private var sessionFile: File? = null
|
||||
|
||||
private fun getDir(): File {
|
||||
val dir = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"VesiScan_ADC"
|
||||
)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return dir
|
||||
}
|
||||
|
||||
fun newSession() {
|
||||
scanCounter = 0
|
||||
lastScanId = 0
|
||||
sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv")
|
||||
}
|
||||
|
||||
/**
|
||||
* 임상 모드: ClinicalSessionStore에 활성 세션이 있으면 그 step 폴더의 adc.csv 사용.
|
||||
* 일반 모드: 기존 세션 파일 사용.
|
||||
*/
|
||||
private fun getFile(): File {
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
return File(clinicalDir, "adc.csv")
|
||||
}
|
||||
if (sessionFile == null) newSession()
|
||||
return sessionFile!!
|
||||
}
|
||||
|
||||
fun log(
|
||||
channels: List<List<UShort>>,
|
||||
volumeMl: Double? = null,
|
||||
lrRatio: Double = 1.0
|
||||
) {
|
||||
try {
|
||||
val file = getFile()
|
||||
val isNew = !file.exists() || file.length() == 0L
|
||||
scanCounter++
|
||||
lastScanId = scanCounter
|
||||
|
||||
val timestamp = timeFmt.format(Date())
|
||||
val volStr = volumeMl?.let { "%.1f".format(it) } ?: ""
|
||||
val lrStr = "%.2f".format(lrRatio)
|
||||
val analyzer = com.medithings.vesiscan.managers.PiezoEchoAnalyzer.shared
|
||||
val thrStr = if (analyzer.useAdaptiveThreshold && analyzer.lastOtsuThreshold > 0)
|
||||
"%.0f".format(analyzer.lastOtsuThreshold) else "%.0f".format(com.medithings.vesiscan.managers.GreenZoneConstants.lowEchoAmp)
|
||||
val dpsStr = "%.3f".format(com.medithings.vesiscan.managers.PiezoHW.distancePerSample)
|
||||
val sgStr = if (analyzer.useSgFilter) "ON" else "OFF"
|
||||
|
||||
val clinicalLabel = ClinicalSessionStore.currentLabel() // 일반 모드면 ""
|
||||
// BLE 펌웨어 버전 (연결 안 됐으면 "")
|
||||
val fwVersion = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value ?: ""
|
||||
|
||||
java.io.FileWriter(file, !isNew).buffered().use { writer ->
|
||||
if (isNew) {
|
||||
val header = buildString {
|
||||
append("scan_id,timestamp,clinical_label,firmware_version,volume_ml,lr_ratio,threshold,dps,sg_filter,channel")
|
||||
for (i in 0 until 100) append(",s$i")
|
||||
}
|
||||
writer.write(header)
|
||||
writer.newLine()
|
||||
}
|
||||
|
||||
for ((chIdx, buffer) in channels.withIndex()) {
|
||||
val row = buildString {
|
||||
append("$scanCounter,$timestamp,$clinicalLabel,$fwVersion,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx")
|
||||
for (sample in buffer) {
|
||||
append(",${sample.toInt()}")
|
||||
}
|
||||
for (i in buffer.size until 100) {
|
||||
append(",")
|
||||
}
|
||||
}
|
||||
writer.write(row)
|
||||
writer.newLine()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* BLE 디버그 로거 — 펌웨어 디버깅용 상세 통신 로그.
|
||||
*
|
||||
* 모든 TX/RX 패킷, RSSI, 연결 상태, 에러를 기록하고
|
||||
* 파일로 내보내기 가능.
|
||||
*
|
||||
* 파일 위치: vesiscan_ble_logs/YYYY-MM-DD_HHmmss.log
|
||||
*/
|
||||
class BleDebugLogger private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: BleDebugLogger? = null
|
||||
fun getInstance(context: Context) = instance ?: synchronized(this) {
|
||||
instance ?: BleDebugLogger(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
private const val MAX_MEMORY_ENTRIES = 2000
|
||||
}
|
||||
|
||||
enum class Level { TX, RX, INFO, WARN, ERROR, RSSI, CONN }
|
||||
|
||||
data class Entry(
|
||||
val timestamp: Long,
|
||||
val level: Level,
|
||||
val message: String,
|
||||
val dataSize: Int = 0,
|
||||
val rssi: Int? = null
|
||||
) {
|
||||
private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
val timeStr: String get() = fmt.format(Date(timestamp))
|
||||
|
||||
fun toLogLine(): String {
|
||||
val tag = level.name.padEnd(5)
|
||||
val extra = when {
|
||||
dataSize > 0 -> " [${dataSize}B]"
|
||||
rssi != null -> " [${rssi}dBm]"
|
||||
else -> ""
|
||||
}
|
||||
return "$timeStr $tag $message$extra"
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("t", timestamp)
|
||||
put("level", level.name)
|
||||
put("msg", message)
|
||||
if (dataSize > 0) put("bytes", dataSize)
|
||||
if (rssi != null) put("rssi", rssi)
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory ring buffer
|
||||
private val entries = CopyOnWriteArrayList<Entry>()
|
||||
|
||||
// Session tracking
|
||||
private var sessionStart: Long = System.currentTimeMillis()
|
||||
var deviceName: String = ""
|
||||
private set
|
||||
private var txCount = 0
|
||||
private var rxCount = 0
|
||||
private var errorCount = 0
|
||||
private var lastRssi: Int = 0
|
||||
private val rssiHistory = mutableListOf<Pair<Long, Int>>()
|
||||
private var packetTimeouts = 0
|
||||
private var channelMissCount = 0
|
||||
|
||||
// File
|
||||
private val logDir = File(context.filesDir, "vesiscan_ble_logs").also { it.mkdirs() }
|
||||
private val dateFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
|
||||
// 실시간 파일 기록 (화면 잠금/앱 종료에도 보존)
|
||||
// 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일)
|
||||
// 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일)
|
||||
private var liveLogFile: File? = null
|
||||
private var liveLogLabel: String = "" // 임상 모드 측정 단위 변경 감지
|
||||
private fun ensureLiveLogFile(): File {
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
val currentLabel = ClinicalSessionStore.currentLabel()
|
||||
// 측정 단위가 바뀌었으면 새 파일로 전환
|
||||
if (liveLogFile == null || liveLogLabel != currentLabel) {
|
||||
liveLogFile = File(clinicalDir, "ble.log")
|
||||
liveLogLabel = currentLabel
|
||||
}
|
||||
} else if (liveLogFile == null || liveLogLabel.isNotEmpty()) {
|
||||
// 일반 모드 진입 또는 임상 모드 종료
|
||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
|
||||
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
|
||||
liveLogLabel = ""
|
||||
}
|
||||
return liveLogFile!!
|
||||
}
|
||||
private fun appendToFile(line: String) {
|
||||
try {
|
||||
ensureLiveLogFile().appendText(line + "\n")
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// ── Logging Methods ──
|
||||
|
||||
fun tx(command: String, dataSize: Int = 0) {
|
||||
txCount++
|
||||
add(Entry(System.currentTimeMillis(), Level.TX, command, dataSize))
|
||||
}
|
||||
|
||||
fun rx(prefix: String, dataSize: Int = 0, detail: String = "") {
|
||||
rxCount++
|
||||
val msg = if (detail.isNotEmpty()) "$prefix $detail" else prefix
|
||||
add(Entry(System.currentTimeMillis(), Level.RX, msg, dataSize))
|
||||
}
|
||||
|
||||
fun rssi(value: Int) {
|
||||
lastRssi = value
|
||||
rssiHistory.add(System.currentTimeMillis() to value)
|
||||
if (rssiHistory.size > 500) rssiHistory.removeAt(0)
|
||||
add(Entry(System.currentTimeMillis(), Level.RSSI, "RSSI=$value dBm", rssi = value))
|
||||
}
|
||||
|
||||
/** mid?/rid: 응답 받은 후 호출 — 펌웨어 정보를 ble.log에 보기 좋게 헤더 형태로 기록 */
|
||||
fun deviceInfo(fw: String, hw: String, sn: String) {
|
||||
appendToFile("─── Device Info ───")
|
||||
if (fw.isNotEmpty()) appendToFile("Firmware: $fw")
|
||||
if (hw.isNotEmpty()) appendToFile("Hardware: $hw")
|
||||
if (sn.isNotEmpty()) appendToFile("Serial: $sn")
|
||||
appendToFile("───────────────────")
|
||||
}
|
||||
|
||||
fun connected(name: String, address: String) {
|
||||
deviceName = name
|
||||
sessionStart = System.currentTimeMillis()
|
||||
txCount = 0; rxCount = 0; errorCount = 0; packetTimeouts = 0; channelMissCount = 0
|
||||
rssiHistory.clear()
|
||||
liveLogFile = null // 새 세션 → 새 파일
|
||||
appendToFile("=== VesiScan BLE Debug Log ===")
|
||||
appendToFile("Device: $name")
|
||||
appendToFile("Session: ${dateFmt.format(Date())}")
|
||||
appendToFile("============================================================")
|
||||
add(Entry(System.currentTimeMillis(), Level.CONN, "CONNECTED $name ($address)"))
|
||||
}
|
||||
|
||||
fun disconnected(reason: String = "", gattStatus: Int = -1) {
|
||||
val msg = buildString {
|
||||
append("DISCONNECTED")
|
||||
if (reason.isNotEmpty()) append(" reason=$reason")
|
||||
if (gattStatus >= 0) append(" gatt_status=$gattStatus")
|
||||
append(" session=${sessionDurationSec()}s tx=$txCount rx=$rxCount err=$errorCount")
|
||||
}
|
||||
add(Entry(System.currentTimeMillis(), Level.CONN, msg))
|
||||
}
|
||||
|
||||
fun reconnecting(attempt: Int, maxAttempt: Int, delayMs: Long) {
|
||||
add(Entry(System.currentTimeMillis(), Level.CONN, "RECONNECTING attempt=$attempt/$maxAttempt delay=${delayMs}ms"))
|
||||
}
|
||||
|
||||
fun reconnectFailed() {
|
||||
errorCount++
|
||||
add(Entry(System.currentTimeMillis(), Level.ERROR, "RECONNECT_FAILED after all attempts"))
|
||||
}
|
||||
|
||||
fun info(message: String) {
|
||||
add(Entry(System.currentTimeMillis(), Level.INFO, message))
|
||||
}
|
||||
|
||||
fun warn(message: String) {
|
||||
add(Entry(System.currentTimeMillis(), Level.WARN, message))
|
||||
}
|
||||
|
||||
fun error(message: String) {
|
||||
errorCount++
|
||||
add(Entry(System.currentTimeMillis(), Level.ERROR, message))
|
||||
}
|
||||
|
||||
fun packetTimeout(channel: Int, retryCount: Int) {
|
||||
packetTimeouts++
|
||||
error("PACKET_TIMEOUT ch=$channel retry=$retryCount")
|
||||
}
|
||||
|
||||
fun channelMissing(expectedChannels: Int, receivedChannels: Int) {
|
||||
channelMissCount++
|
||||
warn("CHANNEL_MISS expected=$expectedChannels received=$receivedChannels")
|
||||
}
|
||||
|
||||
fun endianDetected(isBigEndian: Boolean, firstSampleLE: Int, firstSampleBE: Int) {
|
||||
info("ENDIAN_DETECT ${if (isBigEndian) "BIG" else "LITTLE"} (LE=$firstSampleLE, BE=$firstSampleBE)")
|
||||
}
|
||||
|
||||
fun measurementResult(volumeMl: Double?, lrRatio: Double, validChannels: Int, urineLines: List<Int>, otsuThreshold: Double = 0.0) {
|
||||
val scanId = AdcCsvLogger.lastScanId
|
||||
val msg = buildString {
|
||||
append("MEASUREMENT scan=$scanId")
|
||||
append(" vol=${volumeMl?.let { "%.1f".format(it) } ?: "null"}ml")
|
||||
append(" lr=${"%.2f".format(lrRatio)}")
|
||||
append(" valid=$validChannels/6")
|
||||
append(" urine=[${urineLines.joinToString(",")}]")
|
||||
if (otsuThreshold > 0) append(" otsu=${"%.0f".format(otsuThreshold)}")
|
||||
}
|
||||
add(Entry(System.currentTimeMillis(), Level.INFO, msg))
|
||||
}
|
||||
|
||||
// ── Access ──
|
||||
|
||||
private fun add(entry: Entry) {
|
||||
entries.add(entry)
|
||||
if (entries.size > MAX_MEMORY_ENTRIES) {
|
||||
entries.removeAt(0)
|
||||
}
|
||||
appendToFile(entry.toLogLine())
|
||||
}
|
||||
|
||||
fun getEntries(): List<Entry> = entries.toList()
|
||||
|
||||
fun getRecentEntries(count: Int = 100): List<Entry> {
|
||||
val all = entries.toList()
|
||||
return if (all.size > count) all.subList(all.size - count, all.size) else all
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
entries.clear()
|
||||
}
|
||||
|
||||
// ── Stats ──
|
||||
|
||||
private fun sessionDurationSec(): Long = (System.currentTimeMillis() - sessionStart) / 1000
|
||||
|
||||
fun getStats(): JSONObject = JSONObject().apply {
|
||||
put("session_duration_sec", sessionDurationSec())
|
||||
put("device", deviceName)
|
||||
put("tx_count", txCount)
|
||||
put("rx_count", rxCount)
|
||||
put("error_count", errorCount)
|
||||
put("packet_timeouts", packetTimeouts)
|
||||
put("channel_misses", channelMissCount)
|
||||
put("last_rssi", lastRssi)
|
||||
if (rssiHistory.isNotEmpty()) {
|
||||
put("avg_rssi", rssiHistory.map { it.second }.average().toInt())
|
||||
put("min_rssi", rssiHistory.minOf { it.second })
|
||||
put("max_rssi", rssiHistory.maxOf { it.second })
|
||||
}
|
||||
}
|
||||
|
||||
// ── File Export ──
|
||||
|
||||
private fun writeLogContent(writer: java.io.BufferedWriter) {
|
||||
writer.write("=== VesiScan BLE Debug Log ===\n")
|
||||
writer.write("Device: $deviceName\n")
|
||||
writer.write("Session: ${dateFmt.format(Date(sessionStart))}\n")
|
||||
writer.write("Duration: ${sessionDurationSec()}s\n")
|
||||
writer.write("TX: $txCount RX: $rxCount Errors: $errorCount Timeouts: $packetTimeouts\n")
|
||||
|
||||
if (rssiHistory.isNotEmpty()) {
|
||||
writer.write("RSSI: avg=${rssiHistory.map { it.second }.average().toInt()} ")
|
||||
writer.write("min=${rssiHistory.minOf { it.second }} max=${rssiHistory.maxOf { it.second }}\n")
|
||||
}
|
||||
|
||||
writer.write("=".repeat(60) + "\n\n")
|
||||
|
||||
for (entry in entries) {
|
||||
writer.write(entry.toLogLine() + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
fun exportToFile(): File {
|
||||
val fileName = "ble_debug_${dateFmt.format(Date())}.log"
|
||||
val file = File(logDir, fileName)
|
||||
file.bufferedWriter().use { writeLogContent(it) }
|
||||
return file
|
||||
}
|
||||
|
||||
/** Downloads 폴더에 저장 (파일 탐색기에서 바로 접근 가능) */
|
||||
fun exportToDownloads(): File? {
|
||||
return try {
|
||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(
|
||||
android.os.Environment.DIRECTORY_DOWNLOADS
|
||||
)
|
||||
val fileName = "VesiScan_BLE_${dateFmt.format(Date())}.log"
|
||||
val file = File(downloadsDir, fileName)
|
||||
file.bufferedWriter().use { writeLogContent(it) }
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun exportToJsonFile(): File {
|
||||
val fileName = "ble_debug_${dateFmt.format(Date())}.jsonl"
|
||||
val file = File(logDir, fileName)
|
||||
|
||||
file.bufferedWriter().use { writer ->
|
||||
val header = JSONObject().apply {
|
||||
put("type", "header")
|
||||
put("device", deviceName)
|
||||
put("session_start", sessionStart)
|
||||
put("stats", getStats())
|
||||
}
|
||||
writer.write(header.toString() + "\n")
|
||||
|
||||
for (entry in entries) {
|
||||
writer.write(entry.toJson().toString() + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
fun getShareIntent(): Intent {
|
||||
val file = exportToFile()
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
return Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "VesiScan BLE Debug Log — $deviceName")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLogText(): String {
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("=== VesiScan BLE Debug ===")
|
||||
sb.appendLine("Device: $deviceName | TX:$txCount RX:$rxCount Err:$errorCount Timeout:$packetTimeouts")
|
||||
if (rssiHistory.isNotEmpty()) {
|
||||
sb.appendLine("RSSI: avg=${rssiHistory.map { it.second }.average().toInt()} min=${rssiHistory.minOf { it.second }}")
|
||||
}
|
||||
sb.appendLine("---")
|
||||
for (entry in entries.takeLast(200)) {
|
||||
sb.appendLine(entry.toLogLine())
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** 7일 이상 된 로그 삭제 */
|
||||
fun purgeOldLogs(keepDays: Int = 7) {
|
||||
val cutoff = System.currentTimeMillis() - keepDays * 24 * 60 * 60 * 1000L
|
||||
logDir.listFiles()?.filter { it.lastModified() < cutoff }?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.medithings.vesiscan.MainActivity
|
||||
import com.medithings.vesiscan.R
|
||||
|
||||
/**
|
||||
* BLE 연결 유지를 위한 Foreground Service.
|
||||
*
|
||||
* 화면이 꺼져도 Android Doze 모드에서 앱을 유지하여
|
||||
* BLE 통신(TX/RX), watchdog, Auto 측정이 중단되지 않도록 한다.
|
||||
*/
|
||||
class BleForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "vesiscan_ble_channel"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_START = "com.medithings.vesiscan.BLE_START"
|
||||
const val ACTION_STOP = "com.medithings.vesiscan.BLE_STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
// 배터리 최적화 제외 요청 제거 — 다른 페이지로 이동되는 UX 문제
|
||||
// Foreground Service + PARTIAL_WAKE_LOCK만으로 Doze 대응
|
||||
|
||||
val intent = Intent(context, BleForegroundService::class.java).apply {
|
||||
action = ACTION_START
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.SuppressLint("BatteryLife")
|
||||
private fun requestBatteryOptimizationExemption(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!pm.isIgnoringBatteryOptimizations(context.packageName)) {
|
||||
try {
|
||||
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = android.net.Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, BleForegroundService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
releaseWakeLock()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
else -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(),
|
||||
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
acquireWakeLock()
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
releaseWakeLock()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"VesiScan BLE Connection",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = getString(R.string.ble_service_channel_desc)
|
||||
setShowBadge(false)
|
||||
}
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("VesiScan")
|
||||
.setContentText(getString(R.string.ble_service_connecting))
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock == null) {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"VesiScan::BleWakeLock"
|
||||
).apply {
|
||||
acquire(4 * 60 * 60 * 1000L) // 최대 4시간
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) it.release()
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.medithings.vesiscan.ble.ImuSample
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import com.medithings.vesiscan.models.ClinicalSession
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플.
|
||||
* alignment 모드일 때 alignXxx 필드들이 채워짐 (V1 advisor 출력). */
|
||||
data class MeasurementCycle(
|
||||
val cycle: Int,
|
||||
val timestampMs: Long,
|
||||
val piezoChannels: Map<Int, List<Int>>, // channel idx → samples
|
||||
val imuSamples: List<ImuSample>,
|
||||
// Alignment mode metadata (V1 placementPhase, detected channels, urineLens)
|
||||
val alignPhase: String = "", // VERTICAL / LATERAL / GREEN / ""
|
||||
val alignScore: Int = 0, // placementScore
|
||||
val alignDetected: List<Boolean> = emptyList(), // 6개 채널 검출 여부
|
||||
val alignUrineLens: List<Int> = emptyList(), // 6개 채널 urineLen
|
||||
val alignHint: String = "", // directionHint
|
||||
val alignIcon: String = "", // directionIcon
|
||||
)
|
||||
|
||||
/**
|
||||
* 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store.
|
||||
*
|
||||
* 사용자가 BLE 연결 후 라벨을 입력하고 startMeasurement()를 호출하면
|
||||
* Downloads/VesiScan_Sessions/{folderName}/ 폴더가 생성되고 meta.json이 작성됨.
|
||||
* 이후 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)가
|
||||
* 해당 폴더로 자동 라우팅. endMeasurement()에서 meta.json이 finalize됨.
|
||||
*
|
||||
* zip은 사용하지 않음 — 폴더 자체가 분석 단위.
|
||||
*/
|
||||
object ClinicalSessionStore {
|
||||
|
||||
/** labdb 자료 구분 코드 — "001" = 내부 임상 R&D (외부 일반 VesiScan "000"과 구분). */
|
||||
const val DATA_TYPE: String = "001"
|
||||
|
||||
/** ClinicalHome 화면에 진입한 상태인지. BLE 연결을 위한 DeviceScan 우회/Back 라우팅에 사용. */
|
||||
@Volatile
|
||||
var inClinicalFlow: Boolean = false
|
||||
|
||||
@Volatile
|
||||
var currentSession: ClinicalSession? = null
|
||||
private set
|
||||
|
||||
/** 마지막으로 종료된 측정 (UI에서 "Saved: ..." 표시용) */
|
||||
@Volatile
|
||||
var lastFinalizedFolder: File? = null
|
||||
private set
|
||||
|
||||
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 — 같은 피험자/검사자 여러 측정 시 편의 */
|
||||
var lastExaminer: String = ""
|
||||
var lastNotes: String = ""
|
||||
var lastSubjectType: com.medithings.vesiscan.models.SubjectType =
|
||||
com.medithings.vesiscan.models.SubjectType.PHANTOM
|
||||
var lastSubject: String = "" // 카테고리 내 식별자 (예: "500mL", "S001")
|
||||
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
|
||||
var lastAbdomenThicknessMm: String = ""
|
||||
// 회의 결정: Step/Posture 마지막 선택 유지 — 매번 0cm로 리셋되는 불편 제거
|
||||
var lastPosture: com.medithings.vesiscan.models.ClinicalPosture =
|
||||
com.medithings.vesiscan.models.ClinicalPosture.SUPINE
|
||||
var lastStep: com.medithings.vesiscan.models.ClinicalStep =
|
||||
com.medithings.vesiscan.models.ClinicalStep.ALIGN_0CM
|
||||
|
||||
/** End 시점에 measurement.json 통합 생성용 — 측정 중 사이클 누적 */
|
||||
private val cyclesBuffer = mutableListOf<MeasurementCycle>()
|
||||
|
||||
fun startMeasurement(context: Context, session: ClinicalSession) {
|
||||
currentSession = session
|
||||
lastExaminer = session.examiner
|
||||
lastNotes = session.notes
|
||||
lastSubjectType = session.subjectType
|
||||
lastSubject = session.subject
|
||||
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
|
||||
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
|
||||
lastPosture = session.posture
|
||||
lastStep = session.step
|
||||
cyclesBuffer.clear()
|
||||
currentLogDir()?.mkdirs()
|
||||
writeMeta(context)
|
||||
}
|
||||
|
||||
/** 한 사이클(piezo + IMU) 누적 — End 시 measurement.json에 합쳐짐 */
|
||||
fun addCycle(piezo: List<PiezoChannelData>, imu: List<ImuSample>) {
|
||||
if (currentSession == null) return
|
||||
cyclesBuffer.add(MeasurementCycle(
|
||||
cycle = cyclesBuffer.size,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
piezoChannels = piezo.sortedBy { it.channel }
|
||||
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
|
||||
imuSamples = imu.toList(),
|
||||
))
|
||||
}
|
||||
|
||||
/** Alignment 모드 frame 누적 — V1 advisor 출력 포함 (phase / score / detected / urineLens / hint). */
|
||||
fun addAlignmentFrame(
|
||||
piezo: List<PiezoChannelData>,
|
||||
imu: List<ImuSample>,
|
||||
alignPhase: String,
|
||||
alignScore: Int,
|
||||
alignDetected: List<Boolean>,
|
||||
alignUrineLens: List<Int>,
|
||||
alignHint: String,
|
||||
alignIcon: String,
|
||||
) {
|
||||
if (currentSession == null) return
|
||||
cyclesBuffer.add(MeasurementCycle(
|
||||
cycle = cyclesBuffer.size,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
piezoChannels = piezo.sortedBy { it.channel }
|
||||
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
|
||||
imuSamples = imu.toList(),
|
||||
alignPhase = alignPhase,
|
||||
alignScore = alignScore,
|
||||
alignDetected = alignDetected,
|
||||
alignUrineLens = alignUrineLens,
|
||||
alignHint = alignHint,
|
||||
alignIcon = alignIcon,
|
||||
))
|
||||
}
|
||||
|
||||
val cycleCount: Int get() = cyclesBuffer.size
|
||||
|
||||
/** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */
|
||||
fun currentLogDir(): File? {
|
||||
val s = currentSession ?: return null
|
||||
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
return File(File(downloads, "VesiScan_Sessions"), s.folderName)
|
||||
}
|
||||
|
||||
/** clinical_label 컬럼/필드용. 비활성 시 "" */
|
||||
fun currentLabel(): String = currentSession?.label ?: ""
|
||||
|
||||
/**
|
||||
* 폴더명 정보를 파일명에 포함시켜 분석 PC로 옮긴 후에도 식별 가능하게.
|
||||
* 예: meta_PHANTOM_VBT26050202_SUPINE_ALIGN_0CM_2026-06-09_15-30-00.json
|
||||
*/
|
||||
private fun metaJsonFile(dir: File): File = File(dir, "meta_${dir.name}.json")
|
||||
private fun measurementJsonFile(dir: File): File = File(dir, "measurement_${dir.name}.json")
|
||||
|
||||
fun writeMeta(context: Context) {
|
||||
val s = currentSession ?: return
|
||||
val dir = currentLogDir() ?: return
|
||||
dir.mkdirs()
|
||||
val meta = JSONObject().apply {
|
||||
put("data_type", DATA_TYPE)
|
||||
put("device_name", s.deviceName)
|
||||
put("posture", s.posture.name)
|
||||
put("step", s.step.name)
|
||||
put("is_alignment", s.step.isAlignment)
|
||||
put("examiner", s.examiner)
|
||||
put("notes", s.notes)
|
||||
put("label", s.label)
|
||||
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC)
|
||||
put("subject_type", s.subjectType.name)
|
||||
if (s.subject.isNotBlank()) put("subject", s.subject)
|
||||
// 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜)
|
||||
s.trueVolumeMl?.let { put("true_volume_ml", it) }
|
||||
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
|
||||
put("started_at", s.startedAt)
|
||||
put("started_at_iso",
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
|
||||
s.endedAt?.let {
|
||||
put("ended_at", it)
|
||||
put("ended_at_iso",
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
|
||||
put("duration_ms", it - s.startedAt)
|
||||
}
|
||||
put("app_version_name", try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
|
||||
} catch (_: Exception) { "unknown" })
|
||||
put("app_version_code", try {
|
||||
@Suppress("DEPRECATION")
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
|
||||
} catch (_: Exception) { -1 })
|
||||
put("device_model", android.os.Build.MODEL)
|
||||
put("device_manufacturer", android.os.Build.MANUFACTURER)
|
||||
put("android_release", android.os.Build.VERSION.RELEASE)
|
||||
put("android_sdk", android.os.Build.VERSION.SDK_INT)
|
||||
// VBT 디바이스 정보 (mid?/rid: 응답 파싱값) — 분석 시 펌웨어별 데이터 grouping용
|
||||
val ble = com.medithings.vesiscan.ble.BleManager.getInstance(context)
|
||||
val fw = ble.firmwareVersion.value
|
||||
val hw = ble.hardwareVersion.value
|
||||
val sn = ble.serialNumber.value
|
||||
if (fw.isNotEmpty()) put("firmware_version", fw)
|
||||
if (hw.isNotEmpty()) put("hardware_version", hw)
|
||||
if (sn.isNotEmpty()) put("serial_number", sn)
|
||||
}
|
||||
metaJsonFile(dir).writeText(meta.toString(2))
|
||||
}
|
||||
|
||||
/** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null.
|
||||
* labdb 업로드는 자동 X — Clinical Home 의 'Last saved' 카드의 Upload 버튼으로 수동 트리거. */
|
||||
fun endMeasurement(context: Context): File? {
|
||||
val s = currentSession ?: return null
|
||||
s.endedAt = System.currentTimeMillis()
|
||||
writeMeta(context)
|
||||
writeMeasurementJson(context)
|
||||
val dir = currentLogDir()
|
||||
lastFinalizedFolder = dir
|
||||
currentSession = null
|
||||
cyclesBuffer.clear()
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* End Session 시점에 모든 사이클을 단일 measurement.json으로 통합.
|
||||
* 분석 PC에서 한 줄 로드: data = json.load(open('measurement.json'))
|
||||
*/
|
||||
private fun writeMeasurementJson(context: Context) {
|
||||
val s = currentSession ?: return
|
||||
val dir = currentLogDir() ?: return
|
||||
dir.mkdirs()
|
||||
try {
|
||||
val root = JSONObject()
|
||||
|
||||
// meta: writeMeta()와 동일 내용 포함 (단독 파일로 자족 가능)
|
||||
// alignment 모드 → dataType=002, 그 외 = 001 (기존 measurement)
|
||||
val effectiveDataType = if (s.mode == com.medithings.vesiscan.models.SessionMode.ALIGNMENT)
|
||||
"002" else DATA_TYPE
|
||||
val meta = JSONObject().apply {
|
||||
put("data_type", effectiveDataType)
|
||||
put("session_mode", s.mode.name)
|
||||
if (s.alignmentAlgo.isNotBlank()) put("alignment_algo", s.alignmentAlgo)
|
||||
put("subject_type", s.subjectType.name)
|
||||
if (s.subject.isNotBlank()) put("subject", s.subject)
|
||||
put("device_name", s.deviceName)
|
||||
put("posture", s.posture.name)
|
||||
put("step", s.step.name)
|
||||
put("is_alignment", s.step.isAlignment)
|
||||
put("examiner", s.examiner)
|
||||
put("notes", s.notes)
|
||||
put("label", s.label)
|
||||
s.trueVolumeMl?.let { put("true_volume_ml", it) }
|
||||
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
|
||||
put("started_at", s.startedAt)
|
||||
put("started_at_iso",
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
|
||||
s.endedAt?.let {
|
||||
put("ended_at", it)
|
||||
put("ended_at_iso",
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
|
||||
put("duration_ms", it - s.startedAt)
|
||||
}
|
||||
put("captured_cycles", cyclesBuffer.size)
|
||||
put("app_version_name", try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
|
||||
} catch (_: Exception) { "unknown" })
|
||||
val ble = com.medithings.vesiscan.ble.BleManager.current
|
||||
ble?.firmwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("firmware_version", it) }
|
||||
ble?.hardwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("hardware_version", it) }
|
||||
ble?.serialNumber?.value?.takeIf { it.isNotEmpty() }?.let { put("serial_number", it) }
|
||||
}
|
||||
root.put("meta", meta)
|
||||
|
||||
// cycles: 모든 사이클을 piezo + imu 묶음으로
|
||||
val cyclesArr = JSONArray()
|
||||
for (c in cyclesBuffer) {
|
||||
val piezo = JSONObject()
|
||||
c.piezoChannels.forEach { (ch, samples) ->
|
||||
piezo.put("CH$ch", JSONArray(samples))
|
||||
}
|
||||
val imuArr = JSONArray()
|
||||
for (sample in c.imuSamples) {
|
||||
imuArr.put(JSONObject().apply {
|
||||
put("ax", sample.ax.toDouble())
|
||||
put("ay", sample.ay.toDouble())
|
||||
put("az", sample.az.toDouble())
|
||||
put("gx", sample.gx.toDouble())
|
||||
put("gy", sample.gy.toDouble())
|
||||
put("gz", sample.gz.toDouble())
|
||||
})
|
||||
}
|
||||
cyclesArr.put(JSONObject().apply {
|
||||
put("cycle", c.cycle)
|
||||
put("timestamp_ms", c.timestampMs)
|
||||
put("piezo", piezo)
|
||||
put("imu", imuArr)
|
||||
// Alignment metadata (alignment mode 일 때만 채워짐)
|
||||
if (c.alignPhase.isNotBlank()) {
|
||||
put("align_phase", c.alignPhase)
|
||||
put("align_score", c.alignScore)
|
||||
put("align_hint", c.alignHint)
|
||||
put("align_icon", c.alignIcon)
|
||||
put("align_detected", JSONArray(c.alignDetected))
|
||||
put("align_urine_lens", JSONArray(c.alignUrineLens))
|
||||
}
|
||||
})
|
||||
}
|
||||
root.put("cycles", cyclesArr)
|
||||
|
||||
measurementJsonFile(dir).writeText(root.toString(2))
|
||||
} catch (_: Exception) {
|
||||
// 저장 실패해도 측정 자체는 막지 않음
|
||||
}
|
||||
}
|
||||
|
||||
/** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */
|
||||
fun discardMeasurement() {
|
||||
currentSession = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 측정 중인 세션을 즉시 폐기 + 폴더 완전 삭제 (잘못 측정 인지 시).
|
||||
* endMeasurement()와 달리 meta.json/measurement.json/labdb 업로드 전부 skip.
|
||||
* 반환: 삭제된 폴더 경로 (없으면 null).
|
||||
*/
|
||||
fun discardAndDeleteFolder(): File? {
|
||||
val dir = currentLogDir()
|
||||
currentSession = null
|
||||
cyclesBuffer.clear()
|
||||
if (dir != null && dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 finalize된 측정 폴더(lastFinalizedFolder)를 삭제.
|
||||
* labdb 서버 데이터까지 제거하려면 호출자가 LabdbClient.deleteSession()도 별도 호출.
|
||||
*/
|
||||
fun deleteFinalizedFolder(): File? {
|
||||
val dir = lastFinalizedFolder
|
||||
if (dir != null && dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
lastFinalizedFolder = null
|
||||
return dir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import com.medithings.vesiscan.ble.ImuSample
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* IMU 샘플을 CSV로 저장. 임상 R&D 모드 한정.
|
||||
*
|
||||
* 파일 위치 (임상 모드일 때):
|
||||
* Downloads/VesiScan_Sessions/{folder}/imu.csv
|
||||
*
|
||||
* 한 번의 mtb? 응답에서 15 샘플 → CSV 15행. scan_id로 같은 측정의 piezo adc.csv와 매칭 가능.
|
||||
*
|
||||
* Sensitivity:
|
||||
* ax/ay/az: g (Accel ±4g, 1 LSB = 1/8192 g)
|
||||
* gx/gy/gz: dps (Gyro ±500 dps, 1 LSB = 1/65.536 dps)
|
||||
*
|
||||
* Sample 순서: 한 응답 안에서 oldest→newest. sample_idx 컬럼으로 명시.
|
||||
*/
|
||||
object ImuCsvLogger {
|
||||
|
||||
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
/**
|
||||
* 임상 모드 한정. ClinicalSessionStore.currentLogDir()가 있을 때만 저장.
|
||||
* samples를 oldest→newest 순서로 그대로 기록.
|
||||
*/
|
||||
fun log(samples: List<ImuSample>) {
|
||||
val dir = ClinicalSessionStore.currentLogDir() ?: return
|
||||
if (samples.isEmpty()) return
|
||||
try {
|
||||
dir.mkdirs()
|
||||
val file = File(dir, "imu.csv")
|
||||
val isNew = !file.exists() || file.length() == 0L
|
||||
val scanId = AdcCsvLogger.lastScanId // piezo adc.csv와 매칭용
|
||||
val timestamp = timeFmt.format(Date())
|
||||
val clinicalLabel = ClinicalSessionStore.currentLabel()
|
||||
val fwVersion = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value ?: ""
|
||||
|
||||
file.bufferedWriter().use { _ -> } // ensure exists
|
||||
java.io.FileWriter(file, !isNew).buffered().use { writer ->
|
||||
if (isNew) {
|
||||
writer.write("scan_id,timestamp,clinical_label,firmware_version,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps")
|
||||
writer.newLine()
|
||||
}
|
||||
for ((idx, s) in samples.withIndex()) {
|
||||
writer.write(buildString {
|
||||
append("$scanId,$timestamp,$clinicalLabel,$fwVersion,$idx,")
|
||||
append("%.4f".format(s.ax)); append(",")
|
||||
append("%.4f".format(s.ay)); append(",")
|
||||
append("%.4f".format(s.az)); append(",")
|
||||
append("%.4f".format(s.gx)); append(",")
|
||||
append("%.4f".format(s.gy)); append(",")
|
||||
append("%.4f".format(s.gz))
|
||||
})
|
||||
writer.newLine()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 로그는 측정 자체를 막으면 안 됨 — silent fail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
object KeychainService {
|
||||
private const val FILE_NAME = "medilightv2_secure_prefs"
|
||||
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
FILE_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
fun save(key: String, value: String) {
|
||||
prefs?.edit()?.putString(key, value)?.apply()
|
||||
}
|
||||
|
||||
fun load(key: String): String? = prefs?.getString(key, null)
|
||||
|
||||
fun delete(key: String) {
|
||||
prefs?.edit()?.remove(key)?.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 페어링했던 BLE 기기 다수 영속화.
|
||||
* SharedPreferences "medilight_prefs" 의 "known_devices" 키에 JSON array.
|
||||
*
|
||||
* 기존 단일 키(last_device_address/last_device_name) 는 마이그레이션 위해 1회 읽어 합치고
|
||||
* BleManager 가 새로 add() 호출 시 같이 정리.
|
||||
*/
|
||||
data class KnownDevice(
|
||||
val address: String,
|
||||
val name: String,
|
||||
val lastConnectedAt: Long,
|
||||
)
|
||||
|
||||
object KnownDeviceStore {
|
||||
private const val PREFS = "medilight_prefs"
|
||||
private const val KEY = "known_devices"
|
||||
|
||||
fun getAll(context: Context): List<KnownDevice> {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val raw = prefs.getString(KEY, null)
|
||||
val list = mutableListOf<KnownDevice>()
|
||||
if (!raw.isNullOrBlank()) {
|
||||
runCatching {
|
||||
val arr = JSONArray(raw)
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
val addr = o.optString("address").takeIf { it.isNotBlank() } ?: continue
|
||||
val name = o.optString("name", "Unknown")
|
||||
val ts = o.optLong("lastConnectedAt", 0L)
|
||||
list.add(KnownDevice(addr, name, ts))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 구 키 마이그레이션 — 한 번이라도 들어와 보면 list 에 흡수
|
||||
val legacyAddr = prefs.getString("last_device_address", null)
|
||||
val legacyName = prefs.getString("last_device_name", null)
|
||||
if (!legacyAddr.isNullOrBlank() && list.none { it.address == legacyAddr }) {
|
||||
list.add(KnownDevice(legacyAddr, legacyName ?: "Unknown", System.currentTimeMillis()))
|
||||
saveAll(context, list)
|
||||
}
|
||||
return list.sortedByDescending { it.lastConnectedAt }
|
||||
}
|
||||
|
||||
/** 새 연결 성공 시 호출 — 있으면 lastConnectedAt 갱신, 없으면 추가. 구 단일 키도 같이 채움. */
|
||||
fun add(context: Context, address: String, name: String) {
|
||||
val current = getAll(context).toMutableList()
|
||||
val idx = current.indexOfFirst { it.address == address }
|
||||
val now = System.currentTimeMillis()
|
||||
if (idx >= 0) current[idx] = current[idx].copy(name = name, lastConnectedAt = now)
|
||||
else current.add(KnownDevice(address, name, now))
|
||||
saveAll(context, current)
|
||||
// 구 키도 호환성 유지
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString("last_device_address", address)
|
||||
.putString("last_device_name", name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 페어링 해제 시 호출 — known list 에서 제거. */
|
||||
fun remove(context: Context, address: String) {
|
||||
val current = getAll(context).filter { it.address != address }
|
||||
saveAll(context, current)
|
||||
// 구 키 정리 — 제거한 게 마지막 기기였다면
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (prefs.getString("last_device_address", null) == address) {
|
||||
prefs.edit().remove("last_device_address").remove("last_device_name").apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun mostRecent(context: Context): KnownDevice? = getAll(context).firstOrNull()
|
||||
|
||||
private fun saveAll(context: Context, list: List<KnownDevice>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { d ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("address", d.address)
|
||||
put("name", d.name)
|
||||
put("lastConnectedAt", d.lastConnectedAt)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY, arr.toString())
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* LocalePref — 앱 언어 설정 영속화 + 컨텍스트 wrapping.
|
||||
*
|
||||
* 모드:
|
||||
* - SYSTEM (default): 폰 시스템 언어 따라감 (Korean → 한국어, 그 외 → English)
|
||||
* - KO : 강제 한국어
|
||||
* - EN : 강제 영어
|
||||
*
|
||||
* 적용:
|
||||
* 1. MainActivity.attachBaseContext(base) → wrap(base) 호출
|
||||
* 2. 언어 변경 시 setMode(...) 호출 후 activity recreate (또는 process restart)
|
||||
*
|
||||
* Android 13+ 의 per-app locale API (AppCompatDelegate.setApplicationLocales) 와
|
||||
* 무관하게 Configuration override 방식으로 모든 SDK 에서 동작.
|
||||
*/
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import java.util.Locale
|
||||
|
||||
object LocalePref {
|
||||
|
||||
enum class Mode { SYSTEM, KO, EN }
|
||||
|
||||
private const val PREF = "locale_pref"
|
||||
private const val KEY_MODE = "mode"
|
||||
|
||||
fun getMode(context: Context): Mode {
|
||||
val s = context.applicationContext
|
||||
.getSharedPreferences(PREF, Context.MODE_PRIVATE)
|
||||
.getString(KEY_MODE, Mode.SYSTEM.name) ?: Mode.SYSTEM.name
|
||||
return runCatching { Mode.valueOf(s) }.getOrDefault(Mode.SYSTEM)
|
||||
}
|
||||
|
||||
fun setMode(context: Context, mode: Mode) {
|
||||
context.applicationContext
|
||||
.getSharedPreferences(PREF, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_MODE, mode.name).apply()
|
||||
}
|
||||
|
||||
/** 선택된 모드에 맞춰 Locale 적용된 새 Context 반환. attachBaseContext 에서 사용. */
|
||||
fun wrap(base: Context): Context {
|
||||
val mode = getMode(base)
|
||||
if (mode == Mode.SYSTEM) return base // 시스템 기본 — 손대지 않음
|
||||
|
||||
val locale = when (mode) {
|
||||
Mode.KO -> Locale.KOREAN
|
||||
Mode.EN -> Locale.ENGLISH
|
||||
else -> return base
|
||||
}
|
||||
Locale.setDefault(locale)
|
||||
|
||||
val config = Configuration(base.resources.configuration)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val list = android.os.LocaleList(locale)
|
||||
android.os.LocaleList.setDefault(list)
|
||||
config.setLocales(list)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
config.locale = locale
|
||||
}
|
||||
return base.createConfigurationContext(config)
|
||||
}
|
||||
|
||||
/** 현재 적용된 locale 의 사용자 표시용 라벨. */
|
||||
fun currentLabel(context: Context): String = when (getMode(context)) {
|
||||
Mode.SYSTEM -> "시스템 기본 / System"
|
||||
Mode.KO -> "한국어"
|
||||
Mode.EN -> "English"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.medithings.vesiscan.models.MeasurementRecord
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
class MeasurementHistoryStore private constructor(context: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: MeasurementHistoryStore? = null
|
||||
fun getInstance(context: Context): MeasurementHistoryStore =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: MeasurementHistoryStore(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
private const val MAX_RECORDS = 100
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("measurement_history", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
private val key = "measurement_history"
|
||||
|
||||
var records: List<MeasurementRecord>
|
||||
get() {
|
||||
val json = prefs.getString(key, null) ?: return emptyList()
|
||||
return try {
|
||||
val type = object : TypeToken<List<MeasurementRecord>>() {}.type
|
||||
gson.fromJson(json, type)
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
private set(value) {
|
||||
val trimmed = if (value.size > MAX_RECORDS) value.take(MAX_RECORDS) else value
|
||||
prefs.edit().putString(key, gson.toJson(trimmed)).apply()
|
||||
}
|
||||
|
||||
fun addRecord(record: MeasurementRecord) {
|
||||
val current = records.toMutableList()
|
||||
current.add(0, record)
|
||||
records = current
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
prefs.edit().remove(key).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Measurement log auto-save service.
|
||||
*
|
||||
* All measurement results are saved in JSON Lines format to internal storage.
|
||||
* Medical audit trail + QA verification.
|
||||
*
|
||||
* File location: vesiscan_logs/YYYY-MM-DD.jsonl
|
||||
* Each line = one measurement event (JSON)
|
||||
*/
|
||||
class MeasurementLogService private constructor(private val context: Context) {
|
||||
|
||||
private val logDir: File
|
||||
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US)
|
||||
private val appVersionName: String = try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
|
||||
} catch (_: Exception) { "unknown" }
|
||||
private val appVersionCode: Int = try {
|
||||
@Suppress("DEPRECATION")
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
|
||||
} catch (_: Exception) { -1 }
|
||||
|
||||
init {
|
||||
// Downloads/VesiScan_Events/ 로 이동 — 앱 삭제 시 데이터 손실 방지.
|
||||
// 내부 저장소(filesDir)는 앱 uninstall 시 같이 사라지지만, 외부 Downloads는 유지됨.
|
||||
val downloads = android.os.Environment.getExternalStoragePublicDirectory(
|
||||
android.os.Environment.DIRECTORY_DOWNLOADS
|
||||
)
|
||||
logDir = File(downloads, "VesiScan_Events")
|
||||
if (!logDir.exists()) logDir.mkdirs()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: MeasurementLogService? = null
|
||||
|
||||
fun getInstance(context: Context): MeasurementLogService {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: MeasurementLogService(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Log Events --
|
||||
|
||||
/**
|
||||
* Log a spot measurement result.
|
||||
*/
|
||||
fun logMeasurement(
|
||||
volumeMl: Double?,
|
||||
channels: List<ChannelLogEntry>,
|
||||
rawADC: List<List<UShort>>
|
||||
) {
|
||||
val entry = JSONObject().apply {
|
||||
put("event", "measurement")
|
||||
put("timestamp", timestampFormatter.format(Date()))
|
||||
put("device", Build.MODEL)
|
||||
put("app_version", appVersionName)
|
||||
put("app_version_code", appVersionCode)
|
||||
val clinical = ClinicalSessionStore.currentLabel()
|
||||
if (clinical.isNotEmpty()) put("clinical_label", clinical)
|
||||
// BLE 펌웨어 버전 (연결 시 mid?/rid: 응답에서 받음)
|
||||
val fw = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value
|
||||
if (!fw.isNullOrEmpty()) put("firmware_version", fw)
|
||||
}
|
||||
|
||||
if (volumeMl != null) {
|
||||
entry.put("volume_ml", Math.round(volumeMl * 10.0) / 10.0)
|
||||
}
|
||||
|
||||
// Per-channel detection
|
||||
val chArray = JSONArray()
|
||||
for (ch in channels) {
|
||||
val chObj = JSONObject().apply {
|
||||
put("channel", ch.channel)
|
||||
ch.ant?.let { put("ant", it) }
|
||||
ch.post?.let { put("post", it) }
|
||||
ch.urineLen?.let { put("urine_len", it) }
|
||||
ch.score?.let { put("score", Math.round(it * 10.0) / 10.0) }
|
||||
}
|
||||
chArray.put(chObj)
|
||||
}
|
||||
entry.put("channels", chArray)
|
||||
|
||||
// Raw ADC (compact: comma-separated per channel)
|
||||
val adcObj = JSONObject()
|
||||
for ((i, adc) in rawADC.withIndex()) {
|
||||
adcObj.put("CH$i", adc.joinToString(",") { it.toString() })
|
||||
}
|
||||
entry.put("adc", adcObj)
|
||||
|
||||
appendLog(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log Placement Guide result.
|
||||
*
|
||||
* 임상 모드에서는 구조화된 placements.jsonl로 별도 라우팅.
|
||||
* 일반 모드에서는 기존 events JSONL 묶음에 그대로.
|
||||
*/
|
||||
fun logPlacement(
|
||||
channelDetected: List<Boolean>,
|
||||
direction: String,
|
||||
urineLens: List<Int> = emptyList(),
|
||||
cv: Double = Double.NaN,
|
||||
lrDev: Double = Double.NaN,
|
||||
phase: String = "",
|
||||
score: Int = -1
|
||||
) {
|
||||
val entry = JSONObject().apply {
|
||||
put("event", "placement")
|
||||
put("timestamp", timestampFormatter.format(Date()))
|
||||
val arr = JSONArray()
|
||||
for (d in channelDetected) arr.put(d)
|
||||
put("detected", arr)
|
||||
put("detected_count", channelDetected.count { it })
|
||||
put("direction", direction)
|
||||
if (urineLens.isNotEmpty()) {
|
||||
put("urine_lens", JSONArray().apply { urineLens.forEach { put(it) } })
|
||||
put("urine_sum", urineLens.sum())
|
||||
}
|
||||
if (!cv.isNaN()) put("cv", cv)
|
||||
if (!lrDev.isNaN()) put("lr_dev", lrDev)
|
||||
if (phase.isNotEmpty()) put("phase", phase)
|
||||
if (score >= 0) put("score", score)
|
||||
val clinical = ClinicalSessionStore.currentLabel()
|
||||
if (clinical.isNotEmpty()) put("clinical_label", clinical)
|
||||
// BLE 펌웨어 버전
|
||||
val fw = com.medithings.vesiscan.ble.BleManager.current?.firmwareVersion?.value
|
||||
if (!fw.isNullOrEmpty()) put("firmware_version", fw)
|
||||
}
|
||||
appendLog(entry, "placements.jsonl")
|
||||
}
|
||||
|
||||
/**
|
||||
* Log BLE connection/disconnection.
|
||||
*/
|
||||
fun logConnection(event: String, deviceName: String = "") {
|
||||
val entry = JSONObject().apply {
|
||||
put("event", "ble_$event")
|
||||
put("timestamp", timestampFormatter.format(Date()))
|
||||
put("device_name", deviceName)
|
||||
}
|
||||
appendLog(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error.
|
||||
*/
|
||||
fun logError(message: String) {
|
||||
val entry = JSONObject().apply {
|
||||
put("event", "error")
|
||||
put("timestamp", timestampFormatter.format(Date()))
|
||||
put("message", message)
|
||||
}
|
||||
appendLog(entry)
|
||||
}
|
||||
|
||||
// -- File Operations --
|
||||
|
||||
/**
|
||||
* 임상 모드면 세션 step 폴더의 지정 파일에 추가.
|
||||
* 일반 모드면 Downloads/VesiScan_Events/YYYY-MM-DD.jsonl 에 추가.
|
||||
*/
|
||||
private fun appendLog(entry: JSONObject, clinicalFileName: String = "events.jsonl") {
|
||||
try {
|
||||
val line = entry.toString() + "\n"
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
val file = if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
File(clinicalDir, clinicalFileName)
|
||||
} else {
|
||||
File(logDir, dateFormatter.format(Date()) + ".jsonl")
|
||||
}
|
||||
file.appendText(line)
|
||||
} catch (e: Exception) {
|
||||
// Silently fail — logging should never crash the app
|
||||
}
|
||||
}
|
||||
|
||||
/** Today's log file */
|
||||
val todayLogFile: File
|
||||
get() = File(logDir, dateFormatter.format(Date()) + ".jsonl")
|
||||
|
||||
/** All log files, newest first */
|
||||
fun listLogFiles(): List<File> {
|
||||
return logDir.listFiles { f -> f.extension == "jsonl" }
|
||||
?.sortedByDescending { it.name }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/** Read a log file as lines */
|
||||
fun readLog(file: File): List<String> {
|
||||
return try {
|
||||
file.readLines().filter { it.isNotBlank() }
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Today's log as text */
|
||||
fun todayLogText(): String {
|
||||
return try {
|
||||
todayLogFile.readText()
|
||||
} catch (e: Exception) {
|
||||
"(no logs today)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Total log size in bytes */
|
||||
val totalLogSize: Long
|
||||
get() = listLogFiles().sumOf { it.length() }
|
||||
|
||||
/** Purge logs older than [keepDays] days (default 365, R&D 데이터 장기 보관) */
|
||||
fun purgeOldLogs(keepDays: Int = 365) {
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.add(Calendar.DAY_OF_YEAR, -keepDays)
|
||||
val cutoffStr = dateFormatter.format(calendar.time)
|
||||
for (file in listLogFiles()) {
|
||||
val name = file.nameWithoutExtension
|
||||
if (name < cutoffStr) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** Data class for channel log entries */
|
||||
data class ChannelLogEntry(
|
||||
val channel: Int,
|
||||
val ant: Int? = null,
|
||||
val post: Int? = null,
|
||||
val urineLen: Int? = null,
|
||||
val score: Double? = null
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.models.SensorMode
|
||||
|
||||
class MeasurementService private constructor(context: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: MeasurementService? = null
|
||||
fun getInstance(context: Context): MeasurementService =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: MeasurementService(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
private val bleManager = BleManager.getInstance(context)
|
||||
|
||||
val isMeasuring = mutableStateOf(false)
|
||||
|
||||
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
|
||||
isMeasuring.value = true
|
||||
when (mode) {
|
||||
SensorMode.NIRS -> performNirsMeasurement(completion)
|
||||
SensorMode.PIEZO -> performPiezoMeasurement(completion)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
|
||||
bleManager.onNirsMcjReceived = { _ ->
|
||||
isMeasuring.value = false
|
||||
completion(true)
|
||||
}
|
||||
bleManager.sendMcjQuery()
|
||||
}
|
||||
|
||||
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
|
||||
bleManager.onPiezoDataReceived = { _ ->
|
||||
isMeasuring.value = false
|
||||
completion(true)
|
||||
}
|
||||
bleManager.sendBurst(freqOption = 2, cycles = 5, delayUs = 100)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
isMeasuring.value = false
|
||||
bleManager.sendPiezoStop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.models.UrgencyLevel
|
||||
|
||||
class NotificationService private constructor(private val context: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: NotificationService? = null
|
||||
fun getInstance(context: Context): NotificationService =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: NotificationService(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
private const val CHANNEL_ID = "bladder_urgency"
|
||||
private const val CHANNEL_NAME = "Bladder Monitoring"
|
||||
}
|
||||
|
||||
private var lastNotifiedUrgency: String? = null
|
||||
|
||||
init {
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Bladder level urgency notifications"
|
||||
enableVibration(true)
|
||||
}
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendUrgencyNotification(urgency: UrgencyLevel, volume: Int) {
|
||||
val urgencyKey = urgency.rawStringValue
|
||||
if (urgencyKey == "safe" || urgencyKey == lastNotifiedUrgency) return
|
||||
lastNotifiedUrgency = urgencyKey
|
||||
|
||||
val (title, body) = when (urgency) {
|
||||
UrgencyLevel.WARNING -> "Bladder Level Rising" to "Estimated $volume ml. Prepare for catheterization."
|
||||
UrgencyLevel.URGENT -> "Catheterization Needed" to "Estimated $volume ml. Please catheterize now."
|
||||
UrgencyLevel.SAFE -> return
|
||||
}
|
||||
|
||||
val priority = if (urgency == UrgencyLevel.URGENT)
|
||||
NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setPriority(priority)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(urgencyKey.hashCode(), notification)
|
||||
} catch (_: SecurityException) { }
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
lastNotifiedUrgency = null
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* PostureEventLogger — 자세/walking 알고리즘 검증용 CSV logger.
|
||||
*
|
||||
* 매 cycle 마다 분류 결과 + walking 상태 + 신호값 기록 +
|
||||
* 사용자가 dev settings 의 annotation 버튼 누를 때 ground-truth marker 기록.
|
||||
*
|
||||
* 파일: Downloads/VesiScan_Posture/yyyy-MM-dd_HHmmss.csv
|
||||
* 컬럼:
|
||||
* timestamp_ms, type, posture, isWalking, pitch, wmean, wstd, accMag, gyroMag,
|
||||
* alarm_event, annotation
|
||||
*
|
||||
* Row type:
|
||||
* CYCLE — 매 cycle 의 자세/walking detector 결과
|
||||
* ALARM — 알람 발동 (wakeup / walking)
|
||||
* ANNOTATION — 사용자가 "지금 OOO" 표시 버튼 누름 (ground truth)
|
||||
*
|
||||
* 사용:
|
||||
* 1. logSession(context) 한 번 호출 (PiezoMonitoring 진입 시) — 파일 생성
|
||||
* 2. logCycle(...) 매 cycle 마다 호출
|
||||
* 3. logAlarm(...) 알람 발동 시
|
||||
* 4. annotate("WALK_START") 사용자가 버튼 누를 때
|
||||
* 5. closeSession() 종료 시 (선택)
|
||||
*
|
||||
* 분석: CSV → Excel / python pandas → ANNOTATION 과 CYCLE 의 walking 전이 대조.
|
||||
*/
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object PostureEventLogger {
|
||||
|
||||
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
@Volatile private var sessionFile: File? = null
|
||||
|
||||
val currentFile: File? get() = sessionFile
|
||||
|
||||
private fun getDir(): File {
|
||||
val dir = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"VesiScan_Posture"
|
||||
)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 새 세션 파일 생성 + 헤더 작성. */
|
||||
fun startSession(@Suppress("UNUSED_PARAMETER") context: Context): File {
|
||||
val f = File(getDir(), "${sessionFmt.format(Date())}.csv")
|
||||
try {
|
||||
FileWriter(f, false).buffered().use { w ->
|
||||
w.write("timestamp_ms,type,posture,isWalking,pitch,wmean,wstd,accMag,gyroMag,alarm_event,annotation")
|
||||
w.newLine()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
sessionFile = f
|
||||
return f
|
||||
}
|
||||
|
||||
/** Cycle 의 자세 분류 결과 기록. */
|
||||
fun logCycle(
|
||||
nowMs: Long,
|
||||
posture: String,
|
||||
isWalking: Boolean,
|
||||
pitch: Float,
|
||||
wmean: Float,
|
||||
wstd: Float,
|
||||
accMag: Float,
|
||||
gyroMag: Float,
|
||||
) {
|
||||
appendRow(
|
||||
nowMs, "CYCLE", posture, isWalking.toString(),
|
||||
pitch, wmean, wstd, accMag, gyroMag, "", ""
|
||||
)
|
||||
}
|
||||
|
||||
/** 알람 발동 (wakeup / walking) 기록. */
|
||||
fun logAlarm(nowMs: Long, alarmEvent: String) {
|
||||
appendRow(nowMs, "ALARM", "", "", 0f, 0f, 0f, 0f, 0f, alarmEvent, "")
|
||||
}
|
||||
|
||||
/** 사용자 ground-truth annotation 기록 (예: "WALK_START"). */
|
||||
fun annotate(label: String, nowMs: Long = System.currentTimeMillis()) {
|
||||
appendRow(nowMs, "ANNOTATION", "", "", 0f, 0f, 0f, 0f, 0f, "", label)
|
||||
}
|
||||
|
||||
fun closeSession() {
|
||||
sessionFile = null
|
||||
}
|
||||
|
||||
private fun appendRow(
|
||||
ts: Long, type: String, posture: String, isWalking: String,
|
||||
pitch: Float, wmean: Float, wstd: Float, accMag: Float, gyroMag: Float,
|
||||
alarm: String, annotation: String
|
||||
) {
|
||||
val f = sessionFile ?: return
|
||||
try {
|
||||
FileWriter(f, true).buffered().use { w ->
|
||||
w.write("$ts,$type,$posture,$isWalking,${"%.2f".format(pitch)},${"%.2f".format(wmean)},${"%.2f".format(wstd)},${"%.3f".format(accMag)},${"%.2f".format(gyroMag)},$alarm,$annotation")
|
||||
w.newLine()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* PostureTuningPrefs — Posture / Walking detector 임계값 SharedPreferences 영속화.
|
||||
*
|
||||
* GreenZoneConstants 의 임계값들은 @Volatile var (메모리) 라 앱 재시작 시 default 리셋.
|
||||
* 사용자가 dev slider 로 튜닝한 값을 유지하려면 prefs 에 저장 + 시작 시 로드.
|
||||
*
|
||||
* 사용:
|
||||
* loadAll(context) — 앱/화면 시작 시 한 번 호출 → SharedPreferences 값 → GreenZoneConstants
|
||||
* saveAll(context) — slider 변경 시 호출 → 현재 GreenZoneConstants → SharedPreferences
|
||||
* resetAll(context) — 사용자가 reset 누름 → defaults 로 복원
|
||||
*/
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import com.medithings.vesiscan.managers.GreenZoneConstants
|
||||
|
||||
object PostureTuningPrefs {
|
||||
|
||||
private const val PREF = "posture_tuning_prefs"
|
||||
|
||||
// Defaults — GreenZoneConstants 의 초기값과 일치 유지
|
||||
private const val D_PITCH_LYING = 25f
|
||||
private const val D_ACC_MIN = 0.8f
|
||||
private const val D_ACC_MAX = 1.2f
|
||||
private const val D_POLL_MS = 1000L
|
||||
private const val D_WALK_PITCH = 45f
|
||||
private const val D_WALK_START_WMEAN = 10f
|
||||
private const val D_WALK_START_WSTD = 2f
|
||||
private const val D_WALK_KEEP_WMEAN = 2f
|
||||
private const val D_WALK_START_HOLD_MS = 6000L
|
||||
private const val D_WALK_KEEP_HOLD_MS = 2000L
|
||||
private const val D_WALK_WINDOW_MS = 1500L
|
||||
|
||||
fun loadAll(context: Context) {
|
||||
val p = context.applicationContext.getSharedPreferences(PREF, Context.MODE_PRIVATE)
|
||||
GreenZoneConstants.pitchLyingThreshold = p.getFloat("pitchLying", D_PITCH_LYING)
|
||||
GreenZoneConstants.postureAccMagMin = p.getFloat("accMin", D_ACC_MIN)
|
||||
GreenZoneConstants.postureAccMagMax = p.getFloat("accMax", D_ACC_MAX)
|
||||
GreenZoneConstants.posturePollIntervalMs = p.getLong("pollMs", D_POLL_MS)
|
||||
GreenZoneConstants.walkingMinPitchDeg = p.getFloat("wPitch", D_WALK_PITCH)
|
||||
GreenZoneConstants.walkingStartWmean = p.getFloat("wStartWmean", D_WALK_START_WMEAN)
|
||||
GreenZoneConstants.walkingStartWstd = p.getFloat("wStartWstd", D_WALK_START_WSTD)
|
||||
GreenZoneConstants.walkingKeepWmean = p.getFloat("wKeepWmean", D_WALK_KEEP_WMEAN)
|
||||
GreenZoneConstants.walkingStartHoldMs = p.getLong("wStartHoldMs", D_WALK_START_HOLD_MS)
|
||||
GreenZoneConstants.walkingKeepHoldMs = p.getLong("wKeepHoldMs", D_WALK_KEEP_HOLD_MS)
|
||||
GreenZoneConstants.walkingWindowMs = p.getLong("wWindowMs", D_WALK_WINDOW_MS)
|
||||
}
|
||||
|
||||
fun saveAll(context: Context) {
|
||||
context.applicationContext.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit().apply {
|
||||
putFloat("pitchLying", GreenZoneConstants.pitchLyingThreshold)
|
||||
putFloat("accMin", GreenZoneConstants.postureAccMagMin)
|
||||
putFloat("accMax", GreenZoneConstants.postureAccMagMax)
|
||||
putLong("pollMs", GreenZoneConstants.posturePollIntervalMs)
|
||||
putFloat("wPitch", GreenZoneConstants.walkingMinPitchDeg)
|
||||
putFloat("wStartWmean", GreenZoneConstants.walkingStartWmean)
|
||||
putFloat("wStartWstd", GreenZoneConstants.walkingStartWstd)
|
||||
putFloat("wKeepWmean", GreenZoneConstants.walkingKeepWmean)
|
||||
putLong("wStartHoldMs", GreenZoneConstants.walkingStartHoldMs)
|
||||
putLong("wKeepHoldMs", GreenZoneConstants.walkingKeepHoldMs)
|
||||
putLong("wWindowMs", GreenZoneConstants.walkingWindowMs)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAll(context: Context) {
|
||||
GreenZoneConstants.pitchLyingThreshold = D_PITCH_LYING
|
||||
GreenZoneConstants.postureAccMagMin = D_ACC_MIN
|
||||
GreenZoneConstants.postureAccMagMax = D_ACC_MAX
|
||||
GreenZoneConstants.posturePollIntervalMs = D_POLL_MS
|
||||
GreenZoneConstants.walkingMinPitchDeg = D_WALK_PITCH
|
||||
GreenZoneConstants.walkingStartWmean = D_WALK_START_WMEAN
|
||||
GreenZoneConstants.walkingStartWstd = D_WALK_START_WSTD
|
||||
GreenZoneConstants.walkingKeepWmean = D_WALK_KEEP_WMEAN
|
||||
GreenZoneConstants.walkingStartHoldMs = D_WALK_START_HOLD_MS
|
||||
GreenZoneConstants.walkingKeepHoldMs = D_WALK_KEEP_HOLD_MS
|
||||
GreenZoneConstants.walkingWindowMs = D_WALK_WINDOW_MS
|
||||
saveAll(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* RawBleLogger — 모든 BLE notification 의 raw bytes 를 시간순으로 파일로 dump.
|
||||
*
|
||||
* 용도: IMU FSR / packet 구조 / loss 진단. JSON 가공 거치기 전 원본 그대로 저장.
|
||||
*
|
||||
* 파일: Downloads/VesiScan_RawBLE/yyyy-MM-dd_HHmmss_raw.log
|
||||
* 포맷 (한 줄당 한 notification):
|
||||
* HH:mm:ss.SSS dir=RX prefix=rim len=188 hex=72 69 6D 3A 00 0F ...
|
||||
*
|
||||
* 사용: startCapture() / stopCapture() 토글. 활성화 시 BleManager.processReceivedData
|
||||
* 가 매 notification 마다 write() 호출.
|
||||
*/
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.os.Environment
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object RawBleLogger {
|
||||
|
||||
private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
private val capturing = AtomicBoolean(false)
|
||||
@Volatile private var sessionFile: File? = null
|
||||
@Volatile private var packetCount: Int = 0
|
||||
|
||||
val isCapturing: Boolean get() = capturing.get()
|
||||
val currentFile: File? get() = sessionFile
|
||||
val count: Int get() = packetCount
|
||||
|
||||
private fun getDir(): File {
|
||||
val dir = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"VesiScan_RawBLE"
|
||||
)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 새 파일 만들고 capture 시작. 이미 capturing 중이면 stop 후 새로 시작. */
|
||||
fun startCapture(): File {
|
||||
if (capturing.get()) stopCapture()
|
||||
val f = File(getDir(), "${sessionFmt.format(Date())}_raw.log")
|
||||
try {
|
||||
FileWriter(f, false).buffered().use { w ->
|
||||
w.write("# RawBleLogger session — started ${timeFmt.format(Date())}")
|
||||
w.newLine()
|
||||
w.write("# Format: HH:mm:ss.SSS dir=RX prefix=XXX len=N hex=AA BB ...")
|
||||
w.newLine()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
sessionFile = f
|
||||
packetCount = 0
|
||||
capturing.set(true)
|
||||
return f
|
||||
}
|
||||
|
||||
fun stopCapture(): File? {
|
||||
if (!capturing.get()) return sessionFile
|
||||
val f = sessionFile
|
||||
try {
|
||||
if (f != null) {
|
||||
FileWriter(f, true).buffered().use { w ->
|
||||
w.write("# stopped ${timeFmt.format(Date())} — $packetCount packets")
|
||||
w.newLine()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
capturing.set(false)
|
||||
return f
|
||||
}
|
||||
|
||||
/** BleManager 가 매 notification 받을 때마다 호출. capturing 아니면 즉시 return. */
|
||||
fun write(data: ByteArray) {
|
||||
if (!capturing.get()) return
|
||||
val f = sessionFile ?: return
|
||||
try {
|
||||
val ts = timeFmt.format(Date())
|
||||
val prefix = if (data.size >= 4)
|
||||
String(data, 0, 4, Charsets.US_ASCII).replace(Regex("[^\\x20-\\x7E]"), "?")
|
||||
else "?"
|
||||
val hex = data.joinToString(" ") { "%02X".format(it) }
|
||||
FileWriter(f, true).buffered().use { w ->
|
||||
w.write("$ts dir=RX prefix=$prefix len=${data.size} hex=$hex")
|
||||
w.newLine()
|
||||
}
|
||||
packetCount++
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.media.RingtoneManager
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.medithings.vesiscan.R
|
||||
import java.util.Calendar
|
||||
|
||||
class ReminderService private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: ReminderService? = null
|
||||
fun getInstance(context: Context): ReminderService =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: ReminderService(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
private const val CHANNEL_ID = "catheter_reminder"
|
||||
private const val NOTIFICATION_ID = 2001
|
||||
private const val REQUEST_CODE = 3001
|
||||
private const val PREFS_NAME = "reminder_prefs"
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
init {
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
// Settings
|
||||
var isEnabled: Boolean
|
||||
get() = prefs.getBoolean("enabled", false)
|
||||
set(value) {
|
||||
prefs.edit().putBoolean("enabled", value).apply()
|
||||
if (value) scheduleNext() else cancelAll()
|
||||
}
|
||||
|
||||
var intervalMinutes: Int
|
||||
get() = prefs.getInt("interval_minutes", 120)
|
||||
set(value) { prefs.edit().putInt("interval_minutes", value).apply() }
|
||||
|
||||
var startHour: Int
|
||||
get() = prefs.getInt("start_hour", 8)
|
||||
set(value) { prefs.edit().putInt("start_hour", value).apply() }
|
||||
|
||||
var endHour: Int
|
||||
get() = prefs.getInt("end_hour", 22)
|
||||
set(value) { prefs.edit().putInt("end_hour", value).apply() }
|
||||
|
||||
var useSound: Boolean
|
||||
get() = prefs.getBoolean("use_sound", true)
|
||||
set(value) { prefs.edit().putBoolean("use_sound", value).apply() }
|
||||
|
||||
var useVibration: Boolean
|
||||
get() = prefs.getBoolean("use_vibration", true)
|
||||
set(value) { prefs.edit().putBoolean("use_vibration", value).apply() }
|
||||
|
||||
var usePopup: Boolean
|
||||
get() = prefs.getBoolean("use_popup", true)
|
||||
set(value) { prefs.edit().putBoolean("use_popup", value).apply() }
|
||||
|
||||
var isFlexible: Boolean
|
||||
get() = prefs.getBoolean("is_flexible", true)
|
||||
set(value) { prefs.edit().putBoolean("is_flexible", value).apply() }
|
||||
|
||||
val permissionGranted: Boolean
|
||||
get() = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
|
||||
fun scheduleNext() {
|
||||
if (!isEnabled) return
|
||||
|
||||
val now = Calendar.getInstance()
|
||||
val nextTime = Calendar.getInstance().apply {
|
||||
timeInMillis = now.timeInMillis + intervalMinutes * 60 * 1000L
|
||||
}
|
||||
|
||||
// 시간대 체크: endHour 이후면 다음날 startHour로
|
||||
if (nextTime.get(Calendar.HOUR_OF_DAY) >= endHour ||
|
||||
nextTime.get(Calendar.HOUR_OF_DAY) < startHour) {
|
||||
nextTime.apply {
|
||||
if (get(Calendar.HOUR_OF_DAY) >= endHour) {
|
||||
add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
set(Calendar.HOUR_OF_DAY, startHour)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(context, ReminderReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, REQUEST_CODE, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
|
||||
)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
|
||||
)
|
||||
}
|
||||
} else {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
|
||||
)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
// Fallback to inexact alarm
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun reschedule() {
|
||||
if (isEnabled) {
|
||||
cancelAll()
|
||||
scheduleNext()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
val intent = Intent(context, ReminderReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, REQUEST_CODE, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
}
|
||||
|
||||
private val reminderMessageIds = listOf(
|
||||
R.string.reminder_msg_1,
|
||||
R.string.reminder_msg_2,
|
||||
R.string.reminder_msg_3,
|
||||
R.string.reminder_msg_4,
|
||||
R.string.reminder_msg_5,
|
||||
)
|
||||
|
||||
fun showNotification() {
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(context.getString(R.string.notification_title))
|
||||
.setContentText(context.getString(reminderMessageIds.random()))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
|
||||
if (useSound) {
|
||||
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
|
||||
} else {
|
||||
builder.setSilent(true)
|
||||
}
|
||||
|
||||
if (!usePopup) {
|
||||
builder.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
|
||||
} catch (_: SecurityException) { }
|
||||
|
||||
if (useVibration) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Chain: schedule next alarm
|
||||
scheduleNext()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, context.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = context.getString(R.string.notification_channel_desc)
|
||||
enableVibration(true)
|
||||
}
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReminderReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
ReminderService.getInstance(context).showNotification()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Holds the **last** Single Scan summary line so it can be inspected and
|
||||
* copied to the clipboard from the developer-mode log dialog. Each new
|
||||
* Single Scan replaces the previous entry. Format and clipboard interface
|
||||
* mirror the xbench WallCompareViewModel mbb log so the same downstream
|
||||
* parsers / spreadsheets work unchanged.
|
||||
*/
|
||||
object ScanLogStore {
|
||||
private val tsFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
private val _scanLog = MutableStateFlow<List<String>>(emptyList())
|
||||
val scanLog: StateFlow<List<String>> = _scanLog.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_scanLog.value = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored line with the latest Single Scan summary. If the
|
||||
* latest successful measurement produced anterior/posterior wall indices,
|
||||
* a CSV block is appended so the walls can be pasted into Excel / numpy.
|
||||
* `successfulWalls[ch] = (ant, post)` or `null` if that channel failed.
|
||||
*/
|
||||
fun appendScanSummary(
|
||||
attemptCount: Int,
|
||||
spotVolumes: List<Double>,
|
||||
trimmedMeanMl: Double?,
|
||||
successfulWalls: List<Pair<Int, Int>?>? = null,
|
||||
successfulChannels: List<PiezoChannelData>? = null,
|
||||
antOffsetMm: Int = 0,
|
||||
) {
|
||||
val ts = tsFmt.format(System.currentTimeMillis())
|
||||
val sb = StringBuilder()
|
||||
sb.append("[$ts] SUMMARY attempts=$attemptCount results=${spotVolumes.size}")
|
||||
if (trimmedMeanMl != null) {
|
||||
sb.append(" trimmedMean=${"%.1f".format(trimmedMeanMl)}ml")
|
||||
if (spotVolumes.isNotEmpty()) {
|
||||
sb.append(" values=[${spotVolumes.joinToString(", ") { "%.1f".format(it) }}]")
|
||||
}
|
||||
} else {
|
||||
sb.append(" ✗ insufficient results")
|
||||
}
|
||||
if (antOffsetMm != 0) {
|
||||
sb.append(" antOffset=-${antOffsetMm}mm")
|
||||
}
|
||||
if (successfulWalls != null) {
|
||||
sb.append("\n WALLS (CSV — ch,ant,post,urine):")
|
||||
successfulWalls.forEachIndexed { ch, w ->
|
||||
if (w != null) {
|
||||
sb.append("\n CH$ch,${w.first},${w.second},${w.second - w.first}")
|
||||
} else {
|
||||
sb.append("\n CH$ch,,,")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (successfulChannels != null) {
|
||||
sb.append("\n DATA (CSV — one row per channel, comma-separated samples):")
|
||||
successfulChannels.sortedBy { it.channel }.forEach { ch ->
|
||||
val s = ch.buffer
|
||||
val csv = if (s.isEmpty()) "—"
|
||||
else s.joinToString(",") { (it.toInt() and 0xFFFF).toString() }
|
||||
sb.append("\n CH${ch.channel}[${s.size}]: $csv")
|
||||
}
|
||||
}
|
||||
_scanLog.value = listOf(sb.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.medithings.vesiscan.models.*
|
||||
import com.google.gson.Gson
|
||||
|
||||
class UserStorage private constructor(context: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: UserStorage? = null
|
||||
fun getInstance(context: Context): UserStorage =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: UserStorage(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
init {
|
||||
migratePinToKeychain()
|
||||
}
|
||||
|
||||
var user: User
|
||||
get() {
|
||||
val json = prefs.getString("user_data", null) ?: return User()
|
||||
return try { gson.fromJson(json, User::class.java) } catch (_: Exception) { User() }
|
||||
}
|
||||
set(value) { prefs.edit().putString("user_data", gson.toJson(value)).apply() }
|
||||
|
||||
var isRegistered: Boolean
|
||||
get() = prefs.getBoolean("is_registered", false)
|
||||
set(value) { prefs.edit().putBoolean("is_registered", value).apply() }
|
||||
|
||||
var pin: String
|
||||
get() {
|
||||
KeychainService.load("user_pin")?.let { return it }
|
||||
return prefs.getString("user_pin", "") ?: ""
|
||||
}
|
||||
set(value) {
|
||||
KeychainService.save("user_pin", value)
|
||||
prefs.edit().remove("user_pin").apply()
|
||||
}
|
||||
|
||||
var hasPin: Boolean
|
||||
get() = prefs.getBoolean("has_pin", false)
|
||||
set(value) { prefs.edit().putBoolean("has_pin", value).apply() }
|
||||
|
||||
var sensorMode: SensorMode
|
||||
get() {
|
||||
val raw = prefs.getString("sensor_mode", null) ?: return SensorMode.PIEZO
|
||||
return try { SensorMode.valueOf(raw) } catch (_: Exception) { SensorMode.PIEZO }
|
||||
}
|
||||
set(value) { prefs.edit().putString("sensor_mode", value.name).apply() }
|
||||
|
||||
var lastDeviceAddress: String?
|
||||
get() = prefs.getString("last_device_address", null)
|
||||
set(value) { prefs.edit().putString("last_device_address", value).apply() }
|
||||
|
||||
var lastDeviceName: String?
|
||||
get() = prefs.getString("last_device_name", null)
|
||||
set(value) { prefs.edit().putString("last_device_name", value).apply() }
|
||||
|
||||
var catheterizationLevel: Int
|
||||
get() {
|
||||
val v = prefs.getInt("catheterization_level", 0)
|
||||
return if (v > 0) v else 8
|
||||
}
|
||||
set(value) { prefs.edit().putInt("catheterization_level", value).apply() }
|
||||
|
||||
var bladderLevel: Int
|
||||
get() = prefs.getInt("bladder_level", 0)
|
||||
set(value) { prefs.edit().putInt("bladder_level", value).apply() }
|
||||
|
||||
var catheterCount: Int
|
||||
get() = prefs.getInt("catheter_count", 15)
|
||||
set(value) { prefs.edit().putInt("catheter_count", value).apply() }
|
||||
|
||||
var hasCompletedOnboarding: Boolean
|
||||
get() = prefs.getBoolean("has_completed_onboarding", false)
|
||||
set(value) { prefs.edit().putBoolean("has_completed_onboarding", value).apply() }
|
||||
|
||||
var hasConsented: Boolean
|
||||
get() = prefs.getBoolean("has_consented", false)
|
||||
set(value) { prefs.edit().putBoolean("has_consented", value).apply() }
|
||||
|
||||
var consentTimestamp: Long
|
||||
get() = prefs.getLong("consent_timestamp", 0L)
|
||||
set(value) { prefs.edit().putLong("consent_timestamp", value).apply() }
|
||||
|
||||
var consentPersonalInfo: Boolean
|
||||
get() = prefs.getBoolean("consent_personal_info", false)
|
||||
set(value) { prefs.edit().putBoolean("consent_personal_info", value).apply() }
|
||||
|
||||
var consentMedicalInfo: Boolean
|
||||
get() = prefs.getBoolean("consent_medical_info", false)
|
||||
set(value) { prefs.edit().putBoolean("consent_medical_info", value).apply() }
|
||||
|
||||
var consentDataCollection: Boolean
|
||||
get() = prefs.getBoolean("consent_data_collection", false)
|
||||
set(value) { prefs.edit().putBoolean("consent_data_collection", value).apply() }
|
||||
|
||||
var piezoSettings: PiezoSettings
|
||||
get() {
|
||||
val json = prefs.getString("piezo_settings", null) ?: return PiezoSettings()
|
||||
return try { gson.fromJson(json, PiezoSettings::class.java) } catch (_: Exception) { PiezoSettings() }
|
||||
}
|
||||
set(value) { prefs.edit().putString("piezo_settings", gson.toJson(value)).apply() }
|
||||
|
||||
private fun migratePinToKeychain() {
|
||||
val existingPin = prefs.getString("user_pin", null)
|
||||
if (existingPin.isNullOrEmpty()) return
|
||||
val keychainPin = KeychainService.load("user_pin")
|
||||
if (!keychainPin.isNullOrEmpty()) {
|
||||
prefs.edit().remove("user_pin").apply()
|
||||
return
|
||||
}
|
||||
KeychainService.save("user_pin", existingPin)
|
||||
prefs.edit().remove("user_pin").apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* WakeupAlarmPrefs — 기상 알람 설정 persistence (SharedPreferences).
|
||||
*
|
||||
* 사용자가 "설정 탭" 에서 toggle on/off → 일어남 감지 시 빨간 팝업.
|
||||
* LYING 유지 시간 임계 (default 3 min) 도 함께.
|
||||
*/
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
object WakeupAlarmPrefs {
|
||||
|
||||
private const val PREF_NAME = "wakeup_alarm_prefs"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_LYING_DURATION_SEC = "lying_duration_sec"
|
||||
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
private fun ensure(context: Context): SharedPreferences {
|
||||
val p = prefs
|
||||
if (p != null) return p
|
||||
val newP = context.applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
prefs = newP
|
||||
return newP
|
||||
}
|
||||
|
||||
fun isEnabled(context: Context): Boolean =
|
||||
ensure(context).getBoolean(KEY_ENABLED, false)
|
||||
|
||||
fun setEnabled(context: Context, enabled: Boolean) {
|
||||
ensure(context).edit().putBoolean(KEY_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
/** 일어남으로 인정하기 위한 직전 LYING 유지 시간 (초). default 180 = 3분. */
|
||||
fun lyingDurationSec(context: Context): Int =
|
||||
ensure(context).getInt(KEY_LYING_DURATION_SEC, 180)
|
||||
|
||||
fun setLyingDurationSec(context: Context, sec: Int) {
|
||||
ensure(context).edit().putInt(KEY_LYING_DURATION_SEC, sec).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.medithings.vesiscan.services.labdb
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
sealed class LabdbException(message: String) : IOException(message)
|
||||
class PendingApprovalException : LabdbException("Device is awaiting administrator approval")
|
||||
class ReregisterRequiredException(message: String) : LabdbException(message)
|
||||
class RateLimitedException : LabdbException("Rate limit exceeded (10 req/min)")
|
||||
class LabdbApiError(val httpStatus: Int, val code: String, message: String) : LabdbException("$code ($httpStatus): $message")
|
||||
class LabdbNetworkError(cause: Throwable) : LabdbException("Network error: ${cause.message}")
|
||||
|
||||
data class RegisterResult(val deviceId: String, val apiKey: String, val status: String, val message: String)
|
||||
data class StatusResult(val httpStatus: Int, val status: String, val deviceName: String?, val sessionCount: Int?)
|
||||
data class UploadResult(
|
||||
val ok: Boolean,
|
||||
val sessionId: String,
|
||||
val created: Boolean,
|
||||
val totalRecords: Int,
|
||||
val inserted: Int,
|
||||
val duplicates: Int,
|
||||
val errors: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* https://labdb.medithings.net REST 클라이언트.
|
||||
*
|
||||
* 사용 순서:
|
||||
* 1) register(context, appVersion) — 앱 첫 임상 모드 진입 시 1회
|
||||
* 2) checkStatus() — 임상 화면 진입 시 매번
|
||||
* 3) uploadJson(payload) — End Session 후 measurement.json 업로드
|
||||
*
|
||||
* 모든 메서드는 suspend, IO 디스패처에서 실행.
|
||||
*/
|
||||
object LabdbClient {
|
||||
private const val TAG = "LabdbClient"
|
||||
private const val BASE = "https://labdb.medithings.net/api/v1"
|
||||
private const val APP_NAME = "VesiScan"
|
||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
private val client: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** 디바이스 등록 — 앱 생애 1회. 응답에 status='pending'이면 admin 승인 대기. */
|
||||
suspend fun register(context: Context, appVersion: String): RegisterResult = withContext(Dispatchers.IO) {
|
||||
val ble = BleManager.getInstance(context)
|
||||
val body = JSONObject().apply {
|
||||
put("appName", APP_NAME)
|
||||
put("deviceName", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
ble.firmwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("fwVersion", it) }
|
||||
ble.hardwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("hwNumber", it) }
|
||||
ble.serialNumber.value.takeIf { it.isNotEmpty() }?.let { put("serialNumber", it) }
|
||||
put("appVersion", appVersion)
|
||||
}
|
||||
sanitizeForJsonb(body)
|
||||
Log.d(TAG, ">> POST /devices/register payload=$body")
|
||||
val req = Request.Builder()
|
||||
.url("$BASE/devices/register")
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.build()
|
||||
val (code, json) = exec(req)
|
||||
when (code) {
|
||||
200, 201 -> {
|
||||
val deviceId = json.optString("deviceId")
|
||||
val apiKey = json.optString("apiKey")
|
||||
val status = json.optString("status", "pending")
|
||||
val message = json.optString("message", "")
|
||||
if (apiKey.isBlank()) throw LabdbApiError(code, "INVALID_RESPONSE", "register response missing apiKey")
|
||||
LabdbCredentials.apiKey = apiKey
|
||||
LabdbCredentials.deviceId = deviceId
|
||||
LabdbCredentials.lastStatus = status
|
||||
RegisterResult(deviceId, apiKey, status, message)
|
||||
}
|
||||
else -> throw apiError(code, json)
|
||||
}
|
||||
}
|
||||
|
||||
/** 디바이스 상태 확인. apiKey 무효 시 자격 자동 clear. */
|
||||
suspend fun checkStatus(): StatusResult = withContext(Dispatchers.IO) {
|
||||
val key = LabdbCredentials.apiKey
|
||||
?: return@withContext StatusResult(0, "not_registered", null, null)
|
||||
val req = Request.Builder()
|
||||
.url("$BASE/devices/status")
|
||||
.header("X-API-Key", key)
|
||||
.get()
|
||||
.build()
|
||||
val (code, json) = exec(req)
|
||||
when (code) {
|
||||
200 -> {
|
||||
val status = json.optString("status", "unknown")
|
||||
LabdbCredentials.lastStatus = status
|
||||
StatusResult(
|
||||
httpStatus = 200,
|
||||
status = status,
|
||||
deviceName = json.optString("deviceName").ifBlank { null },
|
||||
sessionCount = if (json.has("sessionCount")) json.optInt("sessionCount") else null,
|
||||
)
|
||||
}
|
||||
401, 404 -> {
|
||||
LabdbCredentials.clear()
|
||||
val err = json.optJSONObject("error")
|
||||
val errCode = err?.optString("code") ?: if (code == 401) "INVALID_API_KEY" else "DEVICE_DELETED"
|
||||
throw ReregisterRequiredException("$errCode: server requires re-registration")
|
||||
}
|
||||
else -> throw apiError(code, json)
|
||||
}
|
||||
}
|
||||
|
||||
/** /upload/json — measurement.json payload 한 번에 전송. testId+rowIndex 기반 idempotent. */
|
||||
suspend fun uploadJson(payload: JSONObject): UploadResult = withContext(Dispatchers.IO) {
|
||||
val key = LabdbCredentials.apiKey
|
||||
?: throw ReregisterRequiredException("No apiKey — register first")
|
||||
// PostgreSQL JSONB rejects \u0000 — strip NULL/control chars from payload
|
||||
sanitizeForJsonb(payload)
|
||||
val req = Request.Builder()
|
||||
.url("$BASE/upload/json")
|
||||
.header("X-API-Key", key)
|
||||
.post(payload.toString().toRequestBody(JSON))
|
||||
.build()
|
||||
val (code, json) = exec(req)
|
||||
when (code) {
|
||||
200, 201 -> UploadResult(
|
||||
ok = json.optBoolean("ok", true),
|
||||
sessionId = json.optString("sessionId"),
|
||||
created = json.optBoolean("created", code == 201),
|
||||
totalRecords = json.optInt("totalRecords"),
|
||||
inserted = json.optInt("inserted"),
|
||||
duplicates = json.optInt("duplicates"),
|
||||
errors = json.optInt("errors"),
|
||||
)
|
||||
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
|
||||
403 -> throw PendingApprovalException()
|
||||
404 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("DEVICE_DELETED") }
|
||||
429 -> throw RateLimitedException()
|
||||
else -> throw apiError(code, json)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 삭제 — DELETE /api/v1/sessions/{testId}. Cascade로 모든 records 함께 제거.
|
||||
* 204(No Content) 또는 200/404(이미 없음) = 성공. 401/403/등은 throw.
|
||||
*/
|
||||
suspend fun deleteSession(testId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
val key = LabdbCredentials.apiKey
|
||||
?: throw ReregisterRequiredException("No apiKey — register first")
|
||||
Log.d(TAG, ">> DELETE /sessions/$testId")
|
||||
val req = Request.Builder()
|
||||
.url("$BASE/sessions/$testId")
|
||||
.header("X-API-Key", key)
|
||||
.delete()
|
||||
.build()
|
||||
val (code, json) = exec(req)
|
||||
when (code) {
|
||||
200, 204, 404 -> true // 404 = 이미 없음, 멱등 처리
|
||||
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
|
||||
403 -> throw PendingApprovalException()
|
||||
429 -> throw RateLimitedException()
|
||||
else -> throw apiError(code, json)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively strip NULL (0x00) and other C0 control chars from every string value.
|
||||
* PostgreSQL JSONB rejects | ||||