distance test
This commit is contained in:
parent
bc2e32dc75
commit
c2f3bdd089
Binary file not shown.
|
|
@ -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,13 +10,11 @@ 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 {
|
||||||
|
|
||||||
private val segmenter by lazy {
|
private val segmenter by lazy {
|
||||||
val options = SubjectSegmenterOptions.Builder()
|
val options = SubjectSegmenterOptions.Builder()
|
||||||
.enableForegroundBitmap()
|
.enableForegroundBitmap()
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
|
||||||
|
// 2. Convert to FrameData
|
||||||
val contentValues = ContentValues().apply {
|
val frameData = meta.toFrameData(bitmap)
|
||||||
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
|
||||||
|
// 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,24 +73,24 @@ 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 ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>?
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue