distance test

This commit is contained in:
SaiD 2025-12-10 00:06:58 +05:30
parent bc2e32dc75
commit c2f3bdd089
19 changed files with 799 additions and 89 deletions

BIN
app/src/main.zip Normal file

Binary file not shown.

View File

@ -2,6 +2,7 @@ package com.example.livingai.data.ml
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect
import com.example.livingai.domain.ml.AIModel import com.example.livingai.domain.ml.AIModel
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation
@ -9,10 +10,8 @@ import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.math.max
// Define a constant color for the segmentation mask private const val MASK_COLOR = 0x5500FF00 // semi-transparent green overlay
private const val MASK_COLOR = 0x5500FF00 // Semi-transparent green
class AIModelImpl : AIModel { class AIModelImpl : AIModel {
@ -23,23 +22,21 @@ class AIModelImpl : AIModel {
SubjectSegmentation.getClient(options) SubjectSegmentation.getClient(options)
} }
override fun deriveInference(bitmap: Bitmap): String { override fun deriveInference(bitmap: Bitmap): String = "Inference Result"
return "Inference Result"
}
override suspend fun segmentImage(bitmap: Bitmap): Pair<Bitmap, BooleanArray>? { override suspend fun segmentImage(bitmap: Bitmap): Triple<Bitmap, BooleanArray, Rect>? {
return suspendCancellableCoroutine { cont -> return suspendCancellableCoroutine { cont ->
val image = InputImage.fromBitmap(bitmap, 0) val image = InputImage.fromBitmap(bitmap, 0)
segmenter.process(image) segmenter.process(image)
.addOnSuccessListener { result -> .addOnSuccessListener { result ->
val foregroundBitmap = result.foregroundBitmap val fg = result.foregroundBitmap ?: return@addOnSuccessListener cont.resume(null)
if (foregroundBitmap != null) {
val colorizedMask = createColorizedMask(foregroundBitmap) val booleanMask = createBooleanMask(fg)
val booleanMask = createBooleanMask(foregroundBitmap) val colorMask = createColorizedMask(fg)
cont.resume(Pair(colorizedMask, booleanMask)) val bbox = computeBoundingBox(booleanMask, fg.width, fg.height)
} else {
cont.resume(null) cont.resume(Triple(colorMask, booleanMask, bbox))
}
} }
.addOnFailureListener { e -> .addOnFailureListener { e ->
cont.resumeWithException(e) cont.resumeWithException(e)
@ -48,10 +45,11 @@ class AIModelImpl : AIModel {
} }
private fun createColorizedMask(maskBitmap: Bitmap): Bitmap { private fun createColorizedMask(maskBitmap: Bitmap): Bitmap {
val width = maskBitmap.width val w = maskBitmap.width
val height = maskBitmap.height val h = maskBitmap.height
val pixels = IntArray(width * height) val pixels = IntArray(w * h)
maskBitmap.getPixels(pixels, 0, width, 0, 0, width, height)
maskBitmap.getPixels(pixels, 0, w, 0, 0, w, h)
for (i in pixels.indices) { for (i in pixels.indices) {
if (Color.alpha(pixels[i]) > 0) { if (Color.alpha(pixels[i]) > 0) {
@ -59,7 +57,7 @@ class AIModelImpl : AIModel {
} }
} }
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
} }
private fun createBooleanMask(bitmap: Bitmap): BooleanArray { private fun createBooleanMask(bitmap: Bitmap): BooleanArray {
@ -67,11 +65,38 @@ class AIModelImpl : AIModel {
val h = bitmap.height val h = bitmap.height
val mask = BooleanArray(w * h) val mask = BooleanArray(w * h)
val pixels = IntArray(w * h) val pixels = IntArray(w * h)
bitmap.getPixels(pixels, 0, w, 0, 0, w, h) bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
for (i in pixels.indices) { for (i in pixels.indices) {
val alpha = Color.alpha(pixels[i]) mask[i] = Color.alpha(pixels[i]) > 0
mask[i] = alpha > 0 // Assuming foreground bitmap has transparent background
} }
return mask return mask
} }
private fun computeBoundingBox(mask: BooleanArray, w: Int, h: Int): Rect {
var minX = Int.MAX_VALUE
var minY = Int.MAX_VALUE
var maxX = Int.MIN_VALUE
var maxY = Int.MIN_VALUE
for (y in 0 until h) {
for (x in 0 until w) {
val idx = y * w + x
if (mask[idx]) {
if (x < minX) minX = x
if (y < minY) minY = y
if (x > maxX) maxX = x
if (y > maxY) maxY = y
}
}
}
return if (minX == Int.MAX_VALUE) {
Rect(0, 0, 0, 0)
} else {
Rect(minX, minY, maxX, maxY)
}
}
} }

View File

@ -0,0 +1,49 @@
package com.example.livingai.data.ml
import com.example.livingai.domain.ml.ArcoreDepthEstimator
import com.example.livingai.domain.ml.CameraInfoData
import com.example.livingai.domain.ml.CameraInfoProvider
import com.example.livingai.domain.ml.DistanceEstimator
import com.example.livingai.domain.ml.DistanceRecommendation
import com.example.livingai.domain.ml.DistanceState
import com.example.livingai.domain.ml.FrameData
import com.example.livingai.domain.ml.KnownDimensionEstimator
import com.example.livingai.utils.Constants
class DistanceEstimatorImpl(
private val mainEstimator: DistanceEstimator = ArcoreDepthEstimator(),
private val fallbackEstimator: DistanceEstimator = KnownDimensionEstimator()
) {
fun processFrame(frame: FrameData): DistanceState {
// Fallback or retrieve camera info
val camInfo = CameraInfoProvider.tryGet()
?: createFallbackCameraInfo(frame)
val main = mainEstimator.analyze(frame, camInfo)
return main.distanceMeters?.let { main }
?: fallbackEstimator.analyze(frame, camInfo)
}
private fun createFallbackCameraInfo(frame: FrameData): CameraInfoData {
// Estimate focal length based on FOV if available, or a reasonable default
// For a typical phone:
// H-FOV ~ 60-70 degrees
// fx = (W/2) / tan(FOV/2)
val w = frame.imageBitmap?.width ?: 1080
val h = frame.imageBitmap?.height ?: 1920
// Assume approx 60 degrees horizontal FOV as a fallback
val fovDegrees = 60.0
val fovRadians = Math.toRadians(fovDegrees)
val focalLengthPx = (w / 2.0) / Math.tan(fovRadians / 2.0)
return CameraInfoData(
focalLengthPixels = focalLengthPx.toFloat(),
sensorWidthPx = w,
sensorHeightPx = h,
principalPointX = w / 2f,
principalPointY = h / 2f
)
}
}

View File

@ -4,45 +4,67 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Matrix import android.graphics.Matrix
import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import com.example.livingai.data.ml.DistanceEstimatorImpl
import com.example.livingai.domain.ml.AIModel import com.example.livingai.domain.ml.AIModel
import com.example.livingai.domain.ml.DistanceState
import com.example.livingai.domain.ml.FrameMetadataProvider
import com.example.livingai.domain.ml.FrameMetadataProvider.toFrameData
import com.example.livingai.domain.repository.CameraRepository import com.example.livingai.domain.repository.CameraRepository
import com.example.livingai.utils.TiltSensorManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
class CameraRepositoryImpl( class CameraRepositoryImpl(
private val aiModel: AIModel, private val aiModel: AIModel,
private val tiltSensorManager: TiltSensorManager,
private val context: Context private val context: Context
) : CameraRepository { ) : CameraRepository {
override suspend fun captureImage(imageProxy: ImageProxy): Bitmap = withContext(Dispatchers.IO) { private val distanceEstimator = DistanceEstimatorImpl()
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
val bitmap = imageProxy.toBitmap()
imageProxy.close()
// Rotate bitmap if needed init {
if (rotationDegrees != 0) { // inject dependencies into metadata provider
val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } FrameMetadataProvider.aiModel = aiModel
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) FrameMetadataProvider.tiltSensorManager = tiltSensorManager
} else { }
bitmap
override suspend fun captureImage(imageProxy: ImageProxy): Bitmap =
withContext(Dispatchers.IO) {
val rotation = imageProxy.imageInfo.rotationDegrees
val bitmap = imageProxy.toBitmap()
imageProxy.close()
if (rotation != 0) {
val m = Matrix().apply { postRotate(rotation.toFloat()) }
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, m, true)
} else bitmap
} }
}
override suspend fun processFrame(bitmap: Bitmap): String = withContext(Dispatchers.Default) { override suspend fun processFrame(bitmap: Bitmap): DistanceState =
aiModel.deriveInference(bitmap) withContext(Dispatchers.Default) {
}
override suspend fun saveImage(bitmap: Bitmap, animalId: String, orientation: String?): String = withContext(Dispatchers.IO) { // 1. Collect metadata
val orientationSuffix = orientation?.let { "_$it" } ?: "" val meta = FrameMetadataProvider.collectMetadata(bitmap)
val filename = "${animalId}${orientationSuffix}.jpg"
val contentValues = ContentValues().apply { // 2. Convert to FrameData
put(MediaStore.Images.Media.DISPLAY_NAME, filename) val frameData = meta.toFrameData(bitmap)
// 3. Run distance estimator
distanceEstimator.processFrame(frameData)
}
override suspend fun saveImage(
bitmap: Bitmap,
animalId: String,
orientation: String?
): String = withContext(Dispatchers.IO) {
val suffix = orientation?.let { "_$it" } ?: ""
val fileName = "$animalId$suffix.jpg"
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/LivingAI/Media/$animalId") put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/LivingAI/Media/$animalId")
@ -51,8 +73,8 @@ class CameraRepositoryImpl(
} }
val resolver = context.contentResolver val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw RuntimeException("Failed to create image record") ?: throw RuntimeException("Image insert failed")
try { try {
resolver.openOutputStream(uri)?.use { out -> resolver.openOutputStream(uri)?.use { out ->
@ -60,13 +82,13 @@ class CameraRepositoryImpl(
} }
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
contentValues.clear() values.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null) resolver.update(uri, values, null, null)
} }
} catch (e: Exception) { } catch (e: Exception) {
resolver.delete(uri, null, null) resolver.delete(uri, null, null)
throw e throw e
} }
uri.toString() uri.toString()

View File

@ -128,7 +128,7 @@ val appModule = module {
single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) } single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) }
single<AnimalDetailsRepository> { AnimalDetailsRepositoryImpl(get()) } single<AnimalDetailsRepository> { AnimalDetailsRepositoryImpl(get()) }
single<AnimalRatingRepository> { AnimalRatingRepositoryImpl(get()) } single<AnimalRatingRepository> { AnimalRatingRepositoryImpl(get()) }
single<CameraRepository> { CameraRepositoryImpl(get(), androidContext()) } single<CameraRepository> { CameraRepositoryImpl(get(), get(), androidContext()) }
single<VideoRepository> { VideoRepositoryImpl(get()) } single<VideoRepository> { VideoRepositoryImpl(get()) }
// Use Cases // Use Cases

View File

@ -1,8 +1,9 @@
package com.example.livingai.domain.ml package com.example.livingai.domain.ml
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Rect
interface AIModel { interface AIModel {
fun deriveInference(bitmap: Bitmap): String fun deriveInference(bitmap: Bitmap): String
suspend fun segmentImage(bitmap: Bitmap): Pair<Bitmap, BooleanArray>? suspend fun segmentImage(bitmap: Bitmap): Triple<Bitmap, BooleanArray, Rect>?
} }

View File

@ -0,0 +1,152 @@
package com.example.livingai.domain.ml
import android.graphics.Rect
import com.example.livingai.utils.Constants
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
class ArcoreDepthEstimator(
private val params: ArcoreDepthParams = ArcoreDepthParams()
) : DistanceEstimator {
data class ArcoreDepthParams(
val targetDistanceMeters: Float = Constants.TARGET_DISTANCE_METERS,
val strictToleranceMeters: Float = Constants.DISTANCE_TOLERANCE_METERS_STRICT,
val relaxedToleranceMeters: Float = Constants.DISTANCE_TOLERANCE_METERS_RELAXED,
val depthConfidenceMin: Float = Constants.DEPTH_CONFIDENCE_MIN,
val minDepthSamples: Int = Constants.MIN_DEPTH_SAMPLES
)
override fun analyze(frame: FrameData, cameraInfo: CameraInfoData): DistanceState {
val isTilted = abs(frame.imuPitchDegrees) > Constants.MAX_ACCEPTABLE_PITCH_DEGREES ||
abs(frame.imuRollDegrees) > Constants.MAX_ACCEPTABLE_ROLL_DEGREES
val isRotated = (frame.cameraRotationDegrees % 360) != 0
val isOrientationCorrect = !isTilted && !isRotated
val centered = checkCentered(frame, cameraInfo)
val depthEstimate = sampleDepthMedian(frame)
val fallbackEstimate = computeKnownDimensionEstimate(frame, cameraInfo)
val fusedDistance = when {
depthEstimate != null ->
Constants.WEIGHT_ARCORE * depthEstimate +
Constants.WEIGHT_KNOWN_DIM * (fallbackEstimate ?: depthEstimate)
fallbackEstimate != null -> fallbackEstimate
else -> null
}
val (recommendation, ready, conf) =
evaluateDistanceAndReadiness(fusedDistance, centered, isOrientationCorrect)
return DistanceState(
distanceMeters = fusedDistance,
recommendation = recommendation,
isCameraTilted = isTilted,
isCameraRotated = isRotated,
isOrientationCorrect = isOrientationCorrect,
isObjectCentered = centered,
readyToCapture = ready,
confidenceScore = conf
)
}
private fun sampleDepthMedian(frame: FrameData): Float? {
val depth = frame.depthMapMeters ?: return null
val w = frame.depthWidth
val h = frame.depthHeight
if (w <= 0 || h <= 0) return null
val box = frame.segmentationBox ?: return null
val left = max(0, box.left)
val top = max(0, box.top)
val right = min(w - 1, box.right)
val bottom = min(h - 1, box.bottom)
val conf = frame.depthConfidence
val samples = ArrayList<Float>()
for (y in top..bottom) {
for (x in left..right) {
val idx = y * w + x
val d = depth[idx]
if (d <= 0f || d.isNaN()) continue
if (conf != null && conf[idx] < params.depthConfidenceMin)
continue
samples.add(d)
}
}
if (samples.size < params.minDepthSamples) return null
samples.sort()
return samples[samples.size / 2]
}
private fun computeKnownDimensionEstimate(
frame: FrameData,
cameraInfo: CameraInfoData
): Float? {
val box = frame.segmentationBox ?: return null
val hPixels = box.height().toFloat()
if (hPixels <= 1f) return null
val f = cameraInfo.focalLengthPixels
val Hreal = Constants.DEFAULT_OBJECT_REAL_HEIGHT_METERS
return (f * Hreal) / hPixels
}
private fun evaluateDistanceAndReadiness(
distMeters: Float?,
centered: Boolean,
orientationOk: Boolean
): Triple<DistanceRecommendation, Boolean, Float> {
if (distMeters == null)
return Triple(DistanceRecommendation.DISTANCE_UNKNOWN, false, 0f)
val diff = distMeters - params.targetDistanceMeters
val absDiff = abs(diff)
val withinStrict = absDiff <= params.strictToleranceMeters
val withinRelaxed = absDiff <= params.relaxedToleranceMeters
val recommendation = when {
withinStrict -> DistanceRecommendation.AT_OPTIMAL_DISTANCE
diff > 0f -> DistanceRecommendation.MOVE_CLOSER
else -> DistanceRecommendation.MOVE_AWAY
}
val ready = withinRelaxed && centered && orientationOk
val closenessScore = 1f - (absDiff / (params.relaxedToleranceMeters * 4f))
val confidence = closenessScore * 0.8f +
(if (centered && orientationOk) 0.2f else 0f)
return Triple(recommendation, ready, confidence)
}
private fun checkCentered(frame: FrameData, cameraInfo: CameraInfoData): Boolean {
val box = frame.segmentationBox ?: return false
val imgW = cameraInfo.sensorWidthPx
val imgH = cameraInfo.sensorHeightPx
val cxObj = (box.left + box.right) / 2f
val cyObj = (box.top + box.bottom) / 2f
val cx = imgW / 2f
val cy = imgH / 2f
val dx = abs(cxObj - cx) / imgW
val dy = abs(cyObj - cy) / imgH
return dx <= Constants.CENTER_TOLERANCE_X_FRACTION &&
dy <= Constants.CENTER_TOLERANCE_Y_FRACTION
}
}

View File

@ -0,0 +1,21 @@
package com.example.livingai.domain.ml
/**
* Singleton provider of camera intrinsic data.
* Must be initialized once per session.
*/
object CameraInfoProvider {
@Volatile
private var cameraInfoData: CameraInfoData? = null
fun init(info: CameraInfoData) {
cameraInfoData = info
}
fun get(): CameraInfoData {
return cameraInfoData
?: throw IllegalStateException("CameraInfoProvider not initialized")
}
fun tryGet(): CameraInfoData? = cameraInfoData
}

View File

@ -0,0 +1,74 @@
package com.example.livingai.domain.ml
import android.graphics.Bitmap
import android.graphics.Rect
import kotlin.math.abs
/**
* Interface for all distance estimators.
*/
interface DistanceEstimator {
fun analyze(
frame: FrameData,
cameraInfo: CameraInfoData
): DistanceState
}
/**
* Frame-specific data for one inference cycle.
*/
data class FrameData(
val imageBitmap: Bitmap?,
val segmentationBox: Rect?,
val segmentationMaskBitmap: Bitmap?,
// Optional ARCore depth inputs
val depthMapMeters: FloatArray?, // row-major R* C
val depthWidth: Int = 0,
val depthHeight: Int = 0,
val depthConfidence: FloatArray? = null,
// IMU orientation
val imuPitchDegrees: Float = 0f,
val imuRollDegrees: Float = 0f,
val imuYawDegrees: Float = 0f,
val cameraRotationDegrees: Int = 0,
val timestampMs: Long = System.currentTimeMillis()
)
/**
* Singleton-provided camera intrinsics for metric calculations.
*/
data class CameraInfoData(
val focalLengthPixels: Float, // fx in pixels
val sensorWidthPx: Int,
val sensorHeightPx: Int,
val principalPointX: Float,
val principalPointY: Float,
val distortionCoeffs: FloatArray? = null,
val cameraModel: String? = null
)
/**
* Output state describing computed distance and capture readiness.
*/
data class DistanceState(
val distanceMeters: Float?,
val recommendation: DistanceRecommendation,
val isCameraTilted: Boolean,
val isCameraRotated: Boolean,
val isOrientationCorrect: Boolean,
val isObjectCentered: Boolean,
val readyToCapture: Boolean,
val confidenceScore: Float = 0f
)
enum class DistanceRecommendation {
MOVE_CLOSER,
MOVE_AWAY,
AT_OPTIMAL_DISTANCE,
DISTANCE_UNKNOWN
}

View File

@ -29,9 +29,10 @@ sealed class FeedbackState(val message: String) {
object Searching : FeedbackState("Searching for subject...") object Searching : FeedbackState("Searching for subject...")
object TooFar : FeedbackState("Move closer") object TooFar : FeedbackState("Move closer")
object TooClose : FeedbackState("Move back") object TooClose : FeedbackState("Move back")
object NotCentered : FeedbackState("Center the subject")
object TooLow : FeedbackState("Raise phone") object TooLow : FeedbackState("Raise phone")
object TooHigh : FeedbackState("Lower phone") object TooHigh : FeedbackState("Lower phone")
object PhoneTooLow : FeedbackState("Raise phone to 60 to 70 cm from ground")
object PhoneTooHigh : FeedbackState("Lower phone to 60 to 70 cm from ground")
object Optimal : FeedbackState("Hold still") object Optimal : FeedbackState("Hold still")
} }
@ -76,14 +77,8 @@ class FeedbackAnalyzerImpl(
thresholds thresholds
) )
val cameraHeight = estimateCameraHeight(pc, tiltDegrees, focalLengthPx) val cameraHeight = estimateCameraHeight(pc.detectionHeight, tiltDegrees, focalLengthPx)
Log.d("FeedbackAnalyzerImpl", "Camera Height: $cameraHeight")
Log.d("FeedbackAnalyzer", "Camera Height: $cameraHeight, frame height: ${pc.frameHeight}, " +
"frame top: ${pc.frameTop}, frame bottom: ${pc.frameBottom}," +
" detection top: ${pc.detectionTop}, detection bottom: ${pc.detectionBottom}, " +
"isTopInside: ${pc.isTopInside}, isBottomInside: ${pc.isBottomInside}," +
" detection height: ${pc.detectionHeight}, tolerance: ${pc.tolerance}")
return when { return when {
// ORDER MATTERS — evaluate alignment first // ORDER MATTERS — evaluate alignment first
isTooHigh(pc) -> FeedbackState.TooHigh isTooHigh(pc) -> FeedbackState.TooHigh
@ -92,8 +87,8 @@ class FeedbackAnalyzerImpl(
isTooFar(pc) -> FeedbackState.TooFar isTooFar(pc) -> FeedbackState.TooFar
// Height estimation last // Height estimation last
isHeightTooLow(cameraHeight) -> FeedbackState.TooLow isHeightTooLow(cameraHeight) -> FeedbackState.PhoneTooLow
isHeightTooHigh(cameraHeight) -> FeedbackState.TooHigh isHeightTooHigh(cameraHeight) -> FeedbackState.PhoneTooHigh
else -> FeedbackState.Optimal else -> FeedbackState.Optimal
} }
@ -122,18 +117,17 @@ class FeedbackAnalyzerImpl(
heightMeters > (thresholds.maxTargetHeightMeters) heightMeters > (thresholds.maxTargetHeightMeters)
private fun estimateCameraHeight( private fun estimateCameraHeight(
pc: Precomputed, pixelHeight: Float,
tiltDegrees: Float, tiltDegrees: Float,
focalLengthPx: Float focalLengthPx: Float
): Float { ): Float {
val tiltRad = Math.toRadians(tiltDegrees.toDouble()) val tiltRad = Math.toRadians(tiltDegrees.toDouble())
val realHeight = thresholds.subjectRealHeightMeters val realHeight = thresholds.subjectRealHeightMeters
val pixelHeight = pc.detectionHeight
if (pixelHeight <= 0f || focalLengthPx <= 0f) return -1f if (pixelHeight <= 0f || focalLengthPx <= 0f) return -1f
val distance = (realHeight * focalLengthPx) / pixelHeight val distance = (realHeight * focalLengthPx) / pixelHeight
Log.d("FeedbackAnalyzerImpl", "Distance: $distance")
return (distance * sin(tiltRad)).toFloat() return (distance * sin(tiltRad)).toFloat()
} }

View File

@ -0,0 +1,182 @@
package com.example.livingai.domain.ml
import android.graphics.Bitmap
import android.graphics.Rect
import com.example.livingai.utils.TiltSensorManager
object FrameMetadataProvider {
lateinit var aiModel: AIModel // injected once from AppModule
var tiltSensorManager: TiltSensorManager? = null
// External data sources
var latestDepthResult: DepthResult? = null
var deviceRotation: Int = 0
suspend fun getSegmentation(bitmap: Bitmap): SegmentationResult? {
return try {
val (_, booleanMask, bbox) = aiModel.segmentImage(bitmap) ?: return null
SegmentationResult(booleanMask, bbox)
} catch (_: Exception) {
null
}
}
data class SegmentationResult(
val mask: BooleanArray,
val boundingBox: Rect
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SegmentationResult
if (!mask.contentEquals(other.mask)) return false
if (boundingBox != other.boundingBox) return false
return true
}
override fun hashCode(): Int {
var result = mask.contentHashCode()
result = 31 * result + boundingBox.hashCode()
return result
}
}
fun getDepthData(): DepthResult {
return latestDepthResult ?: DepthResult(null, 0, 0, null)
}
data class DepthResult(
val depthMeters: FloatArray?,
val width: Int,
val height: Int,
val confidence: FloatArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DepthResult
if (depthMeters != null) {
if (other.depthMeters == null) return false
if (!depthMeters.contentEquals(other.depthMeters)) return false
} else if (other.depthMeters != null) return false
if (width != other.width) return false
if (height != other.height) return false
if (confidence != null) {
if (other.confidence == null) return false
if (!confidence.contentEquals(other.confidence)) return false
} else if (other.confidence != null) return false
return true
}
override fun hashCode(): Int {
var result = depthMeters?.contentHashCode() ?: 0
result = 31 * result + width
result = 31 * result + height
result = 31 * result + (confidence?.contentHashCode() ?: 0)
return result
}
}
fun getIMU(): IMUResult {
val (pitch, roll, yaw) = tiltSensorManager?.tilt?.value ?: Triple(0f, 0f, 0f)
return IMUResult(pitch, roll, yaw)
}
data class IMUResult(val pitch: Float, val roll: Float, val yaw: Float)
fun getRotation(): Int {
return deviceRotation
}
data class FrameCollectedMetadata(
val segmentationBox: Rect?,
val depthMeters: FloatArray?,
val depthWidth: Int,
val depthHeight: Int,
val depthConfidence: FloatArray?,
val pitch: Float,
val roll: Float,
val yaw: Float,
val rotationDegrees: Int
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FrameCollectedMetadata
if (segmentationBox != other.segmentationBox) return false
if (depthMeters != null) {
if (other.depthMeters == null) return false
if (!depthMeters.contentEquals(other.depthMeters)) return false
} else if (other.depthMeters != null) return false
if (depthWidth != other.depthWidth) return false
if (depthHeight != other.depthHeight) return false
if (depthConfidence != null) {
if (other.depthConfidence == null) return false
if (!depthConfidence.contentEquals(other.depthConfidence)) return false
} else if (other.depthConfidence != null) return false
if (pitch != other.pitch) return false
if (roll != other.roll) return false
if (yaw != other.yaw) return false
if (rotationDegrees != other.rotationDegrees) return false
return true
}
override fun hashCode(): Int {
var result = segmentationBox?.hashCode() ?: 0
result = 31 * result + (depthMeters?.contentHashCode() ?: 0)
result = 31 * result + depthWidth
result = 31 * result + depthHeight
result = 31 * result + (depthConfidence?.contentHashCode() ?: 0)
result = 31 * result + pitch.hashCode()
result = 31 * result + roll.hashCode()
result = 31 * result + yaw.hashCode()
result = 31 * result + rotationDegrees
return result
}
}
suspend fun collectMetadata(bitmap: Bitmap): FrameCollectedMetadata {
val seg = getSegmentation(bitmap)
val depth = getDepthData()
val imu = getIMU()
val rot = getRotation()
return FrameCollectedMetadata(
segmentationBox = seg?.boundingBox,
depthMeters = depth.depthMeters,
depthWidth = depth.width,
depthHeight = depth.height,
depthConfidence = depth.confidence,
pitch = imu.pitch,
roll = imu.roll,
yaw = imu.yaw,
rotationDegrees = rot
)
}
fun FrameCollectedMetadata.toFrameData(bitmap: Bitmap): FrameData {
return FrameData(
imageBitmap = bitmap,
segmentationBox = segmentationBox,
segmentationMaskBitmap = null,
depthMapMeters = depthMeters,
depthWidth = depthWidth,
depthHeight = depthHeight,
depthConfidence = depthConfidence,
imuPitchDegrees = pitch,
imuRollDegrees = roll,
imuYawDegrees = yaw,
cameraRotationDegrees = rotationDegrees
)
}
}

View File

@ -0,0 +1,103 @@
package com.example.livingai.domain.ml
import com.example.livingai.utils.Constants
import kotlin.math.abs
import kotlin.math.min
class KnownDimensionEstimator(
private val params: KnownDimensionParams = KnownDimensionParams()
) : DistanceEstimator {
data class KnownDimensionParams(
val knownObjectHeightMeters: Float = Constants.DEFAULT_OBJECT_REAL_HEIGHT_METERS,
val targetDistanceMeters: Float = Constants.TARGET_DISTANCE_METERS,
val strictToleranceMeters: Float = Constants.DISTANCE_TOLERANCE_METERS_STRICT,
val relaxedToleranceMeters: Float = Constants.DISTANCE_TOLERANCE_METERS_RELAXED
)
override fun analyze(frame: FrameData, cameraInfo: CameraInfoData): DistanceState {
val tilted = abs(frame.imuPitchDegrees) > Constants.MAX_ACCEPTABLE_PITCH_DEGREES ||
abs(frame.imuRollDegrees) > Constants.MAX_ACCEPTABLE_ROLL_DEGREES
val rotated = (frame.cameraRotationDegrees % 360) != 0
val orientationOk = !tilted && !rotated
val centered = checkCentered(frame, cameraInfo)
val distEstimate = computeDistance(frame, cameraInfo)
val (recommendation, ready, conf) =
evaluateDistanceAndReadiness(distEstimate, centered, orientationOk)
return DistanceState(
distanceMeters = distEstimate,
recommendation = recommendation,
isCameraTilted = tilted,
isCameraRotated = rotated,
isOrientationCorrect = orientationOk,
isObjectCentered = centered,
readyToCapture = ready,
confidenceScore = conf
)
}
private fun computeDistance(
frame: FrameData,
cameraInfo: CameraInfoData
): Float? {
val box = frame.segmentationBox ?: return null
val hPx = box.height().toFloat()
if (hPx <= 1f) return null
val f = cameraInfo.focalLengthPixels
return (f * params.knownObjectHeightMeters) / hPx
}
private fun evaluateDistanceAndReadiness(
distMeters: Float?,
centered: Boolean,
orientationOk: Boolean
): Triple<DistanceRecommendation, Boolean, Float> {
if (distMeters == null)
return Triple(DistanceRecommendation.DISTANCE_UNKNOWN, false, 0f)
val diff = distMeters - params.targetDistanceMeters
val absDiff = abs(diff)
val withinStrict = absDiff <= params.strictToleranceMeters
val withinRelaxed = absDiff <= params.relaxedToleranceMeters
val recommendation = when {
withinStrict -> DistanceRecommendation.AT_OPTIMAL_DISTANCE
diff > 0f -> DistanceRecommendation.MOVE_CLOSER
else -> DistanceRecommendation.MOVE_AWAY
}
val ready = withinRelaxed && centered && orientationOk
val closenessScore = 1f - min(1f, absDiff / (params.relaxedToleranceMeters * 4f))
val conf = closenessScore * 0.9f +
(if (centered && orientationOk) 0.1f else 0f)
return Triple(recommendation, ready, conf)
}
private fun checkCentered(frame: FrameData, cameraInfo: CameraInfoData): Boolean {
val box = frame.segmentationBox ?: return false
val imgW = cameraInfo.sensorWidthPx
val imgH = cameraInfo.sensorHeightPx
val objCx = (box.left + box.right) / 2f
val objCy = (box.top + box.bottom) / 2f
val cx = imgW / 2f
val cy = imgH / 2f
val dx = abs(objCx - cx) / imgW
val dy = abs(objCy - cy) / imgH
return dx <= Constants.CENTER_TOLERANCE_X_FRACTION &&
dy <= Constants.CENTER_TOLERANCE_Y_FRACTION
}
}

View File

@ -2,9 +2,10 @@ package com.example.livingai.domain.repository
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import com.example.livingai.domain.ml.DistanceState
interface CameraRepository { interface CameraRepository {
suspend fun captureImage(imageProxy: ImageProxy): Bitmap suspend fun captureImage(imageProxy: ImageProxy): Bitmap
suspend fun processFrame(bitmap: Bitmap): String suspend fun processFrame(bitmap: Bitmap): DistanceState
suspend fun saveImage(bitmap: Bitmap, animalId: String, orientation: String?): String suspend fun saveImage(bitmap: Bitmap, animalId: String, orientation: String?): String
} }

View File

@ -6,15 +6,21 @@ import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.camera.view.LifecycleCameraController import androidx.camera.view.LifecycleCameraController
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Camera
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
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
@ -22,9 +28,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.example.livingai.pages.components.CameraPreview import com.example.livingai.pages.components.CameraPreview
@ -121,7 +129,7 @@ fun CameraScreen(
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
controller = controller, controller = controller,
onFrame = { bitmap, rotation -> onFrame = { bitmap, rotation, _ ->
viewModel.onEvent(CameraEvent.FrameReceived(bitmap, rotation)) viewModel.onEvent(CameraEvent.FrameReceived(bitmap, rotation))
} }
) )
@ -146,15 +154,26 @@ fun CameraScreen(
alpha = 0.4f alpha = 0.4f
) )
} }
}
state.savedMaskBitmap?.let { // Debug Overlay
Image( state.distanceState?.let { dist ->
bitmap = it.asImageBitmap(), Box(
contentDescription = "Silhouette Overlay", modifier = Modifier
modifier = Modifier.fillMaxSize(), .align(Alignment.TopEnd)
contentScale = ContentScale.Fit, .padding(16.dp)
alpha = 0.4f .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
) .padding(8.dp)
) {
Column {
Text("Dist: ${dist.distanceMeters ?: "N/A"}", color = Color.White)
Text("Rec: ${dist.recommendation}", color = Color.White)
Text("Tilted: ${dist.isCameraTilted}", color = Color.White)
Text("Rotated: ${dist.isCameraRotated}", color = Color.White)
Text("Centered: ${dist.isObjectCentered}", color = Color.White)
Text("Ready: ${dist.readyToCapture}", color = Color.White)
Text("Conf: ${dist.confidenceScore}", color = Color.White)
}
} }
} }

View File

@ -7,6 +7,7 @@ import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.ml.AIModel import com.example.livingai.domain.ml.AIModel
import com.example.livingai.domain.ml.DistanceState
import com.example.livingai.domain.repository.CameraRepository import com.example.livingai.domain.repository.CameraRepository
import com.example.livingai.domain.usecases.AppDataUseCases import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.utils.ScreenDimensions import com.example.livingai.utils.ScreenDimensions
@ -94,6 +95,9 @@ class CameraViewModel(
if (isProcessingFrame.compareAndSet(false, true)) { if (isProcessingFrame.compareAndSet(false, true)) {
viewModelScope.launch { viewModelScope.launch {
try { try {
// Process the frame for distance and metadata
val distanceState = cameraRepository.processFrame(bitmap)
val result = aiModel.segmentImage(bitmap) val result = aiModel.segmentImage(bitmap)
if (result != null) { if (result != null) {
val (maskBitmap, _) = result val (maskBitmap, _) = result
@ -119,7 +123,8 @@ class CameraViewModel(
fitImageToCrop(rotatedMask, screenDims.screenHeight, screenDims.screenWidth) fitImageToCrop(rotatedMask, screenDims.screenHeight, screenDims.screenWidth)
_state.value = _state.value.copy( _state.value = _state.value.copy(
segmentationMask = output segmentationMask = output,
distanceState = distanceState
) )
if (_state.value.isAutoCaptureEnabled && if (_state.value.isAutoCaptureEnabled &&
@ -139,7 +144,8 @@ class CameraViewModel(
} }
} else { } else {
_state.value = _state.value.copy( _state.value = _state.value.copy(
segmentationMask = null segmentationMask = null,
distanceState = distanceState
) )
} }
} finally { } finally {
@ -161,7 +167,8 @@ data class CameraUiState(
val isAutoCaptureEnabled: Boolean = false, val isAutoCaptureEnabled: Boolean = false,
val matchThreshold: Int = 50, val matchThreshold: Int = 50,
val distanceMethod: String = "Jaccard", val distanceMethod: String = "Jaccard",
val shouldAutoCapture: Boolean = false val shouldAutoCapture: Boolean = false,
val distanceState: DistanceState? = null
) )
sealed class CameraEvent { sealed class CameraEvent {

View File

@ -159,11 +159,11 @@ fun VideoRecordScreen(
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
controller = controller, controller = controller,
onFrame = { bitmap, imageRotation -> onFrame = { bitmap, imageRotation, focalLength ->
frameWidth = bitmap.width frameWidth = bitmap.width
frameHeight = bitmap.height frameHeight = bitmap.height
rotation = imageRotation rotation = imageRotation
viewModel.onEvent(VideoEvent.FrameReceived(bitmap, imageRotation)) viewModel.onEvent(VideoEvent.FrameReceived(bitmap, imageRotation, focalLength = focalLength))
} }
) )

View File

@ -1,24 +1,32 @@
package com.example.livingai.pages.components package com.example.livingai.pages.components
import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.hardware.camera2.CameraCharacteristics
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.view.LifecycleCameraController import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.delay
import java.util.concurrent.Executors import java.util.concurrent.Executors
@OptIn(ExperimentalCamera2Interop::class)
@Composable @Composable
fun CameraPreview( fun CameraPreview(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controller: LifecycleCameraController? = null, controller: LifecycleCameraController? = null,
onFrame: ((Bitmap, Int) -> Unit)? = null onFrame: ((Bitmap, Int, Float) -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@ -26,12 +34,37 @@ fun CameraPreview(
val cameraController = controller ?: remember { LifecycleCameraController(context) } val cameraController = controller ?: remember { LifecycleCameraController(context) }
// State to hold the focal length.
// Updated on the Main thread, read by the analysis background thread.
val focalLengthState = remember { mutableStateOf(0f) }
// Periodically check/update focal length on the Main thread
LaunchedEffect(cameraController) {
while(true) {
try {
val info = cameraController.cameraInfo
if (info != null) {
val camera2Info = Camera2CameraInfo.from(info)
val focalLengths = camera2Info.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
val fl = focalLengths?.firstOrNull() ?: 0f
focalLengthState.value = fl
}
} catch (e: Exception) {
// Ignore errors, e.g. if camera is closing or not ready
}
// Check periodically in case the active camera changes
delay(2000)
}
}
if (onFrame != null) { if (onFrame != null) {
LaunchedEffect(cameraController) { LaunchedEffect(cameraController) {
cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy -> cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy ->
val bitmap = imageProxy.toBitmap() val bitmap = imageProxy.toBitmap()
val rotationDegrees = imageProxy.imageInfo.rotationDegrees val rotationDegrees = imageProxy.imageInfo.rotationDegrees
onFrame(bitmap, rotationDegrees) val currentFocalLength = focalLengthState.value
onFrame(bitmap, rotationDegrees, currentFocalLength)
imageProxy.close() imageProxy.close()
} }
} }

View File

@ -20,4 +20,31 @@ object Constants {
val VIDEO_SILHOETTE_FRAME_HEIGHT = 300.dp val VIDEO_SILHOETTE_FRAME_HEIGHT = 300.dp
const val JACCARD_THRESHOLD = 85 const val JACCARD_THRESHOLD = 85
const val MODEL_HEIGHT = 320F const val MODEL_HEIGHT = 320F
// Target operational distance (meters)
const val TARGET_DISTANCE_METERS = 5.0f
// Tolerances
const val DISTANCE_TOLERANCE_METERS_STRICT = 0.10f
const val DISTANCE_TOLERANCE_METERS_RELAXED = 0.25f
// ARCore confidence threshold
const val DEPTH_CONFIDENCE_MIN = 0.6f
const val MIN_DEPTH_SAMPLES = 20
// IMU orientation limits
const val MAX_ACCEPTABLE_PITCH_DEGREES = 5f
const val MAX_ACCEPTABLE_ROLL_DEGREES = 3f
// Object centering thresholds
const val CENTER_TOLERANCE_X_FRACTION = 0.15f
const val CENTER_TOLERANCE_Y_FRACTION = 0.20f
// Fallback known-object dimension
const val DEFAULT_OBJECT_REAL_HEIGHT_METERS = 1.2f
// Fusion weights
const val WEIGHT_ARCORE = 0.8f
const val WEIGHT_KNOWN_DIM = 0.2f
} }