Créer un OCR caméra Android avec OpenCV et Tesseract
Utilisez un aperçu caméra en direct pour reconnaître du texte imprimé sur l’appareil avec CameraX, OpenCV et Tesseract4Android. Vous allez créer une activité Java qui affiche le texte anglais reconnu sous l’aperçu, traite une trame à la fois et ignore les résultats d’une session d’activité terminée. Cette approche fondée sur un SDK OCR open source vous permet de contrôler le modèle et le prétraitement ; elle ne garantit pas une reconnaissance à la cadence d’images de la caméra.
Prérequis
- Android Studio avec prise en charge de Java, Android SDK Platform 36 et Build Tools 36.0.0
- Un appareil Android équipé d’une caméra et exécutant Android API 23 ou supérieur
- Des connaissances de base en Java et en Android Views
L’exemple fixe les versions de CameraX 1.6.2,
OpenCV 4.13.0 et Tesseract4Android 4.8.0. La compilation utilise
Android Gradle Plugin 9.2.1,
Gradle 9.4.1 et JDK 21, avec une compatibilité source Java 17. La version actuelle de
CameraX relève le minimum à API 23 ; ne remplacez pas le manifeste de la bibliothèque pour imposer
cette combinaison sur API 21. Les vérifications à l’exécution de cet exemple utilisent des émulateurs
Android 16 x86_64. La version minimale de l’OS et les appareils physiques nécessitent toujours des
tests distincts, notamment pour la mise au point, l’éclairage, l’utilisation prolongée de la mémoire
et la vitesse de reconnaissance.
Configurer le projet Android Studio
Créez un projet Empty Views Activity dans Android Studio. Choisissez Java et Groovy DSL, puis définissez Minimum SDK sur API 23. Utilisez un nouveau projet afin que le remplacement de la mise en page et de l’activité n’écrase pas du code d’application existant. Conservez sa déclaration de package, son espace de noms, l’entrée de manifeste de l’activité de lancement et son thème.
Définissez android.useAndroidX=true dans gradle.properties. La configuration de l’application ci-dessous conserve
targetSdk 34 pour cet exemple local ; choisissez et testez votre cible de déploiement
séparément avant de distribuer une application.
Ajouter les dépendances : OpenCV et Tesseract
settings.gradle au niveau du projet
Fusionnez ces dépôts dans le fichier settings.gradle, au sein de son bloc dependencyResolutionManagement existant.
Conservez les dépôts de plugins générés et l’inclusion du module de l’application :
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
build.gradle au niveau de l’application
Fusionnez ces paramètres dans le bloc android existant de l’application et ajoutez les
dépendances. Conservez son espace de noms et son identifiant d’application.
L’artefact Android officiel d’OpenCV
est org.opencv:opencv, et Tesseract4Android
est résolu depuis JitPack.
android {
compileSdk 36
defaultConfig {
minSdk 23
targetSdk 34
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
implementation 'androidx.activity:activity:1.9.3'
implementation 'androidx.camera:camera-camera2:1.6.2'
implementation 'androidx.camera:camera-lifecycle:1.6.2'
implementation 'androidx.camera:camera-view:1.6.2'
implementation 'org.opencv:opencv:4.13.0'
implementation 'cz.adaptech.tesseract4android:tesseract4android:4.8.0'
}
La compatibilité native dépend des deux bibliothèques et du packaging de l’APK. La
version 4.8.0 de Tesseract4Android a ajouté la prise en charge des 16 KB.
Utilisez les deux versions fixées ci-dessus pour OpenCV et CameraX : OpenCV 4.9.0 et
CameraX 1.3.4 contiennent des bibliothèques 64 bits alignées sur 4 KB.
Pour les builds de production, suivez la procédure de vérification 16 KB d’Android
pour l’APK ou l’app bundle final, y compris ses bibliothèques natives transitives.
Copier les fichiers de données entraînées de Tesseract
Placez le modèle anglais de tessdata 4.0.0
dans app/src/main/assets/tessdata/eng.traineddata, en créant les répertoires d’assets si nécessaire. Téléchargez
le binaire brut, et non la page d’aperçu GitHub. Son empreinte SHA-256 est
daa0c97d651c19fba3b25e81317cd697e9908c8208090c94c3905381c23fc047.
Ajoutez la
classe OCRManager.java complète ci-dessous au même package que MainActivity.
Elle copie l’asset dans un fichier temporaire, ne l’installe
qu’après une copie réussie, initialise Tesseract avec le répertoire parent de tessdata et libère
les ressources natives via close().
Le modèle est intégré à l’APK, donc le premier lancement fonctionne hors ligne. L’initialisation crée aussi une copie privée sur l’appareil. Les trames et le texte reconnu restent en mémoire ; cet exemple ne les téléverse ni ne les enregistre. Redémarrer l’activité lance une nouvelle analyse.
Implémenter le gestionnaire OCR
Enregistrez cette classe complète sous OCRManager.java dans le package de votre application (ajoutez
votre déclaration de package). Créez, utilisez et fermez le gestionnaire sur un seul thread de
travail en arrière-plan. L’appelant reste propriétaire de chaque bitmap.
import android.content.Context;
import android.graphics.Bitmap;
import com.googlecode.tesseract.android.TessBaseAPI;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
public final class OCRManager implements AutoCloseable {
private TessBaseAPI tessBaseAPI;
public OCRManager(Context context) throws IOException {
File root = new File(context.getFilesDir(), "tesseract-4.0.0");
File data = new File(root, "tessdata");
if (!data.isDirectory() && !data.mkdirs()) {
throw new IOException("Could not create tessdata directory");
}
File model = new File(data, "eng.traineddata");
if (!model.isFile() || model.length() == 0) {
File temporary = File.createTempFile("eng-", ".tmp", data);
try {
try (InputStream input = context.getAssets().open("tessdata/eng.traineddata");
FileOutputStream output = new FileOutputStream(temporary)) {
byte[] buffer = new byte[8192];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
}
}
if (temporary.length() == 0 || !temporary.renameTo(model)) {
throw new IOException("Could not install English model");
}
} finally {
temporary.delete();
}
}
TessBaseAPI api = new TessBaseAPI();
try {
if (!api.init(root.getAbsolutePath(), "eng")) {
throw new IOException("Could not initialize Tesseract");
}
tessBaseAPI = api;
} finally {
if (tessBaseAPI == null) api.recycle();
}
}
public String extractTextFromImage(Bitmap bitmap) {
if (tessBaseAPI == null) throw new IllegalStateException("OCR manager is closed");
if (bitmap == null || bitmap.isRecycled()) {
throw new IllegalArgumentException("A readable bitmap is required");
}
try {
tessBaseAPI.setImage(bitmap);
String text = tessBaseAPI.getUTF8Text();
if (text == null) throw new IllegalStateException("Recognition failed");
return text;
} finally {
tessBaseAPI.clear();
}
}
@Override
public void close() {
if (tessBaseAPI != null) {
tessBaseAPI.recycle();
tessBaseAPI = null;
}
}
}
Utilisez try-with-resources pour une seule image, ou conservez un gestionnaire unique sur un
exécuteur séquentiel pour des images répétées et mettez close() en file d’attente après la
dernière tâche. Ne le fermez pas depuis le thread UI pendant que l’OCR s’exécute. Une copie ayant
échoué ne devient jamais le modèle installé ; utilisez un nouveau nom de répertoire privé lorsque
vous livrez une autre version du modèle.
Configurer l’accès à la caméra et les autorisations
Ajoutez ces éléments directement sous <manifest> dans AndroidManifest.xml, qui déclare déjà
l’espace de noms XML android :
<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" />
L’activité ci-dessous demande l’autorisation à l’exécution avant de démarrer la caméra. Après un
refus, autorisez l’accès à la caméra dans les paramètres Android de l’application, puis revenez à
l’activité existante. onResume() vérifie l’autorisation actuelle et lance l’initialisation ; il
n’est donc pas nécessaire de redémarrer le processus.
Android exige de vérifier l’autorisation avant d’accéder aux données protégées.
Aucune autorisation de stockage n’est nécessaire pour le modèle intégré ou les fichiers privés de
l’application.
Intégrer le flux de la caméra en direct
Remplacez res/layout/activity_main.xml par cette mise en page :
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.camera.view.PreviewView
android:id="@+id/preview_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/text_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="6"
android:padding="16dp"
android:textSize="16sp" />
</LinearLayout>
Implémenter l’OCR en temps réel
Conservez votre déclaration de package générée et remplacez MainActivity.java par les imports et la
classe suivants. CameraX lie l’aperçu et l’analyseur au cycle de vie de l’activité. L’initialisation,
la reconnaissance et le nettoyage de Tesseract s’exécutent tous sur le même thread de travail
séquentiel. Chaque trame est fermée dans un
bloc finally, y compris les trames ignorées et les échecs, comme l’exige
l’analyse d’images de CameraX.
import android.Manifest;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.os.Bundle;
import android.view.OrientationEventListener;
import android.view.Surface;
import android.widget.TextView;
import androidx.activity.ComponentActivity;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.ImageProxy;
import androidx.camera.core.Preview;
import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.camera.view.PreviewView;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Lifecycle;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.opencv.android.OpenCVLoader;
import org.opencv.android.Utils;
import org.opencv.core.Mat;
import org.opencv.imgproc.Imgproc;
public class MainActivity extends ComponentActivity {
private final ExecutorService worker = Executors.newSingleThreadExecutor();
private volatile boolean stopped;
private volatile boolean active;
private volatile int session;
private boolean initializing;
private OCRManager ocr;
private PreviewView previewView;
private TextView resultText;
private ProcessCameraProvider cameraProvider;
private Preview preview;
private ImageAnalysis analysis;
private OrientationEventListener orientationListener;
private final ActivityResultLauncher<String> cameraPermission = registerForActivityResult(
new ActivityResultContracts.RequestPermission(), granted -> {
if (granted) initializeOCR();
else resultText.setText("Camera permission is required to scan text");
});
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
previewView = findViewById(R.id.preview_view);
resultText = findViewById(R.id.text_result);
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
cameraPermission.launch(Manifest.permission.CAMERA);
}
}
@Override
protected void onResume() {
super.onResume();
active = true;
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED) {
initializeOCR();
}
}
@Override
protected void onPause() {
active = false;
session++;
super.onPause();
}
private void initializeOCR() {
if (stopped || initializing) return;
initializing = true;
resultText.setText("Loading text recognition…");
worker.execute(() -> {
try {
if (!OpenCVLoader.initLocal()) throw new IOException("OpenCV did not load");
ocr = new OCRManager(getApplicationContext());
runOnUiThread(() -> {
if (!stopped) {
resultText.setText("Point the camera at printed text");
startCamera();
}
});
} catch (IOException | RuntimeException | UnsatisfiedLinkError error) {
showResult("Text recognition could not be initialized");
}
});
}
private void startCamera() {
ListenableFuture<ProcessCameraProvider> future = ProcessCameraProvider.getInstance(this);
future.addListener(() -> {
if (stopped) return;
try {
cameraProvider = future.get();
preview = new Preview.Builder().build();
preview.setSurfaceProvider(previewView.getSurfaceProvider());
analysis = new ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build();
analysis.setAnalyzer(worker, this::analyze);
cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA,
preview, analysis);
orientationListener = new OrientationEventListener(this) {
@Override
public void onOrientationChanged(int degrees) {
if (degrees == ORIENTATION_UNKNOWN) return;
int rotation = degrees >= 315 || degrees < 45 ? Surface.ROTATION_0
: degrees < 135 ? Surface.ROTATION_270
: degrees < 225 ? Surface.ROTATION_180 : Surface.ROTATION_90;
analysis.setTargetRotation(rotation);
}
};
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)
&& orientationListener.canDetectOrientation()) {
orientationListener.enable();
}
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
showResult("The camera could not be started");
} catch (ExecutionException | RuntimeException error) {
showResult("The camera could not be started");
}
}, ContextCompat.getMainExecutor(this));
}
private void analyze(ImageProxy frame) {
int frameSession = session;
Bitmap source = null;
Bitmap upright = null;
Bitmap processed = null;
try {
if (stopped || !active) return;
source = frame.toBitmap();
Matrix rotation = new Matrix();
rotation.postRotate(frame.getImageInfo().getRotationDegrees());
upright = Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(),
rotation, true);
processed = preprocessImage(upright);
String text = ocr.extractTextFromImage(processed);
showFrameResult(text.trim().isEmpty() ? "No text found" : text, frameSession);
} catch (RuntimeException error) {
showFrameResult("This frame could not be recognized", frameSession);
} finally {
if (processed != null) processed.recycle();
if (upright != null && upright != source) upright.recycle();
if (source != null) source.recycle();
frame.close();
}
}
private Bitmap preprocessImage(Bitmap source) {
Mat rgba = new Mat();
Mat gray = new Mat();
Bitmap result = null;
try {
Utils.bitmapToMat(source, rgba);
Imgproc.cvtColor(rgba, gray, Imgproc.COLOR_RGBA2GRAY);
Imgproc.threshold(gray, gray, 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU);
result = Bitmap.createBitmap(gray.cols(), gray.rows(), Bitmap.Config.ARGB_8888);
Utils.matToBitmap(gray, result);
return result;
} catch (RuntimeException error) {
if (result != null) result.recycle();
throw error;
} finally {
gray.release();
rgba.release();
}
}
private void showResult(String text) {
runOnUiThread(() -> {
if (!stopped) resultText.setText(text);
});
}
private void showFrameResult(String text, int frameSession) {
runOnUiThread(() -> {
if (!stopped && active && session == frameSession) resultText.setText(text);
});
}
@Override
protected void onStart() {
super.onStart();
if (orientationListener != null && orientationListener.canDetectOrientation()) {
orientationListener.enable();
}
}
@Override
protected void onStop() {
if (orientationListener != null) orientationListener.disable();
super.onStop();
}
@Override
protected void onDestroy() {
stopped = true;
if (orientationListener != null) orientationListener.disable();
if (analysis != null) analysis.clearAnalyzer();
if (cameraProvider != null && preview != null && analysis != null) {
cameraProvider.unbind(preview, analysis);
}
// Cleanup follows any running frame; recycling on the UI thread would race with OCR.
worker.execute(() -> {
if (ocr != null) ocr.close();
});
worker.shutdown();
super.onDestroy();
}
}
Optimiser les performances et l’utilisation de la mémoire
STRATEGY_KEEP_ONLY_LATEST limite l’arriéré sans mettre en file d’attente des copies de bitmaps. L’analyseur
fonctionne de manière synchrone sur son thread de travail séquentiel, de sorte que Tesseract ne
traite jamais deux trames simultanément. L’API
ImageProxy.toBitmap() gère la disposition du tampon de la caméra ; la rotation de son résultat respecte les
métadonnées d’orientation de la trame. L’étape de prétraitement convertit le RGBA en un seul canal
en niveaux de gris avant le seuillage d’Otsu et libère les deux matrices OpenCV, même en cas d’échec.
Pour les caractères éloignés ou petits, rapprochez-vous ou ajustez la résolution d’analyse. Mesurez la qualité de reconnaissance avant d’ajouter du flou ou de réduire la résolution. La binarisation peut aider pour le texte imprimé, mais comparez son résultat avec l’image d’origine dans vos conditions d’éclairage réelles.
Gérer la reconnaissance de texte multilingue
Cet exemple exécutable reconnaît l’anglais. Tesseract peut initialiser plusieurs langues installées
avec une chaîne comme eng+fra+deu, mais modifier cet argument ne suffit pas : chaque fichier de
données entraînées correspondant doit d’abord être installé. Si vous étendez le gestionnaire, testez
ces modèles et leurs cas d’échec séparément. Ne remplacez pas un gestionnaire pendant que son thread
de travail reconnaît une trame.
Tester et déboguer les problèmes courants
Lancez l’application et accordez l’accès à la caméra. Après « Loading text recognition… », visez un texte anglais imprimé en grands caractères nets. Le texte reconnu remplace le message sous l’aperçu ; une trame vide affiche « No text found ». Les résultats se mettent à jour à mesure que la reconnaissance se termine, et non une fois par trame vidéo affichée.
Si vous voyez « Text recognition could not be initialized », vérifiez le chemin et la somme de contrôle du modèle empaqueté, puis recherchez dans le journal de l’appareil des erreurs de chargement de bibliothèques natives. Les assets manquants ou vides échouent pendant la copie ; un modèle corrompu non vide fait échouer l’initialisation. Un modèle privé non vide est réutilisé ; corriger une copie corrompue déjà installée nécessite donc d’effacer les données de cette application exemple ou de la réinstaller. Cela supprime la copie privée du modèle de l’exemple. Pour livrer un autre modèle, il convient d’utiliser un nouveau nom de répertoire.
« The camera could not be started » indique que la liaison de la caméra a échoué. Vérifiez que l’appareil dispose d’une caméra arrière disponible. « This frame could not be recognized » signale un échec propre à une trame ; les trames suivantes peuvent toujours être traitées. Testez un refus suivi de l’octroi de l’autorisation dans Paramètres, le fait de quitter l’application puis d’y revenir, et la recréation de l’activité. Les résultats en cours datant d’avant une pause ne doivent pas remplacer le retour affiché pour la session actuelle.
Sur Android 16, une image synthétique « HELLO ANDROID 123 » est passée par l’analyseur de cette activité dans les quatre rotations, et une image vide a produit « No text found ». La caméra de l’émulateur a également fourni des trames répétées via CameraX. Ces vérifications testent la conversion et la gestion du cycle de vie avec des entrées contrôlées ; elles ne mesurent pas la capacité d’une caméra physique à lire une page.
Avant la mise en production, testez les quatre orientations, la révocation de l’autorisation et l’analyse prolongée sur vos appareils cibles. Comparez l’image seuillée avec l’original en présence de flou, d’un éclairage inégal et de petits caractères. Un nettoyage correct des trames et une file d’attente bornée évitent l’accumulation d’un arriéré ; ils ne permettent pas d’établir la précision de l’OCR, une consommation de batterie acceptable ni un taux de reconnaissance particulier.
