Implementar OCR en apps Android con Google ML Kit
Usa el reconocedor de texto integrado de ML Kit cuando tu app Android necesite leer una foto desde su primera ejecución, incluso sin conexión de red. Este ejemplo crea una pequeña app en Kotlin con dos entradas: elegir una imagen existente o tomar una foto a tamaño completo con la app de cámara del sistema. Muestra texto en alfabeto latino y ofrece un resultado visible cuando la imagen está en blanco, no se puede leer o se cancela la operación.
Requisitos previos
Necesitas conocimientos de Kotlin, una instalación del SDK de Android y un dispositivo o emulador. El ejemplo usa Android Gradle plugin 9.2.1, Gradle 9.4.1, JDK 21, SDK Platform 36 y Build Tools 36.0.0. Estas versiones forman un conjunto reproducible, no un requisito para actualizar una app existente. Consulta la tabla de compatibilidad de AGP si usas un conjunto de herramientas diferente.
Configura JAVA_HOME para que apunte a tu JDK y
ANDROID_HOME a tu SDK, y añade Gradle y el directorio
platform-tools del SDK a tu PATH.
La compilación necesita acceso a la red para descargar las dependencias.
El valor de minSdk de la app es 23, conforme a los
requisitos de configuración de Text Recognition v2 de Google.
Las comprobaciones de ejecución que se muestran a continuación usan Android 16, API 36; no permiten
determinar el comportamiento en todos los sistemas operativos anteriores ni en todas las cámaras
físicas.
Configurar el proyecto Android
Empieza en un directorio nuevo y vacío llamado TextRecognitionApp. Crea los archivos
siguientes en las rutas relativas indicadas, incluidos sus directorios superiores. Son archivos
completos, así que no necesitas combinarlos con una plantilla de Android Studio ni reemplazar
archivos de un proyecto existente.
settings.gradle:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'TextRecognitionApp'
include ':app'
build.gradle:
plugins {
id 'com.android.application' version '9.2.1' apply false
}
gradle.properties:
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2g
AGP 9 incluye la compilación de Kotlin, por lo que este proyecto no aplica un plugin de Kotlin para Android por separado.
Añadir la dependencia de ML Kit
Crea app/build.gradle. La vinculación de vistas genera
ActivityMainBinding a partir del diseño que añadirás en breve.
plugins {
id 'com.android.application'
}
android {
namespace 'com.example.textrecognition'
compileSdk 36
defaultConfig {
applicationId 'com.example.textrecognition'
minSdk 23
targetSdk 36
versionCode 1
versionName '1.0'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.activity:activity-ktx:1.9.3'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.gms:play-services-tasks:18.2.0'
implementation 'com.google.mlkit:text-recognition:16.0.1'
}
La última dependencia empaqueta el modelo de alfabeto latino con la app. La alternativa,
com.google.android.gms:play-services-mlkit-text-recognition:19.0.1, descarga su modelo a través de
Google Play services. Reduce la descarga inicial de la app, pero el reconocimiento no puede devolver
resultados hasta que el modelo esté listo. Elige un enfoque; este tutorial usa únicamente el modelo
integrado. La comparación de instalación de Google
explica las ventajas y desventajas, así como las opciones de descarga.
Configurar los permisos
El selector de fotos
concede acceso a la imagen elegida sin un permiso amplio de acceso a la biblioteca de fotos.
PickVisualMedia usa ACTION_OPEN_DOCUMENT como último recurso cuando no hay
un selector disponible. Delegar la captura a una app de cámara instalada también evita solicitar
acceso directo a la cámara; Android documenta este
enfoque basado en intents de cámara.
Crea app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Text Recognition"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
Crea app/src/main/res/xml/file_paths.xml:
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="ocr_camera" path="ocr-camera/" />
</paths>
FileProvider expone
únicamente este directorio de capturas a través de URI de contenido.
TakePicture proporciona una URI a la cámara para que pueda escribir una imagen
a tamaño completo, en lugar de devolver una miniatura pequeña.
Crear el diseño
Crea app/src/main/res/layout/activity_main.xml. El resultado se puede seleccionar para que puedas copiarlo;
los mensajes de estado usan la misma vista y permanecen visibles después de una operación fallida.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<Button
android:id="@+id/btnCapture"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Capture image" />
<Button
android:id="@+id/btnGallery"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Choose image" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_weight="1">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Choose or capture an image."
android:textIsSelectable="true"
android:textSize="16sp" />
</ScrollView>
</LinearLayout>
Implementar la funcionalidad de OCR
Crea app/src/main/java/com/example/textrecognition/MainActivity.kt. Ambos botones permanecen deshabilitados
mientras haya una actividad externa o un reconocimiento pendiente. La decodificación se ejecuta
en un hilo de trabajo, y ML Kit recibe la URI seleccionada a través de
InputImage.fromFilePath().
Android puede volver a crear tu actividad mientras el selector o la cámara están abiertos. Registrar los lanzadores en un orden estable y guardar el estado adicional de la operación permite que sus resultados lleguen a la actividad de reemplazo. Un escaneo cuyo reconocimiento ya está en curso no se reanuda después de volver a crear la actividad: la nueva pantalla te pide que selecciones una imagen de nuevo.
package com.example.textrecognition
import android.content.ActivityNotFoundException
import android.net.Uri
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.textrecognition.databinding.ActivityMainBinding
import com.google.android.gms.tasks.TaskCompletionSource
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import java.io.File
import java.io.IOException
import java.util.concurrent.Executors
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var pendingCameraName: String? = null
private var pendingPicker = false
private var recognizing = false
private val cameraDirectory: File
get() = File(cacheDir, "ocr-camera")
private val takePictureLauncher = registerForActivityResult(
ActivityResultContracts.TakePicture()
) { success ->
val name = pendingCameraName
pendingCameraName = null
if (name == null) {
setBusy(false)
showMessage("The camera result is no longer available.")
} else {
val file = File(cameraDirectory, name)
if (success && file.isFile && file.length() > 0) {
processImage(cameraUri(file), file)
} else {
file.delete()
setBusy(false)
showMessage(if (success) "The camera returned no image." else "Capture canceled.")
}
}
}
private val selectPictureLauncher = registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri ->
pendingPicker = false
if (uri != null) {
processImage(uri)
} else {
setBusy(false)
showMessage("Selection canceled.")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val padding = binding.root.paddingLeft
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
)
view.setPadding(padding + bars.left, padding + bars.top,
padding + bars.right, padding + bars.bottom)
insets
}
pendingCameraName = savedInstanceState?.getString("pendingCameraName")
pendingPicker = savedInstanceState?.getBoolean("pendingPicker") ?: false
savedInstanceState?.getString("recognizedText")?.let { binding.textView.text = it }
if (savedInstanceState?.getBoolean("recognizing") == true) {
showMessage("Recognition interrupted. Select an image again.")
}
setBusy(pendingCameraName != null || pendingPicker)
// A killed process cannot run its completion listener to delete abandoned captures.
cameraDirectory.listFiles()?.filter {
it.name != pendingCameraName && it.lastModified() < System.currentTimeMillis() - 86_400_000
}?.forEach { it.delete() }
binding.btnCapture.setOnClickListener { captureImage() }
binding.btnGallery.setOnClickListener { selectImage() }
}
private fun selectImage() {
pendingPicker = true
setBusy(true)
showMessage("Waiting for an image…")
try {
selectPictureLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
} catch (error: ActivityNotFoundException) {
cancelSelection()
} catch (error: SecurityException) {
cancelSelection()
}
}
private fun cancelSelection() {
pendingPicker = false
setBusy(false)
showMessage("The image picker could not be opened.")
}
private fun captureImage() {
try {
if (!cameraDirectory.isDirectory && !cameraDirectory.mkdirs()) {
throw IOException("Could not create capture directory")
}
val imageFile = File.createTempFile("IMG_", ".jpg", cameraDirectory)
pendingCameraName = imageFile.name
setBusy(true)
showMessage("Waiting for the camera…")
takePictureLauncher.launch(cameraUri(imageFile))
} catch (error: IOException) {
cancelCapture()
} catch (error: ActivityNotFoundException) {
cancelCapture()
} catch (error: SecurityException) {
cancelCapture()
}
}
private fun cameraUri(file: File): Uri =
FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
private fun cancelCapture() {
pendingCameraName?.let { File(cameraDirectory, it).delete() }
pendingCameraName = null
setBusy(false)
showMessage("The camera could not be opened.")
}
private fun processImage(uri: Uri, temporaryFile: File? = null) {
recognizing = true
setBusy(true)
showMessage("Recognizing text…")
val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
val decoded = TaskCompletionSource<InputImage>()
val executor = Executors.newSingleThreadExecutor()
executor.execute {
try {
decoded.setResult(InputImage.fromFilePath(applicationContext, uri))
} catch (error: Exception) {
decoded.setException(error)
}
}
executor.shutdown()
decoded.task.continueWithTask { task -> recognizer.process(task.result) }
.addOnSuccessListener { visionText ->
if (!isDestroyed) {
showMessage(visionText.text.ifBlank { "No text found. Try a clearer image." })
}
}
.addOnFailureListener {
if (!isDestroyed) showMessage("The image could not be read. Try another image.")
}
.addOnCompleteListener {
// Activity-scoped listeners stop on onStop; this cleanup must still run.
recognizer.close()
temporaryFile?.delete()
recognizing = false
if (!isDestroyed) setBusy(false)
}
}
private fun setBusy(value: Boolean) {
binding.btnCapture.isEnabled = !value
binding.btnGallery.isEnabled = !value
}
private fun showMessage(message: String) {
binding.textView.text = message
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putString("pendingCameraName", pendingCameraName)
outState.putBoolean("pendingPicker", pendingPicker)
outState.putBoolean("recognizing", recognizing)
outState.putString("recognizedText", binding.textView.text.toString())
super.onSaveInstanceState(outState)
}
}
El listener de márgenes mantiene los controles fuera de las
barras del sistema y los recortes de pantalla.
La app conserva el texto obtenido cuando se vuelve a crear la actividad, pero no guarda un historial
de escaneos ni conserva una vista previa. Cada captura recibe un nombre de archivo de caché nuevo.
Las capturas completadas y canceladas se eliminan; una captura abandonada se elimina en una ejecución
posterior, una vez transcurrido un día. No modifiques una captura pendiente en
onDestroy(), porque la cámara aún podría estar escribiéndola.
La imagen del selector se lee para la operación inmediata; su archivo original nunca se elimina. Una tarea de reconocimiento conserva su reconocedor hasta completarse, incluso si se destruye su actividad. Sus callbacks no pueden reemplazar texto en una nueva instancia de la actividad.
Optimizar la precisión y el rendimiento del OCR
Empieza con una foto nítida y bien iluminada de texto impreso. Las directrices de Google para las imágenes de entrada recomiendan al menos 16 × 16 píxeles por carácter; por encima de unos 24 × 24, los caracteres más grandes generalmente no mejoran la precisión. Recorta el fondo innecesario y mantén el texto legible.
Este es un flujo de imágenes estáticas, no un analizador de cámara en vivo. Una vista previa de CameraX necesita su propio permiso, rotación de fotogramas y gestión de contrapresión. El chino, el devanagari, el japonés y el coreano también necesitan sus correspondientes dependencias de modelo y opciones de reconocedor, en lugar de las opciones de alfabeto latino que se usan aquí.
Probar la aplicación
Desde el directorio del proyecto, compila el APK de depuración. Una nueva ejecución reemplaza los resultados de la compilación, incluido el APK; no sobrescribe los archivos de código fuente.
gradle --no-daemon --max-workers=2 :app:assembleDebug
Con el dispositivo de destino conectado, reemplaza YOUR_DEVICE_SERIAL por su número de
serie obtenido con adb devices.
La instalación con -r reemplaza esta app de ejemplo y conserva sus datos.
adb -s YOUR_DEVICE_SERIAL install -r app/build/outputs/apk/debug/app-debug.apk &&
adb -s YOUR_DEVICE_SERIAL shell am start -n com.example.textrecognition/.MainActivity
Elige una imagen local que contenga texto grande, como «INVOICE 12345». Ese texto debería aparecer en el resultado desplazable, y ambos botones deberían volver a estar disponibles. Para comprobar la primera ejecución sin conexión, instala la app sin abrirla, desconecta el dispositivo de la red y, a continuación, abre la app y selecciona una foto local. Los elementos del selector almacenados en la nube podrían seguir necesitando una conexión para recuperar sus bytes.
Comprueba estos resultados antes de adaptar el ejemplo:
| Entrada o interrupción | Resultado esperado |
|---|---|
| Imagen en blanco | «No text found. Try a clearer image». |
| Imagen ilegible o ausente | «The image could not be read. Try another image». |
| Cerrar el selector sin seleccionar | «Selection canceled». Ambos botones habilitados |
| Cancelar la captura | «Capture canceled». Archivo de captura vacío eliminado |
| La cámara informa de un resultado correcto sin escribir bytes | «The camera returned no image». Archivo de captura eliminado |
| Girar el dispositivo mientras el selector o la cámara están abiertos | El resultado pendiente sigue perteneciendo a esa operación |
| Volver a crear la actividad durante el reconocimiento | «Recognition interrupted. Select an image again». Controles habilitados |
| Girar el dispositivo después del reconocimiento | El texto obtenido se conserva |
El ejemplo se probó en un emulador de Android 16 con el modelo de alfabeto latino integrado. Usa teléfonos reales para probar el enfoque, la exposición y la compatibilidad con la app de cámara. Un JPEG girado también necesita metadatos de orientación correctos; no supongas que girar el teléfono puede reparar una imagen codificada incorrectamente.
Solución de problemas
Si la app informa que no hay texto, primero prueba con una imagen más nítida que contenga caracteres impresos más grandes. Un resultado de reconocimiento vacío es diferente de un archivo ilegible. Si la captura no devuelve ninguna imagen, comprueba conjuntamente la app de cámara y la autoridad, la ruta de caché y el nombre del recurso del manifiesto de FileProvider.
Un reconocedor que solo funciona después de conectarse a la red podría estar usando la dependencia
sin el modelo integrado. Comprueba las dependencias de Gradle resueltas antes de añadir lógica de
descarga del modelo a este ejemplo que ya lo incluye. Si la compilación no puede resolver
ActivityMainBinding, verifica el nombre del archivo de diseño y la configuración de
vinculación de vistas.
Si buscas una alternativa que procese los archivos subidos en un servidor, consulta la documentación de /document/ocr.
