Store "source" (unprocessed captured image)
This commit is contained in:
@@ -319,7 +319,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
cameraViewModel.events.collect { event ->
|
cameraViewModel.events.collect { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.jpegBytes)
|
is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.fairscan.app.data.ImageRepository
|
import org.fairscan.app.data.ImageRepository
|
||||||
|
import org.fairscan.app.domain.CapturedPage
|
||||||
import org.fairscan.app.ui.NavigationState
|
import org.fairscan.app.ui.NavigationState
|
||||||
import org.fairscan.app.ui.Screen
|
import org.fairscan.app.ui.Screen
|
||||||
import org.fairscan.app.ui.state.DocumentUiModel
|
import org.fairscan.app.ui.state.DocumentUiModel
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
|
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
|
||||||
|
|
||||||
@@ -67,14 +69,18 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun movePage(id: String, newIndex: Int) {
|
fun movePage(id: String, newIndex: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
imageRepository.movePage(id, newIndex)
|
imageRepository.movePage(id, newIndex)
|
||||||
_pageIds.value = imageRepository.imageIds()
|
_pageIds.value = imageRepository.imageIds()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deletePage(id: String) {
|
fun deletePage(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
imageRepository.delete(id)
|
imageRepository.delete(id)
|
||||||
_pageIds.value = imageRepository.imageIds()
|
_pageIds.value = imageRepository.imageIds()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun startNewDocument() {
|
fun startNewDocument() {
|
||||||
_pageIds.value = persistentListOf()
|
_pageIds.value = persistentListOf()
|
||||||
@@ -93,8 +99,19 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
|
|||||||
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleImageCaptured(jpegBytes: ByteArray) {
|
fun handleImageCaptured(capturedPage: CapturedPage) {
|
||||||
imageRepository.add(jpegBytes)
|
viewModelScope.launch {
|
||||||
|
imageRepository.add(
|
||||||
|
compressJpeg(capturedPage.page, 75),
|
||||||
|
compressJpeg(capturedPage.source, 90)
|
||||||
|
)
|
||||||
_pageIds.value = imageRepository.imageIds()
|
_pageIds.value = imageRepository.imageIds()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun compressJpeg(bitmap: Bitmap, quality: Int): ByteArray {
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
|
||||||
|
return outputStream.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import kotlinx.collections.immutable.toImmutableList
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
const val SOURCE_DIR_NAME = "sources"
|
||||||
const val SCAN_DIR_NAME = "scanned_pages"
|
const val SCAN_DIR_NAME = "scanned_pages"
|
||||||
const val THUMBNAIL_DIR_NAME = "thumbnails"
|
const val THUMBNAIL_DIR_NAME = "thumbnails"
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ class ImageRepository(
|
|||||||
private val thumbnailSizePx: Int,
|
private val thumbnailSizePx: Int,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val sourceDir: File = File(scanRootDir, SOURCE_DIR_NAME).apply {
|
||||||
|
if (!exists()) mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
private val scanDir: File = File(scanRootDir, SCAN_DIR_NAME).apply {
|
private val scanDir: File = File(scanRootDir, SCAN_DIR_NAME).apply {
|
||||||
if (!exists()) mkdirs()
|
if (!exists()) mkdirs()
|
||||||
}
|
}
|
||||||
@@ -76,11 +81,12 @@ class ImageRepository(
|
|||||||
|
|
||||||
fun imageIds(): ImmutableList<String> = fileNames.toImmutableList()
|
fun imageIds(): ImmutableList<String> = fileNames.toImmutableList()
|
||||||
|
|
||||||
fun add(bytes: ByteArray) {
|
fun add(pageBytes: ByteArray, sourceBytes: ByteArray? = null) {
|
||||||
val fileName = "${System.currentTimeMillis()}.jpg"
|
val fileName = "${System.currentTimeMillis()}.jpg"
|
||||||
val file = File(scanDir, fileName)
|
val file = File(scanDir, fileName)
|
||||||
file.writeBytes(bytes)
|
file.writeBytes(pageBytes)
|
||||||
writeThumbnail(file)
|
writeThumbnail(file)
|
||||||
|
sourceBytes?.let { File(sourceDir, fileName).writeBytes(sourceBytes) }
|
||||||
fileNames.add(fileName)
|
fileNames.add(fileName)
|
||||||
saveMetadata()
|
saveMetadata()
|
||||||
}
|
}
|
||||||
@@ -158,6 +164,9 @@ class ImageRepository(
|
|||||||
scanDir.listFiles()?.forEach {
|
scanDir.listFiles()?.forEach {
|
||||||
file -> file.delete()
|
file -> file.delete()
|
||||||
}
|
}
|
||||||
|
sourceDir.listFiles()?.forEach {
|
||||||
|
file -> file.delete()
|
||||||
|
}
|
||||||
saveMetadata() // "empty" json file
|
saveMetadata() // "empty" json file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
app/src/main/java/org/fairscan/app/domain/CapturedPage.kt
Normal file
23
app/src/main/java/org/fairscan/app/domain/CapturedPage.kt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 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.domain
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
|
||||||
|
data class CapturedPage(
|
||||||
|
val page: Bitmap,
|
||||||
|
val source: Bitmap
|
||||||
|
)
|
||||||
@@ -84,6 +84,7 @@ import kotlinx.collections.immutable.toImmutableList
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.fairscan.app.MainViewModel
|
import org.fairscan.app.MainViewModel
|
||||||
import org.fairscan.app.R
|
import org.fairscan.app.R
|
||||||
|
import org.fairscan.app.domain.CapturedPage
|
||||||
import org.fairscan.app.ui.Navigation
|
import org.fairscan.app.ui.Navigation
|
||||||
import org.fairscan.app.ui.Screen
|
import org.fairscan.app.ui.Screen
|
||||||
import org.fairscan.app.ui.components.CameraPermissionState
|
import org.fairscan.app.ui.components.CameraPermissionState
|
||||||
@@ -235,7 +236,8 @@ private fun CameraScreenScaffold(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (cameraUiState.captureState is CaptureState.CapturePreview) {
|
if (cameraUiState.captureState is CaptureState.CapturePreview) {
|
||||||
CapturedImage(cameraUiState.captureState.processed.asImageBitmap(), thumbnailCoords)
|
val page = cameraUiState.captureState.capturedPage.page
|
||||||
|
CapturedImage(page.asImageBitmap(), thumbnailCoords)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,7 +463,9 @@ fun CameraScreenPreview() {
|
|||||||
fun CameraScreenPreviewWithProcessedImage() {
|
fun CameraScreenPreviewWithProcessedImage() {
|
||||||
ScreenPreview(CaptureState.CapturePreview(
|
ScreenPreview(CaptureState.CapturePreview(
|
||||||
debugImage("uncropped/img01.jpg"),
|
debugImage("uncropped/img01.jpg"),
|
||||||
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg")))
|
CapturedPage(
|
||||||
|
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
||||||
|
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.fairscan.app.AppContainer
|
import org.fairscan.app.AppContainer
|
||||||
|
import org.fairscan.app.domain.CapturedPage
|
||||||
import org.fairscan.imageprocessing.Mask
|
import org.fairscan.imageprocessing.Mask
|
||||||
import org.fairscan.imageprocessing.Quad
|
import org.fairscan.imageprocessing.Quad
|
||||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||||
@@ -40,10 +41,9 @@ import org.opencv.android.Utils
|
|||||||
import org.opencv.core.CvType
|
import org.opencv.core.CvType
|
||||||
import org.opencv.core.Mat
|
import org.opencv.core.Mat
|
||||||
import org.opencv.imgproc.Imgproc
|
import org.opencv.imgproc.Imgproc
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
|
|
||||||
sealed interface CameraEvent {
|
sealed interface CameraEvent {
|
||||||
data class ImageCaptured(val jpegBytes: ByteArray) : CameraEvent
|
data class ImageCaptured(val page: CapturedPage) : CameraEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||||
@@ -88,7 +88,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
|||||||
_captureState.value = CaptureState.Capturing(frozenImage)
|
_captureState.value = CaptureState.Capturing(frozenImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCaptureProcessed(captured: Bitmap?) {
|
private fun onCaptureProcessed(captured: CapturedPage?) {
|
||||||
val current = _captureState.value
|
val current = _captureState.value
|
||||||
_captureState.value = when {
|
_captureState.value = when {
|
||||||
current is CaptureState.Capturing && captured != null ->
|
current is CaptureState.Capturing && captured != null ->
|
||||||
@@ -117,23 +117,25 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
|||||||
fun onImageCaptured(imageProxy: ImageProxy?) {
|
fun onImageCaptured(imageProxy: ImageProxy?) {
|
||||||
if (imageProxy != null) {
|
if (imageProxy != null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val image = processCapturedImage(imageProxy)
|
val source = imageProxy.toBitmap()
|
||||||
|
val processed = processCapturedImage(source, imageProxy.imageInfo.rotationDegrees)
|
||||||
imageProxy.close()
|
imageProxy.close()
|
||||||
onCaptureProcessed(image)
|
onCaptureProcessed(processed?.let { CapturedPage(processed, source) })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onCaptureProcessed(null)
|
onCaptureProcessed(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun processCapturedImage(imageProxy: ImageProxy): Bitmap? = withContext(Dispatchers.IO) {
|
private suspend fun processCapturedImage(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
rotationDegrees: Int
|
||||||
|
): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
var corrected: Bitmap? = null
|
var corrected: Bitmap? = null
|
||||||
val bitmap = imageProxy.toBitmap()
|
|
||||||
val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0)
|
val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0)
|
||||||
if (segmentation != null) {
|
if (segmentation != null) {
|
||||||
val mask = segmentation.segmentation
|
val mask = segmentation.segmentation
|
||||||
var quad = detectDocumentQuad(mask, isLiveAnalysis = false)
|
var quad = detectDocumentQuad(mask, isLiveAnalysis = false)
|
||||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
|
||||||
if (quad == null) {
|
if (quad == null) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
lastSuccessfulLiveAnalysisState?.timestamp?.let {
|
lastSuccessfulLiveAnalysisState?.timestamp?.let {
|
||||||
@@ -157,14 +159,11 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
|||||||
return@withContext corrected
|
return@withContext corrected
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addProcessedImage(quality: Int = 75) {
|
fun addProcessedImage() {
|
||||||
val current = _captureState.value
|
val current = _captureState.value
|
||||||
if (current is CaptureState.CapturePreview) {
|
if (current is CaptureState.CapturePreview) {
|
||||||
val outputStream = ByteArrayOutputStream()
|
|
||||||
current.processed.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
|
|
||||||
val jpegBytes = outputStream.toByteArray()
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_events.emit(CameraEvent.ImageCaptured(jpegBytes))
|
_events.emit(CameraEvent.ImageCaptured(current.capturedPage))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_captureState.value = CaptureState.Idle
|
_captureState.value = CaptureState.Idle
|
||||||
@@ -184,7 +183,7 @@ sealed class CaptureState {
|
|||||||
data class CaptureError(override val frozenImage: Bitmap) : CaptureState()
|
data class CaptureError(override val frozenImage: Bitmap) : CaptureState()
|
||||||
data class CapturePreview(
|
data class CapturePreview(
|
||||||
override val frozenImage: Bitmap,
|
override val frozenImage: Bitmap,
|
||||||
val processed: Bitmap,
|
val capturedPage: CapturedPage,
|
||||||
) : CaptureState()
|
) : CaptureState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user