camera changes

This commit is contained in:
SaiD 2025-12-04 23:57:57 +05:30
parent ea5d7109d4
commit 19eddcf496
35 changed files with 702 additions and 265 deletions

View File

@ -45,6 +45,7 @@ android {
dependencies {
implementation(libs.androidx.paging.common)
implementation(libs.androidx.ui)
implementation(libs.androidx.window)
val cameraxVersion = "1.5.0-alpha03"
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.cardview:cardview:1.0.0")

View File

@ -3,26 +3,33 @@ package com.example.livingai
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.pages.home.HomeViewModel
import com.example.livingai.pages.navigation.NavGraph
import com.example.livingai.pages.navigation.Route
import com.example.livingai.ui.theme.LivingAITheme
import com.example.livingai.utils.LocaleHelper
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : ComponentActivity() {
private val viewModel by viewModel<HomeViewModel>()
private val appDataUseCases: AppDataUseCases by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
@ -32,32 +39,42 @@ class MainActivity : ComponentActivity() {
}
}
// The splash screen is shown until the start destination is determined.
// If there's a delay, the splash screen will cover it.
setContent {
LivingAITheme {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb()
),
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb()
val settings by appDataUseCases.getSettings().collectAsState(initial = null)
val context = LocalContext.current
// Update locale and provide it through CompositionLocalProvider
val localizedContext = settings?.let {
LocaleHelper.applyLocale(context, it.language)
} ?: context
CompositionLocalProvider(
LocalContext provides localizedContext,
LocalActivityResultRegistryOwner provides this
) {
LivingAITheme {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb()
),
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb()
)
)
)
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
val startDestination = viewModel.startDestination.value
// Ensure startDestination is not null before rendering NavGraph
if (startDestination != null) {
NavGraph(startDestination = startDestination)
} else {
// Optional: Show a loading indicator if startDestination is null
// for an extended period, though splash screen should handle it.
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
val startDestination = viewModel.startDestination.value
// Ensure startDestination is not null before rendering NavGraph
if (startDestination != null) {
NavGraph(startDestination = startDestination)
} else {
// Optional: Show a loading indicator if startDestination is null
// for an extended period, though splash screen should handle it.
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,60 @@
package com.example.livingai.data.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.example.livingai.domain.model.SettingsData
import com.example.livingai.domain.repository.AppDataRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
class AppDataRepositoryImpl(private val dataStore: DataStore<Preferences>) : AppDataRepository {
private object PreferencesKeys {
val APP_ENTRY = booleanPreferencesKey("app_entry")
val LANGUAGE = stringPreferencesKey("language")
val IS_AUTO_CAPTURE_ON = booleanPreferencesKey("is_auto_capture_on")
val JACCARD_THRESHOLD = floatPreferencesKey("jaccard_threshold")
}
override fun getSettings(): Flow<SettingsData> {
return dataStore.data.catch {
if (it is IOException) {
emit(emptyPreferences())
} else {
throw it
}
}.map {
val language = it[PreferencesKeys.LANGUAGE] ?: "en"
val isAutoCaptureOn = it[PreferencesKeys.IS_AUTO_CAPTURE_ON] ?: false
val jaccardThreshold = it[PreferencesKeys.JACCARD_THRESHOLD] ?: 50f
SettingsData(language, isAutoCaptureOn, jaccardThreshold)
}
}
override suspend fun saveSettings(settings: SettingsData) {
dataStore.edit {
it[PreferencesKeys.LANGUAGE] = settings.language
it[PreferencesKeys.IS_AUTO_CAPTURE_ON] = settings.isAutoCaptureOn
it[PreferencesKeys.JACCARD_THRESHOLD] = settings.jaccardThreshold
}
}
override suspend fun saveAppEntry() {
dataStore.edit { settings ->
settings[PreferencesKeys.APP_ENTRY] = true
}
}
override fun readAppEntry(): Flow<Boolean> {
return dataStore.data.map { preferences ->
preferences[PreferencesKeys.APP_ENTRY] ?: false
}
}
}

View File

@ -4,35 +4,36 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import androidx.window.layout.WindowMetricsCalculator
import coil.ImageLoader
import coil.decode.SvgDecoder
import com.example.livingai.data.local.CSVDataSource
import com.example.livingai.data.manager.LocalUserManagerImpl
import com.example.livingai.data.ml.AIModelImpl
import com.example.livingai.data.repository.SettingsRepositoryImpl
import com.example.livingai.data.repository.AppDataRepositoryImpl
import com.example.livingai.data.repository.business.AnimalDetailsRepositoryImpl
import com.example.livingai.data.repository.business.AnimalProfileRepositoryImpl
import com.example.livingai.data.repository.business.AnimalRatingRepositoryImpl
import com.example.livingai.data.repository.media.CameraRepositoryImpl
import com.example.livingai.data.repository.media.VideoRepositoryImpl
import com.example.livingai.domain.manager.LocalUserManager
import com.example.livingai.domain.ml.AIModel
import com.example.livingai.domain.repository.AppDataRepository
import com.example.livingai.domain.repository.CameraRepository
import com.example.livingai.domain.repository.SettingsRepository
import com.example.livingai.domain.repository.VideoRepository
import com.example.livingai.domain.repository.business.AnimalDetailsRepository
import com.example.livingai.domain.repository.business.AnimalProfileRepository
import com.example.livingai.domain.repository.business.AnimalRatingRepository
import com.example.livingai.domain.repository.business.DataSource
import com.example.livingai.domain.usecases.AppEntry.AppEntryUseCases
import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.domain.usecases.DeleteAnimalProfile
import com.example.livingai.domain.usecases.GetAnimalDetails
import com.example.livingai.domain.usecases.GetAnimalProfiles
import com.example.livingai.domain.usecases.GetAnimalRatings
import com.example.livingai.domain.usecases.GetSettingsUseCase
import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase
import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase
import com.example.livingai.domain.usecases.ReadAppEntry
import com.example.livingai.domain.usecases.SaveAppEntry
import com.example.livingai.domain.usecases.ReadAppEntryUseCase
import com.example.livingai.domain.usecases.SaveAppEntryUseCase
import com.example.livingai.domain.usecases.SaveSettingsUseCase
import com.example.livingai.domain.usecases.SetAnimalDetails
import com.example.livingai.domain.usecases.SetAnimalRatings
import com.example.livingai.pages.addprofile.AddProfileViewModel
@ -46,6 +47,7 @@ import com.example.livingai.pages.settings.SettingsViewModel
import com.example.livingai.utils.Constants
import com.example.livingai.utils.CoroutineDispatchers
import com.example.livingai.utils.DefaultCoroutineDispatchers
import com.example.livingai.utils.ScreenDimensions
import com.example.livingai.utils.SilhouetteManager
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
@ -54,13 +56,16 @@ import org.koin.dsl.module
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = Constants.USER_SETTINGS)
val appModule = module {
single<LocalUserManager> { LocalUserManagerImpl(get()) }
single<DataStore<Preferences>> { androidContext().dataStore }
single<AppDataRepository> { AppDataRepositoryImpl(get()) }
single {
AppEntryUseCases(
readAppEntry = ReadAppEntry(get()),
saveAppEntry = SaveAppEntry(get())
AppDataUseCases(
getSettings = GetSettingsUseCase(get()),
saveSettings = SaveSettingsUseCase(get()),
readAppEntry = ReadAppEntryUseCase(get()),
saveAppEntry = SaveAppEntryUseCase(get())
)
}
@ -86,11 +91,16 @@ val appModule = module {
}
// Initialize silhouettes once
single(createdAtStart = true) {
single<ScreenDimensions>(createdAtStart = true) {
val ctx: Context = androidContext()
// Names expected to be provided as drawable resource names like "front_silhoutte"
val names = listOf("front_silhouette", "side_silhouette", "rear_silhouette")
SilhouetteManager.initialize(ctx, names)
val metrics = WindowMetricsCalculator.getOrCreate()
.computeCurrentWindowMetrics(ctx)
val bounds = metrics.bounds
val screenWidth = bounds.width()
val screenHeight = bounds.height()
SilhouetteManager.initialize(ctx, screenWidth, screenHeight)
ScreenDimensions(screenWidth, screenHeight)
}
// ML Model
@ -100,7 +110,6 @@ val appModule = module {
single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) }
single<AnimalDetailsRepository> { AnimalDetailsRepositoryImpl(get()) }
single<AnimalRatingRepository> { AnimalRatingRepositoryImpl(get()) }
single<SettingsRepository> { SettingsRepositoryImpl(get()) }
single<CameraRepository> { CameraRepositoryImpl(get(), androidContext()) }
single<VideoRepository> { VideoRepositoryImpl(get()) }
@ -129,13 +138,13 @@ val appModule = module {
// ViewModels
viewModel { HomeViewModel(get()) }
viewModel { OnBoardingViewModel(get(), get()) }
viewModel { OnBoardingViewModel(get()) }
viewModel { (savedStateHandle: androidx.lifecycle.SavedStateHandle?) ->
AddProfileViewModel(get(), get(), get(), androidContext(), savedStateHandle)
}
viewModel { ListingsViewModel(get()) }
viewModel { SettingsViewModel(get(), androidContext()) }
viewModel { SettingsViewModel(get()) }
viewModel { RatingViewModel(get(), get(), get(), get()) }
viewModel { CameraViewModel(get(), get()) }
viewModel { CameraViewModel(get(), get(), get(), get()) }
viewModel { VideoViewModel() }
}

View File

@ -5,5 +5,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class SettingsData(
val language: String = "en",
val isAutoCaptureOn: Boolean = false
val isAutoCaptureOn: Boolean = false,
val jaccardThreshold: Float = 50f,
val distanceMethod: String = "Jaccard"
)

View File

@ -0,0 +1,11 @@
package com.example.livingai.domain.repository
import com.example.livingai.domain.model.SettingsData
import kotlinx.coroutines.flow.Flow
interface AppDataRepository {
fun getSettings(): Flow<SettingsData>
suspend fun saveSettings(settings: SettingsData)
suspend fun saveAppEntry()
fun readAppEntry(): Flow<Boolean>
}

View File

@ -0,0 +1,8 @@
package com.example.livingai.domain.usecases
data class AppDataUseCases(
val getSettings: GetSettingsUseCase,
val saveSettings: SaveSettingsUseCase,
val readAppEntry: ReadAppEntryUseCase,
val saveAppEntry: SaveAppEntryUseCase
)

View File

@ -0,0 +1,10 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.repository.AppDataRepository
import javax.inject.Inject
class GetSettingsUseCase @Inject constructor(
private val appDataRepository: AppDataRepository
) {
operator fun invoke() = appDataRepository.getSettings()
}

View File

@ -0,0 +1,11 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.repository.AppDataRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class ReadAppEntryUseCase @Inject constructor(
private val appDataRepository: AppDataRepository
) {
operator fun invoke(): Flow<Boolean> = appDataRepository.readAppEntry()
}

View File

@ -0,0 +1,10 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.repository.AppDataRepository
import javax.inject.Inject
class SaveAppEntryUseCase @Inject constructor(
private val appDataRepository: AppDataRepository
) {
suspend operator fun invoke() = appDataRepository.saveAppEntry()
}

View File

@ -0,0 +1,11 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.model.SettingsData
import com.example.livingai.domain.repository.AppDataRepository
import javax.inject.Inject
class SaveSettingsUseCase @Inject constructor(
private val appDataRepository: AppDataRepository
) {
suspend operator fun invoke(settings: SettingsData) = appDataRepository.saveSettings(settings)
}

View File

@ -6,20 +6,15 @@ import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.camera.view.LifecycleCameraController
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -30,14 +25,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.navigation.NavController
import androidx.core.content.ContextCompat
import com.example.livingai.pages.components.CameraPreview
import com.example.livingai.pages.components.PermissionWrapper
import com.example.livingai.pages.navigation.Route
import com.example.livingai.utils.SetScreenOrientation
import com.example.livingai.utils.SilhouetteManager
import org.koin.androidx.compose.koinViewModel
@Composable
@ -47,12 +40,22 @@ fun CameraScreen(
orientation: String? = null,
animalId: String
) {
val orientationLock = when (orientation) {
"front", "back" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
val isLandscape = when (orientation) {
"front", "back" -> false
else -> true
}
val orientationLock = if (isLandscape) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
SetScreenOrientation(orientationLock)
LaunchedEffect(animalId, orientation) {
viewModel.onEvent(CameraEvent.SetContext(animalId, orientation))
}
PermissionWrapper {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
@ -62,115 +65,101 @@ fun CameraScreen(
setEnabledUseCases(LifecycleCameraController.IMAGE_ANALYSIS or LifecycleCameraController.IMAGE_CAPTURE)
}
}
LaunchedEffect(animalId, orientation) {
viewModel.onEvent(CameraEvent.SetContext(animalId, orientation))
fun takePhoto() {
val executor = ContextCompat.getMainExecutor(context)
controller.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
viewModel.onEvent(CameraEvent.ImageCaptured(image))
}
override fun onError(exception: ImageCaptureException) {
// Handle error, e.g., log it or show a message
}
}
)
}
LaunchedEffect(state.shouldAutoCapture) {
if (state.shouldAutoCapture) {
takePhoto()
viewModel.onEvent(CameraEvent.AutoCaptureTriggered)
}
}
LaunchedEffect(state.capturedImageUri) {
state.capturedImageUri?.let {
navController.navigate(
Route.ViewImageScreen(
imageUri = it.toString(),
imageUri = it.toString(),
shouldAllowRetake = true,
showAccept = true,
orientation = orientation,
animalId = animalId
)
)
viewModel.onEvent(CameraEvent.ClearCapturedImage) // Clear after navigation
viewModel.onEvent(CameraEvent.ClearCapturedImage)
}
}
Scaffold(
floatingActionButton = {
if (!state.isAutoCaptureOn) {
FloatingActionButton(onClick = {
val executor = ContextCompat.getMainExecutor(context)
controller.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
viewModel.onEvent(CameraEvent.ImageCaptured(image))
}
override fun onError(exception: ImageCaptureException) {
// Handle error
}
}
)
}) {
Icon(Icons.Default.Camera, contentDescription = "Capture Image")
}
FloatingActionButton(onClick = ::takePhoto) {
Icon(Icons.Default.Camera, contentDescription = "Capture Image")
}
},
floatingActionButtonPosition = FabPosition.Center
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
modifier = Modifier.fillMaxSize(),
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.fillMaxSize()
) {
CameraPreview(
modifier = Modifier.fillMaxSize(),
controller = controller,
onFrame = { bitmap, rotation ->
if (orientation != null) {
viewModel.onEvent(CameraEvent.FrameReceived(bitmap, rotation))
}
viewModel.onEvent(CameraEvent.FrameReceived(bitmap, rotation))
}
)
// Overlay silhouette if orientation provided
if (state.orientation != null) {
val silhouetteName = "${state.orientation}_silhouette"
val bmp = SilhouetteManager.getOriginal(silhouetteName)
if (bmp != null) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "overlay",
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
alpha = 0.5f
)
}
}
// Overlay Segmentation Mask
// The ML segmentation mask
state.segmentationMask?.let { mask ->
Image(
bitmap = mask.asImageBitmap(),
contentDescription = "segmentation overlay",
modifier = Modifier.matchParentSize(),
contentDescription = "Segmentation Overlay",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.FillBounds,
alpha = 0.5f
)
}
}
// Top Bar with Inference Result and Auto Capture Switch
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (state.inferenceResult != null) {
Text(text = state.inferenceResult!!, color = androidx.compose.ui.graphics.Color.White)
} else {
Box(modifier = Modifier.weight(1f))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Auto Capture", color = androidx.compose.ui.graphics.Color.White)
Switch(
checked = state.isAutoCaptureOn,
onCheckedChange = { viewModel.onEvent(CameraEvent.ToggleAutoCapture) }
state.silhouetteMask?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Silhouette Overlay",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
alpha = 0.4f
)
}
state.savedMaskBitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Silhouette Overlay",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
alpha = 0.4f
)
}
}
if (state.isCapturing) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}

View File

@ -6,101 +6,144 @@ import android.net.Uri
import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.data.ml.AIModelImpl
import com.example.livingai.domain.ml.AIModel
import com.example.livingai.domain.repository.CameraRepository
import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.utils.ScreenDimensions
import com.example.livingai.utils.SilhouetteManager
import com.example.livingai.utils.calculateDistance
import com.example.livingai.utils.fitImageToCrop
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
class CameraViewModel(
private val cameraRepository: CameraRepository,
private val aiModel: AIModel
private val aiModel: AIModel,
private val screenDims: ScreenDimensions,
private val appDataUseCases: AppDataUseCases
) : ViewModel() {
private val _state = MutableStateFlow(CameraUiState())
val state = _state.asStateFlow()
// mutex to prevent parallel captures
private val captureMutex = Mutex()
private val isProcessingFrame = AtomicBoolean(false)
init {
viewModelScope.launch {
appDataUseCases.getSettings().collect { settings ->
_state.value = _state.value.copy(
isAutoCaptureEnabled = settings.isAutoCaptureOn,
matchThreshold = settings.jaccardThreshold.roundToInt(),
distanceMethod = settings.distanceMethod
)
}
}
}
fun onEvent(event: CameraEvent) {
when (event) {
is CameraEvent.CaptureImage -> captureImage()
is CameraEvent.ImageCaptured -> handleImageProxy(event.imageProxy)
is CameraEvent.FrameReceived -> handleFrame(event.bitmap, event.rotationDegrees)
is CameraEvent.ToggleAutoCapture -> toggleAutoCapture()
is CameraEvent.ClearCapturedImage -> clearCaptured()
is CameraEvent.SetContext -> setContext(event.animalId, event.orientation)
is CameraEvent.AutoCaptureTriggered -> {
_state.value = _state.value.copy(shouldAutoCapture = false, isCapturing = true)
}
}
}
private fun setContext(animalId: String, orientation: String?) {
_state.value = _state.value.copy(animalId = animalId, orientation = orientation)
}
val silhouetteMask = orientation?.let { SilhouetteManager.getOriginal(it) }
val savedMask = orientation?.let { SilhouetteManager.getInvertedPurple(it) }
private fun toggleAutoCapture() {
_state.value = _state.value.copy(isAutoCaptureOn = !_state.value.isAutoCaptureOn)
_state.value = _state.value.copy(
animalId = animalId,
orientation = orientation,
silhouetteMask = silhouetteMask,
savedMaskBitmap = savedMask
)
}
private fun clearCaptured() {
_state.value = _state.value.copy(capturedImage = null, capturedImageUri = null, segmentationMask = null)
}
private fun captureImage() {
// UI should handle capture and send ImageCaptured event
_state.value = _state.value.copy(
capturedImageUri = null,
segmentationMask = null
)
}
private fun handleImageProxy(proxy: ImageProxy) {
// convert to bitmap and then save via repository
viewModelScope.launch {
val bitmap = cameraRepository.captureImage(proxy)
val animalId = _state.value.animalId ?: "unknown"
val uriString = cameraRepository.saveImage(bitmap, animalId, _state.value.orientation)
_state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString))
_state.value = _state.value.copy(
capturedImageUri = Uri.parse(uriString),
isCapturing = false // Reset capturing flag
)
}
}
private fun handleFrame(bitmap: Bitmap, rotationDegrees: Int) {
val orientation = _state.value.orientation ?: return
if (_state.value.isCapturing || _state.value.shouldAutoCapture) {
return
}
viewModelScope.launch {
if (captureMutex.tryLock()) {
if (isProcessingFrame.compareAndSet(false, true)) {
viewModelScope.launch {
try {
val result = aiModel.segmentImage(bitmap)
if (result != null) {
val (maskBitmap, maskBool) = result
// Rotate the mask to match the display orientation
val (maskBitmap, _) = result
val rotatedMask = if (rotationDegrees != 0) {
val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) }
Bitmap.createBitmap(maskBitmap, 0, 0, maskBitmap.width, maskBitmap.height, matrix, true)
Bitmap.createBitmap(
maskBitmap,
0,
0,
maskBitmap.width,
maskBitmap.height,
matrix,
true
)
} else {
maskBitmap
}
_state.value = _state.value.copy(segmentationMask = rotatedMask)
val output = if(_state.value.orientation == "front" || _state.value.orientation == "back")
fitImageToCrop(rotatedMask, screenDims.screenWidth, screenDims.screenHeight)
else
fitImageToCrop(rotatedMask, screenDims.screenHeight, screenDims.screenWidth)
val savedMask = SilhouetteManager.getBitmask("${orientation}_silhouette")
if (savedMask != null) {
if (maskBool.size == savedMask.size) {
val jaccard = (aiModel as AIModelImpl).jaccardIndex(maskBool, savedMask)
if (_state.value.isAutoCaptureOn && jaccard > 0.5) {
val animalId = _state.value.animalId ?: "unknown"
val uriString = cameraRepository.saveImage(bitmap, animalId, orientation)
_state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString))
}
_state.value = _state.value.copy(inferenceResult = "Jaccard: %.2f".format(jaccard))
_state.value = _state.value.copy(
segmentationMask = output
)
if (_state.value.isAutoCaptureEnabled &&
_state.value.savedMaskBitmap != null &&
output != null
) {
val isValidCapture = calculateDistance(
_state.value.distanceMethod,
_state.value.savedMaskBitmap!!,
output,
_state.value.matchThreshold
)
if (isValidCapture) {
_state.value = _state.value.copy(shouldAutoCapture = true)
}
}
} else {
_state.value = _state.value.copy(
segmentationMask = null
)
}
} finally {
captureMutex.unlock()
isProcessingFrame.set(false)
}
}
}
@ -110,18 +153,21 @@ class CameraViewModel(
data class CameraUiState(
val animalId: String? = null,
val orientation: String? = null,
val capturedImage: Any? = null,
val capturedImageUri: Uri? = null,
val inferenceResult: String? = null,
val isAutoCaptureOn: Boolean = false,
val segmentationMask: Bitmap? = null
val segmentationMask: Bitmap? = null,
val savedMaskBitmap: Bitmap? = null,
val silhouetteMask: Bitmap? = null,
val isCapturing: Boolean = false,
val isAutoCaptureEnabled: Boolean = false,
val matchThreshold: Int = 50,
val distanceMethod: String = "Jaccard",
val shouldAutoCapture: Boolean = false
)
sealed class CameraEvent {
object CaptureImage : CameraEvent()
data class ImageCaptured(val imageProxy: ImageProxy) : CameraEvent()
data class FrameReceived(val bitmap: Bitmap, val rotationDegrees: Int) : CameraEvent()
object ToggleAutoCapture : CameraEvent()
object ClearCapturedImage : CameraEvent()
data class SetContext(val animalId: String, val orientation: String?) : CameraEvent()
object AutoCaptureTriggered : CameraEvent()
}

View File

@ -3,6 +3,7 @@ package com.example.livingai.pages.camera
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.pm.ActivityInfo
import android.net.Uri
import android.provider.MediaStore
import androidx.camera.core.CameraSelector
@ -12,14 +13,20 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.video.AudioConfig
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
@ -31,12 +38,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.navigation.NavController
import com.example.livingai.pages.components.CameraPreview
import com.example.livingai.pages.components.PermissionWrapper
import com.example.livingai.pages.navigation.Route
import com.example.livingai.ui.theme.RecordingRed
import com.example.livingai.utils.SetScreenOrientation
import org.koin.androidx.compose.koinViewModel
import java.io.File
@ -47,6 +58,8 @@ fun VideoRecordScreen(
navController: NavController,
animalId: String
) {
SetScreenOrientation(orientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
val context = LocalContext.current
val state by viewModel.state.collectAsState()
var recording by remember { mutableStateOf<Recording?>(null) }
@ -116,7 +129,8 @@ fun VideoRecordScreen(
}
}
}
}
},
containerColor = if (state.isRecording) RecordingRed else FloatingActionButtonDefaults.containerColor
) {
Icon(
imageVector = if (state.isRecording) Icons.Default.Stop else Icons.Default.Videocam,
@ -127,9 +141,7 @@ fun VideoRecordScreen(
floatingActionButtonPosition = FabPosition.Center
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CameraPreview(
@ -137,6 +149,29 @@ fun VideoRecordScreen(
controller = controller,
onFrame = { _, _ -> }
)
// Overlay
Column(
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
)
Spacer(
modifier = Modifier
.height(300.dp) // Constant height for the transparent area
.fillMaxWidth()
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
)
}
}
}
}

View File

@ -4,14 +4,14 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.usecases.AppEntry.AppEntryUseCases
import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.pages.navigation.Route
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class HomeViewModel(
private val appEntryUseCases: AppEntryUseCases
private val appDataUseCases: AppDataUseCases
): ViewModel() {
private val _splashCondition = mutableStateOf(true)
val splashCondition: State<Boolean> = _splashCondition
@ -20,7 +20,7 @@ class HomeViewModel(
val startDestination: State<Route> = _startDestination
init {
appEntryUseCases.readAppEntry().onEach { shouldStartFromHomeScreen ->
appDataUseCases.readAppEntry().onEach { shouldStartFromHomeScreen ->
if(shouldStartFromHomeScreen){
_startDestination.value = Route.HomeNavigation
}else{
@ -30,4 +30,4 @@ class HomeViewModel(
_splashCondition.value = false
}.launchIn(viewModelScope)
}
}
}

View File

@ -2,14 +2,12 @@ package com.example.livingai.pages.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.data.local.model.SettingsData
import com.example.livingai.domain.repository.SettingsRepository
import com.example.livingai.domain.usecases.AppEntry.AppEntryUseCases
import com.example.livingai.domain.model.SettingsData
import com.example.livingai.domain.usecases.AppDataUseCases
import kotlinx.coroutines.launch
class OnBoardingViewModel(
private val appEntryUseCases: AppEntryUseCases,
private val settingsRepository: SettingsRepository
private val appDataUseCases: AppDataUseCases
): ViewModel() {
fun onEvent(event: OnBoardingEvents) {
when(event) {
@ -24,7 +22,7 @@ class OnBoardingViewModel(
private fun saveAppEntry() {
viewModelScope.launch {
appEntryUseCases.saveAppEntry()
appDataUseCases.saveAppEntry()
}
}
@ -32,7 +30,7 @@ class OnBoardingViewModel(
viewModelScope.launch {
// Preserving default for other settings or we could fetch existing if needed
// For onboarding, assuming defaults for others is fine.
settingsRepository.saveSettings(SettingsData(language = language, isAutoCaptureOn = false))
appDataUseCases.saveSettings(SettingsData(language = language, isAutoCaptureOn = false))
}
}
}
}

View File

@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -24,6 +26,7 @@ import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.components.CommonScaffold
import com.example.livingai.pages.components.LabeledDropdown
import org.koin.androidx.compose.koinViewModel
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -41,6 +44,8 @@ fun SettingsScreen(
// Find the display name for the currently selected language value
val selectedLanguageEntry = languageMap.entries.find { it.value == settings.language }?.key ?: languageEntries.first()
val distanceMethods = listOf("Jaccard", "Euclidean", "Hamming")
CommonScaffold(
navController = navController,
title = stringResource(id = R.string.top_bar_settings)
@ -80,6 +85,41 @@ fun SettingsScreen(
}
)
}
// Jaccard Threshold Slider and Distance Method
if (settings.isAutoCaptureOn) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Dimentions.SMALL_PADDING_TEXT)
) {
LabeledDropdown(
labelRes = R.string.distance_method,
options = distanceMethods,
selected = settings.distanceMethod,
onSelected = { selectedMethod ->
viewModel.saveSettings(settings.copy(distanceMethod = selectedMethod))
},
modifier = Modifier.padding(vertical = Dimentions.SMALL_PADDING_TEXT)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Match Threshold")
Text(text = "${settings.jaccardThreshold.roundToInt()}%")
}
Slider(
value = settings.jaccardThreshold,
onValueChange = { newValue ->
viewModel.saveSettings(settings.copy(jaccardThreshold = newValue))
},
valueRange = 1f..100f,
steps = 99
)
}
}
}
}
}

View File

@ -1,11 +1,9 @@
package com.example.livingai.pages.settings
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.data.local.model.SettingsData
import com.example.livingai.domain.repository.SettingsRepository
import com.example.livingai.utils.LocaleHelper
import com.example.livingai.domain.model.SettingsData
import com.example.livingai.domain.usecases.AppDataUseCases
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
@ -13,8 +11,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class SettingsViewModel(
private val settingsRepository: SettingsRepository,
private val appContext: Context
private val appDataUseCases: AppDataUseCases
) : ViewModel() {
private val _settings = MutableStateFlow(SettingsData("en", false))
@ -22,23 +19,17 @@ class SettingsViewModel(
init {
getSettings()
// apply locale initially
settingsRepository.getSettings().onEach {
LocaleHelper.applyLocale(appContext, it.language)
}.launchIn(viewModelScope)
}
private fun getSettings() {
settingsRepository.getSettings().onEach {
appDataUseCases.getSettings().onEach {
_settings.value = it
}.launchIn(viewModelScope)
}
fun saveSettings(settings: SettingsData) {
viewModelScope.launch {
settingsRepository.saveSettings(settings)
// apply locale immediately
LocaleHelper.applyLocale(appContext, settings.language)
appDataUseCases.saveSettings(settings)
}
}
}
}

View File

@ -88,3 +88,6 @@ val alternate_container_contentCol = md_theme_light_onTertiaryContainer
val primary_backgroundCol = md_theme_light_background
val secondary_backgroundCol = md_theme_light_surface
val alternate_backgroundCol = md_theme_light_surfaceVariant
// Video Recording
val RecordingRed = Color(0xFFFF0000)

View File

@ -14,4 +14,6 @@ object Constants {
"rightangle",
"angleview"
)
const val JACCARD_THRESHOLD = 85
}

View File

@ -0,0 +1,98 @@
package com.example.livingai.utils
import android.graphics.Bitmap
import kotlin.math.sqrt
fun calculateHammingDistance(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
if (mask1.width != mask2.width || mask1.height != mask2.height) return false
val width = mask1.width
val height = mask1.height
val p1 = IntArray(width * height)
val p2 = IntArray(width * height)
mask1.getPixels(p1, 0, width, 0, 0, width, height)
mask2.getPixels(p2, 0, width, 0, 0, width, height)
var distance = 0
for (i in p1.indices) {
val a = (p1[i] ushr 24) > 0
val b = (p2[i] ushr 24) > 0
if (a != b) distance++
}
val total = width * height
val allowed = total * (100 - thresholdPercent) / 100
return distance <= allowed
}
fun calculateEuclideanDistance(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
if (mask1.width != mask2.width || mask1.height != mask2.height) return false
val width = mask1.width
val height = mask1.height
val p1 = IntArray(width * height)
val p2 = IntArray(width * height)
mask1.getPixels(p1, 0, width, 0, 0, width, height)
mask2.getPixels(p2, 0, width, 0, 0, width, height)
var sum = 0L
for (i in p1.indices) {
val v1 = if ((p1[i] ushr 24) > 0) 255 else 0
val v2 = if ((p2[i] ushr 24) > 0) 255 else 0
val diff = v1 - v2
sum += diff * diff
}
val dist = sqrt(sum.toDouble())
val max = sqrt((width * height).toDouble()) * 255.0
val allowed = max * (100 - thresholdPercent) / 100.0
return dist <= allowed
}
fun calculateJaccardSimilarity(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
if (mask1.width != mask2.width || mask1.height != mask2.height) return false
val width = mask1.width
val height = mask1.height
val p1 = IntArray(width * height)
val p2 = IntArray(width * height)
mask1.getPixels(p1, 0, width, 0, 0, width, height)
mask2.getPixels(p2, 0, width, 0, 0, width, height)
var intersection = 0
var union = 0
for (i in p1.indices) {
val a = ((p1[i] ushr 24) and 0xFF) > 0
val b = ((p2[i] ushr 24) and 0xFF) > 0
if (a && b) intersection++
if (a || b) union++
}
if (union == 0) return false
val score = (intersection.toDouble() / union.toDouble()) * 100.0
return score >= thresholdPercent
}
fun calculateDistance(
method: String,
mask1: Bitmap,
mask2: Bitmap,
thresholdPercent: Int
): Boolean {
return when (method) {
"Hamming" -> calculateHammingDistance(mask1, mask2, thresholdPercent)
"Euclidean" -> calculateEuclideanDistance(mask1, mask2, thresholdPercent)
"Jaccard" -> calculateJaccardSimilarity(mask1, mask2, thresholdPercent)
else -> false
}
}

View File

@ -0,0 +1,68 @@
package com.example.livingai.utils
import android.graphics.Bitmap
import android.graphics.Canvas
fun fitCenterToScreen(
bitmap: Bitmap,
screenW: Int,
screenH: Int
): Bitmap {
val srcW = bitmap.width
val srcH = bitmap.height
if (srcW == 0 || srcH == 0) return bitmap
val scale = minOf(
screenW.toFloat() / srcW,
screenH.toFloat() / srcH
)
val newW = (srcW * scale).toInt()
val newH = (srcH * scale).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, newW, newH, true)
val output = Bitmap.createBitmap(screenW, screenH, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val left = (screenW - newW) / 2f
val top = (screenH - newH) / 2f
canvas.drawBitmap(scaled, left, top, null)
return output
}
fun fitImageToCrop(
bitmap: Bitmap,
screenW: Int,
screenH: Int
): Bitmap {
val srcW = bitmap.width
val srcH = bitmap.height
if (srcW == 0 || srcH == 0) return bitmap
val scale = maxOf(
screenW.toFloat() / srcW,
screenH.toFloat() / srcH
)
val newW = (srcW * scale).toInt()
val newH = (srcH * scale).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, newW, newH, true)
val output = Bitmap.createBitmap(screenW, screenH, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val left = (screenW - newW) / 2f
val top = (screenH - newH) / 2f
canvas.drawBitmap(scaled, left, top, null)
return output
}

View File

@ -0,0 +1,6 @@
package com.example.livingai.utils
data class ScreenDimensions(
val screenWidth: Int,
val screenHeight: Int
)

View File

@ -1,6 +1,8 @@
package com.example.livingai.utils
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
@ -9,12 +11,25 @@ import androidx.compose.ui.platform.LocalContext
fun SetScreenOrientation(orientation: Int) {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as Activity
val originalOrientation = activity.requestedOrientation
activity.requestedOrientation = orientation
onDispose {
// restore original orientation when view disappears
activity.requestedOrientation = originalOrientation
val activity = context.findActivity()
if (activity != null) {
val originalOrientation = activity.requestedOrientation
activity.requestedOrientation = orientation
onDispose {
// restore original orientation when view disappears
activity.requestedOrientation = originalOrientation
}
} else {
onDispose { }
}
}
}
fun Context.findActivity(): Activity? {
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
return null
}

View File

@ -5,73 +5,65 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.annotation.DrawableRes
import androidx.core.graphics.createBitmap
import android.util.Log
import com.example.livingai.R
import java.util.concurrent.ConcurrentHashMap
object SilhouetteManager {
private val originals = ConcurrentHashMap<String, Bitmap>()
private val inverted = ConcurrentHashMap<String, Bitmap>()
private val bitmasks = ConcurrentHashMap<String, BooleanArray>()
private val invertedPurple = ConcurrentHashMap<String, Bitmap>()
fun initialize(context: Context, names: List<String>) {
names.forEach { name ->
val resId = context.resources.getIdentifier(name, "drawable", context.packageName)
if (resId != 0) {
val bmp = BitmapFactory.decodeResource(context.resources, resId)
originals[name] = bmp
val inv = invertBitmap(bmp)
inverted[name] = inv
bitmasks[name] = createBitmask(inv)
}
fun initialize(context: Context, width: Int, height: Int) {
val resources = context.resources
val silhouetteList = mapOf(
"front" to R.drawable.front_silhouette,
"back" to R.drawable.back_silhouette,
"left" to R.drawable.left_silhouette,
"right" to R.drawable.right_silhouette,
"leftangle" to R.drawable.leftangle_silhouette,
"rightangle" to R.drawable.rightangle_silhouette,
"angleview" to R.drawable.angleview_silhouette
)
silhouetteList.entries.toList().forEach { (name, resId) ->
val bmp = BitmapFactory.decodeResource(resources, resId)
originals[name] = bmp
Log.d("Silhouette", "Dims: ${width} x ${height}")
if (name == "front" || name == "back")
invertedPurple[name] = createInvertedPurpleBitmap(bmp, width, height)
else
invertedPurple[name] = createInvertedPurpleBitmap(bmp, height, width)
Log.d("Silhouette", "Dims Mask: ${invertedPurple[name]?.width} x ${invertedPurple[name]?.height}")
}
}
fun getOriginal(name: String): Bitmap? = originals[name]
fun getInverted(name: String): Bitmap? = inverted[name]
fun getBitmask(name: String): BooleanArray? = bitmasks[name]
fun getInvertedPurple(name: String): Bitmap? = invertedPurple[name]
private fun invertBitmap(src: Bitmap): Bitmap {
val bmOut = Bitmap.createBitmap(src.width, src.height, src.config ?: Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmOut)
val paint = Paint()
val colorMatrix = android.graphics.ColorMatrix(
floatArrayOf(
-1f, 0f, 0f, 0f, 255f,
0f, -1f, 0f, 0f, 255f,
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f
)
)
paint.colorFilter = android.graphics.ColorMatrixColorFilter(colorMatrix)
canvas.drawBitmap(src, 0f, 0f, paint)
return bmOut
}
private fun createInvertedPurpleBitmap(
src: Bitmap,
targetWidth: Int,
targetHeight: Int
): Bitmap {
val width = src.width
val height = src.height
val pixels = IntArray(width * height)
src.getPixels(pixels, 0, width, 0, 0, width, height)
val purple = Color.argb(255, 128, 0, 128)
private fun createBitmask(bitmap: Bitmap): BooleanArray {
val w = bitmap.width
val h = bitmap.height
val mask = BooleanArray(w * h)
val pixels = IntArray(w * h)
bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
for (i in pixels.indices) {
val c = pixels[i]
val alpha = Color.alpha(c)
// simple threshold: non-transparent and not near-white (assuming inverted is black on transparent/white)
// The inverted logic makes black -> white, white -> black.
// Wait, if original is black silhouette on transparent:
// Inverted: black becomes white, transparent becomes... white?
// Let's check invertBitmap logic.
// Matrix: R' = 255 - R, G' = 255 - G, B' = 255 - B, A' = A.
// If original is Black (0,0,0,255) -> White (255,255,255,255).
// If original is Transparent (0,0,0,0) -> Transparent (0,0,0,0).
// So "Inverted" means we have White silhouette on Transparent.
// We want bitmask where the silhouette is.
// So we look for non-transparent pixels.
mask[i] = alpha > 16
val alpha = pixels[i] ushr 24
pixels[i] = if (alpha == 0) purple else 0x00000000
}
return mask
val inverted = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
return fitCenterToScreen(inverted, targetWidth, targetHeight)
}
}

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -82,4 +82,6 @@
<string name="label_leftangle">Left Angle View</string>
<string name="label_rightangle">Right Angle View</string>
<string name="label_angleview">Angle View</string>
</resources>
<string name="distance_method">Distance Method</string>
</resources>

View File

@ -16,6 +16,7 @@ pagingCommon = "3.3.6"
ui = "1.9.5"
kotlinxSerialization = "1.6.3"
koin = "4.1.1"
window = "1.5.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -42,6 +43,7 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
androidx-window = { group = "androidx.window", name = "window", version.ref = "window" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }