validation
This commit is contained in:
parent
bcb4ee1a39
commit
d45093a355
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue