New feature: import photos
This commit is contained in:
@@ -25,8 +25,8 @@ import org.fairscan.app.data.FileManager
|
|||||||
import org.fairscan.app.data.LogRepository
|
import org.fairscan.app.data.LogRepository
|
||||||
import org.fairscan.app.data.recentDocumentsDataStore
|
import org.fairscan.app.data.recentDocumentsDataStore
|
||||||
import org.fairscan.app.domain.ImageSegmentationService
|
import org.fairscan.app.domain.ImageSegmentationService
|
||||||
|
import org.fairscan.app.platform.AndroidImageLoader
|
||||||
import org.fairscan.app.platform.AndroidPdfWriter
|
import org.fairscan.app.platform.AndroidPdfWriter
|
||||||
import org.fairscan.app.ui.screens.about.AboutViewModel
|
|
||||||
import org.fairscan.app.ui.screens.camera.CameraViewModel
|
import org.fairscan.app.ui.screens.camera.CameraViewModel
|
||||||
import org.fairscan.app.ui.screens.home.HomeViewModel
|
import org.fairscan.app.ui.screens.home.HomeViewModel
|
||||||
import org.fairscan.app.ui.screens.settings.SettingsRepository
|
import org.fairscan.app.ui.screens.settings.SettingsRepository
|
||||||
@@ -56,6 +56,7 @@ class AppContainer(context: Context) {
|
|||||||
val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
|
val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
|
||||||
val logger = FileLogger(logRepository)
|
val logger = FileLogger(logRepository)
|
||||||
val imageSegmentationService = ImageSegmentationService(context, logger)
|
val imageSegmentationService = ImageSegmentationService(context, logger)
|
||||||
|
val imageLoader = AndroidImageLoader(context.contentResolver)
|
||||||
val recentDocumentsDataStore = context.recentDocumentsDataStore
|
val recentDocumentsDataStore = context.recentDocumentsDataStore
|
||||||
val settingsRepository = SettingsRepository(context)
|
val settingsRepository = SettingsRepository(context)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import androidx.activity.compose.ManagedActivityResultLauncher
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -166,6 +167,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Screen.Main.Camera -> {
|
is Screen.Main.Camera -> {
|
||||||
|
val pickMultiple = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.PickMultipleVisualMedia(10)) { uris ->
|
||||||
|
if (uris.isNotEmpty()) cameraViewModel.importPhotos(uris)
|
||||||
|
}
|
||||||
CameraScreen(
|
CameraScreen(
|
||||||
viewModel,
|
viewModel,
|
||||||
cameraViewModel,
|
cameraViewModel,
|
||||||
@@ -173,7 +178,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
liveAnalysisState,
|
liveAnalysisState,
|
||||||
onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
|
onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
|
||||||
onFinalizePressed = onExportClick,
|
onFinalizePressed = onExportClick,
|
||||||
cameraPermission = cameraPermission
|
cameraPermission = cameraPermission,
|
||||||
|
onImportClicked = {
|
||||||
|
pickMultiple.launch(PickVisualMediaRequest(
|
||||||
|
ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Screen.Main.Document -> {
|
is Screen.Main.Document -> {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package org.fairscan.app.domain
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
import org.fairscan.imageprocessing.decodeJpeg
|
import org.fairscan.imageprocessing.decodeJpeg
|
||||||
import org.fairscan.imageprocessing.encodeJpeg
|
import org.fairscan.imageprocessing.encodeJpeg
|
||||||
import org.opencv.core.Mat
|
import org.opencv.core.Mat
|
||||||
@@ -27,3 +28,7 @@ class Jpeg(val bytes: ByteArray) {
|
|||||||
fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
fun toMat() : Mat = decodeJpeg(bytes)
|
fun toMat() : Mat = decodeJpeg(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImageLoader {
|
||||||
|
suspend fun load(uri: Uri): Bitmap
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
|
* Software Foundation, either version 3 of the License, or (at your option)
|
||||||
|
* any later version.
|
||||||
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
* more details.
|
||||||
|
* You should have received a copy of the GNU General Public License along with
|
||||||
|
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.fairscan.app.platform
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.util.component1
|
||||||
|
import androidx.core.util.component2
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.fairscan.app.domain.ImageLoader
|
||||||
|
|
||||||
|
class AndroidImageLoader(
|
||||||
|
private val contentResolver: ContentResolver
|
||||||
|
) : ImageLoader {
|
||||||
|
|
||||||
|
override suspend fun load(uri: Uri): Bitmap {
|
||||||
|
val bitmap = loadBitmapFromUri(contentResolver, uri)
|
||||||
|
return ensureArgb8888(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadBitmapFromUri(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxPixels: Int = 12_000_000,
|
||||||
|
): Bitmap = withContext(Dispatchers.IO) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
decodeWithImageDecoder(contentResolver, uri, maxPixels)
|
||||||
|
} else {
|
||||||
|
decodeWithBitmapFactory(contentResolver, uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(28)
|
||||||
|
private fun decodeWithImageDecoder(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxPixels: Int
|
||||||
|
): Bitmap {
|
||||||
|
val source = ImageDecoder.createSource(contentResolver, uri)
|
||||||
|
return ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||||
|
val (width, height) = info.size
|
||||||
|
val scale = computeScale(width, height, maxPixels)
|
||||||
|
decoder.setTargetSize((width * scale).toInt(), (height * scale).toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeWithBitmapFactory(contentResolver: ContentResolver, uri: Uri, ): Bitmap {
|
||||||
|
val decodeOptions = BitmapFactory.Options()
|
||||||
|
return contentResolver.openInputStream(uri).use {
|
||||||
|
BitmapFactory.decodeStream(it, null, decodeOptions)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeScale(width: Int, height: Int, maxPixels: Int): Float {
|
||||||
|
val pixels = width * height
|
||||||
|
return if (pixels > maxPixels) {
|
||||||
|
maxPixels.toFloat() / pixels
|
||||||
|
} else {
|
||||||
|
1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureArgb8888(bitmap: Bitmap): Bitmap {
|
||||||
|
return if (bitmap.config != Bitmap.Config.ARGB_8888) {
|
||||||
|
bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||||
|
} else {
|
||||||
|
bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import androidx.camera.view.PreviewView
|
|||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.core.updateTransition
|
import androidx.compose.animation.core.updateTransition
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.LocalIndication
|
import androidx.compose.foundation.LocalIndication
|
||||||
@@ -47,13 +48,16 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||||
import androidx.compose.material.icons.filled.Done
|
import androidx.compose.material.icons.filled.Done
|
||||||
import androidx.compose.material.icons.filled.Highlight
|
import androidx.compose.material.icons.filled.Highlight
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -123,6 +127,7 @@ fun CameraScreen(
|
|||||||
onImageAnalyzed: (ImageProxy) -> Unit,
|
onImageAnalyzed: (ImageProxy) -> Unit,
|
||||||
onFinalizePressed: () -> Unit,
|
onFinalizePressed: () -> Unit,
|
||||||
cameraPermission: CameraPermissionState,
|
cameraPermission: CameraPermissionState,
|
||||||
|
onImportClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||||
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
|
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
|
||||||
@@ -227,8 +232,9 @@ fun CameraScreen(
|
|||||||
thumbnailCoords = thumbnailCoords,
|
thumbnailCoords = thumbnailCoords,
|
||||||
navigation = navigation,
|
navigation = navigation,
|
||||||
captureController,
|
captureController,
|
||||||
cameraPermission.isGranted,
|
isCameraPermissionGranted = cameraPermission.isGranted,
|
||||||
{ cameraPermission.request() },
|
onRequestCameraPermission = { cameraPermission.request() },
|
||||||
|
onImportClicked = onImportClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +252,7 @@ private fun CameraScreenScaffold(
|
|||||||
captureController: CameraCaptureController,
|
captureController: CameraCaptureController,
|
||||||
isCameraPermissionGranted: Boolean,
|
isCameraPermissionGranted: Boolean,
|
||||||
onRequestCameraPermission: () -> Unit,
|
onRequestCameraPermission: () -> Unit,
|
||||||
|
onImportClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var focusPoint by remember { mutableStateOf<Offset?>(null) }
|
var focusPoint by remember { mutableStateOf<Offset?>(null) }
|
||||||
LaunchedEffect(focusPoint) {
|
LaunchedEffect(focusPoint) {
|
||||||
@@ -277,7 +284,7 @@ private fun CameraScreenScaffold(
|
|||||||
navigation = navigation,
|
navigation = navigation,
|
||||||
pageListState = pageListState,
|
pageListState = pageListState,
|
||||||
onBack = navigation.back,
|
onBack = navigation.back,
|
||||||
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed) }
|
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed, onImportClicked) }
|
||||||
) { modifier ->
|
) { modifier ->
|
||||||
if (!isCameraPermissionGranted) {
|
if (!isCameraPermissionGranted) {
|
||||||
CameraPermissionRationale(onRequestCameraPermission, modifier)
|
CameraPermissionRationale(onRequestCameraPermission, modifier)
|
||||||
@@ -518,12 +525,26 @@ fun MessageBox(inferenceTime: Long) {
|
|||||||
private fun Bar(
|
private fun Bar(
|
||||||
pageCount: Int,
|
pageCount: Int,
|
||||||
onFinalizePressed: () -> Unit,
|
onFinalizePressed: () -> Unit,
|
||||||
|
onImportClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onImportClicked,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AddPhotoAlternate,
|
||||||
|
// TODO Externalize string
|
||||||
|
contentDescription = "Import photos",)
|
||||||
|
}
|
||||||
MainActionButton(
|
MainActionButton(
|
||||||
onClick = onFinalizePressed,
|
onClick = onFinalizePressed,
|
||||||
enabled = pageCount > 0,
|
enabled = pageCount > 0,
|
||||||
@@ -637,7 +658,8 @@ private fun ScreenPreview(
|
|||||||
navigation = dummyNavigation(),
|
navigation = dummyNavigation(),
|
||||||
captureController = CameraCaptureController(),
|
captureController = CameraCaptureController(),
|
||||||
isCameraPermissionGranted = isCameraPermissionGranted,
|
isCameraPermissionGranted = isCameraPermissionGranted,
|
||||||
onRequestCameraPermission = {}
|
onRequestCameraPermission = {},
|
||||||
|
onImportClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package org.fairscan.app.ui.screens.camera
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
|
import android.net.Uri
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -42,6 +43,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
|||||||
|
|
||||||
private val imageSegmentationService = appContainer.imageSegmentationService
|
private val imageSegmentationService = appContainer.imageSegmentationService
|
||||||
private val settingsRepository = appContainer.settingsRepository
|
private val settingsRepository = appContainer.settingsRepository
|
||||||
|
private val imageLoader = appContainer.imageLoader
|
||||||
private val logger = appContainer.logger
|
private val logger = appContainer.logger
|
||||||
|
|
||||||
private val _events = MutableSharedFlow<CameraEvent>()
|
private val _events = MutableSharedFlow<CameraEvent>()
|
||||||
@@ -184,6 +186,18 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
|||||||
fun setTorchEnabled(enabled: Boolean) {
|
fun setTorchEnabled(enabled: Boolean) {
|
||||||
_isTorchEnabled.value = enabled
|
_isTorchEnabled.value = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun importPhotos(uris: List<Uri>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
for (uri in uris) {
|
||||||
|
val photoToImport = imageLoader.load(uri)
|
||||||
|
val page = processCapturedImage(photoToImport, 0)
|
||||||
|
page?.let {
|
||||||
|
_events.emit(CameraEvent.ImageCaptured(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CaptureState {
|
sealed class CaptureState {
|
||||||
|
|||||||
Reference in New Issue
Block a user