Build Android camera OCR with OpenCV and Tesseract
Use a live camera preview to recognize printed text on-device with CameraX, OpenCV, and Tesseract4Android. You will build a Java activity that displays recognized English below the preview, processes one frame at a time, and discards results from an activity session that has ended. This open-source OCR SDK path gives you control over the model and preprocessing; it does not promise recognition at the camera’s frame rate.
Prerequisites
- Android Studio with Java support, Android SDK Platform 36, and Build Tools 36.0.0
- A camera-equipped Android device running Android API 23 or higher
- Basic knowledge of Java and Android Views
The example pins CameraX 1.6.2,
OpenCV 4.13.0, and Tesseract4Android 4.8.0. The build uses
Android Gradle Plugin 9.2.1,
Gradle 9.4.1, and JDK 21, with Java 17 source compatibility. Current CameraX raises the minimum to
API 23; do not override the library manifest to force this combination onto API 21. Runtime checks
for this example use Android 16 x86_64 emulators. The minimum OS and physical devices still need
separate testing, including focus, lighting, sustained memory use, and recognition speed.
Setting up the Android studio project
Create an Empty Views Activity project in Android Studio. Choose Java and Groovy DSL, and set Minimum SDK to API 23. Use a new project so replacing the layout and activity does not overwrite existing application code. Keep its package declaration, namespace, launcher activity manifest entry, and theme.
Set android.useAndroidX=true in gradle.properties. The app configuration below keeps
targetSdk 34 for this local example; select and test your deployment target separately before
distributing an app.
Adding dependencies: OpenCV and Tesseract
Project-level settings.gradle
Merge these repositories into settings.gradle’s existing dependencyResolutionManagement block.
Keep the generated plugin repositories and app module inclusion:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
App-level build.gradle
Merge these settings into the app’s existing android block and add the dependencies. Keep its
namespace and application ID. The official OpenCV Android artifact
is org.opencv:opencv, and Tesseract4Android
resolves from 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'
}
Native compatibility depends on both libraries and APK packaging. Tesseract4Android’s
4.8.0 release added 16 KB support.
Use both the OpenCV and CameraX pins above: OpenCV 4.9.0 and CameraX 1.3.4 contain
4 KB-aligned 64-bit libraries.
For release builds, follow Android’s 16 KB verification procedure
for the final APK or app bundle, including its transitive native libraries.
Copying Tesseract trained data files
Put the English model from tessdata 4.0.0
at app/src/main/assets/tessdata/eng.traineddata, creating the asset directories if needed. Download
the raw binary, not the GitHub preview page. Its SHA-256 is
daa0c97d651c19fba3b25e81317cd697e9908c8208090c94c3905381c23fc047.
Add the complete
OCRManager.java class below to the same package as MainActivity.
It copies the asset to a temporary file, installs it only
after a successful copy, initializes Tesseract with the parent of tessdata, and releases native
resources through close().
The model ships in the APK, so the first launch works offline. Initialization also creates a private copy on the device. Frames and recognized text stay in memory; this example neither uploads nor saves them. Restarting the activity starts a new scan.
Implementing the OCR manager
Save this complete class as OCRManager.java in your app’s package (add your package declaration).
Create, use, and close it on one background worker. The caller retains ownership of each 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 for a single image, or keep one manager on a serial executor for repeated
images and enqueue close() after the final task. Do not close it from the UI thread while OCR is
running. A failed copy never becomes the installed model; use a new private directory name when
shipping a different model release.
Configuring camera access and permissions
Add these elements directly under <manifest> in AndroidManifest.xml, which already declares
the android XML namespace:
<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" />
The activity below requests runtime permission before starting the camera. After denial, allow
camera access in the app’s Android settings and return to the existing activity. onResume() checks
the current permission and starts initialization, so this does not require a process restart.
Android requires checking permission before accessing protected data.
No storage permission is needed for the bundled model or app-private files.
Integrating live camera feed
Replace res/layout/activity_main.xml with this 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>
Implementing real-time OCR functionality
Keep your generated package declaration and replace MainActivity.java with the following imports
and class. CameraX binds the preview and analyzer to the activity lifecycle. Initialization,
recognition, and Tesseract cleanup all run on the same serial worker. Each frame is closed in a
finally block, including skipped frames and failures, as required by
CameraX image analysis.
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();
}
}
Optimizing performance and memory usage
STRATEGY_KEEP_ONLY_LATEST bounds the backlog without queueing bitmap copies. The analyzer works
synchronously on its serial worker, so Tesseract never handles two frames concurrently. The
ImageProxy.toBitmap() API handles the camera buffer’s layout; rotating its result respects the
frame’s orientation metadata. The preprocessing step converts RGBA to a single grayscale channel
before Otsu thresholding and releases both OpenCV matrices even on failure.
For distant or small characters, move closer or adjust the analysis resolution. Measure recognition quality before adding blur or reducing resolution. Binarization can help printed text, but compare it with the original image under your actual lighting conditions.
Handling multilingual text recognition
This runnable example recognizes English. Tesseract can initialize multiple installed languages
with a string such as eng+fra+deu, but changing that argument alone is insufficient: each matching
trained-data file must be installed first. If you extend the manager, test those models and their
failure cases separately. Do not replace a manager while its worker is recognizing a frame.
Testing and debugging common issues
Run the app and grant camera access. After “Loading text recognition…”, aim at large, sharply printed English text. Recognized text replaces the message below the preview; a blank frame shows “No text found”. Results update as recognition finishes, rather than once per displayed video frame.
If you see “Text recognition could not be initialized”, check the packaged model path and checksum, then the device log for native library loading errors. Missing or empty assets fail during copying; a nonempty corrupt model fails initialization. A nonempty private model is reused, so correcting an already-installed corrupt copy requires clearing this sample app’s data or reinstalling it. That deletes the sample’s private model copy. Shipping a different model should use a new directory name.
“The camera could not be started” indicates camera binding failed. Check that the device has an available back camera. “This frame could not be recognized” is a per-frame failure; later frames can still be processed. Test denial followed by granting permission in Settings, leaving and returning to the app, and activity recreation. In-flight results from before a pause must not replace the current session’s feedback.
On Android 16, a synthetic “HELLO ANDROID 123” image passed through this activity’s analyzer in all four rotations, and a blank image produced “No text found”. The emulator camera also supplied repeated frames through CameraX. Those checks exercise conversion and lifecycle handling with controlled input; they do not measure how well a physical camera reads a page.
Before shipping, test all four orientations, permission revocation, and prolonged scanning on your target devices. Compare the thresholded image with the original under blur, uneven lighting, and small text. Correct frame cleanup and a bounded queue prevent a backlog; they do not establish OCR accuracy, acceptable battery use, or a particular recognition rate.
