validation

This commit is contained in:
SaiD 2025-12-15 23:23:21 +05:30
parent bcb4ee1a39
commit d45093a355
5 changed files with 288 additions and 84 deletions

View File

@ -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
}
}

View File

@ -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<String>("animalId")
// Call loadAnimal unconditionally to ensure ID generation for new profiles
loadAnimal(animalId)
}
}

View File

@ -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<String?>(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()
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
)
),
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
// 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
// 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)
// 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)
// 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 displayedWidth = imageWidth * scale
val displayedHeight = imageHeight * scale
val offsetX = (canvasWidth - displayedWidth) / 2
val offsetY = (canvasHeight - displayedHeight) / 2
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
// 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())
)
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")
}
}
}
}

View File

@ -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<Route.AddProfileScreen>().savedStateHandle["newImageUri"] = args.imageUri
onAccept = { uri ->
navController.getBackStackEntry<Route.AddProfileScreen>().savedStateHandle["newImageUri"] = uri
navController.getBackStackEntry<Route.AddProfileScreen>().savedStateHandle["newImageOrientation"] = args.orientation
navController.popBackStack<Route.AddProfileScreen>(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() }
)
}

View File

@ -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