Como implementar OCR em apps Android com o Google ML Kit
Use o reconhecedor de texto empacotado do ML Kit quando seu app Android precisar ler uma foto já na primeira execução, inclusive sem conexão de rede. Este exemplo cria um pequeno app em Kotlin com duas entradas: escolher uma imagem existente ou tirar uma foto em tamanho original com o app de câmera do sistema. Ele exibe texto em alfabeto latino e mostra um resultado visível quando a imagem está em branco ou ilegível, ou quando a operação é cancelada.
Pré-requisitos
Você precisa ter familiaridade com Kotlin, uma instalação do Android SDK e um dispositivo ou emulador. O exemplo usa Android Gradle plugin 9.2.1, Gradle 9.4.1, JDK 21, SDK Platform 36 e Build Tools 36.0.0. Esse é um conjunto reproduzível de versões, não um requisito para atualizar um app existente. Consulte a tabela de compatibilidade do AGP se você usar outro conjunto de ferramentas.
Defina JAVA_HOME para o seu JDK e ANDROID_HOME para o seu SDK, e coloque o Gradle e o diretório
platform-tools do SDK no seu PATH. A compilação precisa de acesso à rede para baixar as
dependências. O minSdk do app é 23, seguindo os
requisitos de configuração do Text Recognition v2 do Google.
As verificações em tempo de execução abaixo usam o Android 16, API 36; elas não comprovam o
comportamento em todas as versões mais antigas do sistema nem em todas as câmeras físicas.
Configurar o projeto Android
Comece em um diretório novo e vazio chamado TextRecognitionApp. Crie os arquivos abaixo nos caminhos
relativos indicados, incluindo os diretórios pai. Estes são arquivos completos, então você não
precisa combiná-los com um modelo do Android Studio nem substituir arquivos em um projeto 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
O AGP 9 inclui a compilação de Kotlin, então este projeto não aplica um plugin Kotlin Android separado.
Adicionar a dependência do ML Kit
Crie app/build.gradle. O view binding gera ActivityMainBinding a partir do layout que você vai
adicionar em seguida.
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'
}
A última dependência empacota o modelo latino com o app. A alternativa,
com.google.android.gms:play-services-mlkit-text-recognition:19.0.1, baixa o modelo pelo
Google Play services. Ela reduz o download inicial do app, mas o reconhecimento só pode retornar
resultados quando o modelo estiver pronto. Escolha uma abordagem; este passo a passo usa apenas o
modelo empacotado. A
comparação de opções de instalação
do Google explica as vantagens, as desvantagens e as opções de download.
Configurar as permissões
O seletor de fotos
concede acesso à imagem escolhida sem permissão ampla para a biblioteca de fotos.
PickVisualMedia usa ACTION_OPEN_DOCUMENT como último recurso quando nenhum seletor está disponível.
Delegar a captura a um app de câmera instalado também evita solicitar acesso direto à câmera; o
Android documenta essa abordagem com intent de câmera.
Crie 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>
Crie 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>
O FileProvider expõe
apenas este diretório de captura por meio de URIs de conteúdo. TakePicture fornece uma URI à câmera
para que ela possa gravar uma imagem em tamanho original, em vez de retornar uma pequena miniatura.
Criar o layout
Crie app/src/main/res/layout/activity_main.xml. O resultado é selecionável para que você possa copiá-lo;
as mensagens de status usam a mesma view e continuam visíveis após uma operação com falha.
<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 a funcionalidade de OCR
Crie app/src/main/java/com/example/textrecognition/MainActivity.kt. Os dois botões ficam desabilitados
enquanto uma activity externa ou um reconhecimento estiver pendente. A decodificação é executada
em uma thread de trabalho, e o ML Kit recebe a URI selecionada por meio de InputImage.fromFilePath().
O Android pode recriar sua activity enquanto o seletor ou a câmera está aberto. Registrar os launchers em uma ordem estável e salvar o estado extra da operação permite que os resultados cheguem à activity substituta. Uma leitura que já está sendo reconhecida não é retomada após a recriação: a nova tela pede que você selecione uma imagem novamente.
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)
}
}
O listener de insets mantém os controles afastados das
barras do sistema e dos recortes da tela.
O app mantém o texto concluído após a recriação, mas não salva um histórico de leituras nem mantém
uma prévia. Cada captura recebe um novo nome de arquivo no cache. Capturas concluídas e canceladas
são excluídas; uma captura abandonada é removida em uma execução posterior, depois de um dia. Não
mexa em uma captura pendente em onDestroy(), porque a câmera ainda pode estar gravando nela.
A imagem do seletor é lida para a operação imediata; o arquivo original nunca é excluído. Uma tarefa de reconhecimento é dona do seu reconhecedor até a conclusão, mesmo que a activity dela seja destruída. Os callbacks dessa tarefa não podem substituir o texto em uma nova instância da activity.
Otimizar a precisão e o desempenho do OCR
Comece com uma foto nítida e bem iluminada de texto impresso. As diretrizes para imagens de entrada do Google recomendam pelo menos 16 × 16 pixels por caractere; acima de cerca de 24 × 24, caracteres maiores geralmente não melhoram a precisão. Corte o fundo desnecessário, mantendo o texto legível.
Este é um fluxo de imagem estática, não um analisador de câmera ao vivo. Uma prévia do CameraX precisa de permissão própria e de tratamento de rotação de frames e de backpressure. Chinês, devanágari, japonês e coreano também precisam das dependências de modelo e das opções de reconhecedor correspondentes, em vez das opções latinas usadas aqui.
Testar o aplicativo
No diretório do projeto, compile o APK de depuração. Uma nova execução substitui as saídas da compilação, incluindo o APK; ela não sobrescreve os arquivos-fonte.
gradle --no-daemon --max-workers=2 :app:assembleDebug
Com o dispositivo de destino conectado, substitua YOUR_DEVICE_SERIAL pelo número de série dele informado
por adb devices. A instalação com -r substitui este app de exemplo, mantendo os dados do app.
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
Escolha uma imagem local contendo texto grande, como “FATURA 12345”. Esse texto deve aparecer no resultado rolável, e os dois botões devem voltar a ficar disponíveis. Para uma verificação offline na primeira execução, instale o app sem abri-lo, desconecte a rede do dispositivo e, em seguida, abra o app e selecione uma foto local. Itens do seletor armazenados na nuvem ainda podem precisar de conexão para recuperar seus bytes.
Verifique estes resultados antes de adaptar o exemplo:
| Entrada ou interrupção | Resultado esperado |
|---|---|
| Imagem em branco | Mensagem “No text found. Try a clearer image.” |
| Imagem ilegível ou ausente | Mensagem “The image could not be read. Try another image.” |
| Fechar o seletor sem selecionar | “Selection canceled.”; os dois botões habilitados |
| Cancelar a captura | “Capture canceled.”; arquivo de captura vazio removido |
| A câmera informa sucesso sem gravar bytes | “The camera returned no image.”; arquivo de captura removido |
| Girar o aparelho enquanto o seletor ou a câmera está aberto | O resultado pendente continua pertencendo a essa operação |
| Recriar a activity durante o reconhecimento | “Recognition interrupted. Select an image again.”; controles habilitados |
| Girar o aparelho após o reconhecimento | O texto concluído é preservado |
O exemplo foi testado em um emulador Android 16 com o modelo latino empacotado. Use celulares reais para testar foco, exposição e compatibilidade com apps de câmera. Um JPEG rotacionado também precisa de metadados de orientação corretos; não presuma que girar o celular possa corrigir uma imagem codificada incorretamente.
Solução de problemas
Se o app informar que nenhum texto foi encontrado, primeiro tente uma imagem mais nítida com caracteres impressos maiores. Um resultado de reconhecimento vazio é diferente de um arquivo ilegível. Se a captura não retornar nenhuma imagem, verifique em conjunto o app de câmera e a authority do FileProvider, o caminho do cache e o nome do recurso no manifesto.
Um reconhecedor que só funciona depois de ficar online pode estar usando a dependência não
empacotada. Verifique as dependências resolvidas do Gradle antes de adicionar lógica de download de
modelo a este exemplo empacotado. Se a compilação não conseguir resolver ActivityMainBinding, verifique o
nome do arquivo de layout e a configuração do view binding.
Para uma alternativa que processa em um servidor os arquivos enviados por upload, explore a documentação do /document/ocr.
