Crie OCR de câmera no Android com OpenCV e Tesseract
Use uma prévia ao vivo da câmera para reconhecer texto impresso no próprio dispositivo com CameraX, OpenCV e Tesseract4Android. Você vai criar uma atividade em Java que exibe o texto em inglês reconhecido abaixo da prévia, processa um quadro por vez e descarta resultados de uma sessão da atividade que já terminou. Esse caminho com um SDK de OCR de código aberto dá a você controle sobre o modelo e o pré-processamento; ele não promete reconhecimento na taxa de quadros da câmera.
Pré-requisitos
- Android Studio com suporte a Java, Android SDK Platform 36 e Build Tools 36.0.0
- Um dispositivo Android com câmera executando a API 23 do Android ou superior
- Conhecimentos básicos de Java e das Views do Android
O exemplo fixa as versões CameraX 1.6.2,
OpenCV 4.13.0 e Tesseract4Android 4.8.0. O build usa o
Android Gradle Plugin 9.2.1,
Gradle 9.4.1 e JDK 21, com compatibilidade de código-fonte com Java 17. O
CameraX atual eleva o mínimo para a API 23; não sobrescreva o manifesto da biblioteca para forçar
essa combinação na API 21. As verificações em tempo de execução deste exemplo usam emuladores
x86_64 do Android 16. O SO mínimo e os dispositivos físicos ainda precisam de testes separados,
incluindo foco, iluminação, uso contínuo de memória e velocidade de reconhecimento.
Configurando o projeto no Android Studio
Crie um projeto Empty Views Activity no Android Studio. Escolha Java e Groovy DSL e defina Minimum SDK como API 23. Use um projeto novo para que a substituição do layout e da atividade não sobrescreva código de aplicativo existente. Mantenha a declaração de pacote, o namespace, a entrada da atividade inicial no manifesto e o tema.
Defina android.useAndroidX=true em gradle.properties. A configuração do app abaixo mantém
targetSdk 34 para este exemplo local; escolha e teste o seu alvo de implantação separadamente
antes de distribuir um app.
Adicionando dependências: OpenCV e Tesseract
settings.gradle no nível do projeto
Mescle estes repositórios no settings.gradle, dentro do bloco dependencyResolutionManagement existente.
Mantenha os repositórios de plugins gerados e a inclusão do módulo do app:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
build.gradle no nível do app
Mescle estas configurações no bloco android existente do app e adicione as dependências.
Mantenha o namespace e o ID do aplicativo. O
artefato oficial do OpenCV para Android
é org.opencv:opencv, e o Tesseract4Android
é resolvido a partir do 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'
}
A compatibilidade nativa depende das duas bibliotecas e do empacotamento do APK. No Tesseract4Android,
a versão 4.8.0 adicionou suporte a 16 KB.
Use as duas versões fixadas acima, do OpenCV e do CameraX: OpenCV 4.9.0 e CameraX 1.3.4 contêm
bibliotecas de 64 bits alinhadas a 4 KB.
Para builds de release, siga o procedimento de verificação de 16 KB do Android
para o APK ou app bundle final, incluindo as bibliotecas nativas transitivas dele.
Copiando os arquivos de dados treinados do Tesseract
Coloque o modelo em inglês do tessdata 4.0.0
em app/src/main/assets/tessdata/eng.traineddata, criando os diretórios de assets se necessário. Baixe
o binário bruto, não a página de visualização do GitHub. O SHA-256 dele é
daa0c97d651c19fba3b25e81317cd697e9908c8208090c94c3905381c23fc047.
Adicione a
classe OCRManager.java completa abaixo ao mesmo pacote que MainActivity.
Ela copia o asset para um arquivo temporário, instala-o somente
após uma cópia bem-sucedida, inicializa o Tesseract com o diretório pai de tessdata e libera
os recursos nativos por meio de close().
O modelo vem incluído no APK, então a primeira execução funciona offline. A inicialização também cria uma cópia privada no dispositivo. Os quadros e o texto reconhecido ficam na memória; este exemplo não faz upload deles nem os salva. Reiniciar a atividade inicia uma nova leitura.
Implementando o gerenciador de OCR
Salve esta classe completa como OCRManager.java no pacote do seu app (adicione sua declaração de pacote).
Crie, use e feche o gerenciador em um único worker em segundo plano. Quem faz a chamada mantém a
posse de cada 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;
}
}
}
Use try-with-resources para uma única imagem ou mantenha um gerenciador em um executor serial para
imagens repetidas e enfileire close() após a última tarefa. Não o feche a partir da thread de UI
enquanto o OCR estiver em execução. Uma cópia com falha nunca se torna o modelo instalado; use um
novo nome de diretório privado ao distribuir uma versão diferente do modelo.
Configurando o acesso à câmera e as permissões
Adicione estes elementos diretamente em <manifest> no AndroidManifest.xml, que já declara
o namespace 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" />
A atividade abaixo solicita a permissão em tempo de execução antes de iniciar a câmera. Após uma
negação, permita o acesso à câmera nas configurações do app no Android e volte para a atividade
existente. onResume() verifica a permissão atual e dispara a inicialização, então isso não exige
reiniciar o processo.
O Android exige verificar a permissão antes de acessar dados protegidos.
Nenhuma permissão de armazenamento é necessária para o modelo incluído nem para os arquivos privados
do app.
Integrando a imagem da câmera ao vivo
Substitua res/layout/activity_main.xml por este 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>
Implementando a funcionalidade de OCR em tempo real
Mantenha a declaração de pacote gerada e substitua MainActivity.java pelos imports e pela classe a
seguir. O CameraX vincula a prévia e o analisador ao ciclo de vida da atividade. A inicialização, o
reconhecimento e a limpeza do Tesseract rodam todos no mesmo worker serial. Cada quadro é fechado em
um bloco finally, incluindo quadros ignorados e falhas, conforme exigido pela
análise de imagens do 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();
}
}
Otimizando o desempenho e o uso de memória
STRATEGY_KEEP_ONLY_LATEST limita o acúmulo sem enfileirar cópias de bitmap. O analisador trabalha
de forma síncrona no seu worker serial, então o Tesseract nunca processa dois quadros ao mesmo
tempo. A API ImageProxy.toBitmap() lida com o layout do buffer da câmera; girar o resultado respeita os
metadados de orientação do quadro. A etapa de pré-processamento converte RGBA em um único canal em
tons de cinza antes da limiarização de Otsu e libera as duas matrizes do OpenCV mesmo em caso de
falha.
Para caracteres distantes ou pequenos, aproxime-se ou ajuste a resolução da análise. Meça a qualidade do reconhecimento antes de adicionar desfoque ou reduzir a resolução. A binarização pode ajudar com texto impresso, mas compare-a com a imagem original sob as suas condições reais de iluminação.
Tratando o reconhecimento de texto multilíngue
Este exemplo executável reconhece inglês. O Tesseract pode inicializar vários idiomas instalados
com uma string como eng+fra+deu, mas alterar apenas esse argumento não basta: cada arquivo de
dados treinados correspondente precisa ser instalado antes. Se você estender o gerenciador, teste
esses modelos e os casos de falha deles separadamente. Não substitua um gerenciador enquanto o
worker dele estiver reconhecendo um quadro.
Testando e depurando problemas comuns
Execute o app e conceda acesso à câmera. Depois de “Loading text recognition…”, aponte para um texto em inglês grande e impresso com nitidez. O texto reconhecido substitui a mensagem abaixo da prévia; um quadro em branco mostra “No text found”. Os resultados são atualizados à medida que o reconhecimento termina, e não uma vez por quadro de vídeo exibido.
Se aparecer “Text recognition could not be initialized”, verifique o caminho e o checksum do modelo empacotado e, depois, o log do dispositivo em busca de erros de carregamento de bibliotecas nativas. Assets ausentes ou vazios falham durante a cópia; um modelo corrompido não vazio falha na inicialização. Um modelo privado não vazio é reutilizado, então corrigir uma cópia corrompida já instalada exige limpar os dados deste app de exemplo ou reinstalá-lo. Isso exclui a cópia privada do modelo do exemplo. Ao distribuir um modelo diferente, deve-se usar um novo nome de diretório.
“The camera could not be started” indica que a vinculação da câmera falhou. Verifique se o dispositivo tem uma câmera traseira disponível. “This frame could not be recognized” é uma falha por quadro; os quadros seguintes ainda podem ser processados. Teste a negação seguida da concessão da permissão nas Configurações, a saída e o retorno ao app e a recriação da atividade. Resultados em andamento de antes de uma pausa não devem substituir o feedback da sessão atual.
No Android 16, uma imagem sintética com “HELLO ANDROID 123” passou pelo analisador desta atividade em todas as quatro rotações, e uma imagem em branco produziu “No text found”. A câmera do emulador também forneceu quadros repetidos pelo CameraX. Essas verificações exercitam a conversão e o tratamento do ciclo de vida com entrada controlada; elas não medem o quão bem uma câmera física lê uma página.
Antes de distribuir, teste as quatro orientações, a revogação de permissão e a leitura prolongada nos seus dispositivos de destino. Compare a imagem limiarizada com a original sob desfoque, iluminação irregular e texto pequeno. A limpeza correta dos quadros e uma fila limitada evitam acúmulo; elas não comprovam precisão do OCR, consumo de bateria aceitável nem uma taxa de reconhecimento específica.
