Echtzeit-Texterkennung in Android-Apps mit OpenCV und Tesseract implementieren
Nutzen Sie eine Live-Kameravorschau, um gedruckten Text mit CameraX, OpenCV und Tesseract4Android direkt auf dem Gerät zu erkennen. Dieses Beispiel verarbeitet jeweils ein Einzelbild und verwirft veraltete Einzelbilder, solange die OCR beschäftigt ist. So bleibt die Vorschau reaktionsfähig. Geschwindigkeit und Genauigkeit der Erkennung hängen vom Gerät und vom Text ab.
Voraussetzungen
- Android Studio mit einem Android SDK und Java-Unterstützung
- Ein Android-Gerät mit Kamera und Android API 21 oder höher
- Grundkenntnisse in Java und Android Views
Die Beispiele verwenden die festgelegten Versionen CameraX 1.3.4,
OpenCV 4.9.0 und Tesseract4Android 4.8.0.
Ein Build prüft die Java- und Ressourcenschnittstellen; Kameraberechtigungen, Gerätedrehung,
das Laden nativer Bibliotheken und die Erkennung müssen Sie noch auf Ihren Zielgeräten testen.
Das Android-Studio-Projekt einrichten
Erstellen Sie in Android Studio ein Projekt vom Typ Empty Views Activity.
Wählen Sie Java und Groovy DSL, und setzen Sie
Minimum SDK auf API 21.
Behalten Sie die generierte Paketdeklaration, den Namespace, den Activity-Eintrag im Manifest und
das Design bei. Verwenden Sie für die folgenden Abhängigkeiten compileSdk 34
oder neuer.
Abhängigkeiten hinzufügen: OpenCV und Tesseract
settings.gradle auf Projektebene
Wenn Ihr Projekt Repositorys zentral verwaltet, konfigurieren Sie diese Repositorys in
settings.gradle innerhalb des vorhandenen Blocks dependencyResolutionManagement:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
build.gradle auf App-Ebene
Fügen Sie diese App-Abhängigkeiten hinzu. Das offizielle OpenCV-Artefakt für Android
heißt org.opencv:opencv, und Tesseract4Android
wird aus JitPack bezogen.
dependencies {
implementation 'androidx.activity:activity:1.9.3'
implementation 'androidx.camera:camera-camera2:1.3.4'
implementation 'androidx.camera:camera-lifecycle:1.3.4'
implementation 'androidx.camera:camera-view:1.3.4'
implementation 'org.opencv:opencv:4.9.0'
implementation 'cz.adaptech.tesseract4android:tesseract4android:4.8.0'
}
Trainierte Tesseract-Datendateien kopieren
Legen Sie das englische Modell aus tessdata 4.0.0
unter app/src/main/assets/tessdata/eng.traineddata ab. Fügen Sie die vollständige
Klasse OCRManager.java weiter unten demselben Paket
wie MainActivity hinzu. Sie kopiert die Ressource in eine temporäre Datei und
installiert sie erst nach erfolgreichem Kopieren. Anschließend initialisiert sie Tesseract mit dem
übergeordneten Verzeichnis von tessdata und gibt native Ressourcen mithilfe
von close() frei.
Den OCR-Manager implementieren
Speichern Sie diese vollständige Klasse als OCRManager.java im Paket Ihrer App
(fügen Sie Ihre Paketdeklaration hinzu). Erstellen, verwenden und schließen Sie die Instanz auf
demselben Hintergrund-Worker. Der Aufrufer behält die Verantwortung für jede 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");
try {
tessBaseAPI.setImage(bitmap);
return tessBaseAPI.getUTF8Text();
} finally {
tessBaseAPI.clear();
}
}
@Override
public void close() {
if (tessBaseAPI != null) {
tessBaseAPI.recycle();
tessBaseAPI = null;
}
}
}
Verwenden Sie für ein einzelnes Bild try-with-resources. Für die wiederholte Verarbeitung von
Bildern können Sie einen Manager auf einem seriellen Executor beibehalten und
close() nach der letzten Aufgabe in die Queue einreihen. Schließen Sie ihn
nicht aus dem UI-Thread, während die OCR läuft. Eine fehlgeschlagene Kopie wird niemals zum
installierten Modell. Verwenden Sie einen neuen privaten Verzeichnisnamen, wenn Sie eine andere
Modellversion ausliefern.
Kamerazugriff und Berechtigungen konfigurieren
Fügen Sie diese Elemente direkt unter <manifest> in
AndroidManifest.xml ein. Dort ist der XML-Namespace android
bereits deklariert:
<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" />
Die folgende Activity fordert vor dem Start der Kamera die Berechtigung zur Laufzeit an. Bei einer Ablehnung wird eine eindeutige Meldung angezeigt. Nutzer können die App erneut öffnen, nachdem sie den Kamerazugriff in den Android-Einstellungen erlaubt haben. Für das mitgelieferte Modell und app-private Dateien ist keine Speicherberechtigung erforderlich.
Live-Kamerabilder integrieren
Ersetzen Sie res/layout/activity_main.xml durch dieses Layout:
<?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>
Echtzeit-OCR implementieren
Behalten Sie Ihre generierte Paketdeklaration bei und ersetzen Sie MainActivity.java
durch die folgenden Importe und die Klasse. CameraX bindet die Vorschau und den Analyzer an den
Lebenszyklus der Activity. Initialisierung, Erkennung und die Freigabe der Tesseract-Ressourcen
laufen auf demselben seriellen Worker. Jedes Einzelbild wird in einem Block
finally geschlossen, auch bei übersprungenen Einzelbildern und Fehlern,
wie es die CameraX-Bildanalyse erfordert.
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 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) {
initializeOCR();
} else {
cameraPermission.launch(Manifest.permission.CAMERA);
}
}
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) 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) {
Bitmap source = null;
Bitmap upright = null;
Bitmap processed = null;
try {
if (stopped) 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);
showResult(ocr.extractTextFromImage(processed));
} catch (RuntimeException error) {
showResult("This frame could not be recognized");
} 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);
});
}
@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();
}
}
Leistung und Speicherverbrauch optimieren
STRATEGY_KEEP_ONLY_LATEST begrenzt den Rückstau, ohne Bitmap-Kopien in eine Queue einzureihen.
Der Analyzer arbeitet synchron auf seinem seriellen Worker, sodass Tesseract niemals zwei
Einzelbilder gleichzeitig verarbeitet. Die API ImageProxy.toBitmap() berücksichtigt
das Layout des Kamerapuffers. Beim Drehen des Ergebnisses werden die Orientierungsmetadaten des
Einzelbilds berücksichtigt. Die Vorverarbeitung wandelt RGBA vor der Otsu-Schwellwertbildung in
einen einzelnen Graustufenkanal um und gibt beide OpenCV-Matrizen auch im Fehlerfall frei.
Gehen Sie bei weit entfernten oder kleinen Zeichen näher heran oder passen Sie die Auflösung der Analyse an. Messen Sie die Erkennungsqualität, bevor Sie einen Unschärfefilter hinzufügen oder die Auflösung reduzieren. Binarisierung kann bei gedrucktem Text helfen. Vergleichen Sie das Ergebnis jedoch unter Ihren tatsächlichen Lichtverhältnissen mit dem Originalbild.
Mehrsprachige Texterkennung einrichten
Liefern Sie für Französisch und Deutsch fra.traineddata und
deu.traineddata aus derselben Modellversion 4.0.0 mit.
Erweitern Sie die Installationsschleife in OCRManager, sodass sie alle drei
genannten Dateien vor der Initialisierung kopiert, und übergeben Sie eng+fra+deu
an init() anstelle von eng. Bei fehlenden oder
ungültigen Sprachmodellen muss die Initialisierung mit einer sichtbaren Fehlermeldung scheitern.
Um Sprachen zur Laufzeit zu wechseln, stoppen Sie die Analyse und reihen Sie den Austausch des
Managers auf demselben seriellen Worker nach dem aktuellen Einzelbild in die Queue ein.
Häufige Probleme testen und Fehler beheben
- Testen Sie auf einem physischen Gerät die erstmalige Erteilung und die Ablehnung der Berechtigung sowie deren Entzug in den Einstellungen.
- Drehen Sie das Gerät in alle vier Ausrichtungen und prüfen Sie, ob der Text aufrecht bleibt.
- Verlassen Sie die Activity, kehren Sie zurück und erstellen Sie sie neu. Prüfen Sie, ob die Vorschau wieder startet.
- Testen Sie fehlende und leere Modellressourcen sowie eine nicht verfügbare rückseitige Kamera.
- Vergleichen Sie gedruckten Text bei scharfer Fokussierung, Unschärfe, hellem und schwachem Licht.
- Erstellen Sie ein Leistungsprofil eines längeren Scanvorgangs und prüfen Sie, ob der Speicherverbrauch für Einzelbilder, Bitmaps und native Matrizen begrenzt bleibt.
Ein Android-Build allein kann weder dieses Geräteverhalten noch die OCR-Genauigkeit bestätigen. Testen Sie beide unterstützten ABIs und die Bereitstellungseinstellungen Ihrer App, bevor Sie die nativen Bibliotheken ausliefern.
Fazit
Diese Pipeline verbindet eine Live-Vorschau von CameraX mit serieller OpenCV-Vorverarbeitung und Tesseract-OCR sowie expliziter Modellinstallation und Ressourcenfreigabe. Für Workflows zum Upload und zur serverseitigen Verarbeitung entdecken Sie Uppy und den Robot /document/ocr von Transloadit.
