Crea OCR de cámara Android con OpenCV y Tesseract
Usa una vista previa de la cámara en vivo para reconocer texto impreso en el dispositivo con CameraX, OpenCV y Tesseract4Android. Crearás una actividad Java que muestra el texto reconocido en inglés debajo de la vista previa, procesa un fotograma a la vez y descarta los resultados de una sesión de actividad que ya terminó. Este enfoque con SDK de OCR de código abierto te permite controlar el modelo y el preprocesamiento; no promete reconocer texto a la velocidad de fotogramas de la cámara.
Requisitos previos
- Android Studio con soporte para Java, Android SDK Platform 36 y Build Tools 36.0.0
- Un dispositivo Android con cámara que ejecute Android API 23 o superior
- Conocimientos básicos de Java y Android Views
El ejemplo fija las versiones CameraX 1.6.2,
OpenCV 4.13.0 y Tesseract4Android 4.8.0. La compilación usa
Android Gradle Plugin 9.2.1,
Gradle 9.4.1 y JDK 21, con compatibilidad de código fuente Java 17.
La versión actual de CameraX eleva el mínimo a API 23; no anules el manifiesto de la biblioteca para
forzar esta combinación en API 21. Las comprobaciones en tiempo de ejecución de este ejemplo usan
emuladores Android 16 x86_64. La versión mínima del sistema operativo y los dispositivos físicos
siguen requiriendo pruebas por separado, incluidos el enfoque, la iluminación, el uso sostenido de
memoria y la velocidad de reconocimiento.
Configura el proyecto de Android Studio
Crea un proyecto Empty Views Activity en Android Studio. Elige Java y Groovy DSL, y establece Minimum SDK en API 23. Usa un proyecto nuevo para que reemplazar el diseño y la actividad no sobrescriba código existente de la aplicación. Conserva su declaración de paquete, espacio de nombres, entrada de la actividad de inicio en el manifiesto y tema.
Establece android.useAndroidX=true en gradle.properties. La configuración de la aplicación
que aparece a continuación conserva targetSdk 34 para este ejemplo local; selecciona
y prueba por separado la versión de destino antes de distribuir una aplicación.
Añade dependencias: OpenCV y Tesseract
settings.gradle a nivel de proyecto
En settings.gradle, integra estos repositorios en el bloque dependencyResolutionManagement existente.
Conserva los repositorios de plugins generados y la inclusión del módulo de la aplicación:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
build.gradle a nivel de aplicación
Integra estos ajustes en el bloque android existente de la aplicación y añade
las dependencias. Conserva su espacio de nombres y su ID de aplicación. El
artefacto oficial de OpenCV para Android
es org.opencv:opencv, y Tesseract4Android
se resuelve desde 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 compatibilidad nativa depende tanto de las bibliotecas como del empaquetado del APK. La
versión 4.8.0 de Tesseract4Android añadió soporte para 16 KB.
Usa las versiones fijadas de OpenCV y CameraX indicadas arriba: OpenCV 4.9.0
y CameraX 1.3.4 contienen bibliotecas de 64 bits alineadas a 4 KB.
Para las compilaciones de distribución, sigue el
procedimiento de verificación de 16 KB de Android
para el APK o paquete de aplicación final, incluidas sus bibliotecas nativas transitivas.
Copia los archivos de datos entrenados de Tesseract
Coloca el modelo en inglés de tessdata 4.0.0
en app/src/main/assets/tessdata/eng.traineddata y crea los directorios de recursos si es necesario. Descarga
el archivo binario sin procesar, no la página de vista previa de GitHub. Su SHA-256 es
daa0c97d651c19fba3b25e81317cd697e9908c8208090c94c3905381c23fc047.
Añade la
clase OCRManager.java completa que aparece a continuación al mismo paquete que MainActivity.
Esta copia el recurso a un archivo temporal, lo instala solo después de que la copia se complete
correctamente, inicializa Tesseract con el directorio padre de tessdata y libera
los recursos nativos mediante close().
El modelo se incluye en el APK, por lo que el primer inicio funciona sin conexión. La inicialización también crea una copia privada en el dispositivo. Los fotogramas y el texto reconocido permanecen en memoria; este ejemplo no los sube ni los guarda. Reiniciar la actividad inicia un nuevo escaneo.
Implementa el gestor de OCR
Guarda esta clase completa como OCRManager.java en el paquete de tu aplicación
(añade tu declaración de paquete). Créala, úsala y ciérrala en un único hilo de trabajo en segundo
plano. El código que la invoca conserva la propiedad de cada mapa de bits.
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;
}
}
}
Usa try-with-resources para una sola imagen, o mantén un gestor en un ejecutor serial para procesar
imágenes repetidamente y pon close() en la cola después de la última tarea.
No lo cierres desde el hilo de la interfaz de usuario mientras se ejecuta el OCR. Una copia fallida
nunca se convierte en el modelo instalado; usa un nuevo nombre de directorio privado cuando
distribuyas una versión diferente del modelo.
Configura el acceso a la cámara y los permisos
Añade estos elementos directamente bajo <manifest> en AndroidManifest.xml,
que ya declara el espacio de nombres 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" />
La actividad que aparece a continuación solicita permiso en tiempo de ejecución antes de iniciar la
cámara. Después de una denegación, permite el acceso a la cámara en los ajustes de Android de la
aplicación y vuelve a la actividad existente. onResume() comprueba el permiso
actual e inicia la inicialización, por lo que no es necesario reiniciar el proceso. Android exige
comprobar el permiso antes de acceder a datos protegidos.
No se necesita permiso de almacenamiento para el modelo incluido ni para los archivos privados de
la aplicación.
Integra la transmisión de la cámara en vivo
Reemplaza res/layout/activity_main.xml por este diseño:
<?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>
Implementa la funcionalidad de OCR en tiempo real
Conserva la declaración de paquete generada y reemplaza MainActivity.java por las
siguientes importaciones y clase. CameraX vincula la vista previa y el analizador al ciclo de vida de
la actividad. La inicialización, el reconocimiento y la limpieza de Tesseract se ejecutan en el
mismo hilo de trabajo serial. Cada fotograma se cierra en un bloque
finally, incluidos los fotogramas omitidos y los casos de fallo, como exige el
análisis de imágenes 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();
}
}
Optimiza el rendimiento y el uso de memoria
STRATEGY_KEEP_ONLY_LATEST limita el trabajo pendiente sin poner copias de mapas de bits en la
cola. El analizador trabaja de forma síncrona en su hilo de trabajo serial, de modo que Tesseract
nunca procesa dos fotogramas de manera concurrente. La API
ImageProxy.toBitmap() gestiona la disposición del búfer de la cámara; la rotación de su
resultado respeta los metadatos de orientación del fotograma. El paso de preprocesamiento convierte
RGBA a un único canal en escala de grises antes de aplicar la umbralización de Otsu y libera ambas
matrices de OpenCV incluso si se produce un fallo.
Si los caracteres están lejos o son pequeños, acércate o ajusta la resolución del análisis. Mide la calidad del reconocimiento antes de añadir desenfoque o reducir la resolución. La binarización puede ayudar con el texto impreso, pero compárala con la imagen original en tus condiciones reales de iluminación.
Gestiona el reconocimiento de texto multilingüe
Este ejemplo ejecutable reconoce inglés. Tesseract puede inicializar varios idiomas instalados
con una cadena como eng+fra+deu, pero cambiar solo ese argumento no basta: primero
debe instalarse cada archivo de datos entrenados correspondiente. Si amplías el gestor, prueba esos
modelos y sus casos de fallo por separado. No reemplaces un gestor mientras su hilo de trabajo esté
reconociendo un fotograma.
Prueba y depura problemas comunes
Ejecuta la aplicación y concede acceso a la cámara. Después de «Loading text recognition…», apunta a texto grande en inglés, impreso con nitidez. El texto reconocido reemplaza el mensaje debajo de la vista previa; un fotograma en blanco muestra «No text found». Los resultados se actualizan a medida que termina el reconocimiento, no una vez por cada fotograma de video mostrado.
Si ves «Text recognition could not be initialized», comprueba la ruta y la suma de verificación del modelo empaquetado y, después, revisa el registro del dispositivo en busca de errores al cargar las bibliotecas nativas. Los recursos ausentes o vacíos provocan un fallo durante la copia; un modelo corrupto que no esté vacío provoca un fallo de inicialización. Un modelo privado que no esté vacío se reutiliza, por lo que corregir una copia corrupta ya instalada requiere borrar los datos de esta aplicación de ejemplo o reinstalarla. Eso elimina la copia privada del modelo del ejemplo. Para distribuir un modelo diferente, se debe usar un nuevo nombre de directorio.
«The camera could not be started» indica que falló la vinculación de la cámara. Comprueba que el dispositivo tenga una cámara trasera disponible. «This frame could not be recognized» indica un fallo de un fotograma; los posteriores aún pueden procesarse. Prueba la denegación seguida de la concesión del permiso en Ajustes, salir de la aplicación y volver a ella, y la recreación de la actividad. Los resultados en curso desde antes de una pausa no deben reemplazar la información de la sesión actual.
En Android 16, una imagen sintética con «HELLO ANDROID 123» pasó por el analizador de esta actividad en las cuatro rotaciones, y una imagen en blanco produjo «No text found». La cámara del emulador también proporcionó fotogramas repetidos a través de CameraX. Esas comprobaciones ponen a prueba la conversión y la gestión del ciclo de vida con una entrada controlada; no miden qué tan bien lee una página una cámara física.
Antes de distribuir la aplicación, prueba las cuatro orientaciones, la revocación de permisos y el escaneo prolongado en los dispositivos de destino. Compara la imagen umbralizada con la original en condiciones de desenfoque, iluminación desigual y texto pequeño. La limpieza correcta de los fotogramas y una cola limitada evitan la acumulación de trabajo pendiente; no garantizan la precisión del OCR, un consumo de batería aceptable ni una velocidad de reconocimiento determinada.
