GLM-OCR一键部署实战:基于Android平台的文档识别应用开发
你是不是也遇到过这样的场景?想把手写的笔记、拍下来的文档照片快速转换成可编辑的文字,但现有的OCR应用要么收费,要么识别不准,要么功能受限。作为一个Android开发者,我经常想,要是能自己做一个,完全掌控识别效果和功能,那该多好。
今天,我们就来把这个想法变成现实。我将带你一步步,把一个强大的OCR模型——GLM-OCR,部署到云端,然后集成到你的Android应用里。整个过程,你不需要关心复杂的服务器运维,也不需要从头训练模型,我们利用现成的镜像,实现“一键部署,快速调用”。跟着做下来,你就能拥有一个属于自己的、功能强大的文档扫描识别App。
1. 环境准备与快速部署
我们先来解决最核心的问题:把GLM-OCR模型跑起来。传统方式需要自己搭环境、配依赖,非常麻烦。现在,我们可以借助云平台的镜像功能,实现“开箱即用”。
1.1 选择与启动镜像
首先,你需要一个能提供GPU算力的云平台。这里以星图平台为例,因为它提供了预置的AI镜像,省去了我们大量配置工作。
- 登录平台:访问星图平台,进入控制台。
- 创建实例:点击创建新的计算实例。在镜像选择页面,找到“AI镜像”或“大模型”分类。
- 搜索并选择:在搜索框输入“GLM-OCR”,你应该能看到官方或社区提供的GLM-OCR专用镜像。选择它。
- 配置实例:根据你的需求选择GPU型号(例如,T4或V100对于OCR任务已经足够)和存储空间。其他配置(如网络、安全组)可以暂时保持默认。
- 启动实例:点击创建,等待几分钟,实例就会启动完成。这时,你就拥有了一台已经预装好GLM-OCR模型和相关依赖的云服务器。
这个过程就像在应用商店下载一个已经配置好的软件,而不是自己从零开始编译安装。
1.2 验证服务运行
实例启动后,我们需要确认OCR服务已经正常跑起来了。通常,这类镜像会默认启动一个HTTP API服务。
- 通过平台提供的Web终端或使用SSH工具(如PuTTY、Termius)连接到你的云服务器。
- 查看服务状态。你可以运行一个简单的命令来检查,比如:
(注意:端口号curl http://localhost:8000/health8000是常见默认值,具体请查看你所用镜像的说明文档。如果镜像提供了管理界面,也可以直接通过浏览器访问实例的公网IP和指定端口查看。) - 如果返回类似
{"status": "ok"}的JSON信息,说明服务运行正常。
至此,云端强大的OCR“大脑”已经准备就绪。接下来,我们要让Android这个“身体”学会如何与它对话。
2. Android项目基础搭建
现在,我们回到熟悉的Android Studio,创建一个新项目,并准备好与云端API通信的基础设施。
2.1 创建项目与添加依赖
打开Android Studio,新建一个Empty Activity项目,名字可以叫DocScanner。
为了让我们的应用能够进行网络请求、处理图片和解析JSON,需要在app/build.gradle.kts(或app/build.gradle) 文件的dependencies块中添加必要的库:
dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.activity:activity-compose:1.8.2") // 网络请求 - Retrofit & OkHttp implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") // 图片加载与处理 - Coil implementation("io.coil-kt:coil-compose:2.5.0") // 权限请求 implementation("com.google.accompanist:accompanist-permissions:0.32.0") // UI (使用Jetpack Compose, 如果你用XML,可忽略这部分) implementation(platform("androidx.compose:compose-bom:2023.10.01")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") }别忘了在AndroidManifest.xml中添加网络权限和相机/存储权限(如果需要从相册选择图片):
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- 如果需要拍照或读取相册 --> <uses-permission android:name="android.permission.CAMERA" /> <uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />2.2 配置网络层
网络层是我们应用与云端OCR服务沟通的桥梁。我们使用Retrofit来构建这个桥梁。
- 创建API接口:新建一个Kotlin文件
OcrApiService.kt。import okhttp3.MultipartBody import retrofit2.Response import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part // 定义OCR API的响应数据结构 data class OcrResponse( val text: String, // 识别出的完整文本 val blocks: List<TextBlock>? // 文本块信息,包含位置、置信度等 ) data class TextBlock( val box: List<List<Int>>, // 文字框的四个顶点坐标 [[x1,y1], [x2,y2], ...] val text: String, val confidence: Float ) // 定义Retrofit服务接口 interface OcrApiService { @Multipart @POST("/predict") // 这个端点路径 /predict 是常见约定,请以你的镜像文档为准 suspend fun recognizeImage( @Part image: MultipartBody.Part ): Response<OcrResponse> } - 创建Retrofit实例:创建一个单例或依赖注入的方式来管理Retrofit客户端。新建
NetworkModule.kt或直接在Application类中初始化。
重要:将import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit object ApiClient { // 替换成你的云服务器公网IP和端口 private const val BASE_URL = "http://YOUR_SERVER_IP:PORT/" private fun getClient(): OkHttpClient { val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY // 开发时查看详细日志,发布时改为NONE } return OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .connectTimeout(30, TimeUnit.SECONDS) // OCR可能较耗时,设置长一点超时 .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() } val ocrService: OcrApiService by lazy { Retrofit.Builder() .baseUrl(BASE_URL) .client(getClient()) .addConverterFactory(GsonConverterFactory.create()) .build() .create(OcrApiService::class.java) } }YOUR_SERVER_IP:PORT替换为你实际云服务器的公网IP地址和API服务端口。
基础打好了,接下来就是最激动人心的部分:拍张照,把图片传给云端,然后拿回识别结果。
3. 核心功能实现:拍照、上传与识别
这一部分,我们把相机、图片处理和网络请求串联起来,完成核心业务流程。
3.1 图片获取与预处理
我们可以通过Android的Intent系统调用相机或相册。这里以从相册选择图片为例,展示如何准备图片数据用于上传。
首先,在Activity或Composable中启动图片选择器:
// 使用Activity Result API val pickImageLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri -> uri?.let { processImageUri(it) } } // 触发选择图片 pickImageLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))在processImageUri函数中,我们需要将图片URI转换为文件,并进行必要的预处理(如压缩、旋转校正),然后转换为Retrofit上传所需的格式。
import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.media.ExifInterface import android.net.Uri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import java.io.File import java.io.FileOutputStream suspend fun processImageUri(context: Context, uri: Uri): File? = withContext(Dispatchers.IO) { try { // 1. 将Uri转换为临时文件 val inputStream = context.contentResolver.openInputStream(uri) val tempFile = File.createTempFile("ocr_upload", ".jpg", context.cacheDir) val outputStream = FileOutputStream(tempFile) // 2. 读取图片并处理旋转(很多手机拍照的图片带有旋转信息) val bitmap = BitmapFactory.decodeStream(inputStream) inputStream?.close() val rotatedBitmap = rotateBitmapIfRequired(context, bitmap, uri) // 3. 压缩图片(上传速度快,且大多数OCR模型对分辨率有要求) val compressedBitmap = compressBitmap(rotatedBitmap, maxFileSizeKB = 1024) // 压缩到1MB以内 // 4. 保存压缩后的图片到临时文件 compressedBitmap.compress(Bitmap.CompressFormat.JPEG, 85, outputStream) outputStream.flush() outputStream.close() compressedBitmap.recycle() rotatedBitmap.recycle() return@withContext tempFile } catch (e: Exception) { e.printStackTrace() return@withContext null } } // 处理图片旋转 fun rotateBitmapIfRequired(context: Context, bitmap: Bitmap, uri: Uri): Bitmap { val input = context.contentResolver.openInputStream(uri) ?: return bitmap val exif = ExifInterface(input) input.close() val orientation = exif.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL ) return when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f) ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f) ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f) else -> bitmap } } fun rotateBitmap(source: Bitmap, angle: Float): Bitmap { val matrix = Matrix().apply { postRotate(angle) } return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true) } // 压缩图片 fun compressBitmap(bitmap: Bitmap, maxFileSizeKB: Int): Bitmap { // 简单的按比例压缩,更复杂的可以循环压缩直到满足大小 var quality = 90 var stream = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream) while (stream.size() / 1024 > maxFileSizeKB && quality > 10) { stream.reset() quality -= 15 bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream) } val byteArray = stream.toByteArray() return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) }3.2 调用OCR API并处理结果
图片准备好后,我们就可以调用之前定义的API服务了。
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch fun uploadImageForRecognition(scope: CoroutineScope, imageFile: File, onResult: (Result<OcrResponse>) -> Unit) { scope.launch { try { // 1. 创建Multipart请求体 val requestFile = imageFile.asRequestBody("image/jpeg".toMediaTypeOrNull()) val imagePart = MultipartBody.Part.createFormData("image", imageFile.name, requestFile) // 2. 发起网络请求 val response = ApiClient.ocrService.recognizeImage(imagePart) // 3. 处理响应 if (response.isSuccessful) { val ocrResult = response.body() ocrResult?.let { onResult(Result.success(it)) } ?: run { onResult(Result.failure(Exception("响应体为空"))) } } else { onResult(Result.failure(Exception("请求失败: ${response.code()} ${response.message()}"))) } } catch (e: Exception) { onResult(Result.failure(e)) } finally { // 可选:删除临时文件 // imageFile.delete() } } }3.3 在UI中展示识别结果
拿到OcrResponse后,我们可以把识别出的文字展示给用户。如果API返回了文本块位置信息(blocks),我们甚至可以做一个更酷的功能:在原图上高亮显示出识别区域。
这里用一个简单的Compose UI来展示结果:
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun RecognitionScreen() { val recognizedText = remember { mutableStateOf("") } val isLoading = remember { mutableStateOf(false) } val errorMessage = remember { mutableStateOf<String?>(null) } Column( modifier = Modifier .fillMaxSize() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Button(onClick = { // 触发选择图片流程... // 选择图片后,调用 uploadImageForRecognition isLoading.value = true errorMessage.value = null // 假设我们有一个选中的文件 imageFile uploadImageForRecognition( scope = rememberCoroutineScope(), imageFile = imageFile, onResult = { result -> isLoading.value = false result.onSuccess { ocrResponse -> recognizedText.value = ocrResponse.text }.onFailure { throwable -> errorMessage.value = "识别失败: ${throwable.message}" } } ) }) { Text("选择图片并识别") } if (isLoading.value) { CircularProgressIndicator(modifier = Modifier.padding(16.dp)) } errorMessage.value?.let { Text(text = it, color = MaterialTheme.colorScheme.error) } if (recognizedText.value.isNotEmpty()) { Text( text = "识别结果:", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 16.dp) ) Text( text = recognizedText.value, modifier = Modifier .verticalScroll(rememberScrollState()) .padding(8.dp) ) // 可以添加一个按钮,方便用户复制文本 Button(onClick = { /* 复制文本到剪贴板 */ }) { Text("复制文本") } } } }4. 实用技巧与进阶优化
基础功能跑通后,我们可以让它变得更好用、更健壮。这里分享几个在实际开发中很有用的点。
处理大图与超时:如果用户选择了分辨率非常高的图片,上传和识别时间会很长。我们可以在预处理阶段强制将图片缩放至一个合理的长边(例如1920像素)。同时,确保OkHttpClient的超时设置足够长,并给用户提供进度提示或取消操作的能力。
结果后处理:GLM-OCR返回的文本可能包含一些不必要的空格、换行,或者对于某些格式(如表格)识别不够理想。你可以根据你的应用场景,添加简单的后处理逻辑。例如,用正则表达式合并被意外分割的单词,或者尝试检测并重构简单的表格数据。
离线能力考量:完全依赖网络的服务在无网环境下会失效。对于核心的文档扫描功能,一个折中的方案是:在云端部署强大的GLM-OCR处理复杂场景,同时在App内集成一个轻量级的本地OCR引擎(如Tesseract的优化版本或移动端专用SDK),用于处理网络不佳时的基本识别需求,实现体验互补。
UI/UX优化:参考主流扫描App,添加图片裁剪、透视校正(让拍歪的文档变正)、多页批处理、识别结果编辑与导出(TXT, PDF, Word)等功能,能极大提升应用的专业度和用户满意度。
5. 总结
走完这一趟,你会发现,将一个先进的AI模型集成到移动端应用里,并没有想象中那么遥不可及。关键在于“分而治之”:把复杂的模型推理放在强大的云端,让手机端专注于它擅长的交互、拍照和展示。
通过星图这类平台的镜像服务,我们跳过了最头疼的环境部署环节。在Android端,利用成熟的网络库和协程,可以优雅地处理异步请求和状态管理。整个流程从选择图片、预处理、上传到展示结果,形成了一个清晰的闭环。
我建议你在跑通这个基础版本后,先别急着加复杂功能。多用自己的手机拍一些不同类型的文档(打印体、手写体、表格、带有复杂背景的)测试一下,看看识别效果到底怎么样,哪些地方容易出错。这会让你对GLM-OCR的能力边界有更直观的感受,后续的优化方向也就更明确了。
动手试试吧,当你第一次用自己的App成功提取出照片里的文字时,那种感觉还是挺棒的。在这个过程中遇到的任何问题,也欢迎随时交流。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。