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:
+14
-1
@@ -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()
|
||||
|
||||
+277
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user