Add urine cup camera screen with YOLO auto-capture + V2 measurement

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 11:59:21 +09:00
parent c15fcfccca
commit 78552ae784
2 changed files with 291 additions and 1 deletions
@@ -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()
@@ -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<YoloDetector.DetectionResult?>(null) }
LaunchedEffect(Unit) { withContext(Dispatchers.IO) { isModelLoaded = yoloDetector.loadModel() } }
DisposableEffect(Unit) { onDispose { yoloDetector.close() } }
var capturedBitmap by remember { mutableStateOf<Bitmap?>(null) }
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
var isAnalyzing by remember { mutableStateOf(false) }
var resultVolume by remember { mutableStateOf<Int?>(null) }
var analysisComplete by remember { mutableStateOf(false) }
// 자동 촬영
var detectionStartTime by remember { mutableStateOf<Long?>(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)) }
}
}
}
}