From 78552ae784871377989692594a8f5031278899af Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 2 Apr 2026 11:59:21 +0900 Subject: [PATCH] Add urine cup camera screen with YOLO auto-capture + V2 measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UrineCameraScreen: - YOLO real-time cup detection with green border indicator - Auto-capture: 2s continuous detection (≥90%) triggers shutter - Manual shutter button fallback - V2 SimpleMeasureService analysis (OCR + 3-zone gradient) - Auto-analyze after capture - Result display with volume (ml) - Save to local VoidingRecordStore + toast confirmation - Retake option on failure - Brightness guide text PiezoMonitoringView: - Camera tab in CatheterizeSheet now opens UrineCameraScreen - Full-screen overlay for camera (doesn't leave Piezo screen) - Auto bladder reset on successful measurement Complete Catheterize flow: Catheterize → [수기/음성/카메라] → 입력 → "등록되었습니다" → 레벨 리셋 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../views/monitoring/PiezoMonitoringView.kt | 15 +- .../ui/views/monitoring/UrineCameraScreen.kt | 277 ++++++++++++++++++ 2 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/example/medilightv2android/ui/views/monitoring/UrineCameraScreen.kt diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index e5e4c63..aa5ffc7 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -45,6 +45,7 @@ fun PiezoMonitoringView(appState: AppState) { var isMeasuring by remember { mutableStateOf(false) } var showCatheterizeConfirm by remember { mutableStateOf(false) } var showCatheterizeSheet by remember { mutableStateOf(false) } + var showUrineCamera by remember { mutableStateOf(false) } var showAddCatheterDialog by remember { mutableStateOf(false) } var showDisconnectionBanner by remember { mutableStateOf(false) } var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) } @@ -135,7 +136,8 @@ fun PiezoMonitoringView(appState: AppState) { CatheterizeSheet( onDismiss = { showCatheterizeSheet = false }, onCameraRequested = { - // TODO: 카메라 화면으로 이동 (UrineCameraScreen 추가 필요) + showCatheterizeSheet = false + showUrineCamera = true }, onCatheterized = { appState.resetLevel() @@ -143,6 +145,17 @@ fun PiezoMonitoringView(appState: AppState) { ) } + // 소변컵 카메라 촬영 화면 (풀스크린 오버레이) + if (showUrineCamera) { + UrineCameraScreen( + onDone = { volume -> + showUrineCamera = false + if (volume != null && volume > 0) appState.resetLevel() + }, + onCancel = { showUrineCamera = false } + ) + } + Column( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/UrineCameraScreen.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/UrineCameraScreen.kt new file mode 100644 index 0000000..0d5740e --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/UrineCameraScreen.kt @@ -0,0 +1,277 @@ +package com.example.medilightv2android.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.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.example.medilightv2android.measure.SimpleMeasureService +import com.example.medilightv2android.measure.VoidingRecord +import com.example.medilightv2android.measure.VoidingRecordStore +import com.example.medilightv2android.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 + } + } + }) + } + + // 자동 촬영 로직 + LaunchedEffect(currentDetection, isAutoCapturing, capturedBitmap) { + if (capturedBitmap != null || isAutoCapturing) return@LaunchedEffect + if (currentDetection != null && currentDetection!!.confidence >= 0.90f) { + if (detectionStartTime == null) detectionStartTime = System.currentTimeMillis() + while (currentDetection != null && currentDetection!!.confidence >= 0.90f && !isAutoCapturing && capturedBitmap == null) { + 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("카메라 권한이 필요합니다") + Spacer(Modifier.height(16.dp)) + Button(onClick = { permLauncher.launch(Manifest.permission.CAMERA) }) { Text("권한 허용") } + } + } + } 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, "뒤로") } + Text("소변량 측정", 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("분석 중...", color = Color.Gray) + } + } else if (analysisComplete) { + if (resultVolume != null && resultVolume!! > 0) { + Text("측정 결과", 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("측정 실패", fontSize = 20.sp, color = Color.Red) + Text("밝은 곳에서 다시 촬영해주세요", 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("다시 촬영") } + Button( + onClick = { + val vol = resultVolume + if (vol != null && vol > 0) { + VoidingRecordStore.add(context, VoidingRecord(volume = vol, method = "camera")) + Toast.makeText(context, "배뇨일지에 등록되었습니다", Toast.LENGTH_SHORT).show() + } + onDone(vol) + }, + Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) + ) { Text("저장하기", 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, "닫기", tint = Color.White) } + Spacer(Modifier.weight(1f)) + val statusText = when { + !isModelLoaded -> "모델 로딩 중..." + isAutoCapturing -> "자동 촬영 중..." + autoCaptureProgress > 0 -> "유지하세요... ${(autoCaptureProgress * 100).toInt()}%" + currentDetection != null -> "소변컵 감지됨" + else -> "소변컵을 찾는 중..." + } + 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("눈금이 보이는 면을 정면으로", color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text("밝은 곳에서 촬영해주세요", 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)) } + } + } + } +}