diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1d3769..8b4cc7d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,13 +81,7 @@ dependencies { implementation(libs.rive.android) implementation(libs.androidx.startup.runtime) - // 소변컵 측정 모듈 - implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.0") // YOLO - implementation("com.google.mlkit:text-recognition:16.0.1") // OCR - implementation("androidx.camera:camera-core:1.3.4") // CameraX - implementation("androidx.camera:camera-camera2:1.3.4") - implementation("androidx.camera:camera-lifecycle:1.3.4") - implementation("androidx.camera:camera-view:1.3.4") + // 2026-08-12: YOLO/CameraX/MLKit 제거 (소변컵 사진 인식 기능 폐기). implementation("com.squareup.okhttp3:okhttp:4.12.0") // HF API implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6657d19..4b74999 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,10 +13,6 @@ - - - - diff --git a/app/src/main/assets/urinecup_best.onnx b/app/src/main/assets/urinecup_best.onnx deleted file mode 100644 index 9baff5c..0000000 Binary files a/app/src/main/assets/urinecup_best.onnx and /dev/null differ diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index 3421eee..e4adaa4 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -14,7 +14,7 @@ enum class AppScreen { PIEZO_PERSONALIZATION, PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY, PIEZO_MONITORING, MEASUREMENT_HISTORY, - VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS, + VOIDING_DIARY, REMINDER_SETTINGS, CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼) CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU diff --git a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt index 49167d7..0fda484 100644 --- a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt +++ b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt @@ -29,7 +29,6 @@ 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.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 @@ -149,7 +148,6 @@ fun MedilightApp(modifier: Modifier = Modifier) { 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.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME // 기타 AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME @@ -178,13 +176,6 @@ fun MedilightApp(modifier: Modifier = Modifier) { 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() } ) diff --git a/app/src/main/java/com/medithings/vesiscan/measure/CCPosition.kt b/app/src/main/java/com/medithings/vesiscan/measure/CCPosition.kt deleted file mode 100644 index 42786f2..0000000 --- a/app/src/main/java/com/medithings/vesiscan/measure/CCPosition.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.medithings.vesiscan.measure - -data class CCPosition( - val cc: Int, - val x: Int, - val y: Int, - val predicted: Boolean = false -) diff --git a/app/src/main/java/com/medithings/vesiscan/measure/SimpleMeasureService.kt b/app/src/main/java/com/medithings/vesiscan/measure/SimpleMeasureService.kt deleted file mode 100644 index 73f9d55..0000000 --- a/app/src/main/java/com/medithings/vesiscan/measure/SimpleMeasureService.kt +++ /dev/null @@ -1,287 +0,0 @@ -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>() - 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> { - 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>() - 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): 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 = - suspendCancellableCoroutine { cont -> - val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) - recognizer.process(InputImage.fromBitmap(bitmap, 0)) - .addOnSuccessListener { visionText -> - val positions = mutableListOf() - 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): List { - if (raw.size < 2) return raw - val sortedByY = raw.sortedBy { it.y } - val valid = mutableListOf() - 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 - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/measure/YoloDetector.kt b/app/src/main/java/com/medithings/vesiscan/measure/YoloDetector.kt deleted file mode 100644 index d59e0d3..0000000 --- a/app/src/main/java/com/medithings/vesiscan/measure/YoloDetector.kt +++ /dev/null @@ -1,130 +0,0 @@ -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>)[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): 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" - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 5c2d913..ab8cc79 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -74,7 +74,6 @@ fun PiezoMonitoringView(appState: AppState) { var showCatheterizeSheet by remember { mutableStateOf(false) } var showManualInput by remember { mutableStateOf(false) } var showVoiceInput by remember { mutableStateOf(false) } - var showUrineCamera by remember { mutableStateOf(false) } var showAddCatheterDialog by remember { mutableStateOf(false) } var isAutoMeasuring by remember { mutableStateOf(false) } // 회의 #3: dev mode 진입 시 로그/패널 자동 펼침 (default false였던 게 사용자 입장에서 사라진 것처럼 보임). @@ -1018,22 +1017,6 @@ fun PiezoMonitoringView(appState: AppState) { Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText) } } - // 카메라 입력 - Surface( - onClick = { showCatheterizeSheet = false; showUrineCamera = true }, - shape = RoundedCornerShape(12.dp), - color = Color(0xFFF3E5F5) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.CameraAlt, contentDescription = null, tint = Color(0xFF9C27B0)) - Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.camera_input), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText) - } - } // Quick Save — 현재 측정값으로 저장 + volume 0 리셋 val quickVol = if (lastVolumeMl > 0) lastVolumeMl.toInt() else appState.estimatedVolume Surface( @@ -1163,13 +1146,6 @@ fun PiezoMonitoringView(appState: AppState) { ) } - // 카메라 → AppScreen으로 전환 - LaunchedEffect(showUrineCamera) { - if (showUrineCamera) { - showUrineCamera = false - appState.currentScreen = AppScreen.URINE_CAMERA - } - } Column( modifier = Modifier diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/UrineCameraScreen.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/UrineCameraScreen.kt deleted file mode 100644 index ae3a00b..0000000 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/UrineCameraScreen.kt +++ /dev/null @@ -1,285 +0,0 @@ -package com.medithings.vesiscan.ui.views.monitoring - -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.Matrix -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.* -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import com.medithings.vesiscan.R -import com.medithings.vesiscan.measure.SimpleMeasureService -import com.medithings.vesiscan.measure.VoidingRecord -import com.medithings.vesiscan.measure.VoidingRecordStore -import com.medithings.vesiscan.measure.YoloDetector -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.concurrent.Executors - -/** - * 소변컵 카메라 촬영 + V2 측정 화면 (medilightv2용 간소화 버전) - * YOLO 자동감지 → 2초 자동촬영 → V2 측정 → 로컬 저장 - */ -@Composable -fun UrineCameraScreen( - onDone: (Int?) -> Unit, // 측정 완료 콜백 (volume) - onCancel: () -> Unit -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() - - var hasCameraPerm by remember { - mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) - } - val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasCameraPerm = it } - LaunchedEffect(Unit) { if (!hasCameraPerm) permLauncher.launch(Manifest.permission.CAMERA) } - - val yoloDetector = remember { YoloDetector(context) } - var isModelLoaded by remember { mutableStateOf(false) } - var currentDetection by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { withContext(Dispatchers.IO) { isModelLoaded = yoloDetector.loadModel() } } - DisposableEffect(Unit) { onDispose { yoloDetector.close() } } - - var capturedBitmap by remember { mutableStateOf(null) } - var originalBitmap by remember { mutableStateOf(null) } - var isAnalyzing by remember { mutableStateOf(false) } - var resultVolume by remember { mutableStateOf(null) } - var analysisComplete by remember { mutableStateOf(false) } - - // 자동 촬영 - var detectionStartTime by remember { mutableStateOf(null) } - var autoCaptureProgress by remember { mutableFloatStateOf(0f) } - var isAutoCapturing by remember { mutableStateOf(false) } - val autoCaptureDelay = 2000L - - val imageCapture = remember { - ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY).build() - } - - fun doCapture() { - isAutoCapturing = true - imageCapture.takePicture(ContextCompat.getMainExecutor(context), object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(image: ImageProxy) { - val bmp = image.toBitmap() - val rotation = image.imageInfo.rotationDegrees.toFloat() - image.close() - val rotated = if (rotation != 0f) { - val m = Matrix().apply { postRotate(rotation) } - Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true) - } else bmp - originalBitmap = rotated - val det = currentDetection - if (det != null) { - val box = det.boundingBox - val padH = 0.10f; val padTop = 0.10f; val padBot = 0.05f - val cropX = ((box.left - box.width() * padH).coerceAtLeast(0f) * rotated.width).toInt() - val cropY = ((box.top - box.height() * padTop).coerceAtLeast(0f) * rotated.height).toInt() - val cropR = ((box.right + box.width() * padH).coerceAtMost(1f) * rotated.width).toInt() - val cropB = ((box.bottom + box.height() * padBot).coerceAtMost(1f) * rotated.height).toInt() - val cropW = (cropR - cropX).coerceAtMost(rotated.width - cropX) - val cropH = (cropB - cropY).coerceAtMost(rotated.height - cropY) - capturedBitmap = if (cropW > 50 && cropH > 50) Bitmap.createBitmap(rotated, cropX, cropY, cropW, cropH) else rotated - } else { - capturedBitmap = rotated - } - } - }) - } - - // 자동 촬영 로직. - // 2026-07-07 fix: currentDetection 은 mutableStateOf delegate — smart cast 안 되어 !! 로 unwrap - // 하는데, coroutine 안에서 첫 null-check 와 !! 사이에 다른 recomposition 이 detection 을 - // null 로 만들 수 있음 → NullPointerException. Local val 로 snapshot 후 사용. - LaunchedEffect(currentDetection, isAutoCapturing, capturedBitmap) { - if (capturedBitmap != null || isAutoCapturing) return@LaunchedEffect - val initialDet = currentDetection - if (initialDet != null && initialDet.confidence >= 0.90f) { - if (detectionStartTime == null) detectionStartTime = System.currentTimeMillis() - while (true) { - val det = currentDetection ?: break - if (det.confidence < 0.90f || isAutoCapturing || capturedBitmap != null) break - val elapsed = System.currentTimeMillis() - (detectionStartTime ?: System.currentTimeMillis()) - autoCaptureProgress = (elapsed.toFloat() / autoCaptureDelay).coerceIn(0f, 1f) - if (elapsed >= autoCaptureDelay) { doCapture(); break } - delay(50) - } - } else { detectionStartTime = null; autoCaptureProgress = 0f } - } - - // 자동 분석 - LaunchedEffect(capturedBitmap) { - val bmp = capturedBitmap ?: return@LaunchedEffect - isAnalyzing = true - try { - val result = SimpleMeasureService.measure(bmp) - resultVolume = result.volume - } catch (_: Exception) { resultVolume = null } - isAnalyzing = false - analysisComplete = true - } - - val borderColor by animateColorAsState( - if (currentDetection != null && currentDetection!!.confidence >= 0.90f) Color.Green else Color.Transparent, - tween(300), label = "border" - ) - - if (!hasCameraPerm) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(stringResource(R.string.camera_permission_needed)) - Spacer(Modifier.height(16.dp)) - Button(onClick = { permLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.grant_permission)) } - } - } - } else if (capturedBitmap != null) { - // === 분석 결과 화면 === - Column( - Modifier.fillMaxSize().background(Color.White).padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - IconButton(onClick = onCancel) { Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) } - Text(stringResource(R.string.urine_measurement), fontWeight = FontWeight.SemiBold) - Spacer(Modifier.size(48.dp)) - } - Spacer(Modifier.height(16.dp)) - Image(bitmap = capturedBitmap!!.asImageBitmap(), contentDescription = null, modifier = Modifier.fillMaxWidth().weight(1f)) - Spacer(Modifier.height(16.dp)) - - if (isAnalyzing) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.size(24.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.analyzing), color = Color.Gray) - } - } else if (analysisComplete) { - if (resultVolume != null && resultVolume!! > 0) { - Text(stringResource(R.string.measurement_result), color = Color.Gray, fontSize = 14.sp) - Row(verticalAlignment = Alignment.Bottom) { - Text("$resultVolume", fontSize = 48.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF)) - Text(" ml", fontSize = 20.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 8.dp)) - } - } else { - Text(stringResource(R.string.measurement_fail), fontSize = 20.sp, color = Color.Red) - Text(stringResource(R.string.retry_in_bright), color = Color.Gray, fontSize = 14.sp) - } - Spacer(Modifier.height(16.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton( - onClick = { capturedBitmap = null; originalBitmap = null; analysisComplete = false; resultVolume = null; isAutoCapturing = false; detectionStartTime = null; autoCaptureProgress = 0f }, - Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp) - ) { Text(stringResource(R.string.retake_photo)) } - Button( - onClick = { - val vol = resultVolume - if (vol != null && vol > 0) { - VoidingRecordStore.add(context, VoidingRecord(volume = vol, method = "camera")) - Toast.makeText(context, context.getString(R.string.registered_diary), Toast.LENGTH_SHORT).show() - } - onDone(vol) - }, - Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) - ) { Text(stringResource(R.string.save_to_diary), fontWeight = FontWeight.SemiBold) } - } - } - Spacer(Modifier.height(16.dp)) - } - } else { - // === 카메라 프리뷰 === - Box( - Modifier.fillMaxSize().border( - width = if (borderColor == Color.Transparent) 0.dp else 4.dp, color = borderColor - ) - ) { - val previewView = remember { PreviewView(context) } - val executor = remember { Executors.newSingleThreadExecutor() } - - LaunchedEffect(Unit) { - val future = ProcessCameraProvider.getInstance(context) - future.addListener({ - val provider = future.get() - val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } - val analysis = ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build().also { a -> - a.setAnalyzer(executor) { proxy -> - if (isModelLoaded) { - val bmp = proxy.toBitmap() - val rot = proxy.imageInfo.rotationDegrees.toFloat() - val rotated = if (rot != 0f) { val m = Matrix().apply { postRotate(rot) }; Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true) } else bmp - currentDetection = yoloDetector.detect(rotated) - } - proxy.close() - } - } - try { provider.unbindAll(); provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture, analysis) } catch (_: Exception) {} - }, ContextCompat.getMainExecutor(context)) - } - - AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) - - // 상단 - Row(Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onCancel) { Icon(Icons.Default.Close, stringResource(R.string.close), tint = Color.White) } - Spacer(Modifier.weight(1f)) - val statusText = when { - !isModelLoaded -> stringResource(R.string.model_loading) - isAutoCapturing -> stringResource(R.string.auto_capturing) - autoCaptureProgress > 0 -> stringResource(R.string.hold_still, (autoCaptureProgress * 100).toInt()) - currentDetection != null -> stringResource(R.string.cup_detected) - else -> stringResource(R.string.searching_cup) - } - Text(statusText, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, - modifier = Modifier.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(20.dp)).padding(horizontal = 12.dp, vertical = 6.dp)) - Spacer(Modifier.weight(1f)); Spacer(Modifier.size(48.dp)) - } - - // 가이드 - Column(Modifier.align(Alignment.Center).offset(y = 120.dp).background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(10.dp)).padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Text(stringResource(R.string.camera_guide_1), color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - Text(stringResource(R.string.camera_guide_2), color = Color(0xFFFFD54F), fontSize = 12.sp) - if (autoCaptureProgress > 0) { - Spacer(Modifier.height(8.dp)) - LinearProgressIndicator(progress = { autoCaptureProgress }, Modifier.fillMaxWidth(0.6f).height(4.dp).clip(RoundedCornerShape(2.dp)), color = Color.Green, trackColor = Color.White.copy(alpha = 0.3f)) - } - } - - // 수동 촬영 버튼 - Box(Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp)) { - Button(onClick = { if (!isAutoCapturing) doCapture() }, Modifier.size(80.dp), shape = CircleShape, - colors = ButtonDefaults.buttonColors(containerColor = Color.White), contentPadding = PaddingValues(0.dp) - ) { Box(Modifier.size(64.dp).background(Color.White, CircleShape)) } - } - } - } -} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1271d5f..27ec893 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -41,28 +41,13 @@ 소변량 %d ml - 카메라 권한이 필요합니다 - 권한 허용 - 소변량 측정 - 분석 중… - 측정 결과 - 측정 실패 - 밝은 곳에서 다시 촬영해주세요 - 다시 촬영 저장하기 - 눈금이 보이는 면을 정면으로 - 밝은 곳에서 촬영해주세요 저장 완료 탭하여 음성 입력 - 모델 로딩 중… - 자동 촬영 중… - 유지하세요… %d%% - 소변컵 감지됨 - 소변컵을 찾는 중… 이미지가 너무 어둡습니다 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a37804..af8704d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,28 +41,13 @@ Volume %d ml - Camera permission required - Grant Permission - Urine Measurement - Analyzing… - Measurement Result - Measurement Failed - Try again in a brighter area - Retake Photo Save - Face the graduated side forward - Take photo in a bright area Saved Tap to speak - Loading model… - Auto capturing… - Hold still… %d%% - Cup detected - Searching for cup… Image is too dark