Android-Kamera-OCR mit OpenCV und Tesseract entwickeln
Erkennen Sie gedruckten Text direkt auf dem Gerät mit einer Live-Kameravorschau, CameraX, OpenCV und Tesseract4Android. Sie entwickeln eine Java-Activity, die erkannten englischen Text unter der Vorschau anzeigt, jeweils einen Frame verarbeitet und Ergebnisse aus einer beendeten Activity-Sitzung verwirft. Dieser Ansatz mit einem Open-Source-OCR-SDK gibt Ihnen Kontrolle über Modell und Vorverarbeitung; eine Erkennung mit der Bildrate der Kamera verspricht er nicht.
Voraussetzungen
- Android Studio mit Java-Unterstützung, Android SDK Platform 36 und Build Tools 36.0.0
- Ein Android-Gerät mit Kamera und Android API 23 oder höher
- Grundkenntnisse in Java und Android Views
Das Beispiel verwendet festgelegte Versionen von
CameraX 1.6.2,
OpenCV 4.13.0 und Tesseract4Android 4.8.0.
Der Build nutzt
Android Gradle Plugin 9.2.1,
Gradle 9.4.1 und JDK 21 mit Java-17-Quellcodekompatibilität.
Das aktuelle CameraX hebt die Mindestanforderung auf API 23 an. Überschreiben Sie das
Bibliotheksmanifest nicht, um diese Kombination auf API 21 zu erzwingen. Laufzeitprüfungen für dieses
Beispiel verwenden Android-16-x86_64-Emulatoren. Die niedrigste unterstützte Betriebssystemversion
und physische Geräte müssen weiterhin separat getestet werden, einschließlich Fokus, Beleuchtung,
Speichernutzung im Dauerbetrieb und Erkennungsgeschwindigkeit.
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 23. Verwenden Sie ein neues Projekt, damit beim Ersetzen von Layout und Activity kein bestehender Anwendungscode überschrieben wird. Behalten Sie die Paketdeklaration, den Namespace, den Manifesteintrag der Launcher-Activity und das Theme bei.
Setzen Sie android.useAndroidX=true in gradle.properties. Die folgende
App-Konfiguration behält targetSdk 34 für dieses lokale Beispiel bei. Wählen und
testen Sie Ihre Zielplattform separat, bevor Sie eine App veröffentlichen.
Abhängigkeiten hinzufügen: OpenCV und Tesseract
settings.gradle auf Projektebene
Ergänzen Sie diese Repositories in settings.gradle im vorhandenen Block
dependencyResolutionManagement. Behalten Sie die generierten Plugin-Repositories und die Einbindung
des App-Moduls bei:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
build.gradle auf App-Ebene
Ergänzen Sie diese Einstellungen im vorhandenen Block android der App und
fügen Sie die Abhängigkeiten hinzu. Behalten Sie den Namespace und die Anwendungs-ID bei. Das
offizielle OpenCV-Artefakt für Android ist
org.opencv:opencv, und Tesseract4Android
wird aus JitPack bezogen.
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'
}
Die native Kompatibilität hängt sowohl von den Bibliotheken als auch von der APK-Paketierung ab.
Mit der Version 4.8.0 erhielt Tesseract4Android Unterstützung für 16 KB.
Verwenden Sie die oben festgelegten Versionen von OpenCV und CameraX:
OpenCV 4.9.0 und CameraX 1.3.4 enthalten
64-Bit-Bibliotheken mit einer Ausrichtung von 4 KB.
Befolgen Sie bei Release-Builds das
Android-Prüfverfahren für 16 KB für das endgültige APK oder
App-Bundle einschließlich seiner transitiven nativen Bibliotheken.
Trainierte Datendateien für Tesseract kopieren
Legen Sie das englische Modell aus tessdata 4.0.0
unter app/src/main/assets/tessdata/eng.traineddata ab und erstellen Sie bei Bedarf die Asset-Verzeichnisse.
Laden Sie die rohe Binärdatei herunter, nicht die GitHub-Vorschauseite. Ihr SHA-256-Wert lautet
daa0c97d651c19fba3b25e81317cd697e9908c8208090c94c3905381c23fc047.
Fügen Sie die vollständige
unten stehende Klasse OCRManager.java
demselben Paket wie MainActivity hinzu.
Sie kopiert das Asset in eine temporäre Datei, installiert es erst nach erfolgreichem Kopieren,
initialisiert Tesseract mit dem übergeordneten Verzeichnis von tessdata und
gibt native Ressourcen mit close() frei.
Das Modell wird im APK mitgeliefert, sodass der erste Start offline funktioniert. Bei der Initialisierung wird außerdem eine private Kopie auf dem Gerät erstellt. Frames und erkannter Text bleiben im Arbeitsspeicher; dieses Beispiel lädt sie weder hoch noch speichert es sie. Ein Neustart der Activity beginnt einen neuen Scan.
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 sie auf einem
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");
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;
}
}
}
Verwenden Sie für ein einzelnes Bild try-with-resources oder behalten Sie für wiederholte Bilder
einen Manager auf einem seriellen Executor bei und reihen Sie close() nach
der letzten Aufgabe ein. Schließen Sie ihn nicht vom UI-Thread aus, während die OCR läuft. Eine
fehlgeschlagene Kopie wird nie 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 hinzu. 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 die Laufzeitberechtigung an, bevor sie die Kamera startet. Erlauben
Sie nach einer Ablehnung den Kamerazugriff in den Android-Einstellungen der App und kehren Sie zur
bestehenden Activity zurück. onResume() prüft die aktuelle Berechtigung und
startet die Initialisierung, sodass kein Prozessneustart erforderlich ist. Android verlangt,
die Berechtigung vor dem Zugriff auf geschützte Daten zu prüfen.
Für das mitgelieferte Modell oder app-private Dateien ist keine Speicherberechtigung erforderlich.
Den Live-Kamerastream 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 Imports und die Klasse. CameraX bindet die Vorschau und den Analyzer an den
Lebenszyklus der Activity. Initialisierung, Erkennung und die Ressourcenfreigabe von Tesseract
laufen alle auf demselben seriellen Worker. Jeder Frame wird in einem Block
finally geschlossen, auch bei übersprungenen Frames und Fehlern, wie es die
CameraX-Bildanalyse verlangt.
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();
}
}
Leistung und Speichernutzung 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 nie zwei Frames
gleichzeitig verarbeitet. Die API ImageProxy.toBitmap() berücksichtigt das Layout des
Kamerapuffers; beim Drehen ihres Ergebnisses werden die Orientierungsmetadaten des Frames beachtet.
Die Vorverarbeitung wandelt RGBA vor der Otsu-Schwellwertbildung in einen einzelnen Graustufenkanal
um und gibt auch bei Fehlern beide OpenCV-Matrizen frei.
Gehen Sie bei weit entfernten oder kleinen Zeichen näher heran oder passen Sie die Analyseauflösung an. Messen Sie die Erkennungsqualität, bevor Sie Weichzeichnung hinzufügen oder die Auflösung verringern. Binarisierung kann bei gedrucktem Text helfen. Vergleichen Sie sie jedoch unter Ihren tatsächlichen Lichtverhältnissen mit dem Originalbild.
Mehrsprachige Texterkennung handhaben
Dieses ausführbare Beispiel erkennt Englisch. Tesseract kann mehrere installierte Sprachen mit
einer Zeichenfolge wie eng+fra+deu initialisieren. Dieses Argument allein zu
ändern reicht jedoch nicht aus: Jede zugehörige Datei mit trainierten Daten muss zuvor installiert
sein. Wenn Sie den Manager ausbauen, testen Sie diese Modelle und ihre Fehlerfälle separat.
Ersetzen Sie keinen Manager, während sein Worker einen Frame erkennt.
Häufige Probleme testen und beheben
Starten Sie die App und erlauben Sie den Kamerazugriff. Richten Sie die Kamera nach der Meldung „Loading text recognition…“ auf großen, scharf gedruckten englischen Text. Erkannter Text ersetzt die Meldung unter der Vorschau; bei einem leeren Frame erscheint „No text found“. Ergebnisse werden nach Abschluss der Erkennung aktualisiert, nicht einmal pro angezeigtem Videoframe.
Wenn „Text recognition could not be initialized“ erscheint, prüfen Sie den Pfad und die Prüfsumme des mitgelieferten Modells und anschließend das Geräteprotokoll auf Fehler beim Laden nativer Bibliotheken. Fehlende oder leere Assets führen beim Kopieren zu einem Fehler; ein nicht leeres, aber beschädigtes Modell lässt die Initialisierung fehlschlagen. Ein nicht leeres privates Modell wird wiederverwendet. Um eine bereits installierte beschädigte Kopie zu korrigieren, müssen Sie daher die Daten dieser Beispiel-App löschen oder sie neu installieren. Dadurch wird die private Modellkopie des Beispiels gelöscht. Bei der Auslieferung eines anderen Modells sollte ein neuer Verzeichnisname verwendet werden.
„The camera could not be started“ zeigt an, dass die Kamera nicht gebunden werden konnte. Prüfen Sie, ob das Gerät über eine verfügbare rückseitige Kamera verfügt. „This frame could not be recognized“ betrifft einen einzelnen Frame; spätere Frames können weiterhin verarbeitet werden. Testen Sie eine Ablehnung mit anschließender Erteilung der Berechtigung in den Einstellungen, das Verlassen und die Rückkehr zur App sowie das Neuerstellen der Activity. Noch ausstehende Ergebnisse aus der Zeit vor einer Pause dürfen die Rückmeldung der aktuellen Sitzung nicht ersetzen.
Unter Android 16 durchlief ein synthetisches Bild mit „HELLO ANDROID 123“ den Analyzer dieser Activity in allen vier Drehlagen; ein leeres Bild führte zur Meldung „No text found“. Die Emulatorkamera lieferte außerdem wiederholt Frames über CameraX. Diese Prüfungen testen die Konvertierung und die Handhabung des Lebenszyklus mit kontrollierter Eingabe. Sie messen nicht, wie gut eine physische Kamera eine Seite liest.
Testen Sie vor der Veröffentlichung alle vier Ausrichtungen, den Entzug von Berechtigungen und längeres Scannen auf Ihren Zielgeräten. Vergleichen Sie das Bild nach der Schwellwertbildung bei Unschärfe, ungleichmäßiger Beleuchtung und kleinem Text mit dem Original. Korrekte Ressourcenfreigabe für Frames und eine begrenzte Queue verhindern einen Rückstau. Sie belegen weder die OCR-Genauigkeit noch einen akzeptablen Akkuverbrauch oder eine bestimmte Erkennungsrate.
