Introduce Jpeg class
This commit is contained in:
@@ -51,7 +51,7 @@ class DocumentDetectionTest {
|
|||||||
listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName ->
|
listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName ->
|
||||||
val inputStream = context.assets.open("uncropped/$imageFileName")
|
val inputStream = context.assets.open("uncropped/$imageFileName")
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
var outputJpeg: ByteArray? = null
|
var outputJpeg: Jpeg? = null
|
||||||
|
|
||||||
val segmentationResult = runBlocking {
|
val segmentationResult = runBlocking {
|
||||||
segmentationService.runSegmentationAndReturn(bitmap)
|
segmentationService.runSegmentationAndReturn(bitmap)
|
||||||
@@ -64,7 +64,7 @@ class DocumentDetectionTest {
|
|||||||
quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
|
quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
|
||||||
outputJpeg = extractDocumentFromBitmap(bitmap, resizedQuad, 0, mask, scope).pageJpeg
|
outputJpeg = extractDocumentFromBitmap(bitmap, resizedQuad, 0, mask, scope).pageJpeg
|
||||||
val file = File(context.getExternalFilesDir(null), imageFileName)
|
val file = File(context.getExternalFilesDir(null), imageFileName)
|
||||||
file.writeBytes(outputJpeg)
|
file.writeBytes(outputJpeg.bytes)
|
||||||
Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}")
|
Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,9 +145,6 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ByteArray.toBitmap() : Bitmap =
|
|
||||||
BitmapFactory.decodeByteArray(this, 0, this.size)
|
|
||||||
|
|
||||||
fun handleImageCaptured(capturedPage: CapturedPage) {
|
fun handleImageCaptured(capturedPage: CapturedPage) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val pages = withContext(Dispatchers.IO) {
|
val pages = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -29,6 +28,7 @@ import kotlinx.serialization.json.int
|
|||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.fairscan.app.domain.ExportQuality
|
import org.fairscan.app.domain.ExportQuality
|
||||||
|
import org.fairscan.app.domain.Jpeg
|
||||||
import org.fairscan.app.domain.PageMetadata
|
import org.fairscan.app.domain.PageMetadata
|
||||||
import org.fairscan.app.domain.PageViewKey
|
import org.fairscan.app.domain.PageViewKey
|
||||||
import org.fairscan.app.domain.Rotation
|
import org.fairscan.app.domain.Rotation
|
||||||
@@ -64,8 +64,8 @@ class ImageRepository(
|
|||||||
private val json = Json { prettyPrint = false; encodeDefaults = true }
|
private val json = Json { prettyPrint = false; encodeDefaults = true }
|
||||||
private var pages: PageStore = PageStore(loadPages())
|
private var pages: PageStore = PageStore(loadPages())
|
||||||
|
|
||||||
private val imageCache = createLruCache<PageViewKey, Deferred<ByteArray?>>(maxEntries = 50)
|
private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50)
|
||||||
private val thumbnailCache = createLruCache<PageViewKey, Deferred<ByteArray?>>(maxEntries = 200)
|
private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 200)
|
||||||
|
|
||||||
private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
|
private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
|
||||||
Collections.synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
|
Collections.synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
|
||||||
@@ -139,12 +139,12 @@ class ImageRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) =
|
suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata) =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val id = "${System.currentTimeMillis()}"
|
val id = "${System.currentTimeMillis()}"
|
||||||
val fileName = "$id.jpg"
|
val fileName = "$id.jpg"
|
||||||
File(processedDir, fileName).writeBytes(pageBytes)
|
File(processedDir, fileName).writeBytes(processed.bytes)
|
||||||
File(sourceDir, fileName).writeBytes(sourceBytes)
|
File(sourceDir, fileName).writeBytes(source.bytes)
|
||||||
pages.addOrReplace(
|
pages.addOrReplace(
|
||||||
PageV2(
|
PageV2(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -157,7 +157,7 @@ class ImageRepository(
|
|||||||
saveMetadata()
|
saveMetadata()
|
||||||
// Pre-populate cache for R0
|
// Pre-populate cache for R0
|
||||||
val key = PageViewKey(id, Rotation.R0)
|
val key = PageViewKey(id, Rotation.R0)
|
||||||
imageCache.put(key, CompletableDeferred(pageBytes))
|
imageCache.put(key, CompletableDeferred(processed))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
|
suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
|
||||||
@@ -170,20 +170,20 @@ class ImageRepository(
|
|||||||
saveMetadata()
|
saveMetadata()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun jpegBytes(key: PageViewKey): ByteArray? =
|
suspend fun jpegBytes(key: PageViewKey): Jpeg? =
|
||||||
getOrCompute(imageCache, key, ::computeProcessedImage)
|
getOrCompute(imageCache, key, ::computeProcessedImage)
|
||||||
|
|
||||||
|
|
||||||
suspend fun getThumbnail(key: PageViewKey): ByteArray? =
|
suspend fun getThumbnail(key: PageViewKey): Jpeg? =
|
||||||
getOrCompute(thumbnailCache, key, ::computeThumbnail)
|
getOrCompute(thumbnailCache, key, ::computeThumbnail)
|
||||||
|
|
||||||
// --- Cache compute functions ---
|
// --- Cache compute functions ---
|
||||||
|
|
||||||
private suspend fun getOrCompute(
|
private suspend fun getOrCompute(
|
||||||
cache: MutableMap<PageViewKey, Deferred<ByteArray?>>,
|
cache: MutableMap<PageViewKey, Deferred<Jpeg?>>,
|
||||||
key: PageViewKey,
|
key: PageViewKey,
|
||||||
compute: suspend (PageViewKey) -> ByteArray?
|
compute: suspend (PageViewKey) -> Jpeg?
|
||||||
): ByteArray? {
|
): Jpeg? {
|
||||||
val deferred = cache.computeIfAbsent(key) { k ->
|
val deferred = cache.computeIfAbsent(key) { k ->
|
||||||
scope.async(Dispatchers.IO) { compute(k) }
|
scope.async(Dispatchers.IO) { compute(k) }
|
||||||
}
|
}
|
||||||
@@ -195,32 +195,33 @@ class ImageRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun computeProcessedImage(key: PageViewKey): ByteArray? =
|
private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val baseFile = File(processedDir, "${key.pageId}.jpg")
|
val baseFile = File(processedDir, "${key.pageId}.jpg")
|
||||||
if (!baseFile.exists()) return@withContext null
|
if (!baseFile.exists()) return@withContext null
|
||||||
|
val baseJpeg = Jpeg(baseFile.readBytes())
|
||||||
if (key.rotation == Rotation.R0) {
|
if (key.rotation == Rotation.R0) {
|
||||||
baseFile.readBytes()
|
baseJpeg
|
||||||
} else {
|
} else {
|
||||||
transformations.rotate(
|
transformations.rotate(
|
||||||
baseFile.readBytes(),
|
baseJpeg,
|
||||||
key.rotation.degrees,
|
key.rotation.degrees,
|
||||||
ExportQuality.BALANCED.jpegQuality)
|
ExportQuality.BALANCED.jpegQuality)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun computeThumbnail(key: PageViewKey): ByteArray? =
|
private suspend fun computeThumbnail(key: PageViewKey): Jpeg? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val imageBytes = getOrCompute(imageCache, key, ::computeProcessedImage)
|
val processed = getOrCompute(imageCache, key, ::computeProcessedImage)
|
||||||
?: return@withContext null
|
?: return@withContext null
|
||||||
transformations.resize(imageBytes, thumbnailSizePx)
|
transformations.resize(processed, thumbnailSizePx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Other operations ---
|
// --- Other operations ---
|
||||||
|
|
||||||
fun sourceJpegBytes(id: String): ByteArray? {
|
fun source(id: String): Jpeg? {
|
||||||
val file = File(sourceDir, "$id.jpg")
|
val file = File(sourceDir, "$id.jpg")
|
||||||
return if (file.exists()) file.readBytes() else null
|
return if (file.exists()) Jpeg(file.readBytes()) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun movePage(id: String, newIndex: Int) = mutex.withLock {
|
suspend fun movePage(id: String, newIndex: Int) = mutex.withLock {
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
*/
|
*/
|
||||||
package org.fairscan.app.data
|
package org.fairscan.app.data
|
||||||
|
|
||||||
|
import org.fairscan.app.domain.Jpeg
|
||||||
|
|
||||||
interface ImageTransformations {
|
interface ImageTransformations {
|
||||||
|
|
||||||
fun rotate(input: ByteArray, rotationDegrees: Int, jpegQuality: Int): ByteArray
|
fun rotate(input: Jpeg, rotationDegrees: Int, jpegQuality: Int): Jpeg
|
||||||
|
|
||||||
fun resize(input: ByteArray, maxSize: Int): ByteArray
|
fun resize(input: Jpeg, maxSize: Int): Jpeg
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ package org.fairscan.app.domain
|
|||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
|
|
||||||
data class CapturedPage(
|
data class CapturedPage(
|
||||||
val pageJpeg: ByteArray,
|
val pageJpeg: Jpeg,
|
||||||
val sourceJpeg: Deferred<ByteArray>,
|
val sourceJpeg: Deferred<Jpeg>,
|
||||||
val metadata: PageMetadata,
|
val metadata: PageMetadata,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,15 +15,13 @@
|
|||||||
package org.fairscan.app.domain
|
package org.fairscan.app.domain
|
||||||
|
|
||||||
import org.fairscan.app.data.ImageRepository
|
import org.fairscan.app.data.ImageRepository
|
||||||
import org.fairscan.imageprocessing.decodeJpeg
|
|
||||||
import org.fairscan.imageprocessing.encodeJpeg
|
|
||||||
import org.fairscan.imageprocessing.extractDocument
|
import org.fairscan.imageprocessing.extractDocument
|
||||||
import org.fairscan.imageprocessing.resizeForMaxPixels
|
import org.fairscan.imageprocessing.resizeForMaxPixels
|
||||||
import org.fairscan.imageprocessing.scaledTo
|
import org.fairscan.imageprocessing.scaledTo
|
||||||
import org.opencv.core.Mat
|
import org.opencv.core.Mat
|
||||||
|
|
||||||
fun interface JpegProvider {
|
fun interface JpegProvider {
|
||||||
suspend fun get(): ByteArray
|
suspend fun get(): Jpeg
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun jpegsForExport(
|
suspend fun jpegsForExport(
|
||||||
@@ -34,13 +32,13 @@ suspend fun jpegsForExport(
|
|||||||
val pages = imageRepository.pages()
|
val pages = imageRepository.pages()
|
||||||
return when (exportQuality) {
|
return when (exportQuality) {
|
||||||
ExportQuality.BALANCED -> pages.map {
|
ExportQuality.BALANCED -> pages.map {
|
||||||
JpegProvider { jpegBytes(it, imageRepository) }
|
JpegProvider { jpeg(it, imageRepository) }
|
||||||
}
|
}
|
||||||
|
|
||||||
ExportQuality.LOW -> pages.map { page ->
|
ExportQuality.LOW -> pages.map { page ->
|
||||||
JpegProvider {
|
JpegProvider {
|
||||||
resizeJpegBytesForMaxPixels(
|
resizeJpegBytesForMaxPixels(
|
||||||
jpegBytes = jpegBytes(page, imageRepository),
|
jpeg = jpeg(page, imageRepository),
|
||||||
maxPixels = exportQuality.maxPixels.toDouble(),
|
maxPixels = exportQuality.maxPixels.toDouble(),
|
||||||
jpegQuality = exportQuality.jpegQuality
|
jpegQuality = exportQuality.jpegQuality
|
||||||
)
|
)
|
||||||
@@ -49,35 +47,35 @@ suspend fun jpegsForExport(
|
|||||||
|
|
||||||
ExportQuality.HIGH -> pages.map { page ->
|
ExportQuality.HIGH -> pages.map { page ->
|
||||||
JpegProvider {
|
JpegProvider {
|
||||||
val sourceJpegBytes = imageRepository.sourceJpegBytes(page.id)
|
val source = imageRepository.source(page.id)
|
||||||
val pageMetadata = page.metadata
|
val pageMetadata = page.metadata
|
||||||
val manualRotation = page.manualRotation
|
val manualRotation = page.manualRotation
|
||||||
if (sourceJpegBytes != null && pageMetadata != null)
|
if (source != null && pageMetadata != null)
|
||||||
prepareJpegForHigh(sourceJpegBytes, pageMetadata, manualRotation, exportQuality)
|
prepareJpegForHigh(source, pageMetadata, manualRotation, exportQuality)
|
||||||
else
|
else
|
||||||
jpegBytes(page, imageRepository)
|
jpeg(page, imageRepository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun jpegBytes(page: ScanPage, imageRepository: ImageRepository): ByteArray {
|
private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg {
|
||||||
val key = page.key()
|
val key = page.key()
|
||||||
return imageRepository.jpegBytes(key)
|
return imageRepository.jpegBytes(key)
|
||||||
?: throw IllegalArgumentException("JPEG not found for $key")
|
?: throw IllegalArgumentException("JPEG not found for $key")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resizeJpegBytesForMaxPixels(
|
private fun resizeJpegBytesForMaxPixels(
|
||||||
jpegBytes: ByteArray,
|
jpeg: Jpeg,
|
||||||
maxPixels: Double,
|
maxPixels: Double,
|
||||||
jpegQuality: Int
|
jpegQuality: Int
|
||||||
): ByteArray {
|
): Jpeg {
|
||||||
var decoded: Mat? = null
|
var decoded: Mat? = null
|
||||||
var resized: Mat? = null
|
var resized: Mat? = null
|
||||||
try {
|
try {
|
||||||
decoded = decodeJpeg(jpegBytes)
|
decoded = jpeg.toMat()
|
||||||
resized = resizeForMaxPixels(decoded, maxPixels)
|
resized = resizeForMaxPixels(decoded, maxPixels)
|
||||||
return encodeJpeg(resized, jpegQuality)
|
return Jpeg.fromMat(resized, jpegQuality)
|
||||||
} finally {
|
} finally {
|
||||||
decoded?.release()
|
decoded?.release()
|
||||||
resized?.release()
|
resized?.release()
|
||||||
@@ -85,16 +83,16 @@ private fun resizeJpegBytesForMaxPixels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun prepareJpegForHigh(
|
private fun prepareJpegForHigh(
|
||||||
sourceJpegBytes: ByteArray,
|
source: Jpeg,
|
||||||
pageMetadata: PageMetadata,
|
pageMetadata: PageMetadata,
|
||||||
manualRotation: Rotation,
|
manualRotation: Rotation,
|
||||||
exportQuality: ExportQuality,
|
exportQuality: ExportQuality,
|
||||||
): ByteArray {
|
): Jpeg {
|
||||||
|
|
||||||
var decoded: Mat? = null
|
var decoded: Mat? = null
|
||||||
var page: Mat? = null
|
var page: Mat? = null
|
||||||
try {
|
try {
|
||||||
decoded = decodeJpeg(sourceJpegBytes)
|
decoded = source.toMat()
|
||||||
val quad = pageMetadata.normalizedQuad.scaledTo(1, 1, decoded.width(), decoded.height())
|
val quad = pageMetadata.normalizedQuad.scaledTo(1, 1, decoded.width(), decoded.height())
|
||||||
page = extractDocument(
|
page = extractDocument(
|
||||||
decoded,
|
decoded,
|
||||||
@@ -103,7 +101,7 @@ private fun prepareJpegForHigh(
|
|||||||
pageMetadata.isColored,
|
pageMetadata.isColored,
|
||||||
exportQuality.maxPixels
|
exportQuality.maxPixels
|
||||||
)
|
)
|
||||||
return encodeJpeg(page, exportQuality.jpegQuality)
|
return Jpeg.fromMat(page, exportQuality.jpegQuality)
|
||||||
} finally {
|
} finally {
|
||||||
decoded?.release()
|
decoded?.release()
|
||||||
page?.release()
|
page?.release()
|
||||||
|
|||||||
29
app/src/main/java/org/fairscan/app/domain/Jpeg.kt
Normal file
29
app/src/main/java/org/fairscan/app/domain/Jpeg.kt
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* 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.domain
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import org.fairscan.imageprocessing.decodeJpeg
|
||||||
|
import org.fairscan.imageprocessing.encodeJpeg
|
||||||
|
import org.opencv.core.Mat
|
||||||
|
|
||||||
|
class Jpeg(val bytes: ByteArray) {
|
||||||
|
companion object {
|
||||||
|
fun fromMat(mat: Mat, jpegQuality: Int): Jpeg = Jpeg(encodeJpeg(mat, jpegQuality))
|
||||||
|
}
|
||||||
|
fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
fun toMat() : Mat = decodeJpeg(bytes)
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ class AndroidPdfWriter : PdfWriter {
|
|||||||
doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}"
|
doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}"
|
||||||
doc.use { document ->
|
doc.use { document ->
|
||||||
for (jpegBytes in jpegs) {
|
for (jpegBytes in jpegs) {
|
||||||
val image = JPEGFactory.createFromByteArray(document, jpegBytes.get())
|
val image = JPEGFactory.createFromByteArray(document, jpegBytes.get().bytes)
|
||||||
val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat()))
|
val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat()))
|
||||||
document.addPage(page)
|
document.addPage(page)
|
||||||
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false)
|
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false)
|
||||||
|
|||||||
@@ -15,8 +15,7 @@
|
|||||||
package org.fairscan.app.platform
|
package org.fairscan.app.platform
|
||||||
|
|
||||||
import org.fairscan.app.data.ImageTransformations
|
import org.fairscan.app.data.ImageTransformations
|
||||||
import org.fairscan.imageprocessing.decodeJpeg
|
import org.fairscan.app.domain.Jpeg
|
||||||
import org.fairscan.imageprocessing.encodeJpeg
|
|
||||||
import org.opencv.core.Mat
|
import org.opencv.core.Mat
|
||||||
import org.opencv.core.Size
|
import org.opencv.core.Size
|
||||||
import org.opencv.imgproc.Imgproc
|
import org.opencv.imgproc.Imgproc
|
||||||
@@ -25,16 +24,16 @@ import kotlin.math.min
|
|||||||
class OpenCvTransformations : ImageTransformations {
|
class OpenCvTransformations : ImageTransformations {
|
||||||
|
|
||||||
override fun rotate(
|
override fun rotate(
|
||||||
input: ByteArray,
|
input: Jpeg,
|
||||||
rotationDegrees: Int,
|
rotationDegrees: Int,
|
||||||
jpegQuality: Int
|
jpegQuality: Int
|
||||||
): ByteArray {
|
): Jpeg {
|
||||||
return transform(input, jpegQuality) {
|
return transform(input, jpegQuality) {
|
||||||
org.fairscan.imageprocessing.rotate(it, rotationDegrees)
|
org.fairscan.imageprocessing.rotate(it, rotationDegrees)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resize(input: ByteArray, maxSize: Int): ByteArray {
|
override fun resize(input: Jpeg, maxSize: Int): Jpeg {
|
||||||
return transform(input, 85) { src ->
|
return transform(input, 85) { src ->
|
||||||
val ratio = min(maxSize.toFloat() / src.width(), maxSize.toFloat() / src.height())
|
val ratio = min(maxSize.toFloat() / src.width(), maxSize.toFloat() / src.height())
|
||||||
val newW = (src.width() * ratio).toDouble()
|
val newW = (src.width() * ratio).toDouble()
|
||||||
@@ -46,15 +45,15 @@ class OpenCvTransformations : ImageTransformations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun transform(
|
private fun transform(
|
||||||
inBytes: ByteArray,
|
inJpeg: Jpeg,
|
||||||
jpegQuality: Int,
|
jpegQuality: Int,
|
||||||
transform: (Mat) -> Mat,
|
transform: (Mat) -> Mat,
|
||||||
): ByteArray {
|
): Jpeg {
|
||||||
val input = decodeJpeg(inBytes)
|
val input = inJpeg.toMat()
|
||||||
var output: Mat? = null
|
var output: Mat? = null
|
||||||
try {
|
try {
|
||||||
output = transform.invoke(input)
|
output = transform.invoke(input)
|
||||||
return encodeJpeg(output, jpegQuality)
|
return Jpeg.fromMat(output, jpegQuality)
|
||||||
} finally {
|
} finally {
|
||||||
input.release()
|
input.release()
|
||||||
output?.release()
|
output?.release()
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ 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.domain.CapturedPage
|
||||||
|
import org.fairscan.app.domain.Jpeg
|
||||||
import org.fairscan.app.domain.PageMetadata
|
import org.fairscan.app.domain.PageMetadata
|
||||||
import org.fairscan.app.domain.Rotation.R0
|
import org.fairscan.app.domain.Rotation.R0
|
||||||
import org.fairscan.app.ui.Navigation
|
import org.fairscan.app.ui.Navigation
|
||||||
@@ -286,7 +287,7 @@ private fun CameraScreenScaffold(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (cameraUiState.captureState is CaptureState.CapturePreview) {
|
if (cameraUiState.captureState is CaptureState.CapturePreview) {
|
||||||
val page = bitmap(cameraUiState.captureState.capturedPage.pageJpeg)
|
val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap()
|
||||||
CapturedImage(page.asImageBitmap(), thumbnailCoords)
|
CapturedImage(page.asImageBitmap(), thumbnailCoords)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,10 +535,10 @@ fun CameraScreenPreviewWithProcessedImage() {
|
|||||||
val p = Point(0 , 0)
|
val p = Point(0 , 0)
|
||||||
val quad = Quad(p, p, p, p)
|
val quad = Quad(p, p, p, p)
|
||||||
ScreenPreview(CaptureState.CapturePreview(
|
ScreenPreview(CaptureState.CapturePreview(
|
||||||
bitmap(debugImage("uncropped/img01.jpg")),
|
debugImage("uncropped/img01.jpg").toBitmap(),
|
||||||
CapturedPage(
|
CapturedPage(
|
||||||
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
||||||
CompletableDeferred(ByteArray(0)),
|
CompletableDeferred(Jpeg(ByteArray(0))),
|
||||||
PageMetadata(quad, R0, false))))
|
PageMetadata(quad, R0, false))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +561,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
|
|||||||
contentAlignment = Alignment.TopCenter
|
contentAlignment = Alignment.TopCenter
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
bitmap(debugImage("uncropped/img01.jpg")).asImageBitmap(),
|
debugImage("uncropped/img01.jpg").toBitmap().asImageBitmap(),
|
||||||
modifier=Modifier.rotate(rotationDegrees),
|
modifier=Modifier.rotate(rotationDegrees),
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
@@ -592,9 +593,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun debugImage(imgName: String): ByteArray {
|
private fun debugImage(imgName: String): Jpeg {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
return context.assets.open(imgName).readBytes()
|
return Jpeg(context.assets.open(imgName).readBytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bitmap(jpeg: ByteArray): Bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size)
|
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ import kotlinx.coroutines.withContext
|
|||||||
import org.fairscan.app.AppContainer
|
import org.fairscan.app.AppContainer
|
||||||
import org.fairscan.app.domain.CapturedPage
|
import org.fairscan.app.domain.CapturedPage
|
||||||
import org.fairscan.app.domain.ExportQuality
|
import org.fairscan.app.domain.ExportQuality
|
||||||
|
import org.fairscan.app.domain.Jpeg
|
||||||
import org.fairscan.app.domain.PageMetadata
|
import org.fairscan.app.domain.PageMetadata
|
||||||
import org.fairscan.app.domain.Rotation
|
import org.fairscan.app.domain.Rotation
|
||||||
import org.fairscan.imageprocessing.ImageSize
|
import org.fairscan.imageprocessing.ImageSize
|
||||||
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
|
||||||
import org.fairscan.imageprocessing.encodeJpeg
|
|
||||||
import org.fairscan.imageprocessing.extractDocument
|
import org.fairscan.imageprocessing.extractDocument
|
||||||
import org.fairscan.imageprocessing.isColoredDocument
|
import org.fairscan.imageprocessing.isColoredDocument
|
||||||
import org.fairscan.imageprocessing.scaledTo
|
import org.fairscan.imageprocessing.scaledTo
|
||||||
@@ -197,14 +197,14 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun compressJpeg(bitmap: Bitmap, quality: Int): ByteArray {
|
private fun compressJpeg(bitmap: Bitmap, quality: Int): Jpeg {
|
||||||
val rgba = Mat()
|
val rgba = Mat()
|
||||||
Utils.bitmapToMat(bitmap, rgba)
|
Utils.bitmapToMat(bitmap, rgba)
|
||||||
val bgr = Mat()
|
val bgr = Mat()
|
||||||
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
|
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
|
||||||
rgba.release()
|
rgba.release()
|
||||||
return try {
|
return try {
|
||||||
encodeJpeg(bgr, quality)
|
Jpeg.fromMat(bgr, quality)
|
||||||
} finally {
|
} finally {
|
||||||
bgr.release()
|
bgr.release()
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ fun extractDocumentFromBitmap(
|
|||||||
val isColored = isColoredDocument(bgr, mask, quad)
|
val isColored = isColoredDocument(bgr, mask, quad)
|
||||||
val maxPixels = ExportQuality.BALANCED.maxPixels
|
val maxPixels = ExportQuality.BALANCED.maxPixels
|
||||||
val page = extractDocument(bgr, quad, rotationDegrees, isColored, maxPixels)
|
val page = extractDocument(bgr, quad, rotationDegrees, isColored, maxPixels)
|
||||||
val pageJpeg = encodeJpeg(page, ExportQuality.BALANCED.jpegQuality)
|
val pageJpeg = Jpeg.fromMat(page, ExportQuality.BALANCED.jpegQuality)
|
||||||
bgr.release()
|
bgr.release()
|
||||||
page.release()
|
page.release()
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
|||||||
preparationDir.mkdirs()
|
preparationDir.mkdirs()
|
||||||
val files = jpegs.mapIndexed { index, jpeg ->
|
val files = jpegs.mapIndexed { index, jpeg ->
|
||||||
val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
|
val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
|
||||||
file.writeBytes(jpeg.get())
|
file.writeBytes(jpeg.get().bytes)
|
||||||
file
|
file
|
||||||
}.toList()
|
}.toList()
|
||||||
val sizeInBytes = files.sumOf { it.length() }
|
val sizeInBytes = files.sumOf { it.length() }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package org.fairscan.app.data
|
|||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.fairscan.app.domain.Jpeg
|
||||||
import org.fairscan.app.domain.JpegProvider
|
import org.fairscan.app.domain.JpegProvider
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -74,12 +75,12 @@ class FileManagerTest {
|
|||||||
val fakePdfWriter = object : PdfWriter {
|
val fakePdfWriter = object : PdfWriter {
|
||||||
override suspend fun writePdfFromJpegs(jpegs: List<JpegProvider>, outputStream: OutputStream): Int {
|
override suspend fun writePdfFromJpegs(jpegs: List<JpegProvider>, outputStream: OutputStream): Int {
|
||||||
val list = jpegs.toList()
|
val list = jpegs.toList()
|
||||||
list.forEach { bytes -> outputStream.write(bytes.get()) }
|
list.forEach { bytes -> outputStream.write(bytes.get().bytes) }
|
||||||
return list.size
|
return list.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val manager = FileManager(pdfDir, externalDir, fakePdfWriter)
|
val manager = FileManager(pdfDir, externalDir, fakePdfWriter)
|
||||||
val jpegs = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)).map { JpegProvider { it } }
|
val jpegs = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)).map { JpegProvider { Jpeg(it) } }
|
||||||
val pdf = manager.generatePdf(jpegs)
|
val pdf = manager.generatePdf(jpegs)
|
||||||
assertThat(pdf.pageCount).isEqualTo(2)
|
assertThat(pdf.pageCount).isEqualTo(2)
|
||||||
assertThat(pdf.sizeInBytes).isEqualTo(3)
|
assertThat(pdf.sizeInBytes).isEqualTo(3)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import kotlinx.collections.immutable.toPersistentList
|
|||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.fairscan.app.domain.Jpeg
|
||||||
import org.fairscan.app.domain.PageMetadata
|
import org.fairscan.app.domain.PageMetadata
|
||||||
import org.fairscan.app.domain.PageViewKey
|
import org.fairscan.app.domain.PageViewKey
|
||||||
import org.fairscan.app.domain.Rotation.R0
|
import org.fairscan.app.domain.Rotation.R0
|
||||||
@@ -53,11 +54,11 @@ class ImageRepositoryTest {
|
|||||||
|
|
||||||
fun repo(): ImageRepository {
|
fun repo(): ImageRepository {
|
||||||
val transformations = object : ImageTransformations {
|
val transformations = object : ImageTransformations {
|
||||||
override fun rotate(input: ByteArray, rotationDegrees: Int, jpegQuality: Int): ByteArray {
|
override fun rotate(input: Jpeg, rotationDegrees: Int, jpegQuality: Int): Jpeg {
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
override fun resize(input: ByteArray, maxSize: Int): ByteArray {
|
override fun resize(input: Jpeg, maxSize: Int): Jpeg {
|
||||||
return byteArrayOf(input[0])
|
return jpeg(input.bytes[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ImageRepository(getFilesDir(), transformations, 200, testScope)
|
return ImageRepository(getFilesDir(), transformations, 200, testScope)
|
||||||
@@ -67,13 +68,13 @@ class ImageRepositoryTest {
|
|||||||
fun add_image() = runTest {
|
fun add_image() = runTest {
|
||||||
val repo = repo()
|
val repo = repo()
|
||||||
assertThat(repo.imageIds()).isEmpty()
|
assertThat(repo.imageIds()).isEmpty()
|
||||||
val bytes = byteArrayOf(101, 102, 103)
|
val jpeg = jpeg(101, 102, 103)
|
||||||
repo.add(bytes, byteArrayOf(51), metadata1)
|
repo.add(jpeg, jpeg(51), metadata1)
|
||||||
assertThat(repo.imageIds()).hasSize(1)
|
assertThat(repo.imageIds()).hasSize(1)
|
||||||
val id = repo.imageIds()[0]
|
val id = repo.imageIds()[0]
|
||||||
val key = PageViewKey(id, R0)
|
val key = PageViewKey(id, R0)
|
||||||
assertThat(repo.jpegBytes(key)).isEqualTo(bytes)
|
assertThat(repo.jpegBytes(key)).isEqualTo(jpeg)
|
||||||
assertThat(repo.getThumbnail(key)).isEqualTo(byteArrayOf(101))
|
assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101))
|
||||||
|
|
||||||
val page = repo.pages().first()
|
val page = repo.pages().first()
|
||||||
assertThat(page.id).isEqualTo(id)
|
assertThat(page.id).isEqualTo(id)
|
||||||
@@ -88,8 +89,8 @@ class ImageRepositoryTest {
|
|||||||
@Test
|
@Test
|
||||||
fun delete_image() = runTest {
|
fun delete_image() = runTest {
|
||||||
val repo = repo()
|
val repo = repo()
|
||||||
val bytes = byteArrayOf(101, 102, 103)
|
val jpeg = jpeg(101, 102, 103)
|
||||||
repo.add(bytes, byteArrayOf(51), metadata1)
|
repo.add(jpeg, jpeg(51), metadata1)
|
||||||
assertThat(jpegFiles(processedDir())).hasSize(1)
|
assertThat(jpegFiles(processedDir())).hasSize(1)
|
||||||
assertThat(jpegFiles(sourceDir())).hasSize(1)
|
assertThat(jpegFiles(sourceDir())).hasSize(1)
|
||||||
assertThat(repo.imageIds()).hasSize(1)
|
assertThat(repo.imageIds()).hasSize(1)
|
||||||
@@ -138,7 +139,7 @@ class ImageRepositoryTest {
|
|||||||
File(processedDir(), "1-90.jpg").writeBytes(bytes)
|
File(processedDir(), "1-90.jpg").writeBytes(bytes)
|
||||||
val repo = repo()
|
val repo = repo()
|
||||||
assertThat(repo.imageIds()).containsExactly("1")
|
assertThat(repo.imageIds()).containsExactly("1")
|
||||||
assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes)
|
assertThat(repo.jpegBytes(PageViewKey("1", R0))?.bytes).isEqualTo(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -167,7 +168,7 @@ class ImageRepositoryTest {
|
|||||||
File(processedDir(), "1-90.jpg").writeBytes(bytes)
|
File(processedDir(), "1-90.jpg").writeBytes(bytes)
|
||||||
val repo = repo()
|
val repo = repo()
|
||||||
assertThat(repo.imageIds()).containsExactly("1")
|
assertThat(repo.imageIds()).containsExactly("1")
|
||||||
assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes)
|
assertThat(repo.jpegBytes(PageViewKey("1", R0))?.bytes).isEqualTo(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -179,9 +180,9 @@ class ImageRepositoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clear should delete pages`() = runTest {
|
fun `clear should delete pages`() = runTest {
|
||||||
val bytes = byteArrayOf(101, 102, 103)
|
val jpeg = jpeg(101, 102, 103)
|
||||||
val repo1 = repo()
|
val repo1 = repo()
|
||||||
repo1.add(bytes, byteArrayOf(51), metadata1)
|
repo1.add(jpeg, jpeg(51), metadata1)
|
||||||
assertThat(repo1.imageIds()).isNotEmpty()
|
assertThat(repo1.imageIds()).isNotEmpty()
|
||||||
repo1.clear()
|
repo1.clear()
|
||||||
assertThat(repo1.imageIds()).isEmpty()
|
assertThat(repo1.imageIds()).isEmpty()
|
||||||
@@ -194,7 +195,7 @@ class ImageRepositoryTest {
|
|||||||
@Test
|
@Test
|
||||||
fun rotate() = runTest {
|
fun rotate() = runTest {
|
||||||
val repo = repo()
|
val repo = repo()
|
||||||
repo.add(byteArrayOf(101, 102, 103), byteArrayOf(51), metadata1)
|
repo.add(jpeg(101, 102, 103), jpeg(51), metadata1)
|
||||||
assertThat(repo.pages().last().metadata).isEqualTo(metadata1)
|
assertThat(repo.pages().last().metadata).isEqualTo(metadata1)
|
||||||
val id = repo.pages().last().id
|
val id = repo.pages().last().id
|
||||||
repo.rotate(id, true)
|
repo.rotate(id, true)
|
||||||
@@ -223,9 +224,9 @@ class ImageRepositoryTest {
|
|||||||
@Test
|
@Test
|
||||||
fun movePage() = runTest {
|
fun movePage() = runTest {
|
||||||
val repo = repo()
|
val repo = repo()
|
||||||
repo.add(byteArrayOf(101), byteArrayOf(51), metadata1)
|
repo.add(jpeg(101), jpeg(51), metadata1)
|
||||||
Thread.sleep(1L) // to avoid file name clashes
|
Thread.sleep(1L) // to avoid file name clashes
|
||||||
repo.add(byteArrayOf(110), byteArrayOf(51), metadata1)
|
repo.add(jpeg(110), jpeg(51), metadata1)
|
||||||
val id0 = repo.imageIds().first()
|
val id0 = repo.imageIds().first()
|
||||||
val id1 = repo.imageIds().last()
|
val id1 = repo.imageIds().last()
|
||||||
repo.movePage(id1, 0)
|
repo.movePage(id1, 0)
|
||||||
@@ -275,10 +276,10 @@ class ImageRepositoryTest {
|
|||||||
fun last_added_source_file() = runTest {
|
fun last_added_source_file() = runTest {
|
||||||
val repo = repo()
|
val repo = repo()
|
||||||
assertThat(repo.lastAddedSourceFile()).isNull()
|
assertThat(repo.lastAddedSourceFile()).isNull()
|
||||||
repo.add(byteArrayOf(101), byteArrayOf(51), metadata1)
|
repo.add(jpeg(101), jpeg(51), metadata1)
|
||||||
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(51))
|
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(51))
|
||||||
Thread.sleep(1)
|
Thread.sleep(1)
|
||||||
repo.add(byteArrayOf(102), byteArrayOf(52), metadata1)
|
repo.add(jpeg(102), jpeg(52), metadata1)
|
||||||
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(52))
|
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(52))
|
||||||
|
|
||||||
val id = repo.imageIds().last()
|
val id = repo.imageIds().last()
|
||||||
@@ -307,4 +308,6 @@ class ImageRepositoryTest {
|
|||||||
|
|
||||||
suspend fun ImageRepository.imageIds(): PersistentList<String> =
|
suspend fun ImageRepository.imageIds(): PersistentList<String> =
|
||||||
pages().map { it.id }.toPersistentList()
|
pages().map { it.id }.toPersistentList()
|
||||||
|
|
||||||
|
private fun jpeg(vararg bytes: Byte) = Jpeg(bytes)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user