How I Built a High‑Performance OMR Scanner for Android (Open‑Source)
Introduction
Scanning printed voting cards, surveys, and bubble sheets on Android has always been a challenge. Most existing solutions are either expensive, require an internet connection, or are not accurate enough for real‑world use.
That's why I built OpenScanVision – an open‑source Android library that combines Optical Mark Recognition (OMR) and QR code decoding, working entirely offline.
The Problem
When I started this project, I needed a scanner that could:
- Detect a card in real‑time from a camera feed.
- Correct perspective distortion (cards are rarely perfectly aligned).
- Accurately extract filled bubbles (even in uneven lighting).
- Decode QR codes to identify the card.
- Work offline – no internet connection required.
- Be easy to integrate into any Android app.
Existing libraries were either closed‑source, inaccurate, or tightly coupled to specific UI frameworks. I wanted something modular, accurate, and open.
The Solution: OpenScanVision
OpenScanVision is an Android library that solves these problems with a clean, modular architecture.
Tech Stack
| Component | Technology |
|---|---|
| Language | Kotlin |
| Image Processing | OpenCV (contrib) |
| QR Decoding | Google ML Kit |
| Camera | CameraX |
| UI (Sample App) | Jetpack Compose |
| Publishing | JitPack |
How It Works – The Scanning Pipeline
1. Frame Acquisition
CameraX provides YUV frames at a configurable resolution (default 640×360). The Y (luminance) plane is extracted into an OpenCV grayscale Mat for tracking.
2. ArUco Marker Tracking
The card has 4 ArUco markers (IDs 0–3) printed in the corners. OpenCV detects these markers in real‑time. A Kalman filter smooths the tracking and predicts marker positions when they are temporarily occluded.
3. Homography & Perspective Correction
Once all 4 markers are stable, a homography matrix is computed. This maps the markers to their reference positions, allowing the card to be warped to a canonical template (850×540 pixels).
4. QR Decoding
The QR code is cropped from the original camera frame using the homography – not from the warped image. This preserves sharpness for ML Kit. The crop is enhanced (contrast, denoise, resize) before decoding.
5. OMR Extraction
The warped image is preprocessed with CLAHE (contrast enhancement) and a median blur (denoise). Each bubble is sampled using a weighted disk. A per‑group z‑score classification determines which bubbles are filled. An inner‑core fill ratio rejects false positives from paper grain or printed outlines.
6. Strict Capture Logic
Auto‑capture triggers only when:
- All 4 markers are stable.
- A valid QR code with a recognised prefix (e.g.,
VX,AGN) is decoded.
This eliminates false positives and ensures every scan is high‑quality.
Architecture
The library is split into two modules:
openscanvision/
├── openscanvision-core/ # Core library (OMR + QR engine)
│ └── src/main/java/.../core/
│ ├── OpenScanVision.kt # Public API facade
│ ├── ScanOptions.kt # Builder pattern config
│ ├── ScanResult.kt # Sealed result class
│ └── internal/ # Implementation (hidden)
├── sample/ # Reference app (demo)
└── tools/ # Supporting utilities
The core library has zero UI dependencies – no Compose, no CameraX, no Android Views. This means you can use it in any Android project, even headless services.
Integration Example
Adding OpenScanVision to your project takes just a few lines:
1. Add JitPack to your repositories
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
2. Add the dependency
// app/build.gradle.kts
dependencies {
implementation("com.github.MatiwosKebede:OpenScanVision:openscanvision-core:v1.0.0")
}
3. Scan a card
// Initialize once
OpenScanVision.initialize(context)
// Scan a frame
suspend fun scanCard(bitmap: Bitmap) {
val result = OpenScanVision.scanFromFrame(bitmap)
when (result) {
is ScanResult.Success -> {
println("Filled: ${result.filledIndices}")
println("Confidence: ${result.confidence}")
println("QR: ${result.qrPayload}")
}
is ScanResult.Error -> {
println("Failed: ${result.javaClass.simpleName}")
}
}
}
That's it.
Performance & Accuracy
| Metric | Value |
|---|---|
| Average latency per frame | < 150 ms on modern devices |
| Accuracy (well‑printed cards) | > 99% |
| False positive rate | < 0.5% |
| False negative rate | < 2% |
What's Next?
The roadmap for 2025 includes:
- Multi‑frame averaging for improved accuracy.
- Scan history with Room database.
- Export results as CSV.
- QR‑only / OMR‑only scanning modes.
Contribute
OpenScanVision is MIT‑licensed and open to contributions. If you're interested:
- Star the repo on GitHub ⭐
- Try the sample app and report issues.
- Open a Pull Request with improvements.
Conclusion
Building OpenScanVision taught me a lot about computer vision, Android optimisation, and open‑source maintainership. I hope it helps other developers build better scanning apps.
If you have questions, feedback, or ideas – open an issue on GitHub or leave a comment below.
GitHub: MatiwosKebede/openscanvision
🙏 Acknowledgments
- OpenCV – image processing and homography.
- Google ML Kit – QR code scanning.
- CameraX – camera lifecycle.
- Jetpack Compose – modern UI.
Built with dedication by Matiwos Kebede.
📌 What to Do Next
- Copy the article above.
- Go to Dev.to → Click "New Post".
- Paste the content.
- Add tags:
android,kotlin,opencv,omr,scanning. - Publish!
This article will attract developers who are looking for an OMR, QR and OMR and QR together solution or want to learn about Android + OpenCV. 🚀
Top comments (0)