From d45093a3554c612a4ecd96fd9ae2b1fc8d0fb44e Mon Sep 17 00:00:00 2001 From: SaiD Date: Mon, 15 Dec 2025 23:23:21 +0530 Subject: [PATCH] validation --- .../domain/ml/SubjectSegmenterHelper.kt | 114 ++++++++++++ .../pages/addprofile/AddProfileViewModel.kt | 56 ++++-- .../livingai/pages/camera/ViewImageScreen.kt | 167 +++++++++++------- .../livingai/pages/navigation/NavGraph.kt | 25 ++- .../livingai/pages/navigation/Route.kt | 10 +- 5 files changed, 288 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/example/livingai/domain/ml/SubjectSegmenterHelper.kt diff --git a/app/src/main/java/com/example/livingai/domain/ml/SubjectSegmenterHelper.kt b/app/src/main/java/com/example/livingai/domain/ml/SubjectSegmenterHelper.kt new file mode 100644 index 0000000..bf78676 --- /dev/null +++ b/app/src/main/java/com/example/livingai/domain/ml/SubjectSegmenterHelper.kt @@ -0,0 +1,114 @@ +package com.example.livingai.domain.ml + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.provider.OpenableColumns +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation +import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.OutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class SubjectSegmenterHelper(private val context: Context) { + + suspend fun segmentAndSave(inputUri: Uri): Uri? { + return suspendCancellableCoroutine { continuation -> + try { + val image = InputImage.fromFilePath(context, inputUri) + val options = SubjectSegmenterOptions.Builder() + .enableForegroundBitmap() + .build() + val segmenter = SubjectSegmentation.getClient(options) + + segmenter.process(image) + .addOnSuccessListener { result -> + val foreground = result.foregroundBitmap + if (foreground != null) { + try { + val resultBitmap = Bitmap.createBitmap( + foreground.width, + foreground.height, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(resultBitmap) + canvas.drawColor(Color.BLACK) + canvas.drawBitmap(foreground, 0f, 0f, null) + + val originalName = getFileName(inputUri) ?: "image" + val nameWithoutExt = originalName.substringBeforeLast('.') + val baseName = nameWithoutExt.replace(Regex("_segmented.*"), "") + val filename = "${baseName}_segmented_${System.currentTimeMillis()}.jpg" + + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, filename) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/LivingAI/Segmented") + } + } + + val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + + if (uri != null) { + val outputStream: OutputStream? = context.contentResolver.openOutputStream(uri) + outputStream?.use { out -> + resultBitmap.compress(Bitmap.CompressFormat.JPEG, 100, out) + } + continuation.resume(uri) + } else { + continuation.resume(null) + } + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } else { + continuation.resume(null) + } + } + .addOnFailureListener { e -> + continuation.resumeWithException(e) + } + .addOnCompleteListener { + segmenter.close() + } + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + } + + private fun getFileName(uri: Uri): String? { + var result: String? = null + if (uri.scheme == "content") { + val cursor = context.contentResolver.query(uri, null, null, null, null) + try { + if (cursor != null && cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) { + result = cursor.getString(index) + } + } + } catch (e: Exception) { + // ignore + } finally { + cursor?.close() + } + } + if (result == null) { + result = uri.path + val cut = result?.lastIndexOf('/') + if (cut != null && cut != -1) { + result = result?.substring(cut + 1) + } + } + return result + } +} diff --git a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt index 6ce96a1..15d371a 100644 --- a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt +++ b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope import com.example.livingai.domain.model.AnimalDetails import com.example.livingai.domain.usecases.GetAnimalDetails import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase +import com.example.livingai.utils.Constants import com.example.livingai.utils.CoroutineDispatchers import com.example.livingai.utils.IdGenerator import kotlinx.coroutines.flow.launchIn @@ -102,10 +103,45 @@ class AddProfileViewModel( val uri = Uri.parse(path) val filename = getFileName(uri) ?: path.substringAfterLast('/') val nameWithoutExt = filename.substringBeforeLast('.') + + // Find orientation in filename + var foundOrientation: String? = null + for (o in Constants.silhouetteList) { + // Check if filename contains the orientation string + // We use ignoreCase just in case, though Constants are lowercase + if (nameWithoutExt.contains(o, ignoreCase = true)) { + // If we found a match, we verify it's not a substring of another word if possible, + // but here the orientations are quite distinct (front, back, left, right, angleview). + // To be safer, we could check for delimiters, but usually containment is enough for now. + foundOrientation = o + // Prioritize exact matches or longer matches if necessary? + // "left" is in "leftangle". "leftangle" should be matched first if we iterate in order? + // Constants list: front, back, left, right, leftangle... + // If file is "leftangle", it matches "left". + // We should probably check longer keys first or exact match between delimiters. + } + } + + // Better approach: Split by underscore and check exact match against list val parts = nameWithoutExt.split('_') - if (parts.size >= 2) { - val orientation = parts.last() - photoMap[orientation] = path + val matchingPart = parts.find { part -> + Constants.silhouetteList.any { it.equals(part, ignoreCase = true) } + } + + if (matchingPart != null) { + // Normalize to the key in Constants (lowercase) + val key = Constants.silhouetteList.find { it.equals(matchingPart, ignoreCase = true) } + if (key != null) { + photoMap[key] = path + } + } else { + // Fallback to substring search if underscore splitting fails (e.g. if naming changed) + // We sort by length descending so "leftangle" is checked before "left" + val sortedOrientations = Constants.silhouetteList.sortedByDescending { it.length } + val match = sortedOrientations.find { nameWithoutExt.contains(it, ignoreCase = true) } + if (match != null) { + photoMap[match] = path + } } } withContext(dispatchers.main) { @@ -187,7 +223,7 @@ class AddProfileViewModel( } val ageInt = age.value.toIntOrNull() - if (ageInt == null || ageInt < 0) { + if (ageInt == null || ageInt <= 0 || ageInt > 20) { ageError.value = "Invalid age" isValid = false } else { @@ -195,7 +231,7 @@ class AddProfileViewModel( } val milkInt = milkYield.value.toIntOrNull() - if (milkInt == null || milkInt < 0) { + if (milkInt == null || milkInt <= 0 || milkInt > 75) { milkYieldError.value = "Invalid milk yield" isValid = false } else { @@ -203,7 +239,7 @@ class AddProfileViewModel( } val calvingInt = calvingNumber.value.toIntOrNull() - if (calvingInt == null || calvingInt < 0) { + if (calvingInt == null || calvingInt < 0 || calvingInt > 12) { calvingNumberError.value = "Invalid calving number" isValid = false } else { @@ -213,8 +249,8 @@ class AddProfileViewModel( return isValid } - fun saveAnimalDetails() { - if (!validateInputs()) return + fun saveAnimalDetails(): Boolean { + if (!validateInputs()) return false val id = _currentAnimalId.value ?: IdGenerator.generateAnimalId().also { _currentAnimalId.value = it } @@ -236,6 +272,8 @@ class AddProfileViewModel( profileEntryUseCase.setAnimalDetails(details) _saveSuccess.value = true } + + return true } fun onSaveComplete() { @@ -243,9 +281,7 @@ class AddProfileViewModel( } init { - // Try to auto-load when editing via saved state handle val animalId: String? = savedStateHandle?.get("animalId") - // Call loadAnimal unconditionally to ensure ID generation for new profiles loadAnimal(animalId) } } diff --git a/app/src/main/java/com/example/livingai/pages/camera/ViewImageScreen.kt b/app/src/main/java/com/example/livingai/pages/camera/ViewImageScreen.kt index 7deec47..84b50f9 100644 --- a/app/src/main/java/com/example/livingai/pages/camera/ViewImageScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/camera/ViewImageScreen.kt @@ -9,11 +9,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -22,6 +19,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,9 +32,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.exifinterface.media.ExifInterface import coil.compose.rememberAsyncImagePainter +import com.example.livingai.domain.ml.SubjectSegmenterHelper import com.example.livingai.ui.theme.LivingAITheme -import java.io.InputStream -import kotlin.math.max +import kotlinx.coroutines.launch import kotlin.math.min @Composable @@ -45,17 +43,25 @@ fun ViewImageScreen( shouldAllowRetake: Boolean, showAccept: Boolean, showBack: Boolean, + showSegment: Boolean = false, onRetake: () -> Unit, - onAccept: () -> Unit, + onAccept: (String) -> Unit, + onSegmented: (String) -> Unit = {}, onBack: () -> Unit ) { val context = LocalContext.current + val scope = rememberCoroutineScope() var boundingBox by remember { mutableStateOf(null) } var imageWidth by remember { mutableStateOf(0f) } var imageHeight by remember { mutableStateOf(0f) } - LaunchedEffect(imageUri) { - val uri = Uri.parse(imageUri) + val displayedUri = Uri.parse(imageUri) + var isSegmenting by remember { mutableStateOf(false) } + + val segmenterHelper = remember { SubjectSegmenterHelper(context) } + + LaunchedEffect(displayedUri) { + val uri = displayedUri try { context.contentResolver.openInputStream(uri)?.use { inputStream -> val exif = ExifInterface(inputStream) @@ -79,62 +85,66 @@ fun ViewImageScreen( Box(modifier = Modifier .fillMaxSize() .padding(it)) { - Image( - painter = rememberAsyncImagePainter( - model = Uri.parse(imageUri), - onSuccess = { state -> - if (imageWidth == 0f || imageHeight == 0f) { - imageWidth = state.result.drawable.intrinsicWidth.toFloat() - imageHeight = state.result.drawable.intrinsicHeight.toFloat() - } - } - ), - contentDescription = "Captured Image", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - alignment = Alignment.Center - ) - // Draw Bounding Box if available - if (boundingBox != null && imageWidth > 0 && imageHeight > 0) { - Canvas(modifier = Modifier.fillMaxSize()) { - val canvasWidth = size.width - val canvasHeight = size.height - - // Parse bounding box string "left,top,right,bottom" - val parts = boundingBox!!.split(",") - if (parts.size == 4) { - val left = parts[0].toFloatOrNull() ?: 0f - val top = parts[1].toFloatOrNull() ?: 0f - val right = parts[2].toFloatOrNull() ?: 0f - val bottom = parts[3].toFloatOrNull() ?: 0f + if (isSegmenting) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + Image( + painter = rememberAsyncImagePainter( + model = displayedUri, + onSuccess = { state -> + if (imageWidth == 0f || imageHeight == 0f) { + imageWidth = state.result.drawable.intrinsicWidth.toFloat() + imageHeight = state.result.drawable.intrinsicHeight.toFloat() + } + } + ), + contentDescription = "Captured Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + alignment = Alignment.Center + ) + + // Draw Bounding Box if available + if (boundingBox != null && imageWidth > 0 && imageHeight > 0) { + Canvas(modifier = Modifier.fillMaxSize()) { + val canvasWidth = size.width + val canvasHeight = size.height - // Calculate scale and offset (CenterInside/Fit logic) - // Image composable uses 'Fit' by default which scales to fit within bounds while maintaining aspect ratio - val widthRatio = canvasWidth / imageWidth - val heightRatio = canvasHeight / imageHeight - val scale = min(widthRatio, heightRatio) + // Parse bounding box string "left,top,right,bottom" + val parts = boundingBox!!.split(",") + if (parts.size == 4) { + val left = parts[0].toFloatOrNull() ?: 0f + val top = parts[1].toFloatOrNull() ?: 0f + val right = parts[2].toFloatOrNull() ?: 0f + val bottom = parts[3].toFloatOrNull() ?: 0f + + // Calculate scale and offset (CenterInside/Fit logic) + val widthRatio = canvasWidth / imageWidth + val heightRatio = canvasHeight / imageHeight + val scale = min(widthRatio, heightRatio) - val displayedWidth = imageWidth * scale - val displayedHeight = imageHeight * scale - - val offsetX = (canvasWidth - displayedWidth) / 2 - val offsetY = (canvasHeight - displayedHeight) / 2 - - // Transform coordinates - val rectLeft = left * scale + offsetX - val rectTop = top * scale + offsetY - val rectRight = right * scale + offsetX - val rectBottom = bottom * scale + offsetY - - drawRect( - color = Color.Yellow, - topLeft = Offset(rectLeft, rectTop), - size = Size(rectRight - rectLeft, rectBottom - rectTop), - style = Stroke(width = 3.dp.toPx()) - ) + val displayedWidth = imageWidth * scale + val displayedHeight = imageHeight * scale + + val offsetX = (canvasWidth - displayedWidth) / 2 + val offsetY = (canvasHeight - displayedHeight) / 2 + + // Transform coordinates + val rectLeft = left * scale + offsetX + val rectTop = top * scale + offsetY + val rectRight = right * scale + offsetX + val rectBottom = bottom * scale + offsetY + + drawRect( + color = Color.Yellow, + topLeft = Offset(rectLeft, rectTop), + size = Size(rectRight - rectLeft, rectBottom - rectTop), + style = Stroke(width = 3.dp.toPx()) + ) + } } - } + } } Row( @@ -144,18 +154,39 @@ fun ViewImageScreen( .padding(16.dp), horizontalArrangement = Arrangement.SpaceEvenly ) { - if (shouldAllowRetake) { + if (shouldAllowRetake && !isSegmenting) { OutlinedButton(onClick = onRetake) { Text("Retake") } } - if (showAccept) { - Button(onClick = onAccept) { - Text("Accept") + + if (!isSegmenting) { + if (showSegment) { + Button(onClick = { + scope.launch { + isSegmenting = true + val resultUri = segmenterHelper.segmentAndSave(displayedUri) + if (resultUri != null) { + onSegmented(resultUri.toString()) + } + isSegmenting = false + } + }) { + Text("Segment") + } } - } else { - Button(onClick = onBack) { - Text("Back") + + if (showAccept) { + Button(onClick = { onAccept(displayedUri.toString()) }) { + Text("Accept") + } + } + + // Show Back if explicitly requested OR if Accept is not shown (to avoid empty state or getting stuck) + if (showBack || !showAccept) { + Button(onClick = onBack) { + Text("Back") + } } } } diff --git a/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt b/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt index d73df8c..6c7c8c9 100644 --- a/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt +++ b/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt @@ -83,8 +83,9 @@ fun NavGraph( navController = navController, viewModel = viewModel, onSave = { - viewModel.saveAnimalDetails() - navController.popBackStack(Route.HomeScreen, inclusive = false) + val isSaved = viewModel.saveAnimalDetails() + if (isSaved) + navController.popBackStack(Route.HomeScreen, inclusive = false) }, onCancel = { navController.popBackStack() }, onTakePhoto = { orientation -> @@ -95,6 +96,8 @@ fun NavGraph( shouldAllowRetake = true, orientation = orientation, showAccept = false, + showBack = true, + showSegment = true, animalId = currentId ?: "unknown" )) } else { @@ -141,15 +144,27 @@ fun NavGraph( shouldAllowRetake = args.shouldAllowRetake, showAccept = args.showAccept, showBack = args.showBack, + showSegment = args.showSegment, onRetake = { navController.popBackStack() // navController.navigate(Route.CameraScreen(...)) }, - onAccept = { - navController.getBackStackEntry().savedStateHandle["newImageUri"] = args.imageUri + onAccept = { uri -> + navController.getBackStackEntry().savedStateHandle["newImageUri"] = uri navController.getBackStackEntry().savedStateHandle["newImageOrientation"] = args.orientation navController.popBackStack(inclusive = false) }, + onSegmented = { segmentedUri -> + navController.navigate(Route.ViewImageScreen( + imageUri = segmentedUri, + shouldAllowRetake = false, + orientation = args.orientation, + showAccept = true, + showBack = true, + showSegment = false, + animalId = args.animalId + )) + }, onBack = { navController.popBackStack() } ) } @@ -172,4 +187,4 @@ fun NavGraph( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/livingai/pages/navigation/Route.kt b/app/src/main/java/com/example/livingai/pages/navigation/Route.kt index 83945a1..113c363 100644 --- a/app/src/main/java/com/example/livingai/pages/navigation/Route.kt +++ b/app/src/main/java/com/example/livingai/pages/navigation/Route.kt @@ -25,7 +25,15 @@ sealed class Route { @Serializable data class VideoRecordScreen(val animalId: String) : Route() @Serializable - data class ViewImageScreen(val imageUri: String, val shouldAllowRetake: Boolean, val orientation: String? = null, val showAccept: Boolean = false, val showBack: Boolean = false, val animalId: String) : Route() + data class ViewImageScreen( + val imageUri: String, + val shouldAllowRetake: Boolean, + val orientation: String? = null, + val showAccept: Boolean = false, + val showBack: Boolean = false, + val showSegment: Boolean = false, + val animalId: String + ) : Route() @Serializable data class ViewVideoScreen(val videoUri: String, val shouldAllowRetake: Boolean, val animalId: String) : Route() @Serializable