Implémenter l’OCR dans les apps Android avec Google ML Kit
Utilisez le module de reconnaissance de texte embarqué de ML Kit lorsque votre app Android doit lire une photo dès son premier lancement, y compris sans connexion réseau. Cet exemple crée une petite app Kotlin avec deux entrées : choisir une image existante ou prendre une photo en pleine résolution avec l’app appareil photo du système. Elle affiche le texte en écriture latine et donne un résultat visible lorsque l’image est vide, illisible ou annulée.
Prérequis
Vous devez connaître Kotlin et disposer d’une installation du SDK Android ainsi que d’un appareil ou d’un émulateur. L’exemple utilise le plugin Android Gradle 9.2.1, Gradle 9.4.1, JDK 21, SDK Platform 36 et Build Tools 36.0.0. Il s’agit d’un ensemble de versions reproductible, et non d’une obligation de mettre à niveau une app existante. Consultez le tableau de compatibilité d’AGP si vous utilisez une autre chaîne d’outils.
Définissez JAVA_HOME sur votre JDK et ANDROID_HOME sur votre SDK, puis ajoutez Gradle et le répertoire
platform-tools du SDK à votre PATH. La compilation nécessite un accès réseau pour récupérer les
dépendances. La valeur minSdk de l’app est 23, conformément aux
exigences de configuration de Text Recognition v2 de Google.
Les vérifications d’exécution ci-dessous utilisent Android 16, API 36 ; elles ne permettent pas
d’établir le comportement sur toutes les versions antérieures de l’OS ni sur tous les appareils
photo physiques.
Configuration du projet Android
Commencez dans un nouveau répertoire vide nommé TextRecognitionApp. Créez les fichiers ci-dessous aux
chemins relatifs indiqués, y compris leurs répertoires parents. Ce sont des fichiers complets :
vous n’avez donc pas besoin de les combiner avec un modèle Android Studio ni de remplacer des
fichiers dans un projet existant.
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 intègre la compilation Kotlin, ce projet n’applique donc pas de plugin Kotlin Android distinct.
Ajout de la dépendance ML Kit
Créez app/build.gradle. Le view binding génère ActivityMainBinding à partir de la mise en page que vous
ajouterez bientôt.
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 dernière dépendance embarque le modèle latin dans l’app. L’alternative,
com.google.android.gms:play-services-mlkit-text-recognition:19.0.1, télécharge son modèle via les
services Google Play. Elle réduit la taille du téléchargement initial de l’app, mais la
reconnaissance ne peut renvoyer de résultats qu’une fois le modèle prêt. Choisissez une seule
approche ; ce tutoriel utilise uniquement le modèle embarqué. La
comparaison des modes d’installation
de Google explique les compromis et les options de téléchargement.
Configuration des autorisations
Le sélecteur de photos
accorde l’accès à l’image choisie sans autorisation étendue sur la photothèque. PickVisualMedia
utilise ACTION_OPEN_DOCUMENT comme dernier recours lorsqu’aucun sélecteur n’est disponible.
Déléguer la capture à une app appareil photo installée évite également de demander un accès direct
à l’appareil photo ; Android documente cette
approche par intent d’appareil photo.
Créez 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>
Créez 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 n’expose
que ce répertoire de capture via des URI de contenu. TakePicture fournit un URI à l’appareil
photo pour qu’il puisse écrire une image en pleine résolution, au lieu de renvoyer une petite
miniature.
Création de la mise en page
Créez app/src/main/res/layout/activity_main.xml. Le résultat est sélectionnable afin que vous puissiez le copier ;
les messages d’état utilisent la même vue et restent visibles après l’échec d’une opération.
<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>
Implémentation de la fonctionnalité OCR
Créez app/src/main/java/com/example/textrecognition/MainActivity.kt. Les deux boutons restent désactivés
tant qu’une activité externe ou une reconnaissance est en attente. Le décodage s’exécute sur un
thread de travail, et ML Kit reçoit l’URI sélectionné via InputImage.fromFilePath().
Android peut recréer votre activité pendant que le sélecteur ou l’appareil photo est ouvert. Enregistrer les lanceurs dans un ordre stable et sauvegarder l’état supplémentaire de l’opération permet à leurs résultats d’atteindre l’activité de remplacement. Un scan déjà en cours de reconnaissance n’est pas repris après la recréation : le nouvel écran vous demande de sélectionner à nouveau une image.
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)
}
}
L’écouteur d’encarts maintient les commandes à l’écart des
barres système et des encoches d’écran.
L’app conserve le texte obtenu lors d’une recréation, mais n’enregistre pas d’historique des scans
et ne conserve pas d’aperçu. Chaque capture reçoit un nouveau nom de fichier en cache. Les captures
terminées et annulées sont supprimées ; une capture abandonnée est supprimée lors d’un lancement
ultérieur, au bout d’un jour. Ne touchez pas à une capture en attente dans
onDestroy(), car l’appareil photo peut encore être en train de l’écrire.
L’image du sélecteur est lue pour l’opération immédiate ; son fichier d’origine n’est jamais supprimé. Une tâche de reconnaissance conserve son module de reconnaissance jusqu’à la fin, même si son activité est détruite. Ses callbacks ne peuvent pas remplacer le texte dans une nouvelle instance d’activité.
Optimisation de la précision et des performances de l’OCR
Commencez par une photo nette et bien éclairée de texte imprimé. Les consignes relatives aux images d’entrée de Google recommandent au moins 16 × 16 pixels par caractère ; au-delà d’environ 24 × 24, des caractères plus grands n’améliorent généralement pas la précision. Recadrez l’arrière-plan inutile tout en gardant le texte lisible.
Il s’agit d’un flux d’image fixe, et non d’un analyseur de caméra en direct. Un aperçu CameraX nécessite sa propre autorisation ainsi que la gestion de la rotation des trames et de la contre-pression. Le chinois, le devanagari, le japonais et le coréen nécessitent aussi les dépendances de modèle correspondantes et les options de reconnaissance adaptées, plutôt que les options latines utilisées ici.
Test de l’application
Depuis le répertoire du projet, compilez l’APK de débogage. Une nouvelle exécution remplace les sorties de compilation, y compris l’APK ; elle n’écrase pas les fichiers source.
gradle --no-daemon --max-workers=2 :app:assembleDebug
Une fois votre appareil cible connecté, remplacez YOUR_DEVICE_SERIAL par son numéro de série indiqué par
adb devices. L’installation avec -r remplace cette app d’exemple tout en conservant ses
données.
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
Choisissez une image locale contenant du texte en grands caractères, comme « INVOICE 12345 ». Ce texte devrait apparaître dans le résultat défilable, et les deux boutons devraient redevenir disponibles. Pour vérifier un premier lancement hors ligne, installez l’app sans la lancer, déconnectez l’appareil du réseau, puis ouvrez l’app et sélectionnez une photo locale. Les éléments du sélecteur stockés dans le cloud peuvent encore nécessiter une connexion pour récupérer leurs octets.
Vérifiez ces résultats avant d’adapter l’exemple :
| Entrée ou interruption | Résultat attendu |
|---|---|
| Image vide | « No text found. Try a clearer image. » |
| Image illisible ou manquante | « The image could not be read. Try another image. » |
| Fermer le sélecteur sans sélectionner | « Selection canceled. » ; les deux boutons activés |
| Annuler la capture | « Capture canceled. » ; fichier de capture vide supprimé |
| L’appareil photo signale un succès sans écrire d’octets | « The camera returned no image. » ; fichier de capture supprimé |
| Faire pivoter pendant que le sélecteur ou l’appareil photo est ouvert | Le résultat en attente appartient toujours à cette opération |
| Recréer l’activité pendant la reconnaissance | « Recognition interrupted. Select an image again. » ; commandes activées |
| Faire pivoter après la reconnaissance | Le texte obtenu est conservé |
L’exemple a été testé sur un émulateur Android 16 avec le modèle latin embarqué. Utilisez de vrais téléphones pour tester la mise au point, l’exposition et la compatibilité avec les apps appareil photo. Un JPEG pivoté nécessite aussi des métadonnées d’orientation correctes ; ne supposez pas que faire pivoter le téléphone puisse corriger une image mal encodée.
Dépannage
Si l’app ne signale aucun texte, essayez d’abord une image plus nette avec des caractères imprimés plus grands. Un résultat de reconnaissance vide est différent d’un fichier illisible. Si la capture ne renvoie aucune image, vérifiez conjointement l’app appareil photo, l’autorité du FileProvider, le chemin du cache et le nom de ressource du manifeste.
Un module de reconnaissance qui ne fonctionne qu’après une connexion à Internet utilise peut-être la
dépendance non embarquée. Vérifiez vos dépendances Gradle résolues avant d’ajouter une logique de
téléchargement de modèle à cet exemple embarqué. Si la compilation ne parvient pas à résoudre
ActivityMainBinding, vérifiez le nom du fichier de mise en page et le paramètre de view binding.
Pour une alternative qui traite les fichiers envoyés sur un serveur, consultez la documentation de /document/ocr (English).
