video changes

This commit is contained in:
SaiD 2025-12-05 11:46:39 +05:30
parent 61f60ba11a
commit 4ba7aa398d
16 changed files with 573 additions and 29 deletions

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -39,6 +39,10 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
mlModelBinding = true
}
aaptOptions {
noCompress += "tflite"
} }
} }
@ -61,6 +65,12 @@ dependencies {
implementation("com.google.mlkit:object-detection:17.0.2") implementation("com.google.mlkit:object-detection:17.0.2")
implementation("com.google.android.gms:play-services-mlkit-subject-segmentation:16.0.0-beta1") implementation("com.google.android.gms:play-services-mlkit-subject-segmentation:16.0.0-beta1")
// Tensorflow Lite
implementation(libs.tensorflow.lite.support)
implementation(libs.tensorflow.lite)
implementation(libs.tensorflow.lite.gpu)
implementation(libs.tensorflow.lite.task.vision)
//Koin //Koin
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.koin.androidx.compose) implementation(libs.koin.androidx.compose)

View File

@ -25,6 +25,11 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/LivingAI.Starting.Theme"> android:theme="@style/LivingAI.Starting.Theme">
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="obj" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

Binary file not shown.

View File

@ -74,17 +74,4 @@ class AIModelImpl : AIModel {
} }
return mask return mask
} }
fun jaccardIndex(maskA: BooleanArray, maskB: BooleanArray): Double {
val len = max(maskA.size, maskB.size)
var inter = 0
var union = 0
for (i in 0 until len) {
val a = if (i < maskA.size) maskA[i] else false
val b = if (i < maskB.size) maskB[i] else false
if (a || b) union++
if (a && b) inter++
}
return if (union == 0) 0.0 else inter.toDouble() / union.toDouble()
}
} }

View File

@ -1,6 +1,7 @@
package com.example.livingai.di package com.example.livingai.di
import android.content.Context import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
@ -16,6 +17,11 @@ import com.example.livingai.data.repository.business.AnimalRatingRepositoryImpl
import com.example.livingai.data.repository.media.CameraRepositoryImpl import com.example.livingai.data.repository.media.CameraRepositoryImpl
import com.example.livingai.data.repository.media.VideoRepositoryImpl import com.example.livingai.data.repository.media.VideoRepositoryImpl
import com.example.livingai.domain.ml.AIModel import com.example.livingai.domain.ml.AIModel
import com.example.livingai.domain.ml.AnalyzerThresholds
import com.example.livingai.domain.ml.FeedbackAnalyzer
import com.example.livingai.domain.ml.FeedbackAnalyzerImpl
import com.example.livingai.domain.ml.ObjectDetector
import com.example.livingai.domain.ml.ObjectDetectorImpl
import com.example.livingai.domain.repository.AppDataRepository import com.example.livingai.domain.repository.AppDataRepository
import com.example.livingai.domain.repository.CameraRepository import com.example.livingai.domain.repository.CameraRepository
import com.example.livingai.domain.repository.VideoRepository import com.example.livingai.domain.repository.VideoRepository
@ -49,6 +55,7 @@ import com.example.livingai.utils.CoroutineDispatchers
import com.example.livingai.utils.DefaultCoroutineDispatchers import com.example.livingai.utils.DefaultCoroutineDispatchers
import com.example.livingai.utils.ScreenDimensions import com.example.livingai.utils.ScreenDimensions
import com.example.livingai.utils.SilhouetteManager import com.example.livingai.utils.SilhouetteManager
import com.example.livingai.utils.TiltSensorManager
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
@ -105,6 +112,17 @@ val appModule = module {
// ML Model // ML Model
single<AIModel> { AIModelImpl() } single<AIModel> { AIModelImpl() }
single<ObjectDetector> {
ObjectDetectorImpl(
context = androidContext(),
onResults = { _, _ -> }, // Callback will be set by ViewModel
onError = { error -> Log.e("ObjectDetector", "Error: $error") }
)
}
single<FeedbackAnalyzer> { FeedbackAnalyzerImpl(AnalyzerThresholds()) }
single { TiltSensorManager(androidContext()) }
// Repositories // Repositories
single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) } single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) }
@ -146,5 +164,5 @@ val appModule = module {
viewModel { SettingsViewModel(get()) } viewModel { SettingsViewModel(get()) }
viewModel { RatingViewModel(get(), get(), get(), get()) } viewModel { RatingViewModel(get(), get(), get(), get()) }
viewModel { CameraViewModel(get(), get(), get(), get()) } viewModel { CameraViewModel(get(), get(), get(), get()) }
viewModel { VideoViewModel() } viewModel { VideoViewModel(get(), get(), get()) }
} }

View File

@ -0,0 +1,150 @@
package com.example.livingai.domain.ml
import android.graphics.RectF
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sin
// ------------------------------------------------------------
// CONFIG CLASS
// ------------------------------------------------------------
data class AnalyzerThresholds(
// Size thresholds
val minCoverage: Float = 0.15f,
val maxCoverage: Float = 0.70f,
// Vertical centering thresholds
val topMarginRatio: Float = 0.05f,
val bottomMarginRatio: Float = 0.05f,
val centerToleranceRatio: Float = 0.10f,
// Height estimation thresholds
val targetHeightMeters: Float = 0.70f,
val heightToleranceMeters: Float = 0.05f, // ±5 cm allowed
// Real height of subject (for estimating camera distance)
val subjectRealHeightMeters: Float = 1.70f // default human height or silhouette height
)
// ------------------------------------------------------------
// STATES
// ------------------------------------------------------------
sealed class FeedbackState(val message: String) {
object Idle : FeedbackState("")
object Searching : FeedbackState("Searching for subject...")
object TooFar : FeedbackState("Move closer")
object TooClose : FeedbackState("Move back")
object NotCentered : FeedbackState("Center the subject")
object TooLow : FeedbackState("Raise phone")
object TooHigh : FeedbackState("Lower phone")
object Optimal : FeedbackState("Hold still")
}
// ------------------------------------------------------------
// ANALYZER
// ------------------------------------------------------------
interface FeedbackAnalyzer {
fun analyze(
detection: ObjectDetector.DetectionResult?,
frameWidth: Int,
frameHeight: Int,
tiltDegrees: Float, // phone pitch for height estimation
focalLengthPx: Float // camera intrinsics for height estimation
): FeedbackState
}
// ------------------------------------------------------------
// IMPLEMENTATION
// ------------------------------------------------------------
class FeedbackAnalyzerImpl(
private val thresholds: AnalyzerThresholds
) : FeedbackAnalyzer {
override fun analyze(
detection: ObjectDetector.DetectionResult?,
frameWidth: Int,
frameHeight: Int,
tiltDegrees: Float,
focalLengthPx: Float
): FeedbackState {
if (detection == null) return FeedbackState.Searching
if (frameWidth <= 0 || frameHeight <= 0) return FeedbackState.Idle
val box = detection.boundingBox
val pc = Precomputed(box, frameWidth, frameHeight, thresholds)
val cameraHeight = estimateCameraHeight(pc, tiltDegrees, focalLengthPx)
return when {
isTooFar(pc) -> FeedbackState.TooFar
isTooClose(pc) -> FeedbackState.TooClose
isNotCentered(pc) -> FeedbackState.NotCentered
isHeightTooLow(cameraHeight) -> FeedbackState.TooLow
isHeightTooHigh(cameraHeight) -> FeedbackState.TooHigh
else -> FeedbackState.Optimal
}
}
private fun isTooFar(pc: Precomputed) =
pc.verticalCoverage < thresholds.minCoverage
private fun isTooClose(pc: Precomputed) =
pc.verticalCoverage > thresholds.maxCoverage
private fun isNotCentered(pc: Precomputed) =
!pc.topWithinMargin || !pc.bottomWithinMargin || !pc.centeredVertically
private fun isHeightTooLow(camHeight: Float) =
camHeight < (thresholds.targetHeightMeters - thresholds.heightToleranceMeters)
private fun isHeightTooHigh(camHeight: Float) =
camHeight > (thresholds.targetHeightMeters + thresholds.heightToleranceMeters)
private fun estimateCameraHeight(
pc: Precomputed,
tiltDegrees: Float,
focalLengthPx: Float
): Float {
val tiltRad = Math.toRadians(tiltDegrees.toDouble())
val realHeight = thresholds.subjectRealHeightMeters
val pixelHeight = pc.heightPx
if (pixelHeight <= 0f || focalLengthPx <= 0f) return -1f
val distance = (realHeight * focalLengthPx) / pixelHeight
val height = distance * sin(tiltRad)
return height.toFloat() // in meters
}
private data class Precomputed(
val box: RectF,
val frameWidth: Int,
val frameHeight: Int,
val t: AnalyzerThresholds
) {
val top = box.top
val bottom = box.bottom
val heightPx = max(0f, bottom - top)
val verticalCoverage: Float = heightPx / frameHeight
private val topMarginPx = frameHeight * t.topMarginRatio
private val bottomMarginPx = frameHeight * t.bottomMarginRatio
private val centerTolerancePx = frameHeight * t.centerToleranceRatio
val topWithinMargin = top >= topMarginPx
val bottomWithinMargin = bottom <= (frameHeight - bottomMarginPx)
val centerY = box.centerY()
val frameCenterY = frameHeight / 2f
val centeredVertically = abs(centerY - frameCenterY) <= centerTolerancePx
}
}

View File

@ -0,0 +1,12 @@
package com.example.livingai.domain.ml
import android.graphics.Bitmap
import android.graphics.RectF
interface ObjectDetector {
var onResults: (List<DetectionResult>, Long) -> Unit
var onError: (String) -> Unit
fun detect(bitmap: Bitmap, imageRotation: Int)
data class DetectionResult(val boundingBox: RectF, val text: String, val score: Float)
}

View File

@ -0,0 +1,119 @@
package com.example.livingai.domain.ml
import android.content.Context
import android.graphics.Bitmap
import android.graphics.RectF
import org.tensorflow.lite.DataType
import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.support.image.ImageProcessor
import org.tensorflow.lite.support.image.TensorImage
import org.tensorflow.lite.support.image.ops.ResizeOp
import org.tensorflow.lite.support.image.ops.Rot90Op
import java.io.FileInputStream
import java.nio.channels.FileChannel
class ObjectDetectorImpl(
private val context: Context,
override var onResults: (List<ObjectDetector.DetectionResult>, Long) -> Unit,
override var onError: (String) -> Unit
) : ObjectDetector {
private var interpreter: Interpreter? = null
private val modelName = "efficientdet-lite0.tflite"
private val inputSize = 320 // EfficientDet-Lite0 expects 320x320
init {
setupInterpreter()
}
private fun setupInterpreter() {
try {
val assetFileDescriptor = context.assets.openFd(modelName)
val fileInputStream = FileInputStream(assetFileDescriptor.fileDescriptor)
val fileChannel = fileInputStream.channel
val startOffset = assetFileDescriptor.startOffset
val declaredLength = assetFileDescriptor.declaredLength
val modelBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength)
val options = Interpreter.Options()
options.setNumThreads(4)
interpreter = Interpreter(modelBuffer, options)
} catch (e: Exception) {
onError(e.message ?: "Error loading model")
}
}
override fun detect(bitmap: Bitmap, imageRotation: Int) {
val tflite = interpreter ?: return
val startTime = System.currentTimeMillis()
// 1. Preprocess Image
// Rotate -> Resize -> Convert to TensorImage (UINT8)
val imageProcessor = ImageProcessor.Builder()
.add(Rot90Op(-imageRotation / 90))
.add(ResizeOp(inputSize, inputSize, ResizeOp.ResizeMethod.BILINEAR))
.build()
var tensorImage = TensorImage(DataType.UINT8)
tensorImage.load(bitmap)
tensorImage = imageProcessor.process(tensorImage)
// 2. Prepare Output Buffers
val outputBoxes = Array(1) { Array(25) { FloatArray(4) } }
val outputClasses = Array(1) { FloatArray(25) }
val outputScores = Array(1) { FloatArray(25) }
val outputCount = FloatArray(1)
val outputs = mapOf(
0 to outputBoxes,
1 to outputClasses,
2 to outputScores,
3 to outputCount
)
// 3. Run Inference
try {
tflite.runForMultipleInputsOutputs(arrayOf(tensorImage.buffer), outputs)
} catch (e: Exception) {
onError(e.message ?: "Inference failed")
return
}
val inferenceTime = System.currentTimeMillis() - startTime
// 4. Parse Results
val results = mutableListOf<ObjectDetector.DetectionResult>()
// Calculate dimensions of the rotated image (the coordinate space of the detections)
val rotatedWidth = if (imageRotation % 180 == 0) bitmap.width else bitmap.height
val rotatedHeight = if (imageRotation % 180 == 0) bitmap.height else bitmap.width
for (i in 0 until 25) {
val score = outputScores[0][i]
if (score < 0.4f) continue
val classId = outputClasses[0][i].toInt()
val label = if (classId == 20) "Cow" else "Object $classId"
// Get box: [ymin, xmin, ymax, xmax] (normalized 0..1)
val box = outputBoxes[0][i]
val ymin = box[0]
val xmin = box[1]
val ymax = box[2]
val xmax = box[3]
// Scale to rotated image dimensions
val left = xmin * rotatedWidth
val top = ymin * rotatedHeight
val right = xmax * rotatedWidth
val bottom = ymax * rotatedHeight
val boundingBox = RectF(left, top, right, bottom)
results.add(ObjectDetector.DetectionResult(boundingBox, label, score))
}
onResults(results, inferenceTime)
}
}

View File

@ -4,7 +4,6 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentValues import android.content.ContentValues
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.video.MediaStoreOutputOptions import androidx.camera.video.MediaStoreOutputOptions
@ -13,6 +12,7 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.CameraController import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.video.AudioConfig import androidx.camera.view.video.AudioConfig
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -28,7 +28,9 @@ import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -38,18 +40,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import com.example.livingai.domain.ml.FeedbackState
import com.example.livingai.pages.components.CameraPreview import com.example.livingai.pages.components.CameraPreview
import com.example.livingai.pages.components.PermissionWrapper import com.example.livingai.pages.components.PermissionWrapper
import com.example.livingai.pages.navigation.Route import com.example.livingai.pages.navigation.Route
import com.example.livingai.ui.theme.RecordingRed import com.example.livingai.ui.theme.RecordingRed
import com.example.livingai.utils.Constants
import com.example.livingai.utils.SetScreenOrientation import com.example.livingai.utils.SetScreenOrientation
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.io.File import kotlin.math.max
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@Composable @Composable
@ -64,6 +72,10 @@ fun VideoRecordScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var recording by remember { mutableStateOf<Recording?>(null) } var recording by remember { mutableStateOf<Recording?>(null) }
var frameWidth by remember { mutableStateOf(0) }
var frameHeight by remember { mutableStateOf(0) }
var rotation by remember { mutableStateOf(0) }
// We need RECORD_AUDIO permission for video with audio // We need RECORD_AUDIO permission for video with audio
PermissionWrapper( PermissionWrapper(
permissions = listOf( permissions = listOf(
@ -73,7 +85,7 @@ fun VideoRecordScreen(
) { ) {
val controller = remember { val controller = remember {
LifecycleCameraController(context).apply { LifecycleCameraController(context).apply {
setEnabledUseCases(CameraController.VIDEO_CAPTURE) setEnabledUseCases(CameraController.VIDEO_CAPTURE or CameraController.IMAGE_ANALYSIS)
cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
} }
} }
@ -147,7 +159,12 @@ fun VideoRecordScreen(
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
controller = controller, controller = controller,
onFrame = { _, _ -> } onFrame = { bitmap, imageRotation ->
frameWidth = bitmap.width
frameHeight = bitmap.height
rotation = imageRotation
viewModel.onEvent(VideoEvent.FrameReceived(bitmap, imageRotation))
}
) )
// Overlay // Overlay
@ -162,7 +179,7 @@ fun VideoRecordScreen(
) )
Spacer( Spacer(
modifier = Modifier modifier = Modifier
.height(300.dp) // Constant height for the transparent area .height(Constants.VIDEO_SILHOETTE_FRAME_HEIGHT) // Constant height for the transparent area
.fillMaxWidth() .fillMaxWidth()
) )
Box( Box(
@ -172,6 +189,82 @@ fun VideoRecordScreen(
.background(Color.Black.copy(alpha = 0.5f)) .background(Color.Black.copy(alpha = 0.5f))
) )
} }
// Feedback Text Overlay
if (state.feedback !is FeedbackState.Idle && state.feedback !is FeedbackState.Optimal) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 80.dp),
contentAlignment = Alignment.TopCenter
) {
Text(
text = state.feedback.message,
color = Color.White,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
// Bounding Box Overlay
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
if (frameWidth > 0 && frameHeight > 0) {
// Determine source dimensions based on rotation
val isRotated = rotation == 90 || rotation == 270
val sourceWidth = if (isRotated) frameHeight.toFloat() else frameWidth.toFloat()
val sourceHeight = if (isRotated) frameWidth.toFloat() else frameHeight.toFloat()
// Calculate scale preserving aspect ratio (CenterCrop equivalent)
val scaleX = canvasWidth / sourceWidth
val scaleY = canvasHeight / sourceHeight
val scale = max(scaleX, scaleY)
// Calculate the dimensions of the scaled image
val scaledWidth = sourceWidth * scale
val scaledHeight = sourceHeight * scale
// Calculate the offset to center the image
val dx = (canvasWidth - scaledWidth) / 2
val dy = (canvasHeight - scaledHeight) / 2
// Draw framing box (debug/guide)
val targetHeight = Constants.VIDEO_SILHOETTE_FRAME_HEIGHT.toPx()
val targetTop = (canvasHeight - targetHeight) / 2
drawRect(
color = Color.Blue,
topLeft = Offset(0f, targetTop),
size = Size(canvasWidth, targetHeight),
style = Stroke(width = 2.dp.toPx())
)
state.cowDetection?.let { result ->
val box = result.boundingBox
val boxColor = if (state.feedback is FeedbackState.Optimal) Color.Green else Color.Yellow
drawRect(
color = boxColor,
topLeft = Offset(
x = box.left * scale + dx,
y = box.top * scale + dy
),
size = Size(
width = box.width() * scale,
height = box.height() * scale
),
style = Stroke(width = 3.dp.toPx())
)
}
}
}
} }
} }
} }

View File

@ -1,15 +1,68 @@
package com.example.livingai.pages.camera package com.example.livingai.pages.camera
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.ml.FeedbackAnalyzer
import com.example.livingai.domain.ml.FeedbackState
import com.example.livingai.domain.ml.ObjectDetector
import com.example.livingai.utils.TiltSensorManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class VideoViewModel : ViewModel() { class VideoViewModel(
private val objectDetector: ObjectDetector,
private val feedbackAnalyzer: FeedbackAnalyzer,
private val tiltSensorManager: TiltSensorManager
) : ViewModel() {
private val _state = MutableStateFlow(VideoState()) private val _state = MutableStateFlow(VideoState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
init {
tiltSensorManager.start()
viewModelScope.launch {
tiltSensorManager.tilt.collect { (pitch, _, _) ->
_state.value = _state.value.copy(tilt = pitch)
}
}
objectDetector.onResults = { results, _ ->
if (results.isNotEmpty()) {
Log.d(
"VideoViewModel",
"Detected objects: ${results.joinToString { "${it.text} (${it.score})" }}"
)
}
val cow = results.firstOrNull { it.text.contains("cow", ignoreCase = true) }
val feedback = feedbackAnalyzer.analyze(
detection = cow,
frameWidth = state.value.frameWidth,
frameHeight = state.value.frameHeight,
tiltDegrees = state.value.tilt,
focalLengthPx = state.value.focalLength
)
_state.value = _state.value.copy(
cowDetection = cow,
feedback = feedback
)
}
objectDetector.onError = { error ->
Log.e("VideoViewModel", "Detection error: $error")
}
}
override fun onCleared() {
super.onCleared()
tiltSensorManager.stop()
}
fun onEvent(event: VideoEvent) { fun onEvent(event: VideoEvent) {
when (event) { when (event) {
is VideoEvent.VideoRecorded -> { is VideoEvent.VideoRecorded -> {
@ -24,13 +77,28 @@ class VideoViewModel : ViewModel() {
is VideoEvent.ClearRecordedVideo -> { is VideoEvent.ClearRecordedVideo -> {
_state.value = _state.value.copy(recordedVideoUri = null) _state.value = _state.value.copy(recordedVideoUri = null)
} }
is VideoEvent.FrameReceived -> {
// Update frame dimensions and focal length, but let TiltSensorManager handle tilt
_state.value = _state.value.copy(
frameWidth = event.bitmap.width,
frameHeight = event.bitmap.height,
focalLength = event.focalLength
)
objectDetector.detect(event.bitmap, event.rotation)
}
} }
} }
} }
data class VideoState( data class VideoState(
val isRecording: Boolean = false, val isRecording: Boolean = false,
val recordedVideoUri: Uri? = null val recordedVideoUri: Uri? = null,
val cowDetection: ObjectDetector.DetectionResult? = null,
val feedback: FeedbackState = FeedbackState.Idle,
val frameWidth: Int = 0,
val frameHeight: Int = 0,
val tilt: Float = 0f,
val focalLength: Float = 0f
) )
sealed class VideoEvent { sealed class VideoEvent {
@ -38,4 +106,10 @@ sealed class VideoEvent {
object StartRecording : VideoEvent() object StartRecording : VideoEvent()
object StopRecording : VideoEvent() object StopRecording : VideoEvent()
object ClearRecordedVideo : VideoEvent() object ClearRecordedVideo : VideoEvent()
data class FrameReceived(
val bitmap: Bitmap,
val rotation: Int,
val tilt: Float = 0f,
val focalLength: Float = 0f
) : VideoEvent()
} }

View File

@ -18,7 +18,7 @@ import java.util.concurrent.Executors
fun CameraPreview( fun CameraPreview(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controller: LifecycleCameraController? = null, controller: LifecycleCameraController? = null,
onFrame: (Bitmap, Int) -> Unit onFrame: ((Bitmap, Int) -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@ -26,12 +26,14 @@ fun CameraPreview(
val cameraController = controller ?: remember { LifecycleCameraController(context) } val cameraController = controller ?: remember { LifecycleCameraController(context) }
LaunchedEffect(cameraController) { if (onFrame != null) {
cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy -> LaunchedEffect(cameraController) {
val bitmap = imageProxy.toBitmap() cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy ->
val rotationDegrees = imageProxy.imageInfo.rotationDegrees val bitmap = imageProxy.toBitmap()
onFrame(bitmap, rotationDegrees) val rotationDegrees = imageProxy.imageInfo.rotationDegrees
imageProxy.close() onFrame(bitmap, rotationDegrees)
imageProxy.close()
}
} }
} }

View File

@ -1,5 +1,7 @@
package com.example.livingai.utils package com.example.livingai.utils
import androidx.compose.ui.unit.dp
object Constants { object Constants {
const val USER_SETTINGS = "USER_SETTINGS" const val USER_SETTINGS = "USER_SETTINGS"
const val APP_ENTRY = "APP_ENTRY" const val APP_ENTRY = "APP_ENTRY"
@ -15,5 +17,6 @@ object Constants {
"angleview" "angleview"
) )
val VIDEO_SILHOETTE_FRAME_HEIGHT = 300.dp
const val JACCARD_THRESHOLD = 85 const val JACCARD_THRESHOLD = 85
} }

View File

@ -0,0 +1,58 @@
package com.example.livingai.utils
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class TiltSensorManager(
context: Context
) : SensorEventListener {
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private val _tilt = MutableStateFlow(Triple(0f, 0f, 0f)) // pitch, roll, azimuth
val tilt: StateFlow<Triple<Float, Float, Float>> = _tilt.asStateFlow()
fun start() {
rotationVectorSensor?.let {
sensorManager.registerListener(
this,
it,
SensorManager.SENSOR_DELAY_GAME
)
}
}
fun stop() {
sensorManager.unregisterListener(this)
}
override fun onSensorChanged(event: SensorEvent?) {
event ?: return
if (event.sensor.type != Sensor.TYPE_ROTATION_VECTOR) return
val rotationMatrix = FloatArray(9)
val orientationAngles = FloatArray(3)
// Convert rotation vector to rotation matrix
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
// Convert rotation matrix to orientation angles
SensorManager.getOrientation(rotationMatrix, orientationAngles)
// orientationAngles: [azimuth, pitch, roll] in radians
val azimuth = Math.toDegrees(orientationAngles[0].toDouble()).toFloat()
val pitch = Math.toDegrees(orientationAngles[1].toDouble()).toFloat()
val roll = Math.toDegrees(orientationAngles[2].toDouble()).toFloat()
_tilt.value = Triple(pitch, roll, azimuth)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}

View File

@ -17,6 +17,9 @@ ui = "1.9.5"
kotlinxSerialization = "1.6.3" kotlinxSerialization = "1.6.3"
koin = "4.1.1" koin = "4.1.1"
window = "1.5.1" window = "1.5.1"
tensorflowLiteSupport = "0.4.4"
tensorflowLite = "2.14.0"
tensorflowLiteTaskVision = "0.4.4"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -44,6 +47,10 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
androidx-window = { group = "androidx.window", name = "window", version.ref = "window" } androidx-window = { group = "androidx.window", name = "window", version.ref = "window" }
tensorflow-lite-support = { group = "org.tensorflow", name = "tensorflow-lite-support", version.ref = "tensorflowLiteSupport" }
tensorflow-lite = { group = "org.tensorflow", name = "tensorflow-lite", version.ref = "tensorflowLite" }
tensorflow-lite-gpu = { group = "org.tensorflow", name = "tensorflow-lite-gpu", version.ref = "tensorflowLite" }
tensorflow-lite-task-vision = { group = "org.tensorflow", name = "tensorflow-lite-task-vision", version.ref = "tensorflowLiteTaskVision" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }