Implementación del reconocimiento de texto en tiempo real en apps 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. Este ejemplo procesa un fotograma a la vez y descarta los fotogramas atrasados mientras el OCR está ocupado, lo que mantiene fluida la vista previa. La velocidad y la precisión del reconocimiento dependen del dispositivo y del texto.
Requisitos previos
- Android Studio con un SDK de Android y soporte para Java
- Un dispositivo Android con cámara que ejecute la API 21 de Android o una versión superior
- Conocimientos básicos de Java y de las vistas de Android
Los ejemplos fijan las versiones de CameraX 1.3.4, OpenCV 4.9.0 y Tesseract4Android 4.8.0.
La compilación verifica los contratos de Java y de los recursos; aún debes probar los permisos de
cámara, la rotación del dispositivo, la carga de bibliotecas nativas y el reconocimiento en los
dispositivos de destino.
Configuración del proyecto de Android Studio
Crea un proyecto Empty Views Activity en Android Studio. Elige
Java y Groovy DSL, y establece Minimum SDK en API 21.
Conserva la declaración de paquete, el espacio de nombres, la entrada de la actividad en el manifiesto
y el tema generados. Usa compileSdk 34 o una versión posterior para las dependencias
que se indican a continuación.
Incorporación de dependencias: OpenCV y Tesseract
settings.gradle del proyecto
En un proyecto que centraliza los repositorios, configura estos repositorios en settings.gradle,
dentro de su bloque dependencyResolutionManagement existente:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
build.gradle de la app
Añade estas dependencias de la app. El artefacto oficial de OpenCV para Android
es org.opencv:opencv, y Tesseract4Android
se resuelve desde JitPack.
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'
}
Copia de los archivos de datos de entrenamiento de Tesseract
Coloca el modelo en inglés de tessdata 4.0.0
en app/src/main/assets/tessdata/eng.traineddata. Añade la
clase OCRManager.java completa que aparece a continuación
al mismo paquete que MainActivity.
Esta clase 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().
Implementación del gestor de OCR
Guarda esta clase completa como OCRManager.java en el paquete de tu app (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 llama 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");
try {
tessBaseAPI.setImage(bitmap);
return tessBaseAPI.getUTF8Text();
} finally {
tessBaseAPI.clear();
}
}
@Override
public void close() {
if (tessBaseAPI != null) {
tessBaseAPI.recycle();
tessBaseAPI = null;
}
}
}
Usa try-with-resources para una sola imagen, o conserva un gestor en un ejecutor secuencial para
procesar imágenes de forma repetida y pon close() en la cola después de la última
tarea. No lo cierres desde el hilo de la interfaz de usuario mientras el OCR esté en ejecución.
Una copia fallida nunca se convierte en el modelo instalado; usa un nombre de directorio privado
nuevo cuando distribuyas una versión diferente del modelo.
Configuración del acceso a la cámara y los permisos
Añade estos elementos directamente dentro de <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 se muestra a continuación solicita permiso en tiempo de ejecución antes de iniciar la cámara. Si se deniega, muestra un mensaje claro; el usuario puede volver a abrir la app después de permitir el acceso a la cámara en la configuración de Android. No se necesita permiso de almacenamiento para el modelo incluido ni para los archivos privados de la app.
Integración de la imagen 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>
Implementación de 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 liberación de recursos de Tesseract se
realizan en el mismo hilo de trabajo secuencial. Cada fotograma se cierra en un bloque
finally, incluidos los fotogramas omitidos y los casos de error, 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 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();
}
}
Optimización del rendimiento y del uso de memoria
STRATEGY_KEEP_ONLY_LATEST limita los fotogramas pendientes sin poner copias de mapas de bits en
la cola. El analizador trabaja de forma síncrona en su hilo de trabajo secuencial, por lo que
Tesseract nunca procesa dos fotogramas simultáneamente. 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 en un único canal de escala de
grises antes de aplicar la umbralización de Otsu y libera ambas matrices de OpenCV incluso si se
produce un error.
Para los caracteres lejanos o 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.
Gestión del reconocimiento de texto en varios idiomas
Para francés y alemán, incluye fra.traineddata y deu.traineddata de la
misma versión del modelo 4.0.0. Amplía el bucle de instalación de
OCRManager para copiar los tres archivos mencionados antes de la inicialización,
y pasa eng+fra+deu a init() en lugar de eng.
Los modelos de idioma ausentes o no válidos deben provocar un fallo visible de la inicialización.
Para cambiar de idioma en tiempo de ejecución, detén el análisis y pon la sustitución del gestor en
la cola del mismo hilo de trabajo secuencial, después del fotograma actual.
Pruebas y depuración de problemas comunes
- En un dispositivo físico, prueba la concesión inicial del permiso, su denegación y su revocación en la configuración.
- Gira el dispositivo por las cuatro orientaciones y verifica que el texto se mantenga derecho.
- Sal de la actividad y vuelve a ella, recréala y confirma que la vista previa se reanuda.
- Prueba los casos en que los recursos del modelo estén ausentes o vacíos, y en que la cámara trasera no esté disponible.
- Compara el texto impreso con enfoque nítido, desenfoque, luz intensa y poca luz.
- Analiza el rendimiento durante un escaneo prolongado y comprueba que la memoria de los fotogramas, los mapas de bits y las matrices nativas se mantenga acotada.
Una compilación de Android por sí sola no permite verificar estos comportamientos del dispositivo ni la precisión del OCR. Prueba ambas ABI compatibles y la configuración de despliegue de tu app antes de distribuir las bibliotecas nativas.
Conclusión
Este pipeline conecta una vista previa en vivo de CameraX con el preprocesamiento secuencial de OpenCV y el OCR de Tesseract, con instalación explícita del modelo y liberación de recursos. Para los flujos de trabajo de subida y procesamiento del lado del servidor, explora Uppy y el Robot /document/ocr de Transloadit.
