GLM-OCR在Android端集成指南:移动端文档扫描应用开发
GLM-OCR在Android端集成指南:移动端文档扫描应用开发
如果你正在开发一款需要文字识别功能的Android应用,比如文档扫描、名片管理或者翻译工具,那么集成一个高效、准确的OCR引擎就是核心任务。今天,我们就来聊聊如何把GLM-OCR这个强大的模型搬到你的Android应用里,让它能实时“看懂”摄像头捕捉到的文字。
整个过程听起来可能有点复杂,但别担心,我会带你一步步走下来。我们会从怎么把模型“瘦身”到适合手机运行开始,讲到怎么用摄像头拍出清晰的图片,再到怎么调用模型识别文字,最后把识别出来的文字漂亮地展示出来。跟着这篇指南,你就能在自己的App里实现一个完整的文档扫描功能。
1. 环境准备与项目搭建
在开始写代码之前,我们需要先把“舞台”搭好。这包括准备好模型文件,以及配置好Android项目。
1.1 模型准备:让GLM-OCR“瘦身”上手机
直接用在服务器上的大模型,对于手机来说负担太重了。我们需要对它进行转换和优化,这个过程通常叫做模型轻量化或移动端部署。
首先,你需要获取GLM-OCR的原始模型文件(通常是.onnx或.pt格式)。然后,使用专门的工具进行转换。这里推荐使用ONNX Runtime或TensorFlow Lite的转换工具,因为它们对移动端支持非常好。
一个典型的转换流程(以PyTorch模型转TFLite为例)可能像这样:
import torch
import torchvision
import onnx
from onnx_tf.backend import prepare
import tensorflow as tf
# 1. 加载你的PyTorch模型
model = YourGLMOCRModel()
model.load_state_dict(torch.load('glm-ocr.pth'))
model.eval()
# 2. 创建一个示例输入(模拟手机图片尺寸,例如 320x480)
dummy_input = torch.randn(1, 3, 480, 320)
# 3. 导出为ONNX格式
torch.onnx.export(model, dummy_input, "glm-ocr.onnx",
input_names=['input'], output_names=['output'],
dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}})
# 4. 将ONNX模型转换为TensorFlow格式(可选步骤,如需TFLite)
# ... (使用onnx-tf或类似工具)
# 5. 最终转换为TFLite格式
converter = tf.lite.TFLiteConverter.from_saved_model('tf_model_directory')
converter.optimizations = [tf.lite.Optimize.DEFAULT] # 启用优化
converter.target_spec.supported_types = [tf.float16] # 可选:使用FP16减少模型大小
tflite_model = converter.convert()
# 6. 保存最终模型
with open('glm-ocr.tflite', 'wb') as f:
f.write(tflite_model)
转换完成后,你会得到一个.tflite或.onnx文件。把它放到你Android项目的 app/src/main/assets/ 目录下。这样,应用在安装时就会把这个模型文件打包进去。
1.2 Android项目配置
打开你的Android Studio项目,我们需要在 app/build.gradle 文件里添加一些依赖。这里我们假设使用TFLite来运行模型。
android {
...
// 确保你使用了足够新的NDK版本(模型可能用到一些新算子)
ndkVersion "25.1.8937393"
aaptOptions {
noCompress "tflite" // 防止AAPT压缩我们的模型文件
}
}
dependencies {
...
// TensorFlow Lite 核心库
implementation 'org.tensorflow:tensorflow-lite:2.14.0'
// 可选:如果需要GPU加速
implementation 'org.tensorflow:tensorflow-lite-gpu:2.14.0'
// 可选:支持库,提供一些工具类
implementation 'org.tensorflow:tensorflow-lite-support:0.4.4'
// 相机X库,用于更便捷地处理相机
def camerax_version = "1.3.0-rc01"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
// 用于图片处理和UI
implementation 'com.github.bumptech.glide:glide:4.15.1'
}
别忘了在 AndroidManifest.xml 中添加相机权限:
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
2. 核心功能实现:从拍照到识别
环境搭好了,现在我们来构建核心功能。整个过程可以分解为三个主要步骤:用相机拍照并处理图片、调用模型识别文字、把识别结果显示出来。
2.1 相机图像采集与预处理
我们使用CameraX来简化相机操作。它的API现代且生命周期感知,能省去很多麻烦。预处理的目标是把相机拍到的画面,变成模型能“吃下去”的格式。
首先,创建一个用于预览和拍照的Fragment或Activity布局:
<!-- activity_scan.xml -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 可以加一个矩形框,提示用户对准文档 -->
<View
android:id="@+id/documentFrame"
android:layout_width="300dp"
android:layout_height="400dp"
android:background="@android:color/transparent"
android:backgroundTint="#4CAF50"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<Button
android:id="@+id/captureButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="扫描"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginBottom="50dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>
然后,在Activity中设置相机并处理图像:
// ScanActivity.kt
class ScanActivity : AppCompatActivity() {
private lateinit var cameraExecutor: ExecutorService
private lateinit var imageAnalyzer: ImageAnalysis
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_scan)
cameraExecutor = Executors.newSingleThreadExecutor()
// 请求相机权限(代码略,需使用ActivityResult API)
// ...
// 设置相机
startCamera()
captureButton.setOnClickListener {
// 触发图像分析
analyzeCurrentFrame()
}
}
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(viewFinder.surfaceProvider)
}
// 构建图像分析用例,用于实时处理帧
imageAnalyzer = ImageAnalysis.Builder()
.setTargetResolution(Size(1080, 1920)) // 设置分析分辨率
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(cameraExecutor, ImageAnalysis.Analyzer { imageProxy ->
// 图像分析逻辑可以在这里实时进行,例如边缘检测提示用户对齐
// 为了省电和性能,我们只在点击按钮时分析
imageProxy.close()
})
}
// 选择后置摄像头
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
// 解绑所有用例再重新绑定
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
this, cameraSelector, preview, imageAnalyzer)
} catch(exc: Exception) {
Log.e(TAG, "相机绑定失败", exc)
}
}, ContextCompat.getMainExecutor(this))
}
private fun analyzeCurrentFrame() {
// 这里我们临时设置一个分析器来捕获当前帧
imageAnalyzer.setAnalyzer(cameraExecutor) { imageProxy ->
// 将ImageProxy转换为Bitmap,并进行预处理
val bitmap = imageProxy.toBitmap() // 需要实现toBitmap扩展函数
val processedBitmap = preprocessImageForOCR(bitmap)
// 在后台线程运行OCR
runOnUiThread {
showLoading(true)
}
val ocrResult = runOcrOnImage(processedBitmap)
runOnUiThread {
showLoading(false)
displayOcrResult(ocrResult)
}
imageProxy.close()
// 分析一次后移除分析器,避免持续分析
imageAnalyzer.clearAnalyzer()
}
}
// 关键:图像预处理函数
private fun preprocessImageForOCR(srcBitmap: Bitmap): Bitmap {
// 1. 裁剪:只取中间文档框部分(假设documentFrame是屏幕上的矩形框)
val cropRect = getDocumentFrameRect() // 获取框的屏幕坐标并转换为图片坐标
val croppedBitmap = Bitmap.createBitmap(
srcBitmap,
cropRect.left,
cropRect.top,
cropRect.width(),
cropRect.height()
)
// 2. 调整大小:缩放到模型输入尺寸,例如 320x480
val scaledBitmap = Bitmap.createScaledBitmap(croppedBitmap, 320, 480, true)
// 3. 增强:可以尝试增加对比度、二值化等,提升识别率
val enhancedBitmap = applyContrastEnhancement(scaledBitmap)
// 4. 转换为模型需要的输入格式(例如,归一化到[0,1]或[-1,1],并转换为RGB数组)
return enhancedBitmap
}
// 其他辅助函数...
private fun getDocumentFrameRect(): Rect { ... }
private fun applyContrastEnhancement(bitmap: Bitmap): Bitmap { ... }
}
预处理是提升识别准确率的关键。好的预处理能让模糊、倾斜、光照不均的图片变得“清晰可读”。
2.2 调用OCR模型进行识别
现在,图片准备好了,该模型上场了。我们需要加载TFLite模型,并喂给它处理好的图片数据。
首先,创建一个OCR推理类:
// GLMOCRPredictor.kt
class GLMOCRPredictor(context: Context) {
private var interpreter: Interpreter? = null
private val modelInputWidth = 320
private val modelInputHeight = 480
private val modelInputChannel = 3
init {
try {
// 1. 从assets加载模型文件
val modelFile = loadModelFile(context, "glm-ocr.tflite")
// 2. 创建Interpreter,可以添加选项如使用GPU
val options = Interpreter.Options()
// options.setUseNNAPI(true) // 使用NNAPI加速
// 如果需要GPU委托(确保设备支持)
// val gpuDelegate = GpuDelegate()
// options.addDelegate(gpuDelegate)
interpreter = Interpreter(modelFile, options)
Log.d("OCR", "模型加载成功")
} catch (e: Exception) {
Log.e("OCR", "模型加载失败", e)
}
}
private fun loadModelFile(context: Context, filename: String): MappedByteBuffer {
val fileDescriptor = context.assets.openFd(filename)
val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
val fileChannel = inputStream.channel
val startOffset = fileDescriptor.startOffset
val declaredLength = fileDescriptor.declaredLength
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength)
}
// 核心识别函数
fun recognize(bitmap: Bitmap): OcrResult {
if (interpreter == null) {
return OcrResult(error = "模型未加载")
}
// 1. 将Bitmap转换为模型输入需要的Float数组
val inputBuffer = preprocessBitmapToFloatArray(bitmap)
// 2. 准备输出缓冲区(根据你的模型输出结构定义)
// 假设模型输出两个东西:文本框坐标和识别文本
val outputLocations = Array(1) { Array(100) { FloatArray(4) } } // 假设最多100个框,每个框4个坐标
val outputTexts = Array(1) { Array(100) { FloatArray(字符集大小) } } // 假设文本用概率分布表示
val outputsMap = HashMap<Int, Any>()
outputsMap[0] = outputLocations
outputsMap[1] = outputTexts
// 3. 运行推理
interpreter?.runForMultipleInputsOutputs(arrayOf<Any>(inputBuffer), outputsMap)
// 4. 后处理:将模型输出转换为可读的文字和框
val detectedBoxes = processOutputBoxes(outputLocations[0])
val recognizedTexts = processOutputTexts(outputTexts[0])
// 5. 将文本框和文字配对,并映射回原始图片坐标
val finalResults = pairBoxesAndTexts(detectedBoxes, recognizedTexts, bitmap)
return OcrResult(
textBlocks = finalResults,
success = true
)
}
private fun preprocessBitmapToFloatArray(bitmap: Bitmap): FloatArray {
// 确保Bitmap尺寸正确
val resizedBitmap = Bitmap.createScaledBitmap(bitmap, modelInputWidth, modelInputHeight, true)
val inputBuffer = FloatArray(modelInputWidth * modelInputHeight * modelInputChannel)
val pixels = IntArray(modelInputWidth * modelInputHeight)
resizedBitmap.getPixels(pixels, 0, modelInputWidth, 0, 0, modelInputWidth, modelInputHeight)
// 将像素值归一化到模型需要的范围(例如,[0, 255] -> [0, 1])
for (i in pixels.indices) {
val pixel = pixels[i]
inputBuffer[i * 3] = ((pixel shr 16) and 0xFF) / 255.0f // R
inputBuffer[i * 3 + 1] = ((pixel shr 8) and 0xFF) / 255.0f // G
inputBuffer[i * 3 + 2] = (pixel and 0xFF) / 255.0f // B
}
return inputBuffer
}
// 后处理函数(简化版,实际更复杂)
private fun processOutputBoxes(boxes: Array<FloatArray>): List<RectF> { ... }
private fun processOutputTexts(textProbs: Array<FloatArray>): List<String> { ... }
private fun pairBoxesAndTexts(boxes: List<RectF>, texts: List<String>, originalBitmap: Bitmap): List<TextBlock> { ... }
data class OcrResult(
val textBlocks: List<TextBlock> = emptyList(),
val success: Boolean = false,
val error: String? = null
)
data class TextBlock(
val text: String,
val boundingBox: RectF, // 文本框在原图中的位置
val confidence: Float
)
}
这个类封装了模型加载和推理的全过程。recognize 函数是核心,它接收一个预处理好的Bitmap,运行模型,并返回结构化的识别结果。
2.3 识别结果的可视化与编辑
识别出文字和位置后,我们需要把它直观地展示给用户,并允许他们进行简单的编辑。
我们可以创建一个自定义的 OverlayView,在图片上绘制识别出的文本框和文字:
// OcrResultOverlayView.kt
class OcrResultOverlayView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var originalBitmap: Bitmap? = null
private var textBlocks: List<TextBlock> = emptyList()
private var selectedBlockIndex: Int = -1
private val paint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 4f
}
private val textPaint = Paint().apply {
isAntiAlias = true
color = Color.WHITE
textSize = 24f
style = Paint.Style.FILL
}
private val selectedPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 6f
color = Color.RED
}
fun setOcrResult(bitmap: Bitmap, blocks: List<TextBlock>) {
originalBitmap = bitmap
textBlocks = blocks
invalidate() // 触发重绘
}
fun setSelectedBlock(index: Int) {
selectedBlockIndex = index
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
originalBitmap?.let { bitmap ->
// 1. 绘制原始图片(可能需要缩放以适应View)
val scaleX = width.toFloat() / bitmap.width
val scaleY = height.toFloat() / bitmap.height
val scale = scaleX.coerceAtMost(scaleY)
val scaledWidth = bitmap.width * scale
val scaledHeight = bitmap.height * scale
val left = (width - scaledWidth) / 2
val top = (height - scaledHeight) / 2
canvas.drawBitmap(bitmap, null, RectF(left, top, left + scaledWidth, top + scaledHeight), null)
// 2. 绘制所有文本框
textBlocks.forEachIndexed { index, block ->
val box = block.boundingBox
// 将框的坐标从原图坐标转换到当前View的绘制坐标
val drawRect = RectF(
left + box.left * scale,
top + box.top * scale,
left + box.right * scale,
top + box.bottom * scale
)
// 根据是否被选中使用不同画笔
val currentPaint = if (index == selectedBlockIndex) selectedPaint else paint.apply { color = Color.GREEN }
canvas.drawRect(drawRect, currentPaint)
// 3. 在框的上方绘制识别出的文字
canvas.drawText(block.text, drawRect.left, drawRect.top - 10, textPaint)
}
}
}
// 处理触摸事件,让用户可以点击选择文本框进行编辑
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
originalBitmap?.let { bitmap ->
val scaleX = width.toFloat() / bitmap.width
val scaleY = height.toFloat() / bitmap.height
val scale = scaleX.coerceAtMost(scaleY)
val left = (width - bitmap.width * scale) / 2
val top = (height - bitmap.height * scale) / 2
// 将触摸点坐标转换回原图坐标
val touchXInBitmap = (event.x - left) / scale
val touchYInBitmap = (event.y - top) / scale
// 查找被点击的文本框
val clickedIndex = textBlocks.indexOfFirst { block ->
block.boundingBox.contains(touchXInBitmap, touchYInBitmap)
}
if (clickedIndex != -1) {
selectedBlockIndex = clickedIndex
invalidate()
// 触发编辑事件
onTextBlockSelectedListener?.onSelected(textBlocks[clickedIndex], clickedIndex)
return true
}
}
}
}
return super.onTouchEvent(event)
}
var onTextBlockSelectedListener: OnTextBlockSelectedListener? = null
interface OnTextBlockSelectedListener {
fun onSelected(block: TextBlock, index: Int)
}
}
在Activity中,我们可以这样使用这个OverlayView,并提供一个编辑界面:
// 在ScanActivity中
private fun displayOcrResult(result: GLMOCRPredictor.OcrResult) {
if (!result.success) {
Toast.makeText(this, "识别失败: ${result.error}", Toast.LENGTH_SHORT).show()
return
}
// 1. 显示带标注的图片
overlayView.setOcrResult(processedBitmap, result.textBlocks)
// 2. 在侧边或底部显示所有识别出的文本,方便整体编辑
val allText = result.textBlocks.joinToString("\n") { it.text }
textResultTextView.text = allText
// 3. 设置点击监听,点击某个文本框时弹出编辑对话框
overlayView.onTextBlockSelectedListener = object : OcrResultOverlayView.OnTextBlockSelectedListener {
override fun onSelected(block: GLMOCRPredictor.TextBlock, index: Int) {
showTextEditDialog(block.text, index)
}
}
}
private fun showTextEditDialog(originalText: String, blockIndex: Int) {
val editText = EditText(this).apply {
setText(originalText)
}
AlertDialog.Builder(this)
.setTitle("编辑识别文本")
.setView(editText)
.setPositiveButton("确认") { _, _ ->
val newText = editText.text.toString()
// 更新数据源和UI
// (这里需要维护一个可变的textBlocks列表)
updatedTextBlocks[blockIndex] = updatedTextBlocks[blockIndex].copy(text = newText)
overlayView.setOcrResult(processedBitmap, updatedTextBlocks)
updateAllTextDisplay()
}
.setNegativeButton("取消", null)
.show()
}
这样,用户就能看到被绿色框框住的文字,点击某个框还能修改识别有误的内容,体验就完整了。
3. 优化与进阶技巧
基础功能跑通后,我们可以考虑一些优化,让应用更流畅、更准确、更好用。
性能优化:
- 异步处理:确保图像预处理和OCR推理都在后台线程进行,避免阻塞UI。
- 模型量化:在转换模型时使用
tf.lite.Optimize.DEFAULT和tf.float16,能显著减少模型体积和提升推理速度。 - 缓存与复用:
Interpreter的初始化比较耗时,应该作为单例全局复用。 - 降低分析频率:在实时预览时,不要每帧都进行OCR,可以设置一个时间间隔(如每秒1-2次),或者只在用户点击“扫描”按钮时分析当前帧。
准确率提升:
- 图像预处理增强:尝试在预处理阶段加入更复杂的算法,比如透视变换矫正倾斜文档,自适应二值化应对光照不均,去噪滤波让文字更清晰。
- 后处理优化:模型输出的文字可能有不连贯或错误字符。可以引入一个字典或语言模型进行纠错,或者对相似位置的文本块进行合理的段落合并。
- 多模型融合:对于复杂场景,可以尝试先用一个轻量模型检测文本区域,再针对每个区域用精度更高的模型进行识别。
功能扩展:
- 实时检测提示:在预览时,可以运行一个轻量的文本检测模型,实时在屏幕上画出文本区域,引导用户将文档对准。
- 多语言支持:如果GLM-OCR支持,可以增加语言切换功能,提升多语言文档的识别率。
- 结果导出:增加将识别结果导出为TXT、PDF或Word文件的功能,并保留排版格式。
- 历史记录:将扫描记录保存到本地数据库,方便用户查看和管理。
4. 常见问题与调试
集成过程中,你可能会遇到下面这些问题:
- 模型加载失败:检查模型文件是否正确放置在
assets目录,并且build.gradle中设置了aaptOptions { noCompress "tflite" }。同时确认模型格式与Interpreter兼容。 - 推理速度慢:首先检查是否在后台线程运行。如果还慢,可以尝试降低输入图片的分辨率,或者启用GPU/NNAPI加速(需测试设备兼容性)。
- 识别准确率低:
- 检查预处理:确保传递给模型的图片是清晰、方正、对比度足够的。可以在UI上先显示预处理后的图片看看效果。
- 检查模型输入:确认图片缩放、颜色通道(RGB/BGR)和归一化范围(如
[0,1]或[-1,1])与模型训练时完全一致。 - 检查后处理:模型输出的坐标和文字序列可能需要复杂的解码(例如CTC解码),确保你的后处理逻辑正确。
- 内存溢出(OOM):大尺寸Bitmap是内存杀手。务必及时回收不再使用的Bitmap(调用
recycle()),并在处理完成后及时关闭ImageProxy。
调试时,可以把预处理后的图片保存到手机相册,看看是不是你期望的样子。也可以把模型输出的原始数据(文本框坐标、字符概率)打印出来,验证后处理逻辑是否正确。
整体走下来,在Android里集成GLM-OCR的核心思路就是把模型转换好、把图片处理好、把结果展示好。一开始可能会在模型输入输出格式、坐标转换这些地方卡一下,多调试几次就顺了。实际用起来,识别效果很大程度上取决于图片预处理做得好不好,这块值得多花点心思。如果你刚开始做,建议先确保基础流程跑通,再慢慢加上实时预览、纠错这些进阶功能。代码里有些地方我做了简化,比如后处理逻辑,你需要根据GLM-OCR模型的实际输出格式来完善它。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐



所有评论(0)