OCR: basic integration of Tesseract

This commit is contained in:
Pierre-Yves Nicolas
2026-06-10 19:02:02 +02:00
parent 6ad21eeb59
commit 90413cd97f
9 changed files with 229 additions and 4 deletions
+1
View File
@@ -136,6 +136,7 @@ dependencies {
implementation(libs.reorderable)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.kotlinx.serialization.json)
implementation(libs.tesseract4android)
testImplementation(libs.junit)
testImplementation(libs.assertj)
@@ -24,6 +24,7 @@ import org.fairscan.app.data.FileLogger
import org.fairscan.app.data.FileManager
import org.fairscan.app.data.LogRepository
import org.fairscan.app.domain.ImageSegmentationService
import org.fairscan.app.domain.OcrService
import org.fairscan.app.platform.AndroidImageLoader
import org.fairscan.app.platform.AndroidPdfWriter
import org.fairscan.app.ui.screens.camera.CameraViewModel
@@ -46,10 +47,11 @@ const val THUMBNAIL_SIZE_DP = 120
class AppContainer(context: Context) {
private val cacheDir = context.cacheDir
val preparationDir = File(context.cacheDir, "pdfs")
val ocrService = OcrService(context)
val fileManager = FileManager(
preparationDir,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()
AndroidPdfWriter(ocrService)
)
val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
val logger = FileLogger(logRepository)
@@ -0,0 +1,72 @@
package org.fairscan.app.domain
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Rect
import com.googlecode.tesseract.android.TessBaseAPI
import com.googlecode.tesseract.android.TessBaseAPI.PageIteratorLevel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.fairscan.imageprocessing.ImageRect
import org.fairscan.imageprocessing.OcrTextBox
import java.io.File
class OcrService(private val context: Context) {
private var tess: TessBaseAPI? = null
private val mutex = Mutex()
fun initialize() {
prepareTessdata(context)
val tess = TessBaseAPI()
val dataPath: String = File(context.filesDir, "tesseract").absolutePath
// Initialize API for specified language
// (can be called multiple times during Tesseract lifetime)
if (!tess.init(dataPath, "eng")) { // could be multiple languages, like "eng+deu+fra"
tess.recycle()
return
}
this.tess = tess
}
// FIXME: Tesseract language-specific data should be downloaded from the SettingsScreen
fun prepareTessdata(context: Context) {
val destDir = File(context.filesDir, "tesseract/tessdata")
val destFile = File(destDir, "eng.traineddata")
if (destFile.exists()) return
destDir.mkdirs()
context.assets.open("tesseract/tessdata_fast/eng.traineddata").use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
suspend fun runOcr(bitmap: Bitmap): List<OcrTextBox> {
mutex.withLock {
val tess = this.tess ?: return listOf()
val textBoxes = mutableListOf<OcrTextBox>()
tess.setImage(bitmap)
tess.getUTF8Text() // Trigger text recognition
val iterator = tess.resultIterator
iterator.begin()
do {
val word = iterator.getUTF8Text(PageIteratorLevel.RIL_WORD) ?: continue
val boundingBox = iterator.getBoundingRect(PageIteratorLevel.RIL_WORD)
val confidence = iterator.confidence(PageIteratorLevel.RIL_WORD)
if (confidence > 50) {
textBoxes.add(OcrTextBox(word, boundingBox.toImageRect()))
}
} while (iterator.next(PageIteratorLevel.RIL_WORD))
iterator.delete()
return textBoxes
}
}
private fun Rect.toImageRect(): ImageRect = ImageRect(left, top, right, bottom)
}
@@ -14,28 +14,35 @@
*/
package org.fairscan.app.platform
import android.graphics.Bitmap
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream.AppendMode
import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
import com.tom_roush.pdfbox.pdmodel.font.PDType1Font
import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory
import com.tom_roush.pdfbox.pdmodel.graphics.image.PDImageXObject
import com.tom_roush.pdfbox.pdmodel.graphics.state.RenderingMode
import org.fairscan.app.BuildConfig
import org.fairscan.app.data.PdfWriter
import org.fairscan.app.domain.PageToExport
import org.fairscan.app.domain.OcrService
import org.fairscan.imageprocessing.EstimatedDimensions
import org.fairscan.imageprocessing.OcrCoordinateConverter
import org.fairscan.imageprocessing.PaperFormats
import java.io.OutputStream
import java.util.Calendar
class AndroidPdfWriter : PdfWriter {
class AndroidPdfWriter(val ocrService: OcrService) : PdfWriter {
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 (page in pages) {
val image = JPEGFactory.createFromByteArray(document, page.jpeg.get().bytes)
val jpeg = page.jpeg.get()
val image = JPEGFactory.createFromByteArray(document, jpeg.bytes)
// PDF has 72 points (units) per inch, 1 inch = 25.4 mm
val pointsPerMm = 72f / 25.4f
@@ -62,6 +69,9 @@ class AndroidPdfWriter : PdfWriter {
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false)
contentStream.drawImage(image, 0f, 0f, widthPoints, heightPoints)
createText(jpeg.toBitmap(), image, widthPoints, heightPoints, contentStream)
contentStream.close()
}
// TODO So the whole document is in memory before this line...
@@ -69,6 +79,33 @@ class AndroidPdfWriter : PdfWriter {
}
return doc.numberOfPages
}
private suspend fun createText(
bitmap: Bitmap,
image: PDImageXObject,
widthPoints: Float,
heightPoints: Float,
contentStream: PDPageContentStream,
) {
val ocr = ocrService.runOcr(bitmap)
val ocrConverter = OcrCoordinateConverter(
imageWidth = image.width,
imageHeight = image.height,
pageWidth = widthPoints,
pageHeight = heightPoints
)
val font = PDType1Font.HELVETICA
for (textBox in ocr) {
val pdfRect = ocrConverter.convert(textBox.box)
val fontSize = pdfRect.height * 0.8f
contentStream.beginText()
contentStream.setFont(font, fontSize)
contentStream.setRenderingMode(RenderingMode.NEITHER)
contentStream.newLineAtOffset(pdfRect.x, pdfRect.y)
contentStream.showText(textBox.text)
contentStream.endText()
}
}
}
fun constrainToMaxFormat(widthMm: Double, heightMm: Double): Pair<Double, Double> {
@@ -73,6 +73,13 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
private val _events = MutableSharedFlow<ExportEvent>()
val events = _events.asSharedFlow()
// FIXME ocrService is initialized here but used in AndroidPdfWriter
init {
viewModelScope.launch {
container.ocrService.initialize()
}
}
private suspend fun generatePdf(
exportQuality: ExportQuality
): ExportResult.Pdf = withContext(Dispatchers.IO) {
+2 -1
View File
@@ -23,6 +23,7 @@ kotlinSerialization = "1.10.0"
reorderable = "3.0.0"
jetbrainsKotlinJvm = "2.3.10"
coroutines-test = "1.10.2"
tesseract4android = "4.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -60,7 +61,7 @@ reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reo
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-test" }
tesseract4android = { group = "cz.adaptech.tesseract4android", name = "tesseract4android-openmp", version.ref = "tesseract4android" }
assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" }
@@ -0,0 +1,56 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* 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.imageprocessing
data class OcrTextBox(
val text: String,
val box: ImageRect,
)
data class ImageRect(
val left: Int,
val top: Int,
val right: Int, // coordinate of the right side (from the left of the image)
val bottom: Int, // coordinate of the bottom side (from the top of the image)
) {
val width get() = right - left
val height get() = bottom - top
}
data class PdfRect(
val x: Float, // in points, from left side
val y: Float, // in points, from bottom side (PDF convention)
val width: Float,
val height: Float
)
class OcrCoordinateConverter(
private val imageWidth: Int,
private val imageHeight: Int,
private val pageWidth: Float, // in PDF points
private val pageHeight: Float // in PDF points
) {
fun convert(rect: ImageRect): PdfRect {
val scaleX = pageWidth / imageWidth
val scaleY = pageHeight / imageHeight
val x = rect.left * scaleX
val y = pageHeight - (rect.bottom * scaleY) // Y axis is inverted
val width = rect.width * scaleX
val height = rect.height * scaleY
return PdfRect(x, y, width, height)
}
}
@@ -0,0 +1,48 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* 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.imageprocessing
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class OcrTest {
@Test
fun `top left in image becomes bottom left in PDF`() {
val converter = OcrCoordinateConverter(
imageWidth = 1000, imageHeight = 2000,
pageWidth = 500f, pageHeight = 1000f
)
val result = converter.convert(ImageRect(0, 0, 100, 200))
assertThat(result.x).isEqualTo(0f)
assertThat(result.y).isEqualTo(1000f - 100f)
assertThat(result.width).isEqualTo(50f)
assertThat(result.height).isEqualTo(100f)
}
@Test
fun `bottom right in image becomes top right in PDF`() {
val converter = OcrCoordinateConverter(
imageWidth = 1000, imageHeight = 2000,
pageWidth = 500f, pageHeight = 1000f
)
val result = converter.convert(ImageRect(900, 1800, 1000, 2000))
assertThat(result.x).isEqualTo(450f)
assertThat(result.y).isEqualTo(0f)
assertThat(result.width).isEqualTo(50f)
assertThat(result.height).isEqualTo(100f)
}
}
+1
View File
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}