Introduction
Understand ONNX fundamentals and architecture
Set up your development environment
Generate a synthetic Sudoku digit dataset
Train the digit recognizer
Run inference and evaluate the model
Build the Sudoku processor pipeline
Optimize the model for Arm64 deployment
Deploy the model to Android
Next Steps
You’ll now transition from desktop deployment to a fully on-device Android application. The goal is to demonstrate how the optimized Sudoku pipeline–image preprocessing, ONNX inference, and deterministic solving–can be packaged and executed entirely on a mobile device, without relying on cloud services.
Rather than starting with a live camera feed, you begin with a fixed input bitmap generated earlier in the Learning Path. This approach allows you to focus on correctness, performance, and integration details before introducing additional complexity such as camera permissions, real-time capture, and varying lighting conditions.
In this section, you will:
By the end of this section, you will have a working Android app that takes a Sudoku image, runs neural network inference and solving on-device, and displays the solution.
Start by creating a new Android project using Android Studio. This project will host the Sudoku solver application and serve as the foundation for integrating ONNX Runtime and OpenCV.

This template creates a minimal Android application without additional UI components, which is ideal for a focused, step-by-step integration.

You will now define the user interface of the Android application. The goal of this view is to remain intentionally simple while clearly exposing the end-to-end Sudoku workflow. The interface will consist of:
This layout is sufficient to validate that the ONNX model, preprocessing pipeline, and solver are all working correctly on Android before adding more advanced features such as camera input or animations.
To define the view, open the file res/layout/activity_main.xml and replace its contents with the following layout definition:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<!-- Pinned header: buttons + status -->
<LinearLayout
android:id="@+id/header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<!-- Buttons row -->
<LinearLayout
android:id="@+id/buttonRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:baselineAligned="false">
<Button
android:id="@+id/btnLoadRandom"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Load image" />
<Space
android:layout_width="12dp"
android:layout_height="0dp" />
<Button
android:id="@+id/btnSolve"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Solve"
android:enabled="false" />
</LinearLayout>
<!-- Status -->
<TextView
android:id="@+id/txtStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Ready"
android:textSize="14sp"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
</LinearLayout>
<!-- Scrollable content (images) -->
<ScrollView
android:id="@+id/scrollContent"
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
app:layout_constraintTop_toBottomOf="@id/header"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:id="@+id/contentRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Input label -->
<TextView
android:id="@+id/txtInputLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Input"
android:textStyle="bold"
android:textSize="16sp"
android:layout_gravity="center_horizontal"
android:paddingTop="8dp"
android:paddingBottom="6dp" />
<!-- Input image -->
<ImageView
android:id="@+id/imgInput"
android:layout_width="match_parent"
android:layout_height="420dp"
android:contentDescription="Input Sudoku"
android:scaleType="centerCrop"
android:adjustViewBounds="true"
android:background="@android:color/darker_gray" />
<!-- Solved label -->
<TextView
android:id="@+id/txtOutputLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Solved"
android:textStyle="bold"
android:textSize="16sp"
android:layout_gravity="center_horizontal"
android:paddingTop="16dp"
android:paddingBottom="6dp" />
<!-- Output image -->
<ImageView
android:id="@+id/imgOutput"
android:layout_width="match_parent"
android:layout_height="420dp"
android:contentDescription="Solved Sudoku"
android:scaleType="centerCrop"
android:adjustViewBounds="true"
android:background="@android:color/darker_gray" />
<!-- Extra bottom padding so last image isn't flush -->
<Space
android:layout_width="0dp"
android:layout_height="16dp" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
This layout uses a ConstraintLayout as the root container to ensure it adapts cleanly across different screen sizes. The UI is split into two parts: a pinned header and a scrollable content area. The pinned header at the top contains a horizontal button row with two equally sized buttons:
Below the header, the screen contains a ScrollView that holds the image content:
Because the image area is scrollable, the layout remains usable even on smaller screens, while the buttons and status remain accessible at all times.
When rendered, this produces a clear, vertically structured interface with a fixed control panel at the top and large input/output images underneath, as shown in the figure below.

At this stage, the UI is intentionally minimal. In the next step, you will connect this view to the application logic in MainActivity, load a sample Sudoku bitmap, and wire up the Load image and Solve buttons to the ONNX-based processing pipeline.
Before wiring the application logic, you need to provide the Android app with a small set of Sudoku images that it can load and solve. For this learning path, you will use a fixed collection of pre-generated images (from Preparing a Synthetic Sudoku Digit Dataset) instead of a camera feed. This keeps the Android integration simple and allows us to focus on ONNX inference and solver integration first.
For example, you might choose: data/grids/val/000000_cam.png data/grids/val/000000_clean.png …
Using both clean and camera-like images is useful later for testing robustness.
Rename your files accordingly, for example:
app/src/main/res/drawable/
After copying, Android Studio will automatically generate resource IDs for these images.
You should now be able to reference these images in Kotlin code using identifiers such as:
R.drawable.sudoku_01 R.drawable.sudoku_cam_01
In this tutorial, the Load image button will randomly select one of these drawable resources and display it in the app. This provides a deterministic and repeatable input source while validating the full Sudoku pipeline on Android.
At this point, the Android project has all the static resources it needs. In the next step, you will implement MainActivity.kt, wire up the Load image and Solve buttons, and display the selected Sudoku image in the UI.
In addition to the input images, the Android application needs access to the trained ONNX model so that it can run inference directly on the device. Android does not allow arbitrary file access by default, so the model must be bundled with the app as an asset.
For the initial Android integration, it is recommended to start with the FP32 model, as it offers the broadest compatibility. You can switch to the quantized model later once everything is working end-to-end.
app/src/main/assets/
If you prefer to keep assets organized, you can also create a subfolder:
app/src/main/assets/models/
Both approaches work. In the examples that follow, you will assume the model is placed directly under assets/.
07_PrepareModelForAndroid.py:
import onnx
from onnx import external_data_helper
IN_PATH = "artifacts/sudoku_digitnet.onnx"
OUT_PATH = "artifacts/sudoku_digitnet_android.onnx"
model = onnx.load(IN_PATH)
# If the model references external data, load it into the model object
external_data_helper.load_external_data_for_model(model, base_dir="artifacts")
# Clear external locations so it can be saved as a single file
for init in model.graph.initializer:
if init.data_location == onnx.TensorProto.EXTERNAL:
init.data_location = onnx.TensorProto.DEFAULT
# Remove external data metadata entries
del init.external_data[:]
# Save as a single-file ONNX
onnx.save_model(model, OUT_PATH, save_as_external_data=False)
print("Saved:", OUT_PATH)
app/src/main/assets/sudoku_digitnet_android.onnx
or, if using a subfolder:
app/src/main/assets/models/sudoku_digitnet_android.onnx
assets.open("sudoku_digitnet_android.onnx")
If you placed the model in a subfolder, include the relative path:
assets.open("models/sudoku_digitnet_android.onnx")
This input stream will be passed to ONNX Runtime to create an inference session on the device. At this point, the Android project contains:
In the next step, you will implement MainActivity.kt, wire up the Load image and Solve buttons, and verify that the app can successfully load both the image and the ONNX model before running inference.
Open app/src/main/java/com/arm/sudokusolveronnx/MainActivity.kt and replace it with:
package com.arm.sudokusolveronnx
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import kotlin.random.Random
class MainActivity : AppCompatActivity() {
private lateinit var btnLoadRandom: Button
private lateinit var btnSolve: Button
private lateinit var txtStatus: TextView
private lateinit var imgInput: ImageView
private lateinit var imgOutput: ImageView
private var currentBitmap: Bitmap? = null
// Clean and camera-like pools (you copied these into res/drawable/)
private val sudokuCleanImages = listOf(
R.drawable.sudoku_01,
R.drawable.sudoku_02,
R.drawable.sudoku_03,
R.drawable.sudoku_04,
R.drawable.sudoku_05,
R.drawable.sudoku_06,
R.drawable.sudoku_07,
R.drawable.sudoku_08,
R.drawable.sudoku_09,
R.drawable.sudoku_10,
)
private val sudokuCamImages = listOf(
R.drawable.sudoku_cam_01,
R.drawable.sudoku_cam_02,
R.drawable.sudoku_cam_03,
R.drawable.sudoku_cam_04,
R.drawable.sudoku_cam_05,
R.drawable.sudoku_cam_06,
R.drawable.sudoku_cam_07,
R.drawable.sudoku_cam_08,
R.drawable.sudoku_cam_09,
R.drawable.sudoku_cam_10,
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnLoadRandom = findViewById(R.id.btnLoadRandom)
btnSolve = findViewById(R.id.btnSolve)
txtStatus = findViewById(R.id.txtStatus)
imgInput = findViewById(R.id.imgInput)
imgOutput = findViewById(R.id.imgOutput)
btnSolve.isEnabled = false
txtStatus.text = "Ready"
btnLoadRandom.setOnClickListener {
loadRandomSudokuImage(useCameraLike = true)
}
btnSolve.setOnClickListener {
txtStatus.text = "Solve clicked (engine not wired yet)"
imgOutput.setImageBitmap(currentBitmap) // temporary: mirror input
}
}
private fun loadRandomSudokuImage(useCameraLike: Boolean) {
val pool = if (useCameraLike) sudokuCamImages else sudokuCleanImages
val resId = pool[Random.nextInt(pool.size)]
val bmp = BitmapFactory.decodeResource(resources, resId)
currentBitmap = bmp
imgInput.setImageBitmap(bmp)
imgOutput.setImageDrawable(null)
btnSolve.isEnabled = true
txtStatus.text = if (useCameraLike) "Loaded camera-like Sudoku" else "Loaded clean Sudoku"
}
}
What this gives you immediately
Run the app now. This verifies your layout IDs are correct and your drawables are packaged properly.
Here is a clean, reader-facing addition you can include in Step 1 (MainActivity wiring) to explain the expected compile error and how to fix it. It’s written in the same instructional tone as the rest of the learning path.
After implementing MainActivity.kt and running the app for the first time, you may encounter a compile-time error. This is expected and related to the Android SDK level used by the project template.
The error occurs because recent versions of Android Studio and its dependencies (in particular androidx.activity and related libraries) require a newer Compile SDK than the default project configuration provides.
Android Studio templates sometimes lag behind the latest library requirements. In this project, we are using up-to-date AndroidX components, which expect the project to be compiled against Android API level 35.
This does not affect which devices your app can run on. It only affects which APIs are available at compile time.
To fix the error, open the Gradle build file for the app module:
app/build.gradle.kts
Update the android {} block so that compileSdk is set to 35, as shown below:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.arm.sudokusolveronnx"
compileSdk = 35
defaultConfig {
applicationId = "com.arm.sudokusolveronnx"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
After making this change:
The project should now compile and launch successfully.
At this point, the app should start, display the UI, and allow you to load random Sudoku images. In the next step, you will replace the placeholder logic in the Solve button with the real ONNX- and OpenCV-based Sudoku processing engine.
With the user interface and static resources in place, you can now wire the full Sudoku processing pipeline on Android. Conceptually, this pipeline mirrors the Python implementation developed earlier in the learning path, but is reimplemented using Android-compatible components.
The pipeline consists of four stages:
To support this pipeline, add three dependencies:
Open build.gradle.kts and add the following
dependencies {
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.18.0")
implementation("org.opencv:opencv:4.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
Then sync Gradle. Using the Maven dependency keeps the setup simple for this tutorial.
Then, make sure you have:
app/src/main/assets/sudoku_digitnet_android.onnx
The Android implementation is organized into three small, focused components:
This separation keeps the codebase readable and makes it easy to extend or replace individual stages later.
Create these Kotlin files under:
app/src/main/java/com/arm/sudokusolveronnx/
package com.arm.sudokusolveronnx
object SudokuSolver {
fun solve(board: Array<IntArray>): Boolean {
val pos = findEmpty(board) ?: return true
val r = pos.first
val c = pos.second
for (v in 1..9) {
if (isValid(board, r, c, v)) {
board[r][c] = v
if (solve(board)) return true
board[r][c] = 0
}
}
return false
}
private fun findEmpty(board: Array<IntArray>): Pair<Int, Int>? {
for (r in 0 until 9) for (c in 0 until 9) if (board[r][c] == 0) return r to c
return null
}
private fun isValid(board: Array<IntArray>, r: Int, c: Int, v: Int): Boolean {
for (j in 0 until 9) if (board[r][j] == v) return false
for (i in 0 until 9) if (board[i][c] == v) return false
val br = (r / 3) * 3
val bc = (c / 3) * 3
for (i in br until br + 3) for (j in bc until bc + 3) if (board[i][j] == v) return false
return true
}
}
package com.arm.sudokusolveronnx
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import android.graphics.Bitmap
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
import java.nio.FloatBuffer
class SudokuEngine(
private val context: Context,
private val modelAssetName: String = "sudoku_digitnet_android.onnx",
private val warpSize: Int = 450,
private val inputSize: Int = 28,
private val blankConfThreshold: Float = 0.65f
) {
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
private val session: OrtSession
init {
val modelBytes = context.assets.open(modelAssetName).use { it.readBytes() }
val opts = OrtSession.SessionOptions()
session = env.createSession(modelBytes, opts)
}
private data class WarpResult(
val warped: Mat,
val H: Mat // perspective transform from original -> warped
)
data class Result(
val recognized: Array<IntArray>,
val solved: Array<IntArray>?,
val solvedBitmap: Bitmap?,
val overlayBitmap: Bitmap?
)
fun solveBitmap(input: Bitmap): Result {
// Bitmap -> Mat (BGR/RGBA depending on Utils, but works for our pipeline)
val bgr = Mat()
Utils.bitmapToMat(input, bgr)
val warp = detectAndWarp(bgr) ?: return Result(emptyBoard(), null, null, null)
val board = recognizeBoard(warp.warped)
val solved = board.map { it.clone() }.toTypedArray()
val ok = SudokuSolver.solve(solved)
return if (ok) {
val solvedGrid = BoardRenderer.render(solved) // keep if you still want it
val overlay = makeOverlayBitmap(input, warp.H, board, solved)
Result(board, solved, solvedGrid, overlay)
} else {
Result(board, null, null, null)
}
}
private fun emptyBoard(): Array<IntArray> = Array(9) { IntArray(9) }
private fun detectAndWarp(bgr: Mat): WarpResult? {
val gray = Mat()
Imgproc.cvtColor(bgr, gray, Imgproc.COLOR_BGR2GRAY)
Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0)
val thr = Mat()
Imgproc.adaptiveThreshold(
gray, thr, 255.0,
Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C,
Imgproc.THRESH_BINARY_INV,
31, 7.0
)
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0))
Imgproc.morphologyEx(thr, thr, Imgproc.MORPH_CLOSE, kernel, Point(-1.0, -1.0), 2)
val contours = ArrayList<MatOfPoint>()
Imgproc.findContours(thr, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
if (contours.isEmpty()) return null
contours.sortByDescending { Imgproc.contourArea(it) }
var quad: MatOfPoint2f? = null
for (i in 0 until minOf(20, contours.size)) {
val c = contours[i]
val peri = Imgproc.arcLength(MatOfPoint2f(*c.toArray()), true)
val approx = MatOfPoint2f()
Imgproc.approxPolyDP(MatOfPoint2f(*c.toArray()), approx, 0.02 * peri, true)
if (approx.total().toInt() == 4) {
quad = approx
break
}
}
if (quad == null) return null
val pts = orderQuad(quad.toArray())
val dst = arrayOf(
Point(0.0, 0.0),
Point((warpSize - 1).toDouble(), 0.0),
Point((warpSize - 1).toDouble(), (warpSize - 1).toDouble()),
Point(0.0, (warpSize - 1).toDouble())
)
val M = Imgproc.getPerspectiveTransform(MatOfPoint2f(*pts), MatOfPoint2f(*dst))
val warped = Mat()
Imgproc.warpPerspective(bgr, warped, M, Size(warpSize.toDouble(), warpSize.toDouble()))
return WarpResult(warped = warped, H = M)
}
private fun orderQuad(pts: Array<Point>): Array<Point> {
// order: TL, TR, BR, BL
val sum = pts.map { it.x + it.y }
val diff = pts.map { it.x - it.y }
val tl = pts[sum.indices.minBy { sum[it] }]
val br = pts[sum.indices.maxBy { sum[it] }]
val tr = pts[diff.indices.maxBy { diff[it] }]
val bl = pts[diff.indices.minBy { diff[it] }]
return arrayOf(tl, tr, br, bl)
}
private fun recognizeBoard(warpedBgr: Mat): Array<IntArray> {
val step = warpSize / 9
val inputs = FloatArray(81 * 1 * inputSize * inputSize)
var idx = 0
for (r in 0 until 9) {
for (c in 0 until 9) {
val cell = warpedBgr.submat(r * step, (r + 1) * step, c * step, (c + 1) * step)
val tensor = preprocessCell(cell) // FloatArray length = 1*28*28
System.arraycopy(tensor, 0, inputs, idx * inputSize * inputSize, inputSize * inputSize)
idx++
}
}
val shape = longArrayOf(81, 1, inputSize.toLong(), inputSize.toLong())
val fb = FloatBuffer.wrap(inputs)
val inputTensor = OnnxTensor.createTensor(env, fb, shape)
val out = session.run(mapOf("input" to inputTensor))
val logits = out[0].value as Array<FloatArray> // [81][10]
out.close()
inputTensor.close()
val board = Array(9) { IntArray(9) }
for (i in 0 until 81) {
val probs = softmax(logits[i])
var bestK = 0
var bestV = probs[0]
for (k in 1 until probs.size) {
if (probs[k] > bestV) { bestV = probs[k]; bestK = k }
}
val r = i / 9
val c = i % 9
board[r][c] = if (bestV < blankConfThreshold) 0 else bestK
}
return board
}
private fun preprocessCell(cellBgr: Mat): FloatArray {
val gray = Mat()
Imgproc.cvtColor(cellBgr, gray, Imgproc.COLOR_BGR2GRAY)
val m = (0.12 * minOf(gray.rows(), gray.cols())).toInt()
val cropped = gray.submat(m, gray.rows() - m, m, gray.cols() - m)
val resized = Mat()
Imgproc.resize(cropped, resized, Size(inputSize.toDouble(), inputSize.toDouble()), 0.0, 0.0, Imgproc.INTER_AREA)
val out = FloatArray(inputSize * inputSize)
var k = 0
for (y in 0 until inputSize) {
for (x in 0 until inputSize) {
val v = resized.get(y, x)[0].toFloat() / 255f
out[k++] = (v - 0.5f) / 0.5f
}
}
return out
}
private fun softmax(x: FloatArray): FloatArray {
var max = x[0]
for (v in x) if (v > max) max = v
val e = FloatArray(x.size)
var sum = 0f
for (i in x.indices) {
val v = kotlin.math.exp((x[i] - max).toDouble()).toFloat()
e[i] = v
sum += v
}
for (i in e.indices) e[i] /= (sum + 1e-12f)
return e
}
private fun makeOverlayBitmap(
originalBitmap: Bitmap,
H: Mat,
recognized: Array<IntArray>,
solved: Array<IntArray>
): Bitmap {
// Convert original to Mat (could be RGBA on Android)
val original = Mat()
Utils.bitmapToMat(originalBitmap, original)
// Ensure original is BGR (3 channels)
val originalBgr = Mat()
if (original.channels() == 4) {
Imgproc.cvtColor(original, originalBgr, Imgproc.COLOR_RGBA2BGR)
} else {
original.copyTo(originalBgr)
}
// Create layer in warped space (BGR)
val layer = Mat.zeros(warpSize, warpSize, CvType.CV_8UC3)
val step = warpSize / 9
for (r in 0 until 9) {
for (c in 0 until 9) {
if (recognized[r][c] != 0) continue
val d = solved[r][c]
val x = (c * step + step * 0.32).toInt()
val y = (r * step + step * 0.72).toInt()
Imgproc.putText(
layer, d.toString(),
Point(x.toDouble(), y.toDouble()),
Imgproc.FONT_HERSHEY_SIMPLEX,
1.2,
Scalar(0.0, 200.0, 0.0), // green in BGR
2,
Imgproc.LINE_AA
)
}
}
// Inverse warp to original size (BGR)
val invH = Mat()
Core.invert(H, invH)
val back = Mat.zeros(originalBgr.size(), CvType.CV_8UC3)
Imgproc.warpPerspective(layer, back, invH, originalBgr.size())
// Mask where back has pixels
val mask = Mat()
Imgproc.cvtColor(back, mask, Imgproc.COLOR_BGR2GRAY)
Imgproc.threshold(mask, mask, 1.0, 255.0, Imgproc.THRESH_BINARY)
// Blend (same size + same channels)
val blended = Mat()
Core.addWeighted(originalBgr, 0.6, back, 0.4, 0.0, blended)
// Copy only where mask is present
val outBgr = originalBgr.clone()
blended.copyTo(outBgr, mask)
// Convert back to bitmap (need RGBA for Android Bitmap)
val outRgba = Mat()
Imgproc.cvtColor(outBgr, outRgba, Imgproc.COLOR_BGR2RGBA)
val outBmp = Bitmap.createBitmap(originalBitmap.width, originalBitmap.height, Bitmap.Config.ARGB_8888)
Utils.matToBitmap(outRgba, outBmp)
return outBmp
}
}
package com.arm.sudokusolveronnx
import android.graphics.*
object BoardRenderer {
fun render(board: Array<IntArray>, cell: Int = 80, margin: Int = 24): Bitmap {
val size = 9 * cell + 2 * margin
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
canvas.drawColor(Color.WHITE)
val thin = Paint().apply { color = Color.BLACK; strokeWidth = 2f; isAntiAlias = true }
val thick = Paint().apply { color = Color.BLACK; strokeWidth = 6f; isAntiAlias = true }
// grid
for (i in 0..9) {
val p = if (i % 3 == 0) thick else thin
val x = (margin + i * cell).toFloat()
val y = (margin + i * cell).toFloat()
canvas.drawLine(x, margin.toFloat(), x, (margin + 9 * cell).toFloat(), p)
canvas.drawLine(margin.toFloat(), y, (margin + 9 * cell).toFloat(), y, p)
}
val textPaint = Paint().apply {
color = Color.BLACK
textSize = (cell * 0.62f)
isAntiAlias = true
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
val fm = textPaint.fontMetrics
val textYOffset = (fm.ascent + fm.descent) / 2f
for (r in 0 until 9) {
for (c in 0 until 9) {
val v = board[r][c]
if (v == 0) continue
val cx = margin + c * cell + cell / 2f
val cy = margin + r * cell + cell / 2f - textYOffset
canvas.drawText(v.toString(), cx, cy, textPaint)
}
}
return bmp
}
}
Instead of simply rendering a solved grid, the application overlays the missing digits directly onto the original Sudoku image. This is achieved by drawing the solution in the rectified grid space and then mapping it back to the input image using the inverse perspective transform.
Only cells that were originally empty are filled, and the solution digits are rendered in green to distinguish them from the original puzzle. This approach closely matches how real-world Sudoku solver apps present results and provides an intuitive visual confirmation that the pipeline is working correctly.
MainActivity acts as a thin integration layer between the UI and the processing engine. Its responsibilities are intentionally minimal:
All heavy computation is delegated to SudokuEngine, which ensures that the UI remains responsive during processing.
package com.arm.sudokusolveronnx
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random
import org.opencv.android.OpenCVLoader
class MainActivity : AppCompatActivity() {
private lateinit var btnLoadRandom: Button
private lateinit var btnSolve: Button
private lateinit var txtStatus: TextView
private lateinit var imgInput: ImageView
private lateinit var imgOutput: ImageView
private lateinit var engine: SudokuEngine
private var currentBitmap: Bitmap? = null
// Clean and camera-like pools (you copied these into res/drawable/)
private val sudokuCleanImages = listOf(
R.drawable.sudoku_01,
R.drawable.sudoku_02,
R.drawable.sudoku_03,
R.drawable.sudoku_04,
R.drawable.sudoku_05,
R.drawable.sudoku_06,
R.drawable.sudoku_07,
R.drawable.sudoku_08,
R.drawable.sudoku_09,
R.drawable.sudoku_10,
)
private val sudokuCamImages = listOf(
R.drawable.sudoku_cam_01,
R.drawable.sudoku_cam_02,
R.drawable.sudoku_cam_03,
R.drawable.sudoku_cam_04,
R.drawable.sudoku_cam_05,
R.drawable.sudoku_cam_06,
R.drawable.sudoku_cam_07,
R.drawable.sudoku_cam_08,
R.drawable.sudoku_cam_09,
R.drawable.sudoku_cam_10,
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnLoadRandom = findViewById(R.id.btnLoadRandom)
btnSolve = findViewById(R.id.btnSolve)
txtStatus = findViewById(R.id.txtStatus)
imgInput = findViewById(R.id.imgInput)
imgOutput = findViewById(R.id.imgOutput)
btnSolve.isEnabled = false
val ok = OpenCVLoader.initLocal()
txtStatus.text = if (ok) "OpenCV ready" else "OpenCV init failed"
engine = SudokuEngine(this)
btnLoadRandom.setOnClickListener {
loadRandomSudokuImage(useCameraLike = true)
}
btnSolve.setOnClickListener {
val bmp = currentBitmap ?: return@setOnClickListener
txtStatus.text = "Solving..."
btnSolve.isEnabled = false
lifecycleScope.launch {
val result = withContext(Dispatchers.Default) {
engine.solveBitmap(bmp)
}
if (result.overlayBitmap != null) {
imgOutput.setImageBitmap(result.overlayBitmap)
txtStatus.text = "Solved"
} else {
txtStatus.text = "Solve failed (recognition errors)"
}
btnSolve.isEnabled = true
}
}
}
private fun loadRandomSudokuImage(useCameraLike: Boolean) {
val pool = if (useCameraLike) sudokuCamImages else sudokuCleanImages
val resId = pool[Random.nextInt(pool.size)]
val bmp = BitmapFactory.decodeResource(resources, resId)
currentBitmap = bmp
imgInput.setImageBitmap(bmp)
imgOutput.setImageDrawable(null)
btnSolve.isEnabled = true
txtStatus.text = if (useCameraLike) "Loaded camera-like Sudoku" else "Loaded clean Sudoku"
}
}
Depending on your OpenCV packaging, you may use initLocal() or initDebug().
With the full pipeline integrated, the application can now be tested end-to-end on an Android device or emulator.
To test the app:
The figures below show two representative test cases. In each example, the upper image corresponds to the original Sudoku puzzle, while the lower image shows the same puzzle with the missing digits filled in and overlaid in green. This visual comparison confirms that grid detection, digit recognition, solving, and rendering are all functioning correctly on-device.

These tests demonstrate that the application is robust to perspective distortion and partial digit placement, and that the model performs reliably when deployed via ONNX Runtime on Android.
You built an end-to-end workflow for deploying ONNX models on Arm64 and mobile platforms–from model development in Python to on-device Android deployment. Throughout this Learning Path, you trained a lightweight digit recognizer and constructed an OpenCV-based pipeline for grid detection and rectification, exported models to ONNX with dynamic batch support, validated correctness with ONNX Runtime, applied practical optimizations including quantization and session tuning, and integrated everything into an on-device Android app with no cloud dependency. The final application processes Sudoku images from capture to completed solution, all running locally on the device.
Challenging lighting, strong perspective distortion, faint or occluded digits, and imperfect crops can cause recognition errors that propagate into the solver, increasing solve time or occasionally preventing a valid solution. These behaviors highlight the trade-offs between model size, robustness, and performance at the edge.
Next steps to enhance the application:
This Learning Path provides a solid foundation for building, optimizing, and deploying ONNX-based machine learning applications on Arm64 and mobile platforms.
All source code used throughout this learning path is available in the following repositories:
Throughout this Learning Path, you built a complete machine learning deployment pipeline from training to mobile deployment. You created and trained a digit recognition model in PyTorch, exported it to the portable ONNX format with dynamic batch support, and validated inference correctness across frameworks. You then integrated the model into an end-to-end Sudoku processing system using OpenCV for image processing and grid detection, applied performance optimizations including INT8 quantization, and measured real-world latency characteristics on Arm64 hardware. Finally, you deployed the entire pipeline as a fully functional Android application that performs on-device inference with ONNX Runtime, demonstrating how to bring machine learning models from development to production on Arm-based mobile platforms without cloud dependencies.
You can now apply these techniques to other vision-based applications on Arm platforms, experiment with different model architectures and optimization strategies, or extend this foundation to solve similar document processing and grid-based recognition tasks.