chore: import upstream snapshot with attribution
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:59:26 +08:00
commit e904b667c6
2490 changed files with 596352 additions and 0 deletions
@@ -0,0 +1,19 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.benchmark
object BenchmarkFixtures {
const val defaultReferenceImageName = "android_ocr_benchmark_reference.jpg"
}
@@ -0,0 +1,374 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.benchmark
import android.os.Build
import android.os.Debug
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.paddle.ocr.EngineConfig
import com.paddle.ocr.PaddleOCR
import com.paddle.ocr.PaddleOCRConfig
import com.paddle.ocr.model.OCRRunResult
import com.paddle.ocr.util.OpenCVUtils
import kotlinx.coroutines.runBlocking
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.pow
import kotlin.math.sqrt
@RunWith(AndroidJUnit4::class)
class OCRBenchmarkTest {
companion object {
private const val TAG = "OCRBenchmark"
// Instrumentation arguments (passed via -Pandroid.testInstrumentationRunnerArguments.*)
private const val ARG_WARMUP = "warmup"
private const val ARG_ITERATIONS = "iterations"
private const val ARG_REC_BATCH_SIZE = "rec_batch_size"
private const val DEFAULT_WARMUP = 3
private const val DEFAULT_ITERATIONS = 10
private lateinit var ocr: PaddleOCR
private lateinit var outputDir: File
@JvmStatic
@BeforeClass
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
OpenCVUtils.init(context)
outputDir = File(context.getExternalFilesDir(null), "ocr_benchmark")
outputDir.mkdirs()
println("[OCRBenchmark] Output dir: ${outputDir.absolutePath}")
runBlocking {
val batchSize = getInstrumentationArgument(ARG_REC_BATCH_SIZE)?.toIntOrNull() ?: 1
val config = PaddleOCRConfig(recBatchSize = batchSize)
ocr = PaddleOCR.create(context, config, EngineConfig())
}
println("[OCRBenchmark] Engine ready, coldLoadTimeMs=${ocr.coldLoadTimeMs}")
}
private fun getInstrumentationArgument(key: String): String? {
val args = InstrumentationRegistry.getArguments()
return args[key] as? String
}
private fun saveAndPrint(filename: String, json: JSONObject) {
val file = File(outputDir, filename)
file.writeText(json.toString(2))
println("[OCRBenchmark] Saved: ${file.absolutePath}")
println("[OCRBenchmark] ---- $filename ----")
json.toString(2).lines().forEach { println("[OCRBenchmark] $it") }
println("[OCRBenchmark] ---- end ----")
}
}
// ========================================================================
// Accuracy
// ========================================================================
@Test
fun testOCRExportJSON() {
val imageBytes = loadBenchmarkImageBytes()
val result = runBlocking { ocr.recognize(imageBytes) }
val payload = buildExportPayload(result)
saveAndPrint("accuracy_export.json", payload)
assertTrue("Should have results", payload.getJSONArray("items").length() > 0)
assertTrue("Should detect text", result.lineCount > 0)
}
// ========================================================================
// Latency
// ========================================================================
@Test
fun testLatencyBenchmark() {
val imageBytes = loadBenchmarkImageBytes()
val warmup = getInstrumentationArgument(ARG_WARMUP)?.toIntOrNull() ?: DEFAULT_WARMUP
val iterations = getInstrumentationArgument(ARG_ITERATIONS)?.toIntOrNull() ?: DEFAULT_ITERATIONS
println("[OCRBenchmark] Warming up $warmup iterations...")
runBlocking { repeat(warmup) { ocr.recognize(imageBytes) } }
println("[OCRBenchmark] Measuring $iterations iterations...")
val totals = mutableListOf<Long>()
val dets = mutableListOf<Long>()
val detPre = mutableListOf<Long>()
val detInf = mutableListOf<Long>()
val detPost = mutableListOf<Long>()
val recs = mutableListOf<Long>()
val recPre = mutableListOf<Long>()
val recInf = mutableListOf<Long>()
val recPost = mutableListOf<Long>()
val overheads = mutableListOf<Long>()
val perLineMs = mutableListOf<Long>()
val detShapes = mutableListOf<List<Int>>()
val recShapes = mutableListOf<List<Int>>()
var firstLineCount = 0
runBlocking {
repeat(iterations) { i ->
val r = ocr.recognize(imageBytes)
if (i == 0) firstLineCount = r.lineCount
totals.add(r.totalTimeMs)
dets.add(r.detectionTimeMs)
detPre.add(r.detPreprocessMs)
detInf.add(r.detInferenceMs)
detPost.add(r.detPostprocessMs)
recs.add(r.recognitionTimeMs)
recPre.add(r.recPreprocessMs)
recInf.add(r.recInferenceMs)
recPost.add(r.recPostprocessMs)
overheads.add(r.pipelineOverheadMs)
detShapes.add(r.detInputShape)
recShapes.addAll(r.recInputShapes)
perLineMs.addAll(r.perLineRecMs)
if ((i + 1) % 5 == 0) println("[OCRBenchmark] $i/${iterations}...")
}
}
val memInfo = Debug.MemoryInfo()
Debug.getMemoryInfo(memInfo)
val payload = JSONObject().apply {
put("schemaVersion", 1)
put("deviceModel", Build.MODEL)
put("osVersion", Build.VERSION.RELEASE)
put("ortExecutionProvider", "CPU")
put("inputImage", BenchmarkFixtures.defaultReferenceImageName)
put("inputBytes", imageBytes.size)
put("warmupIterations", warmup)
put("measuredIterations", iterations)
put("firstMeasuredLineCount", firstLineCount)
put("coldLoadTimeMs", ocr.coldLoadTimeMs)
if (totals.isNotEmpty()) {
put("firstMeasuredRun", JSONObject().apply {
put("totalTimeMs", totals.first())
put("detectionTimeMs", dets.first())
put("recognitionTimeMs", recs.first())
put("pipelineOverheadTimeMs", overheads.first())
})
}
put("totalTimeMs", summarizeMs(totals).toJson())
put("detectionTimeMs", summarizeMs(dets).toJson())
put("detectionPreprocessTimeMs", summarizeMs(detPre).toJson())
put("detectionInferenceTimeMs", summarizeMs(detInf).toJson())
put("detectionPostprocessTimeMs", summarizeMs(detPost).toJson())
put("recognitionTimeMs", summarizeMs(recs).toJson())
put("recognitionPreprocessTimeMs", summarizeMs(recPre).toJson())
put("recognitionInferenceTimeMs", summarizeMs(recInf).toJson())
put("recognitionPostprocessTimeMs", summarizeMs(recPost).toJson())
put("pipelineOverheadTimeMs", summarizeMs(overheads).toJson())
put("memoryPssKb", memInfo.totalPss)
put("inputShapeDistribution", JSONObject().apply {
put("detection", summarizeShapes(detShapes))
put("recognition", summarizeShapes(recShapes))
})
if (perLineMs.isNotEmpty()) {
put("recognitionPerLine", JSONObject().apply {
put("pooled", JSONObject().apply {
put("count", perLineMs.size)
put("totalMs", summarizeMs(perLineMs).toJson())
})
})
}
}
saveAndPrint("latency_benchmark.json", payload)
printSpeedSummary(warmup, iterations, totals, dets, detPre, detInf, detPost, recs, recPre, recInf, recPost, overheads, firstLineCount)
assertTrue("Should complete all iterations", totals.size == iterations)
assertTrue("Should detect text", firstLineCount > 0)
}
private fun printSpeedSummary(
warmup: Int,
iterations: Int,
totals: List<Long>,
dets: List<Long>,
detPre: List<Long>,
detInf: List<Long>,
detPost: List<Long>,
recs: List<Long>,
recPre: List<Long>,
recInf: List<Long>,
recPost: List<Long>,
overheads: List<Long>,
lineCount: Int,
) {
val bar = "+-----------------------------+----------+----------+----------+----------+"
val header = "| Stage | Mean ms | Stdev | P90 | Min ms |"
fun row(label: String, samples: List<Long>): String {
if (samples.isEmpty()) return "| %-27s | %8s | %8s | %8s | %8s |".format(label, "-", "-", "-", "N/A")
val mean = samples.map { it.toDouble() }.average()
val variance = if (samples.size > 1) samples.map { (it - mean).pow(2) }.average() else 0.0
val stdev = sqrt(variance)
val sorted = samples.sorted()
val p90 = sorted[(0.9 * (sorted.size - 1)).toInt()]
val min = sorted.first()
return "| %-27s | %8.2f | %8.2f | %8d | %8d |".format(label, mean, stdev, p90, min)
}
println("[OCRBenchmark] ")
println("[OCRBenchmark] ╔════════════════════════════════════════════════════════════════════════════════╗")
println("[OCRBenchmark] ║ PP-OCRv6 Speed Benchmark Results ║")
println("[OCRBenchmark] ╠════════════════════════════════════════════════════════════════════════════════╣")
println("[OCRBenchmark] ║ Device: ${Build.MODEL} | OS: Android ${Build.VERSION.RELEASE} | Lines: $lineCount")
println("[OCRBenchmark] ║ Cold load: ${ocr.coldLoadTimeMs}ms | Warmup: $warmup | Measured: $iterations")
println("[OCRBenchmark] ╠════════════════════════════════════════════════════════════════════════════════╣")
println("[OCRBenchmark] $bar")
println("[OCRBenchmark] $header")
println("[OCRBenchmark] $bar")
println("[OCRBenchmark] ${row("Total pipeline", totals)}")
println("[OCRBenchmark] $bar")
println("[OCRBenchmark] ${row(" Detection (total)", dets)}")
println("[OCRBenchmark] ${row(" Preprocess", detPre)}")
println("[OCRBenchmark] ${row(" Inference", detInf)}")
println("[OCRBenchmark] ${row(" Postprocess", detPost)}")
println("[OCRBenchmark] ${row(" Recognition (total)", recs)}")
println("[OCRBenchmark] ${row(" Preprocess", recPre)}")
println("[OCRBenchmark] ${row(" Inference", recInf)}")
println("[OCRBenchmark] ${row(" Postprocess", recPost)}")
println("[OCRBenchmark] ${row(" Pipeline overhead", overheads)}")
println("[OCRBenchmark] $bar")
println("[OCRBenchmark] ╚════════════════════════════════════════════════════════════════════════════════╝")
println("[OCRBenchmark] ")
}
// ========================================================================
// Memory
// ========================================================================
@Test
fun testMemoryBenchmark() {
val imageBytes = loadBenchmarkImageBytes()
val warmup = getInstrumentationArgument(ARG_WARMUP)?.toIntOrNull() ?: DEFAULT_WARMUP
val iterations = getInstrumentationArgument(ARG_ITERATIONS)?.toIntOrNull() ?: DEFAULT_ITERATIONS
runBlocking { repeat(warmup) { ocr.recognize(imageBytes) } }
Runtime.getRuntime().gc()
Thread.sleep(500)
val before = Debug.MemoryInfo()
Debug.getMemoryInfo(before)
runBlocking { repeat(iterations) { ocr.recognize(imageBytes) } }
val after = Debug.MemoryInfo()
Debug.getMemoryInfo(after)
val payload = JSONObject().apply {
put("schemaVersion", 1)
put("deviceModel", Build.MODEL)
put("iterations", iterations)
put("memoryBeforePssKb", before.totalPss)
put("memoryAfterPssKb", after.totalPss)
put("memoryDeltaPssKb", after.totalPss - before.totalPss)
put("nativeBeforeKb", before.nativePss)
put("nativeAfterKb", after.nativePss)
put("dalvikBeforeKb", before.dalvikPss)
put("dalvikAfterKb", after.dalvikPss)
}
saveAndPrint("memory_benchmark.json", payload)
assertTrue("Memory should be measurable", after.totalPss > 0)
}
// ========================================================================
// Helpers
// ========================================================================
private fun loadBenchmarkImageBytes(): ByteArray {
val context = InstrumentationRegistry.getInstrumentation().context
val stem = BenchmarkFixtures.defaultReferenceImageName.substringBeforeLast(".")
val resId = context.resources.getIdentifier(stem, "raw", context.packageName)
if (resId == 0) {
throw IllegalStateException(
"Missing ${BenchmarkFixtures.defaultReferenceImageName} " +
"(package=${context.packageName})"
)
}
return context.resources.openRawResource(resId).use { it.readBytes() }
}
data class TimingSummary(val mean: Double, val stdev: Double, val p90: Double) {
fun toJson() = JSONObject().apply {
put("mean", mean)
put("stdev", stdev)
put("p90", p90)
}
}
private fun summarizeMs(samples: List<Long>): TimingSummary {
if (samples.isEmpty()) return TimingSummary(0.0, 0.0, 0.0)
val doubles = samples.map { it.toDouble() }
val mean = doubles.average()
val variance = if (doubles.size > 1)
doubles.map { (it - mean).pow(2) }.average()
else 0.0
val stdev = sqrt(variance)
val sorted = doubles.sorted()
val p90Idx = (0.9 * (sorted.size - 1)).toInt().coerceIn(0, sorted.size - 1)
return TimingSummary(mean, stdev, sorted[p90Idx])
}
private fun summarizeShapes(shapes: List<List<Int>>): JSONArray {
val counts = mutableMapOf<String, Int>()
shapes.forEach { shape ->
val key = shape.joinToString(",")
counts[key] = (counts[key] ?: 0) + 1
}
return JSONArray().apply {
counts.entries.sortedByDescending { it.value }.forEach { (shape, count) ->
put(JSONObject().apply {
put("shape", JSONArray(shape.split(",").map { it.toInt() }))
put("count", count)
})
}
}
}
private fun buildExportPayload(result: OCRRunResult): JSONObject {
val items = JSONArray().apply {
result.results.forEach { r ->
put(JSONObject().apply {
put("polygon", JSONArray().apply {
r.box.points.forEach { pt ->
put(JSONArray(listOf(pt.x.toInt(), pt.y.toInt())))
}
})
put("text", r.text)
put("score", r.confidence.toDouble())
})
}
}
return JSONObject().apply {
put("schemaVersion", 1)
put("source", "android_ocr_demo")
put("items", items)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,19 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr
data class EngineConfig(
val numThreads: Int = 4,
)
@@ -0,0 +1,118 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr
import android.content.Context
import android.graphics.Bitmap
import com.paddle.ocr.engine.OCREngine
import com.paddle.ocr.engine.OCREngineResult
import com.paddle.ocr.model.OCRRunResult
import com.paddle.ocr.model.OCRError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class PaddleOCR private constructor(
private val engine: OCREngine,
) {
/** Time spent loading ONNX models (milliseconds). */
val coldLoadTimeMs: Long get() = engine.coldLoadTimeMs
companion object {
suspend fun create(context: Context): PaddleOCR {
val appContext = context.applicationContext
return withContext(Dispatchers.IO) {
val engine = OCREngine(appContext, PaddleOCRConfig(), EngineConfig())
PaddleOCR(engine)
}
}
suspend fun create(
context: Context,
config: PaddleOCRConfig,
engineConfig: EngineConfig = EngineConfig(),
): PaddleOCR {
val appContext = context.applicationContext
return withContext(Dispatchers.IO) {
val engine = OCREngine(appContext, config, engineConfig)
PaddleOCR(engine)
}
}
suspend fun create(
context: Context,
config: PaddleOCRConfig,
engineConfig: EngineConfig,
detModelAssetPath: String,
recModelAssetPath: String,
recConfigAssetPath: String,
): PaddleOCR {
val appContext = context.applicationContext
return withContext(Dispatchers.IO) {
val engine = OCREngine(
appContext, config, engineConfig,
detModelAsset = detModelAssetPath,
recModelAsset = recModelAssetPath,
recConfigAsset = recConfigAssetPath,
)
PaddleOCR(engine)
}
}
}
suspend fun recognize(bitmap: Bitmap): OCRRunResult {
if (bitmap.width == 0 || bitmap.height == 0) {
throw OCRError.InvalidImage()
}
return recognizeResult { engine.run(bitmap) }
}
suspend fun recognize(imageBytes: ByteArray): OCRRunResult {
if (imageBytes.isEmpty()) {
throw OCRError.InvalidImage()
}
return recognizeResult { engine.run(imageBytes) }
}
private suspend fun recognizeResult(runEngine: () -> OCREngineResult): OCRRunResult {
return withContext(Dispatchers.IO) {
val result = runEngine()
OCRRunResult(
results = result.results,
detectionTimeMs = result.detectionTimeMs,
recognitionTimeMs = result.recognitionTimeMs,
totalTimeMs = result.totalTimeMs,
lineCount = result.lineCount,
detPreprocessMs = result.detPreprocessMs,
detInferenceMs = result.detInferenceMs,
detPostprocessMs = result.detPostprocessMs,
recPreprocessMs = result.recPreprocessMs,
recInferenceMs = result.recInferenceMs,
recPostprocessMs = result.recPostprocessMs,
pipelineOverheadMs = result.pipelineOverheadMs,
coldLoadTimeMs = result.coldLoadTimeMs,
detInputShape = result.detInputShape,
recInputShapes = result.recInputShapes,
perLineRecMs = result.perLineRecMs,
)
}
}
suspend fun release() {
withContext(Dispatchers.IO) {
engine.release()
}
}
}
@@ -0,0 +1,31 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr
data class PaddleOCRConfig(
val detImgMode: String = "BGR",
val detLimitSideLen: Int = 64,
val detLimitType: String = "min",
val detMaxSideLimit: Int = 4000,
val detThresh: Float = 0.3f,
val detBoxThresh: Float = 0.6f,
val detUnclipRatio: Float = 1.5f,
val detMaxCandidates: Int = 3000,
val detUseDilation: Boolean = false,
val detScoreMode: String = "fast",
val detBoxType: String = "quad",
val recScoreThresh: Float = 0.0f,
val recBatchSize: Int = 1,
)
@@ -0,0 +1,96 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.engine
import android.graphics.Bitmap
import com.paddle.ocr.PaddleOCRConfig
import com.paddle.ocr.model.OCRBox
import com.paddle.ocr.postprocess.DBPostProcessor
import com.paddle.ocr.preprocess.DetPreprocessResult
import com.paddle.ocr.preprocess.DetPreprocessor
import org.opencv.core.Mat
class DetectionEngine(
private val ortManager: ORTSessionManager,
private val config: PaddleOCRConfig,
) {
data class DetectionResult(
val boxes: List<OCRBox>,
val preprocessMs: Long,
val inferenceMs: Long,
val postprocessMs: Long,
val timeMs: Long,
val inputShape: List<Int>,
)
fun detect(bitmap: Bitmap): DetectionResult {
return detect {
DetPreprocessor.preprocess(
bitmap,
config.detLimitSideLen,
config.detLimitType,
config.detMaxSideLimit,
config.detImgMode,
)
}
}
fun detect(src: Mat): DetectionResult {
return detect {
DetPreprocessor.preprocess(
src,
config.detLimitSideLen,
config.detLimitType,
config.detMaxSideLimit,
config.detImgMode,
)
}
}
private fun detect(preprocessor: () -> DetPreprocessResult): DetectionResult {
val preStart = System.currentTimeMillis()
val preResult = preprocessor()
val preprocessMs = System.currentTimeMillis() - preStart
val infStart = System.currentTimeMillis()
val (outputData, outputShape) = ortManager.runDetection(preResult.tensorData, preResult.shape)
val inferenceMs = System.currentTimeMillis() - infStart
val postStart = System.currentTimeMillis()
val boxes = DBPostProcessor.process(
pred = outputData,
predShape = outputShape,
thresh = config.detThresh,
boxThresh = config.detBoxThresh,
unclipRatio = config.detUnclipRatio,
maxCandidates = config.detMaxCandidates,
useDilation = config.detUseDilation,
scoreMode = config.detScoreMode,
boxType = config.detBoxType,
originalH = preResult.originalH,
originalW = preResult.originalW,
)
val postprocessMs = System.currentTimeMillis() - postStart
return DetectionResult(
boxes = boxes,
preprocessMs = preprocessMs,
inferenceMs = inferenceMs,
postprocessMs = postprocessMs,
timeMs = preprocessMs + inferenceMs + postprocessMs,
inputShape = listOf(1, 3, preResult.shape[2].toInt(), preResult.shape[3].toInt()),
)
}
}
@@ -0,0 +1,184 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.engine
import android.content.Context
import android.graphics.Bitmap
import com.paddle.ocr.EngineConfig
import com.paddle.ocr.PaddleOCRConfig
import com.paddle.ocr.model.ModelConfig
import com.paddle.ocr.model.OCRError
import com.paddle.ocr.model.OCRResult
import com.paddle.ocr.postprocess.BoxSorter
import com.paddle.ocr.postprocess.QuadTextCrop
import com.paddle.ocr.util.BitmapUtils
class OCREngine(
context: Context,
private val config: PaddleOCRConfig,
engineConfig: EngineConfig,
detModelAsset: String = "models/det/inference.onnx",
recModelAsset: String = "models/rec/inference.onnx",
recConfigAsset: String = "models/rec/inference.yml",
) {
private val ortManager = ORTSessionManager(context, engineConfig)
private val detectionEngine: DetectionEngine
private val recognitionEngine: RecognitionEngine
val coldLoadTimeMs: Long get() = ortManager.coldLoadTimeMs
init {
val configured = try {
ortManager.loadModels(detModelAsset, recModelAsset)
val recModelConfig = ModelConfig.parse(context, recConfigAsset)
recModelConfig
} catch (t: Throwable) {
ortManager.release()
throw t
}
detectionEngine = DetectionEngine(ortManager, config)
recognitionEngine = RecognitionEngine(ortManager, configured.characterList)
}
fun run(bitmap: Bitmap): OCREngineResult {
val srcMat = BitmapUtils.bitmapToBGRMat(bitmap)
return runWithOwnedMat(srcMat)
}
fun run(imageBytes: ByteArray): OCREngineResult {
val srcMat = BitmapUtils.imdecodeBGR(imageBytes)
if (srcMat.empty()) {
srcMat.release()
throw OCRError.InvalidImage()
}
return runWithOwnedMat(srcMat)
}
private fun runWithOwnedMat(srcMat: org.opencv.core.Mat): OCREngineResult {
return try {
run(srcMat)
} finally {
srcMat.release()
}
}
private fun run(srcMat: org.opencv.core.Mat): OCREngineResult {
val totalStart = System.currentTimeMillis()
val detResult = detectionEngine.detect(srcMat)
val boxes = detResult.boxes
if (boxes.isEmpty()) {
val elapsed = System.currentTimeMillis() - totalStart
return OCREngineResult(
results = emptyList(),
detectionTimeMs = detResult.timeMs,
recognitionTimeMs = 0,
totalTimeMs = elapsed,
lineCount = 0,
detPreprocessMs = detResult.preprocessMs,
detInferenceMs = detResult.inferenceMs,
detPostprocessMs = detResult.postprocessMs,
detInputShape = detResult.inputShape,
coldLoadTimeMs = ortManager.coldLoadTimeMs,
)
}
// 2. Sort boxes
val sortedBoxes = BoxSorter.sortInReadingOrder(boxes)
// 3. Crop and recognize text regions
var totalRecPreMs = 0L
var totalRecInfMs = 0L
var totalRecPostMs = 0L
var totalRecMs = 0L
val allResults = mutableListOf<OCRResult>()
val recInputShapes = mutableListOf<List<Int>>()
val perLineRecMs = mutableListOf<Long>()
val batchSize = config.recBatchSize.coerceAtLeast(1)
var i = 0
while (i < sortedBoxes.size) {
val batchCrops = mutableListOf<org.opencv.core.Mat>()
val batchBoxIndices = mutableListOf<Int>()
var next = i
while (next < sortedBoxes.size && batchCrops.size < batchSize) {
val crop = QuadTextCrop.crop(srcMat, sortedBoxes[next])
if (crop.rows() > 0 && crop.cols() > 0) {
batchCrops.add(crop)
batchBoxIndices.add(next)
} else {
crop.release()
}
next++
}
try {
if (batchCrops.isNotEmpty()) {
val batchResult = recognitionEngine.recognize(batchCrops)
totalRecPreMs += batchResult.preprocessMs
totalRecInfMs += batchResult.inferenceMs
totalRecPostMs += batchResult.postprocessMs
totalRecMs += batchResult.timeMs
recInputShapes.add(batchResult.inputShape)
if (batchSize == 1) {
perLineRecMs.add(batchResult.timeMs)
}
for (j in batchResult.texts.indices) {
val boxIdx = batchBoxIndices[j]
val (text, confidence) = batchResult.texts[j]
if (confidence >= config.recScoreThresh) {
allResults.add(
OCRResult(
box = sortedBoxes[boxIdx],
text = text,
confidence = confidence,
)
)
}
}
}
} finally {
batchCrops.forEach { it.release() }
}
i = next
}
val totalElapsed = System.currentTimeMillis() - totalStart
val pipelineOverhead = totalElapsed - detResult.timeMs - totalRecMs
return OCREngineResult(
results = allResults,
detectionTimeMs = detResult.timeMs,
recognitionTimeMs = totalRecMs,
totalTimeMs = totalElapsed,
lineCount = allResults.size,
detPreprocessMs = detResult.preprocessMs,
detInferenceMs = detResult.inferenceMs,
detPostprocessMs = detResult.postprocessMs,
recPreprocessMs = totalRecPreMs,
recInferenceMs = totalRecInfMs,
recPostprocessMs = totalRecPostMs,
pipelineOverheadMs = pipelineOverhead,
coldLoadTimeMs = ortManager.coldLoadTimeMs,
detInputShape = detResult.inputShape,
recInputShapes = recInputShapes,
perLineRecMs = perLineRecMs,
)
}
fun release() {
ortManager.release()
}
}
@@ -0,0 +1,39 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.engine
import com.paddle.ocr.model.OCRResult
data class OCREngineResult(
val results: List<OCRResult>,
val detectionTimeMs: Long,
val recognitionTimeMs: Long,
val totalTimeMs: Long,
val lineCount: Int,
// Detailed per-stage timing
val detPreprocessMs: Long = 0,
val detInferenceMs: Long = 0,
val detPostprocessMs: Long = 0,
val recPreprocessMs: Long = 0,
val recInferenceMs: Long = 0,
val recPostprocessMs: Long = 0,
val pipelineOverheadMs: Long = 0,
val coldLoadTimeMs: Long = 0,
// Input tensor shapes
val detInputShape: List<Int> = emptyList(),
val recInputShapes: List<List<Int>> = emptyList(),
// Per-line timing (only populated when recBatchSize == 1)
val perLineRecMs: List<Long> = emptyList(),
)
@@ -0,0 +1,161 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.engine
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import com.paddle.ocr.EngineConfig
import com.paddle.ocr.model.OCRError
import java.nio.FloatBuffer
class ORTSessionManager(
private val context: Context,
private val config: EngineConfig,
) {
private var env: OrtEnvironment? = null
private var detSession: OrtSession? = null
private var recSession: OrtSession? = null
private var detInputName: String = "x"
private var recInputName: String = "x"
var coldLoadTimeMs: Long = 0
private set
fun loadModels(detAssetPath: String, recAssetPath: String) {
val loadStart = System.currentTimeMillis()
env = OrtEnvironment.getEnvironment()
val opts = OrtSession.SessionOptions().apply {
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT)
setIntraOpNumThreads(config.numThreads)
}
try {
val ortEnv = env ?: throw OCRError.ModelLoadFailed("OCR", Exception("Environment not initialized"))
val detBytes = readModelAsset(detAssetPath)
val recBytes = readModelAsset(recAssetPath)
try {
detSession = ortEnv.createSession(detBytes, opts)
} catch (t: Throwable) {
throw OCRError.ModelLoadFailed("detection", t)
}
try {
recSession = ortEnv.createSession(recBytes, opts)
} catch (t: Throwable) {
detSession?.close()
detSession = null
throw OCRError.ModelLoadFailed("recognition", t)
}
detInputName = try {
detSession!!.inputNames.iterator().next()
} catch (t: Throwable) {
throw OCRError.ModelLoadFailed("detection", t)
}
recInputName = try {
recSession!!.inputNames.iterator().next()
} catch (t: Throwable) {
throw OCRError.ModelLoadFailed("recognition", t)
}
coldLoadTimeMs = System.currentTimeMillis() - loadStart
} finally {
opts.close()
}
}
fun runDetection(input: FloatArray, shape: LongArray): Pair<FloatArray, LongArray> {
val session = detSession
?: throw OCRError.ModelLoadFailed("detection", Exception("Session not initialized"))
val ortEnv = env
?: throw OCRError.ModelLoadFailed("detection", Exception("Environment not initialized"))
return runSession(ortEnv, session, detInputName, input, shape, "detection")
}
fun runRecognition(input: FloatArray, shape: LongArray): Pair<FloatArray, LongArray> {
val session = recSession
?: throw OCRError.ModelLoadFailed("recognition", Exception("Session not initialized"))
val ortEnv = env
?: throw OCRError.ModelLoadFailed("recognition", Exception("Environment not initialized"))
return runSession(ortEnv, session, recInputName, input, shape, "recognition")
}
fun release() {
try {
detSession?.close()
} finally {
detSession = null
try {
recSession?.close()
} finally {
recSession = null
env = null
}
}
}
private fun readModelAsset(assetPath: String): ByteArray {
return try {
context.assets.open(assetPath).use { it.readBytes() }
} catch (t: Throwable) {
throw OCRError.ModelNotFound(assetPath, t)
}
}
private fun runSession(
ortEnv: OrtEnvironment,
session: OrtSession,
inputName: String,
input: FloatArray,
shape: LongArray,
modelName: String,
): Pair<FloatArray, LongArray> {
val tensor = try {
OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(input), shape)
} catch (t: Throwable) {
throw OCRError.InferenceFailed(modelName, t)
}
val result = try {
try {
session.run(mapOf(inputName to tensor))
} catch (t: Throwable) {
throw OCRError.InferenceFailed(modelName, t)
}
} finally {
tensor.close()
}
return try {
try {
val outputName = session.outputNames.iterator().next()
val ortValue = result.get(outputName)
.orElseThrow { Exception("No output tensor found") }
val outputTensor = ortValue as? OnnxTensor
?: throw Exception("Output is not an ONNX tensor")
Pair(copyFloatBuffer(outputTensor.floatBuffer), outputTensor.info.shape)
} catch (t: Throwable) {
throw OCRError.InferenceFailed(modelName, t)
}
} finally {
result.close()
}
}
private fun copyFloatBuffer(buffer: FloatBuffer): FloatArray {
val duplicate = buffer.duplicate()
duplicate.rewind()
val output = FloatArray(duplicate.remaining())
duplicate.get(output)
return output
}
}
@@ -0,0 +1,61 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.engine
import com.paddle.ocr.postprocess.CTCDecoder
import com.paddle.ocr.preprocess.RecPreprocessor
import org.opencv.core.Mat
class RecognitionEngine(
private val ortManager: ORTSessionManager,
private val characterList: List<String>,
) {
data class RecognitionResult(
val texts: List<Pair<String, Float>>,
val preprocessMs: Long,
val inferenceMs: Long,
val postprocessMs: Long,
val timeMs: Long,
val inputShape: List<Int>,
)
fun recognize(crops: List<Mat>): RecognitionResult {
// Preprocess
val preStart = System.currentTimeMillis()
val preResult = RecPreprocessor.preprocessBatch(crops)
val preprocessMs = System.currentTimeMillis() - preStart
// Inference
val infStart = System.currentTimeMillis()
val (outputData, outputShape) = ortManager.runRecognition(preResult.tensorData, preResult.shape)
val inferenceMs = System.currentTimeMillis() - infStart
// Postprocess (CTC decode)
val postStart = System.currentTimeMillis()
val decoded = CTCDecoder.decode(outputData, outputShape, characterList)
val postprocessMs = System.currentTimeMillis() - postStart
val inputShape = preResult.shape.map { it.toInt() }
val timeMs = preprocessMs + inferenceMs + postprocessMs
return RecognitionResult(
texts = decoded,
preprocessMs = preprocessMs,
inferenceMs = inferenceMs,
postprocessMs = postprocessMs,
timeMs = timeMs,
inputShape = inputShape,
)
}
}
@@ -0,0 +1,123 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.model
import android.content.Context
import com.paddle.ocr.util.YamlUtils
data class ModelConfig(
val characterList: List<String>,
) {
companion object {
fun parse(context: Context, assetPath: String): ModelConfig {
val content = try {
context.assets.open(assetPath).bufferedReader().use { it.readText() }
} catch (t: Throwable) {
throw OCRError.ConfigParseFailed(assetPath, t)
}
val characterDict = try {
extractCharacterDict(content, assetPath)
} catch (e: OCRError.ConfigParseFailed) {
throw e
} catch (t: Throwable) {
throw OCRError.ConfigParseFailed(assetPath, t)
}
val charListWithSpace = characterDict.toMutableList().apply {
if (lastOrNull() != " ") add(" ")
}
return ModelConfig(characterList = charListWithSpace)
}
private fun extractCharacterDict(content: String, assetPath: String): List<String> {
val lines = content.replace("\r\n", "\n").replace('\r', '\n').lines()
val postProcessLine = lines.indexOfFirst { it.trim() == "PostProcess:" }
if (postProcessLine < 0) {
throw OCRError.ConfigParseFailed("Missing PostProcess in $assetPath")
}
val postProcessIndent = YamlUtils.leadingSpaces(lines[postProcessLine])
val characterDictLine = findCharacterDictLine(lines, postProcessLine + 1, postProcessIndent)
if (characterDictLine < 0) {
throw OCRError.ConfigParseFailed("Missing character_dict in $assetPath")
}
val characterDictIndent = YamlUtils.leadingSpaces(lines[characterDictLine])
val characters = mutableListOf<String>()
var lineIndex = characterDictLine + 1
while (lineIndex < lines.size) {
val line = lines[lineIndex]
val indent = YamlUtils.leadingSpaces(line)
val content = line.substring(indent)
val keyLikeContent = content.trim()
if (keyLikeContent.isEmpty() || keyLikeContent.startsWith("#")) {
lineIndex++
continue
}
if (!content.startsWith("-")) {
if (indent <= characterDictIndent) break
lineIndex++
continue
}
characters.add(parseYamlListScalar(content.substring(1)))
lineIndex++
}
if (characters.isEmpty()) {
throw OCRError.ConfigParseFailed("Empty character_dict in $assetPath")
}
return characters
}
private fun findCharacterDictLine(
lines: List<String>,
startLine: Int,
postProcessIndent: Int,
): Int {
var lineIndex = startLine
while (lineIndex < lines.size) {
val line = lines[lineIndex]
val trimmed = line.trim()
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
lineIndex++
continue
}
val indent = YamlUtils.leadingSpaces(line)
if (indent <= postProcessIndent) break
if (trimmed == "character_dict:") return lineIndex
lineIndex++
}
return -1
}
private fun parseYamlListScalar(rawValue: String): String {
val value = rawValue.dropWhile { it == ' ' }
if (value.length >= 2 && value.first() == '\'' && value.last() == '\'') {
return value.substring(1, value.length - 1).replace("''", "'")
}
if (value.length >= 2 && value.first() == '"' && value.last() == '"') {
return value.substring(1, value.length - 1)
.replace("\\\"", "\"")
.replace("\\\\", "\\")
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
}
return value
}
}
}
@@ -0,0 +1,25 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.model
import android.graphics.PointF
data class OCRBox(
val points: List<PointF>,
) {
init {
require(points.size == 4) { "OCRBox must have exactly 4 points, got ${points.size}" }
}
}
@@ -0,0 +1,24 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.model
sealed class OCRError(message: String, cause: Throwable? = null) : Exception(message, cause) {
class ModelNotFound(modelPath: String, cause: Throwable? = null) : OCRError("Model not found: $modelPath", cause)
class ModelLoadFailed(modelName: String, cause: Throwable) : OCRError("Failed to load $modelName model", cause)
class ConfigParseFailed(path: String, cause: Throwable? = null) : OCRError("Failed to parse config: $path", cause)
class InvalidImage : OCRError("Input image is empty or invalid")
class InferenceFailed(stage: String, cause: Throwable) : OCRError("Inference failed at stage '$stage'", cause)
class DecodeError(message: String, cause: Throwable? = null) : OCRError(message, cause)
}
@@ -0,0 +1,22 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.model
data class OCRResult(
val box: OCRBox,
val text: String,
val confidence: Float,
val wordBoxes: List<OCRBox>? = null,
)
@@ -0,0 +1,37 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.model
data class OCRRunResult(
val results: List<OCRResult>,
val detectionTimeMs: Long,
val recognitionTimeMs: Long,
val totalTimeMs: Long,
val lineCount: Int,
// Detailed per-stage timing
val detPreprocessMs: Long = 0,
val detInferenceMs: Long = 0,
val detPostprocessMs: Long = 0,
val recPreprocessMs: Long = 0,
val recInferenceMs: Long = 0,
val recPostprocessMs: Long = 0,
val pipelineOverheadMs: Long = 0,
val coldLoadTimeMs: Long = 0,
// Input tensor shapes
val detInputShape: List<Int> = emptyList(),
val recInputShapes: List<List<Int>> = emptyList(),
// Per-line timing (only populated when recBatchSize == 1)
val perLineRecMs: List<Long> = emptyList(),
)
@@ -0,0 +1,47 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.postprocess
import com.paddle.ocr.model.OCRBox
object BoxSorter {
private const val ROW_THRESHOLD_Y = 10f
fun sortInReadingOrder(boxes: List<OCRBox>): List<OCRBox> {
if (boxes.size <= 1) return boxes
val list = boxes.toMutableList()
list.sortWith(compareBy({ it.points[0].y }, { it.points[0].x }))
// Align with PaddleX SortQuadBoxes: bubble left-to-right inside a 10px row band.
for (i in 0 until list.size - 1) {
var j = i
while (j >= 0) {
val next = list[j + 1]
val curr = list[j]
if (kotlin.math.abs(next.points[0].y - curr.points[0].y) < ROW_THRESHOLD_Y &&
next.points[0].x < curr.points[0].x
) {
list[j] = next
list[j + 1] = curr
j--
} else {
break
}
}
}
return list
}
}
@@ -0,0 +1,66 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.postprocess
object CTCDecoder {
private const val BLANK_IDX = 0
fun decode(output: FloatArray, shape: LongArray, characterList: List<String>): List<Pair<String, Float>> {
val batchSize = shape[0].toInt()
val timeSteps = shape[1].toInt()
val numClasses = shape[2].toInt()
val results = mutableListOf<Pair<String, Float>>()
for (b in 0 until batchSize) {
val baseOffset = b * timeSteps * numClasses
val indices = IntArray(timeSteps)
val probs = FloatArray(timeSteps)
for (t in 0 until timeSteps) {
val offset = baseOffset + t * numClasses
var maxIdx = 0
var maxVal = output[offset]
for (c in 1 until numClasses) {
val v = output[offset + c]
if (v > maxVal) {
maxVal = v
maxIdx = c
}
}
indices[t] = maxIdx
probs[t] = maxVal
}
val keptProbs = mutableListOf<Float>()
val sb = StringBuilder()
var prevIdx = -1
for (t in 0 until timeSteps) {
val idx = indices[t]
if (idx != BLANK_IDX && idx != prevIdx) {
val charIdx = idx - 1
if (charIdx >= 0 && charIdx < characterList.size) {
sb.append(characterList[charIdx])
keptProbs.add(probs[t])
}
}
prevIdx = idx
}
val confidence = if (keptProbs.isNotEmpty()) keptProbs.average().toFloat() else 0f
results.add(Pair(sb.toString(), confidence))
}
return results
}
}
@@ -0,0 +1,189 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.postprocess
import android.graphics.PointF
import com.paddle.ocr.model.OCRBox
import com.paddle.ocr.util.MathUtils
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.MatOfPoint
import org.opencv.core.MatOfPoint2f
import org.opencv.core.Point
import org.opencv.core.Scalar
import org.opencv.imgproc.Imgproc
object DBPostProcessor {
private const val MIN_SIZE_BEFORE_UNCLIP = 3f
private const val MIN_SIZE_AFTER_UNCLIP = 5f
fun process(
pred: FloatArray,
predShape: LongArray,
thresh: Float,
boxThresh: Float,
unclipRatio: Float,
maxCandidates: Int,
useDilation: Boolean,
scoreMode: String,
boxType: String,
originalH: Int,
originalW: Int,
): List<OCRBox> {
require(boxType == "quad") { "Only DBPostProcess box_type=quad is supported" }
val pH = predShape[2].toInt()
val pW = predShape[3].toInt()
val scaleX = originalW.toDouble() / pW
val scaleY = originalH.toDouble() / pH
val normalizedScoreMode = scoreMode.lowercase()
val rawProb = Mat(pH, pW, CvType.CV_32FC1)
val mask = Mat(pH, pW, CvType.CV_8UC1)
val contours = mutableListOf<MatOfPoint>()
val hierarchy = Mat()
var contourMask = mask
return try {
rawProb.put(0, 0, pred)
val threshMat = Mat()
try {
Imgproc.threshold(rawProb, threshMat, thresh.toDouble(), 255.0, Imgproc.THRESH_BINARY)
threshMat.convertTo(mask, CvType.CV_8UC1)
} finally {
threshMat.release()
}
contourMask = if (useDilation) {
val kernel = Mat.ones(2, 2, CvType.CV_8UC1)
try {
Mat().also { Imgproc.dilate(mask, it, kernel) }
} finally {
kernel.release()
}
} else {
mask
}
Imgproc.findContours(contourMask, contours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE)
val boxes = mutableListOf<OCRBox>()
val numContours = minOf(contours.size, maxCandidates)
for (index in 0 until numContours) {
val contour = contours[index]
val contour2f = MatOfPoint2f(*contour.toArray())
val rect = try {
Imgproc.minAreaRect(contour2f)
} finally {
contour2f.release()
}
val sside = minOf(rect.size.width, rect.size.height)
if (sside < MIN_SIZE_BEFORE_UNCLIP) continue
val rectPoints = Array(4) { Point() }
rect.points(rectPoints)
val orderedPoints = QuadGeometry.orderMinAreaRectPoints(rectPoints)
val score = if (normalizedScoreMode == "slow") {
computeBoxScore(rawProb, contour.toList())
} else {
computeBoxScore(rawProb, orderedPoints)
}
if (score < boxThresh) continue
val expandedPts = PolygonUnclip.unclip(orderedPoints, unclipRatio)
val expandedRect = MatOfPoint2f().let { expanded2f ->
try {
expanded2f.fromList(expandedPts)
Imgproc.minAreaRect(expanded2f)
} finally {
expanded2f.release()
}
}
val sside2 = minOf(expandedRect.size.width, expandedRect.size.height)
if (sside2 < MIN_SIZE_AFTER_UNCLIP) continue
val expandedBox = Array(4) { Point() }
expandedRect.points(expandedBox)
val ePts = QuadGeometry.orderMinAreaRectPoints(expandedBox)
val scaled = listOf(
scalePoint(ePts[0], scaleX, scaleY, originalW, originalH),
scalePoint(ePts[1], scaleX, scaleY, originalW, originalH),
scalePoint(ePts[2], scaleX, scaleY, originalW, originalH),
scalePoint(ePts[3], scaleX, scaleY, originalW, originalH),
)
val boxW = kotlin.math.hypot(
scaled[1].x - scaled[0].x,
scaled[1].y - scaled[0].y,
)
val boxH = kotlin.math.hypot(
scaled[3].x - scaled[0].x,
scaled[3].y - scaled[0].y,
)
if (boxW <= 3 || boxH <= 3) continue
boxes.add(OCRBox(points = scaled))
}
boxes
} finally {
hierarchy.release()
if (contourMask !== mask) {
contourMask.release()
}
contours.forEach { it.release() }
mask.release()
rawProb.release()
}
}
private fun scalePoint(
point: Point,
scaleX: Double,
scaleY: Double,
originalW: Int,
originalH: Int,
): PointF {
return PointF(
MathUtils.roundHalfToEven(point.x * scaleX).coerceIn(0, originalW).toFloat(),
MathUtils.roundHalfToEven(point.y * scaleY).coerceIn(0, originalH).toFloat(),
)
}
private fun computeBoxScore(probMap: Mat, points: List<Point>): Float {
if (points.isEmpty()) return 0f
val h = probMap.rows()
val w = probMap.cols()
val xmin = points.minOf { kotlin.math.floor(it.x).toInt() }.coerceIn(0, w - 1)
val xmax = points.maxOf { kotlin.math.ceil(it.x).toInt() }.coerceIn(0, w - 1)
val ymin = points.minOf { kotlin.math.floor(it.y).toInt() }.coerceIn(0, h - 1)
val ymax = points.maxOf { kotlin.math.ceil(it.y).toInt() }.coerceIn(0, h - 1)
val mask = Mat(ymax - ymin + 1, xmax - xmin + 1, CvType.CV_8UC1, Scalar(0.0))
val pts = MatOfPoint().apply {
fromList(
points.map { Point((it.x - xmin).toInt().toDouble(), (it.y - ymin).toInt().toDouble()) }
)
}
Imgproc.fillPoly(mask, mutableListOf(pts), Scalar(1.0))
val roi = probMap.submat(ymin, ymax + 1, xmin, xmax + 1)
val mean = Core.mean(roi, mask)
roi.release()
mask.release()
pts.release()
return mean.`val`[0].toFloat()
}
}
@@ -0,0 +1,138 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.postprocess
import org.opencv.core.Point
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.atan2
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
internal object PolygonUnclip {
private const val EPS = 1e-6
private const val ARC_TOLERANCE = 0.25
fun unclip(points: List<Point>, unclipRatio: Float): List<Point> {
if (points.size < 3) return points
val signedArea = signedArea(points)
val area = abs(signedArea)
val perimeter = perimeter(points)
if (!area.isFinite() || !perimeter.isFinite() || area <= EPS || perimeter <= EPS) {
return points
}
val distance = area * unclipRatio / perimeter
if (!distance.isFinite() || distance <= EPS) return points
val clockwiseInImageCoords = signedArea > 0.0
val normals = mutableListOf<Point>()
for (i in points.indices) {
val start = points[i]
val end = points[(i + 1) % points.size]
val dx = end.x - start.x
val dy = end.y - start.y
val len = hypot(dx, dy)
if (!len.isFinite() || len <= EPS) return points
val normal = if (clockwiseInImageCoords) {
Point(dy / len, -dx / len)
} else {
Point(-dy / len, dx / len)
}
normals.add(normal)
}
val expanded = mutableListOf<Point>()
for (i in points.indices) {
appendRoundJoin(
output = expanded,
center = points[i],
fromNormal = normals[(i - 1 + normals.size) % normals.size],
toNormal = normals[i],
distance = distance,
clockwiseInImageCoords = clockwiseInImageCoords,
)
}
return if (expanded.size >= 3) expanded else points
}
private fun appendRoundJoin(
output: MutableList<Point>,
center: Point,
fromNormal: Point,
toNormal: Point,
distance: Double,
clockwiseInImageCoords: Boolean,
) {
var startAngle = atan2(fromNormal.y, fromNormal.x)
var endAngle = atan2(toNormal.y, toNormal.x)
if (clockwiseInImageCoords) {
while (endAngle < startAngle) endAngle += 2.0 * PI
} else {
while (endAngle > startAngle) endAngle -= 2.0 * PI
}
val sweep = endAngle - startAngle
val steps = ceil(abs(sweep) / arcStepAngle(distance)).toInt().coerceAtLeast(1)
for (step in 0..steps) {
val angle = startAngle + sweep * step.toDouble() / steps.toDouble()
appendPoint(
output,
Point(
center.x + cos(angle) * distance,
center.y + sin(angle) * distance,
)
)
}
}
private fun arcStepAngle(distance: Double): Double {
val ratio = (1.0 - ARC_TOLERANCE / distance).coerceIn(-1.0, 1.0)
val step = 2.0 * acos(ratio)
return if (step.isFinite() && step > EPS) step else PI / 8.0
}
private fun appendPoint(output: MutableList<Point>, point: Point) {
val last = output.lastOrNull()
if (last == null || hypot(point.x - last.x, point.y - last.y) > EPS) {
output.add(point)
}
}
private fun signedArea(points: List<Point>): Double {
var sum = 0.0
for (i in points.indices) {
val p1 = points[i]
val p2 = points[(i + 1) % points.size]
sum += p1.x * p2.y - p2.x * p1.y
}
return sum / 2.0
}
private fun perimeter(points: List<Point>): Double {
var length = 0.0
for (i in points.indices) {
val p1 = points[i]
val p2 = points[(i + 1) % points.size]
length += hypot(p2.x - p1.x, p2.y - p1.y)
}
return length
}
}
@@ -0,0 +1,43 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.postprocess
import org.opencv.core.Point
internal object QuadGeometry {
fun orderMinAreaRectPoints(points: Array<Point>): List<Point> {
val sorted = points.sortedBy { it.x }
val topLeft: Point
val bottomLeft: Point
if (sorted[1].y > sorted[0].y) {
topLeft = sorted[0]
bottomLeft = sorted[1]
} else {
topLeft = sorted[1]
bottomLeft = sorted[0]
}
val topRight: Point
val bottomRight: Point
if (sorted[3].y > sorted[2].y) {
topRight = sorted[2]
bottomRight = sorted[3]
} else {
topRight = sorted[3]
bottomRight = sorted[2]
}
return listOf(topLeft, topRight, bottomRight, bottomLeft)
}
}
@@ -0,0 +1,86 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.postprocess
import com.paddle.ocr.model.OCRBox
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.MatOfPoint2f
import org.opencv.core.Point
import org.opencv.core.Size
import org.opencv.imgproc.Imgproc
import kotlin.math.hypot
import kotlin.math.max
object QuadTextCrop {
private const val VERTICAL_CROP_RATIO = 1.5
fun crop(src: Mat, box: OCRBox): Mat {
// Align with PaddleX CropByPolys.get_minarea_rect_crop: recompute minAreaRect
// from the detected quad before perspective transform.
val rectPoints = box.points.map { Point(it.x.toDouble(), it.y.toDouble()) }
val rectInput = MatOfPoint2f()
rectInput.fromList(rectPoints)
val boundingBox = Imgproc.minAreaRect(rectInput)
rectInput.release()
val boxPoints = Array(4) { Point() }
boundingBox.points(boxPoints)
val ordered = QuadGeometry.orderMinAreaRectPoints(boxPoints)
val widthTop = hypot(ordered[0].x - ordered[1].x, ordered[0].y - ordered[1].y)
val widthBottom = hypot(ordered[2].x - ordered[3].x, ordered[2].y - ordered[3].y)
val heightLeft = hypot(ordered[0].x - ordered[3].x, ordered[0].y - ordered[3].y)
val heightRight = hypot(ordered[1].x - ordered[2].x, ordered[1].y - ordered[2].y)
val dstW = max(widthTop, widthBottom).toInt().coerceAtLeast(1)
val dstH = max(heightLeft, heightRight).toInt().coerceAtLeast(1)
val srcPts = MatOfPoint2f()
srcPts.fromList(ordered)
val dstPts = MatOfPoint2f()
dstPts.fromList(
listOf(
Point(0.0, 0.0),
Point(dstW.toDouble(), 0.0),
Point(dstW.toDouble(), dstH.toDouble()),
Point(0.0, dstH.toDouble()),
)
)
val m = Imgproc.getPerspectiveTransform(srcPts, dstPts)
srcPts.release()
dstPts.release()
val dst = Mat(dstH, dstW, CvType.CV_8UC3)
Imgproc.warpPerspective(
src,
dst,
m,
Size(dstW.toDouble(), dstH.toDouble()),
Imgproc.INTER_CUBIC,
Core.BORDER_REPLICATE,
)
m.release()
if (dst.rows().toDouble() / dst.cols() >= VERTICAL_CROP_RATIO) {
val rotated = Mat()
Core.rotate(dst, rotated, Core.ROTATE_90_COUNTERCLOCKWISE)
dst.release()
return rotated
}
return dst
}
}
@@ -0,0 +1,103 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.preprocess
import android.graphics.Bitmap
import com.paddle.ocr.util.BitmapUtils
import com.paddle.ocr.util.ImageUtils
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.Scalar
import org.opencv.imgproc.Imgproc
data class DetPreprocessResult(
val tensorData: FloatArray,
val shape: LongArray,
val originalH: Int,
val originalW: Int,
)
object DetPreprocessor {
private val mean = doubleArrayOf(0.485, 0.456, 0.406)
private val std = doubleArrayOf(0.229, 0.224, 0.225)
private const val scale = 1.0 / 255.0
fun preprocess(
bitmap: Bitmap,
limitSideLen: Int,
limitType: String,
maxSideLimit: Int,
imgMode: String,
): DetPreprocessResult {
val src = BitmapUtils.bitmapToBGRMat(bitmap)
return try {
preprocess(src, limitSideLen, limitType, maxSideLimit, imgMode)
} finally {
src.release()
}
}
fun preprocess(
src: Mat,
limitSideLen: Int,
limitType: String,
maxSideLimit: Int,
imgMode: String,
): DetPreprocessResult {
val originalH = src.rows()
val originalW = src.cols()
val input = if (imgMode.uppercase() == "RGB") {
Mat().also { Imgproc.cvtColor(src, it, Imgproc.COLOR_BGR2RGB) }
} else {
src
}
val resized = ImageUtils.resizeToMultipleOf32(input, limitSideLen, limitType, maxSideLimit)
if (input !== src) input.release()
val h = resized.rows()
val w = resized.cols()
val floatMat = Mat(h, w, CvType.CV_32FC3)
resized.convertTo(floatMat, CvType.CV_32F)
val channels = mutableListOf<Mat>()
Core.split(floatMat, channels)
for (c in 0..2) {
Core.multiply(channels[c], Scalar(scale), channels[c])
Core.subtract(channels[c], Scalar(mean[c]), channels[c])
Core.divide(channels[c], Scalar(std[c]), channels[c])
}
resized.release()
floatMat.release()
val channelSize = h * w
val tensorData = FloatArray(3 * channelSize)
for (c in 0..2) {
val chan = channels[c]
val buf = FloatArray(channelSize)
chan.get(0, 0, buf)
System.arraycopy(buf, 0, tensorData, c * channelSize, channelSize)
chan.release()
}
return DetPreprocessResult(
tensorData = tensorData,
shape = longArrayOf(1, 3, h.toLong(), w.toLong()),
originalH = originalH,
originalW = originalW,
)
}
}
@@ -0,0 +1,105 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.preprocess
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.Size
import org.opencv.imgproc.Imgproc
import kotlin.math.ceil
data class RecPreprocessResult(
val tensorData: FloatArray,
val shape: LongArray,
)
object RecPreprocessor {
private const val FIXED_HEIGHT = 48
private const val MAX_IMG_W = 3200
fun preprocessBatch(crops: List<Mat>): RecPreprocessResult {
// Convert BGR to RGB and resize to fixed height while preserving aspect ratio
val resizedMats = mutableListOf<Mat>()
for (crop in crops) {
// Convert BGR to RGB (model expects RGB input)
val rgb = Mat()
Imgproc.cvtColor(crop, rgb, Imgproc.COLOR_BGR2RGB)
val h = rgb.rows()
val w = rgb.cols()
val aspectRatio = if (h > 0) w.toDouble() / h else 1.0
val newW = ceil(FIXED_HEIGHT * aspectRatio).toInt().coerceAtMost(MAX_IMG_W)
val dst = Mat()
Imgproc.resize(rgb, dst, Size(newW.toDouble(), FIXED_HEIGHT.toDouble()), 0.0, 0.0, Imgproc.INTER_LINEAR)
rgb.release()
resizedMats.add(dst)
}
// Convert to float and normalize: (x / 255 - 0.5) / 0.5 = x / 127.5 - 1
val floatMats = mutableListOf<Mat>()
for (mat in resizedMats) {
val floatMat = Mat(mat.rows(), mat.cols(), CvType.CV_32FC3)
mat.convertTo(floatMat, CvType.CV_32F)
// Use Scalar with all 3 channels set — single-value Scalar only sets val[0]!
Core.divide(floatMat, org.opencv.core.Scalar(127.5, 127.5, 127.5), floatMat)
Core.subtract(floatMat, org.opencv.core.Scalar(1.0, 1.0, 1.0), floatMat)
floatMats.add(floatMat)
mat.release() // Release resized mat
}
resizedMats.clear()
val maxW = floatMats.maxOf { it.cols() }
val n = floatMats.size
// Pad to max width
val paddedMats = mutableListOf<Mat>()
for (mat in floatMats) {
if (mat.cols() == maxW) {
paddedMats.add(mat)
} else {
val padded = Mat(FIXED_HEIGHT, maxW, CvType.CV_32FC3, org.opencv.core.Scalar(0.0))
val roi = padded.submat(0, FIXED_HEIGHT, 0, mat.cols())
mat.copyTo(roi)
roi.release()
mat.release()
paddedMats.add(padded)
}
}
floatMats.clear()
// Build tensor data
val channelSize = FIXED_HEIGHT * maxW
val tensorData = FloatArray(n * 3 * channelSize)
for (b in 0 until n) {
val mat = paddedMats[b]
val channels = mutableListOf<Mat>()
Core.split(mat, channels)
for (c in 0..2) {
val buf = FloatArray(channelSize)
channels[c].get(0, 0, buf)
System.arraycopy(buf, 0, tensorData, (b * 3 + c) * channelSize, channelSize)
channels[c].release()
}
mat.release()
}
paddedMats.clear()
return RecPreprocessResult(
tensorData = tensorData,
shape = longArrayOf(n.toLong(), 3, FIXED_HEIGHT.toLong(), maxW.toLong()),
)
}
}
@@ -0,0 +1,72 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.util
import android.graphics.Bitmap
import org.opencv.android.Utils
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.MatOfByte
import org.opencv.imgcodecs.Imgcodecs
import org.opencv.imgproc.Imgproc
object BitmapUtils {
fun imdecodeBGR(imageBytes: ByteArray): Mat {
val encoded = MatOfByte(*imageBytes)
return try {
Imgcodecs.imdecode(encoded, Imgcodecs.IMREAD_COLOR)
} finally {
encoded.release()
}
}
fun bitmapToBGRMat(bitmap: Bitmap): Mat {
return bitmapToMat(bitmap, Imgproc.COLOR_RGBA2BGR)
}
fun bitmapToRGBMat(bitmap: Bitmap): Mat {
return bitmapToMat(bitmap, Imgproc.COLOR_RGBA2RGB)
}
fun bgrMatToBitmap(mat: Mat): Bitmap {
val rgba = Mat()
return try {
Imgproc.cvtColor(mat, rgba, Imgproc.COLOR_BGR2RGBA)
Bitmap.createBitmap(rgba.cols(), rgba.rows(), Bitmap.Config.ARGB_8888).also { bitmap ->
Utils.matToBitmap(rgba, bitmap)
}
} finally {
rgba.release()
}
}
private fun bitmapToMat(bitmap: Bitmap, colorConversionCode: Int): Mat {
val bmp = bitmap.copy(Bitmap.Config.ARGB_8888, false)
val rgba = Mat(bmp.height, bmp.width, CvType.CV_8UC4)
val dst = Mat()
return try {
Utils.bitmapToMat(bmp, rgba)
Imgproc.cvtColor(rgba, dst, colorConversionCode)
dst
} catch (t: Throwable) {
dst.release()
throw t
} finally {
bmp.recycle()
rgba.release()
}
}
}
@@ -0,0 +1,52 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.util
import org.opencv.core.Mat
import org.opencv.core.Size
import org.opencv.imgproc.Imgproc
object ImageUtils {
fun resizeToMultipleOf32(
src: Mat,
limitSideLen: Int,
limitType: String,
maxSideLimit: Int,
): Mat {
val h = src.rows()
val w = src.cols()
var ratio = when (limitType.lowercase()) {
"max" -> if (maxOf(h, w) > limitSideLen) limitSideLen.toDouble() / maxOf(h, w) else 1.0
"min" -> if (minOf(h, w) < limitSideLen) limitSideLen.toDouble() / minOf(h, w) else 1.0
"resize_long" -> limitSideLen.toDouble() / maxOf(h, w)
else -> throw IllegalArgumentException("Unsupported det limit type: $limitType")
}
var newH = (h * ratio).toInt()
var newW = (w * ratio).toInt()
if (maxOf(newH, newW) > maxSideLimit) {
ratio = maxSideLimit.toDouble() / maxOf(newH, newW)
newH = (newH * ratio).toInt()
newW = (newW * ratio).toInt()
}
newH = maxOf(MathUtils.roundHalfToEven(newH / 32.0) * 32, 32)
newW = maxOf(MathUtils.roundHalfToEven(newW / 32.0) * 32, 32)
val dst = Mat()
Imgproc.resize(src, dst, Size(newW.toDouble(), newH.toDouble()), 0.0, 0.0, Imgproc.INTER_LINEAR)
return dst
}
}
@@ -0,0 +1,31 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.util
import kotlin.math.floor
object MathUtils {
fun roundHalfToEven(value: Double): Int {
val floored = floor(value)
val diff = value - floored
return when {
diff < 0.5 -> floored.toInt()
diff > 0.5 -> floored.toInt() + 1
floored.toInt() % 2 == 0 -> floored.toInt()
else -> floored.toInt() + 1
}
}
}
@@ -0,0 +1,34 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.util
import android.content.Context
import android.util.Log
object OpenCVUtils {
private var initialized = false
fun init(context: Context): Boolean {
if (initialized) return true
try {
System.loadLibrary("opencv_java4")
initialized = true
} catch (e: UnsatisfiedLinkError) {
Log.e("OpenCVUtils", "Failed to initialize OpenCV: ${e.message}")
}
return initialized
}
}
@@ -0,0 +1,22 @@
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.paddle.ocr.util
object YamlUtils {
fun leadingSpaces(line: String): Int {
return line.indexOfFirst { it != ' ' }.let { if (it < 0) line.length else it }
}
}