OCR in Android-Apps mit Google ML Kit implementieren
Nutzen Sie die mitgelieferte Texterkennung von ML Kit, wenn Ihre Android-App schon beim ersten Start Text aus einem Foto lesen soll, auch ohne Netzwerkverbindung. Dieses Beispiel erstellt eine kleine Kotlin-App mit zwei Eingabewegen: ein vorhandenes Bild auswählen oder mit der Kamera-App des Systems ein Foto in voller Größe aufnehmen. Sie zeigt Text in lateinischer Schrift an und liefert ein sichtbares Ergebnis, wenn das Bild leer oder unlesbar ist oder der Vorgang abgebrochen wird.
Voraussetzungen
Sie benötigen Kotlin-Kenntnisse, eine Android-SDK-Installation und ein Gerät oder einen Emulator. Das Beispiel verwendet Android Gradle plugin 9.2.1, Gradle 9.4.1, JDK 21, SDK Platform 36 und Build Tools 36.0.0. Diese Versionen ermöglichen eine reproduzierbare Umgebung; sie sind keine Vorgabe, eine bestehende App zu aktualisieren. Lesen Sie die AGP-Kompatibilitätstabelle, wenn Sie eine andere Toolchain verwenden.
Setzen Sie JAVA_HOME auf Ihr JDK und ANDROID_HOME auf Ihr SDK.
Nehmen Sie Gradle und das SDK-Verzeichnis platform-tools in Ihren
PATH auf. Der Build benötigt Netzwerkzugriff, um Abhängigkeiten abzurufen.
Der Wert für minSdk der App ist 23, entsprechend den
Einrichtungsanforderungen für Text Recognition v2 von Google.
Die folgenden Laufzeitprüfungen verwenden Android 16, API 36. Sie belegen nicht das Verhalten auf
jedem älteren Betriebssystem oder mit jeder physischen Kamera.
Das Android-Projekt einrichten
Beginnen Sie in einem neuen, leeren Verzeichnis namens TextRecognitionApp.
Erstellen Sie die folgenden Dateien unter den angegebenen relativen Pfaden, einschließlich ihrer
übergeordneten Verzeichnisse. Es handelt sich um vollständige Dateien. Sie müssen sie daher weder
mit einer Android-Studio-Vorlage kombinieren noch Dateien in einem bestehenden Projekt ersetzen.
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 enthält die Kotlin-Kompilierung. Deshalb verwendet dieses Projekt kein separates Kotlin-Android-Plugin.
Die ML-Kit-Abhängigkeit hinzufügen
Erstellen Sie app/build.gradle. View Binding generiert
ActivityMainBinding aus dem Layout, das Sie gleich hinzufügen.
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'
}
Die letzte Abhängigkeit bündelt das Modell für lateinische Schrift mit der App. Die Alternative,
com.google.android.gms:play-services-mlkit-text-recognition:19.0.1, lädt ihr Modell über
Google Play-Dienste herunter. Sie reduziert den anfänglichen App-Download, doch die Texterkennung
kann erst Ergebnisse liefern, wenn das Modell bereitsteht. Wählen Sie einen Ansatz; diese Anleitung
verwendet ausschließlich das mitgelieferte Modell. Googles
Vergleich der Installationsvarianten
erläutert die Vor- und Nachteile sowie die Downloadoptionen.
Berechtigungen konfigurieren
Die Fotoauswahl
gewährt Zugriff auf das ausgewählte Bild, ohne eine umfassende Berechtigung für die Fotomediathek
zu verlangen. PickVisualMedia verwendet
ACTION_OPEN_DOCUMENT als letzte Ausweichlösung, wenn keine Fotoauswahl verfügbar ist.
Auch das Delegieren der Aufnahme an eine installierte Kamera-App vermeidet die Anforderung eines
direkten Kamerazugriffs. Android dokumentiert diesen
Ansatz mit Kamera-Intent.
Erstellen Sie 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>
Erstellen Sie 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 macht
nur dieses Aufnahmeverzeichnis über Content-URIs zugänglich. TakePicture
übergibt der Kamera eine URI, damit sie ein Bild in voller Größe schreiben kann, statt ein kleines
Thumbnail zurückzugeben.
Das Layout erstellen
Erstellen Sie app/src/main/res/layout/activity_main.xml. Der Ergebnistext lässt sich zum Kopieren auswählen.
Statusmeldungen verwenden dieselbe Ansicht und bleiben nach einem fehlgeschlagenen Vorgang sichtbar.
<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>
Die OCR-Funktion implementieren
Erstellen Sie app/src/main/java/com/example/textrecognition/MainActivity.kt. Beide Schaltflächen bleiben deaktiviert,
solange eine externe Activity oder die Texterkennung noch nicht abgeschlossen ist. Das Decodieren
erfolgt in einem Worker-Thread, und ML Kit erhält die ausgewählte URI über
InputImage.fromFilePath().
Android kann Ihre Activity neu erstellen, während die Fotoauswahl oder Kamera geöffnet ist. Wenn Sie die Launcher in einer stabilen Reihenfolge registrieren und den zusätzlichen Vorgangsstatus speichern, erreichen deren Ergebnisse die neu erstellte Activity. Ein Scan, dessen Texterkennung bereits läuft, wird nach der Neuerstellung nicht fortgesetzt: Der neue Bildschirm fordert Sie auf, erneut ein Bild auszuwählen.
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)
}
}
Der Inset-Listener hält die Bedienelemente außerhalb von
Systemleisten und Displayaussparungen.
Die App behält fertig erkannten Text bei einer Neuerstellung bei, speichert aber weder einen
Scanverlauf noch eine Vorschau. Jede Aufnahme erhält einen neuen Dateinamen im Cache.
Abgeschlossene und abgebrochene Aufnahmen werden gelöscht; eine verwaiste Aufnahme wird bei einem
späteren Start nach einem Tag entfernt. Lassen Sie eine noch laufende Aufnahme in
onDestroy() unverändert, da die Kamera möglicherweise noch in die Datei schreibt.
Das Bild aus der Fotoauswahl wird für den unmittelbar anstehenden Vorgang gelesen; seine Originaldatei wird nie gelöscht. Eine Texterkennungsaufgabe behält ihre Erkennungsinstanz bis zum Abschluss, selbst wenn ihre Activity zerstört wird. Ihre Callbacks können keinen Text in einer neuen Activity-Instanz ersetzen.
OCR-Genauigkeit und Leistung optimieren
Beginnen Sie mit einem scharfen, gut ausgeleuchteten Foto von gedrucktem Text. Googles Richtlinien für Eingabebilder empfehlen mindestens 16 × 16 Pixel pro Zeichen. Oberhalb von etwa 24 × 24 verbessern größere Zeichen die Genauigkeit in der Regel nicht. Schneiden Sie unnötigen Hintergrund weg, ohne die Lesbarkeit des Textes zu beeinträchtigen.
Dieser Ablauf verarbeitet Einzelbilder, keine Live-Kamerabilder. Eine CameraX-Vorschau benötigt eine eigene Berechtigung sowie die Handhabung von Bildrotation und Backpressure. Chinesisch, Devanagari, Japanisch und Koreanisch benötigen ebenfalls die entsprechenden Modellabhängigkeiten und Erkennungsoptionen anstelle der hier verwendeten Optionen für lateinische Schrift.
Die Anwendung testen
Erstellen Sie die Debug-APK aus dem Projektverzeichnis. Ein erneuter Build ersetzt die Build-Ausgaben einschließlich der APK, überschreibt jedoch keine Quelldateien.
gradle --no-daemon --max-workers=2 :app:assembleDebug
Schließen Sie Ihr Zielgerät an und ersetzen Sie YOUR_DEVICE_SERIAL durch dessen
Seriennummer aus adb devices.
Die Installation mit -r ersetzt diese Beispiel-App und behält deren
App-Daten bei.
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
Wählen Sie ein lokales Bild mit großem Text wie „INVOICE 12345“. Dieser Text sollte im scrollbar angezeigten Ergebnis erscheinen, und beide Schaltflächen sollten wieder verfügbar sein. Um den ersten Start offline zu prüfen, installieren Sie die App, ohne sie zu starten, trennen Sie die Netzwerkverbindung des Geräts und öffnen Sie dann die App, um ein lokales Foto auszuwählen. Bilder aus der Cloud können in der Fotoauswahl weiterhin eine Verbindung benötigen, um ihre Bytes abzurufen.
Prüfen Sie diese Ergebnisse, bevor Sie das Beispiel anpassen:
| Eingabe oder Unterbrechung | Erwartetes Ergebnis |
|---|---|
| Leeres Bild | „No text found. Try a clearer image.“ |
| Unlesbares oder fehlendes Bild | „The image could not be read. Try another image.“ |
| Fotoauswahl ohne Auswahl schließen | „Selection canceled.“; beide Schaltflächen aktiviert |
| Aufnahme abbrechen | „Capture canceled.“; leere Aufnahmedatei entfernt |
| Kamera meldet Erfolg, ohne Bytes zu schreiben | „The camera returned no image.“; Aufnahmedatei entfernt |
| Gerät drehen, während Fotoauswahl oder Kamera geöffnet ist | Das ausstehende Ergebnis gehört weiterhin zu diesem Vorgang |
| Activity während der Texterkennung neu erstellen | „Recognition interrupted. Select an image again.“; Bedienelemente aktiviert |
| Gerät nach der Texterkennung drehen | Fertig erkannter Text bleibt erhalten |
Das Beispiel wurde auf einem Android-16-Emulator mit dem mitgelieferten Modell für lateinische Schrift getestet. Verwenden Sie echte Smartphones, um Fokus, Belichtung und die Kompatibilität mit Kamera-Apps zu testen. Ein gedrehtes JPEG benötigt außerdem korrekte Ausrichtungsmetadaten. Gehen Sie nicht davon aus, dass das Drehen des Smartphones ein falsch codiertes Bild korrigieren kann.
Fehlerbehebung
Wenn die App keinen Text meldet, versuchen Sie es zunächst mit einem klareren Bild mit größeren Druckzeichen. Ein leeres Erkennungsergebnis ist etwas anderes als eine unlesbare Datei. Wenn die Aufnahme kein Bild liefert, prüfen Sie die Kamera-App zusammen mit der FileProvider-Authority, dem Cache-Pfad und dem Ressourcennamen im Manifest.
Eine Texterkennung, die erst nach dem Herstellen einer Internetverbindung funktioniert, verwendet
möglicherweise die Abhängigkeit ohne mitgeliefertes Modell. Prüfen Sie Ihre aufgelösten
Gradle-Abhängigkeiten, bevor Sie diesem Beispiel mit mitgeliefertem Modell eine Logik für den
Modelldownload hinzufügen. Wenn der Build ActivityMainBinding nicht auflösen kann,
prüfen Sie den Layout-Dateinamen und die Einstellung für View Binding.
Eine Alternative, die hochgeladene Dateien auf einem Server verarbeitet, finden Sie in der Dokumentation zu /document/ocr.
