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