Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
package org.fairscan.app.data

import kotlinx.serialization.Serializable
import org.fairscan.imageprocessing.CameraIntrinsics
import org.fairscan.imageprocessing.ColorMode

@Serializable
Expand Down Expand Up @@ -47,6 +46,9 @@ data class PageV2(
val colorMode: ColorMode? = null,
val focalLength: Float? = null,
val sensorWidth: Float? = null,
val subjectDistance: Float? = null,
val sourceWidth: Int? = null,
val sourceHeight: Int? = null,
)

@Serializable
Expand Down
8 changes: 4 additions & 4 deletions app/src/main/java/org/fairscan/app/data/FileManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/
package org.fairscan.app.data

import org.fairscan.app.domain.JpegProvider
import org.fairscan.app.domain.PageToExport
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
Expand All @@ -26,7 +26,7 @@ data class GeneratedPdf(
)

fun interface PdfWriter {
suspend fun writePdfFromJpegs(jpegs: List<JpegProvider>, outputStream: OutputStream): Int
suspend fun writePdfFromJpegs(pages: List<PageToExport>, outputStream: OutputStream): Int
}

class FileManager(
Expand All @@ -43,12 +43,12 @@ class FileManager(
}
}

suspend fun generatePdf(jpegs: List<JpegProvider>): GeneratedPdf {
suspend fun generatePdf(pages: List<PageToExport>): GeneratedPdf {
pdfDir.mkdirs()
require(pdfDir.exists() && pdfDir.isDirectory) { "Invalid pdfDir: $pdfDir" }
val file = File(pdfDir, "${System.currentTimeMillis()}.pdf")
val pageCount = FileOutputStream(file).use {
pdfWriter.writePdfFromJpegs(jpegs, it)
pdfWriter.writePdfFromJpegs(pages, it)
}
val sizeBytes = file.length()
return GeneratedPdf(file, sizeBytes, pageCount)
Expand Down
18 changes: 15 additions & 3 deletions app/src/main/java/org/fairscan/app/data/ImageRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.domain.ScanPage
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.OpticalMeasures
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.cameraIntrinsics
Expand Down Expand Up @@ -154,8 +156,11 @@ class ImageRepository(
manualRotationDegrees = Rotation.R0.degrees,
isColored = metadata.autoColorMode == ColorMode.COLOR,
colorMode = colorMode,
focalLength = metadata.cameraIntrinsics?.focalLength,
sensorWidth = metadata.cameraIntrinsics?.sensorWidth,
focalLength = metadata.opticalMeasures?.cameraIntrinsics?.focalLength,
sensorWidth = metadata.opticalMeasures?.cameraIntrinsics?.sensorWidth,
subjectDistance = metadata.opticalMeasures?.subjectDistance,
sourceWidth = metadata.sourceSize?.width?.toInt(),
sourceHeight = metadata.sourceSize?.height?.toInt(),
)
)
saveMetadata()
Expand Down Expand Up @@ -402,10 +407,17 @@ fun NormalizedQuad.toQuad(): Quad =

fun PageV2.toMetadata(): PageMetadata? {
if (quad == null || isColored == null) return null
val cameraIntrinsics = cameraIntrinsics(focalLength, sensorWidth)
val sourceSize =
if (sourceWidth != null && sourceHeight != null)
ImageSize(sourceWidth, sourceHeight)
else
null
return PageMetadata(
(userQuad ?: quad).toQuad(),
Rotation.fromDegrees(baseRotationDegrees),
if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE,
cameraIntrinsics(focalLength, sensorWidth)
sourceSize,
cameraIntrinsics?.let { OpticalMeasures(it, subjectDistance) },
)
}
39 changes: 34 additions & 5 deletions app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,55 @@ package org.fairscan.app.domain

import org.fairscan.app.data.ImageRepository
import org.fairscan.app.platform.processedImage
import org.fairscan.imageprocessing.EstimatedDimensions
import org.fairscan.imageprocessing.estimateRealDimensions
import org.fairscan.imageprocessing.resizeForMaxPixels
import org.fairscan.imageprocessing.scaledTo
import org.opencv.core.Mat

fun interface JpegProvider {
suspend fun get(): Jpeg
}

suspend fun jpegsForExport(
data class PageToExport(
val metadata: PageMetadata?,
val jpeg: JpegProvider,
) {
fun estimatedDimensions(): EstimatedDimensions? {
if (metadata == null)
return null
val size = metadata.sourceSize
if (size == null)
return null
val quad = metadata.normalizedQuad.scaledTo(1.0, 1.0, size.width, size.height)
val realDimensions = estimateRealDimensions(
quad, size.width.toInt(), size.height.toInt(), metadata.opticalMeasures
).snapToStandardFormat()
return realDimensions.applyRotation(metadata.baseRotation)
}
}

private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDimensions {
if ((rotation == Rotation.R90 || rotation == Rotation.R270)
&& this is EstimatedDimensions.Physical) {
return EstimatedDimensions.Physical(heightMm, widthMm)
}
return this
}

suspend fun pagesToExport(
imageRepository: ImageRepository,
exportQuality: ExportQuality
): List<JpegProvider> {
): List<PageToExport> {

val pages = imageRepository.pages()
return when (exportQuality) {
ExportQuality.BALANCED -> pages.map {
JpegProvider { jpeg(it, imageRepository) }
PageToExport(it.metadata) { jpeg(it, imageRepository) }
}

ExportQuality.LOW -> pages.map { page ->
JpegProvider {
PageToExport(page.metadata) {
resizeJpegBytesForMaxPixels(
jpeg = jpeg(page, imageRepository),
maxPixels = exportQuality.maxPixels.toDouble(),
Expand All @@ -45,7 +74,7 @@ suspend fun jpegsForExport(
}

ExportQuality.HIGH -> pages.map { page ->
JpegProvider {
PageToExport(page.metadata) {
val source = imageRepository.source(page.id)
val metadata = page.metadata
val colorMode = page.colorMode
Expand Down
6 changes: 4 additions & 2 deletions app/src/main/java/org/fairscan/app/domain/Page.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@
*/
package org.fairscan.app.domain

import org.fairscan.imageprocessing.CameraIntrinsics
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.OpticalMeasures
import org.fairscan.imageprocessing.Quad

data class PageMetadata(
val normalizedQuad: Quad,
val baseRotation: Rotation,
val autoColorMode: ColorMode,
val cameraIntrinsics: CameraIntrinsics?,
val sourceSize: ImageSize?,
val opticalMeasures: OpticalMeasures?,
)

data class ScanPage(
Expand Down
42 changes: 30 additions & 12 deletions app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,40 @@ import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory
import org.fairscan.app.BuildConfig
import org.fairscan.app.data.PdfWriter
import org.fairscan.app.domain.JpegProvider
import org.fairscan.app.domain.PageToExport
import org.fairscan.imageprocessing.EstimatedDimensions
import org.fairscan.imageprocessing.PaperFormats
import java.io.OutputStream
import java.util.Calendar

class AndroidPdfWriter : PdfWriter {
override suspend fun writePdfFromJpegs(jpegs: List<JpegProvider>, outputStream: OutputStream): Int {
override suspend fun writePdfFromJpegs(pages: List<PageToExport>, outputStream: OutputStream): Int {
val doc = PDDocument()
doc.documentInformation.creationDate = Calendar.getInstance()
doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}"
doc.use { document ->
for (jpegBytes in jpegs) {
val image = JPEGFactory.createFromByteArray(document, jpegBytes.get().bytes)
for (page in pages) {
val image = JPEGFactory.createFromByteArray(document, page.jpeg.get().bytes)

// Let's say that the physical dimensions of the page are close to US Letter
// US Letter: 215.9×279.4 mm (A4: 210×297 mm)
val maxDimInMm = 279.4f
// PDF has 72 points (units) per inch, 1 inch = 25.4 mm
val pointsPerMm = 72f / 25.4f

val widthPx = image.width.toFloat()
val heightPx = image.height.toFloat()

val maxPx = maxOf(widthPx, heightPx)
val scalePxToMm = maxDimInMm / maxPx

val widthPoints = widthPx * scalePxToMm * pointsPerMm
val heightPoints = heightPx * scalePxToMm * pointsPerMm
val dimensions = page.estimatedDimensions()
val (widthMm, heightMm) = when (dimensions) {
is EstimatedDimensions.Physical ->
clipToMaxFormat(dimensions.widthMm, dimensions.heightMm)
else -> {
// No physical dimensions available
val maxDimMm = PaperFormats.A4.heightMm
val scalePxToMm = maxDimMm / maxOf(widthPx, heightPx)
clipToMaxFormat(widthPx * scalePxToMm, heightPx * scalePxToMm)
}
}
val widthPoints = widthMm.toFloat() * pointsPerMm
val heightPoints = heightMm.toFloat() * pointsPerMm

val page = PDPage(PDRectangle(widthPoints, heightPoints))
document.addPage(page)
Expand All @@ -63,3 +70,14 @@ class AndroidPdfWriter : PdfWriter {
return doc.numberOfPages
}
}

fun clipToMaxFormat(widthMm: Double, heightMm: Double): Pair<Double, Double> {
// Normalize to portrait for comparison
val (w, h) = if (widthMm <= heightMm) widthMm to heightMm else heightMm to widthMm
val portrait = widthMm <= heightMm

val maxFormat = PaperFormats.A4
val scale = minOf(maxFormat.widthMm / w, maxFormat.heightMm / h, 1.0)
val clipped = w * scale to h * scale
return if (portrait) clipped else clipped.second to clipped.first
}
13 changes: 8 additions & 5 deletions app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.screens.settings.DefaultColorMode
import org.fairscan.imageprocessing.CameraIntrinsics
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.OpticalMeasures
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.autoColorMode
Expand Down Expand Up @@ -102,7 +103,7 @@ fun processedImage(
sourceMat = source.toMat()
val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height())
page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels,
metadata.cameraIntrinsics)
metadata.opticalMeasures)
return Jpeg.fromMat(page, exportQuality.jpegQuality)
} finally {
sourceMat?.release()
Expand All @@ -117,7 +118,7 @@ fun extractDocumentFromBitmap(
mask: Mask?,
viewModelScope: CoroutineScope,
defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO,
cameraIntrinsics: CameraIntrinsics?,
opticalMeasures: OpticalMeasures?,
): CapturedPage {
val exportQuality = ExportQuality.BALANCED
var colorMode = ColorMode.COLOR
Expand All @@ -144,15 +145,17 @@ fun extractDocumentFromBitmap(
autoColorMode = autoColorMode(bgr, mask, quad)
colorMode = defaultColorMode.colorMode ?: autoColorMode
page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels,
cameraIntrinsics)
opticalMeasures)
}

val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality)
bgr.release()
page.release()

val baseRotation = Rotation.fromDegrees(rotationDegrees)
val metadata = PageMetadata(normalizedQuad, baseRotation, autoColorMode, cameraIntrinsics)
val sourceSize = ImageSize(source.width, source.height)
val metadata =
PageMetadata(normalizedQuad, baseRotation, autoColorMode, sourceSize, opticalMeasures)
val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) {
compressSource(source)
}
Expand Down
Loading
Loading